Betfair | Order Flow Imbalance — A Java Implementation

Order flow imbalance is a concept borrowed from equity market microstructure research, and it translates surprisingly well to Betfair. The core idea: at any moment, there’s a flow of money arriving on the back side and a flow arriving on the lay side. If backs are arriving faster than lays, the price is likely to shorten. If lays are arriving faster, the price is likely to drift. OFI captures this directional pressure more precisely than a static snapshot of available money.

What OFI Measures

OFI measures the net directional pressure at the best available prices over a time window. The intuition in equities terms: are more people buying than selling? On Betfair: are more people backing than laying at the best prices?

The formal definition I use adapts the Cont, Kukanov, and Stoikov (2014) OFI measure:

OFI(t) = ΔBestBack(t) - ΔBestLay(t)

Where:

Positive OFI → backing pressure dominating → price likely to shorten. Negative OFI → laying pressure dominating → price likely to drift.

Extracting Delta Data from the Streaming API

The Streaming API sends batb (best available to back) and batl (best available to lay) as ladder changes. Each update contains only the changed levels, not the full ladder. To calculate OFI you need to track changes at the best price level specifically:

public class OfiTracker {

    // Map: selectionId -> (bestBackPrice -> bestBackSize)
    private final Map<Long, double[]> bestBack = new ConcurrentHashMap<>();
    private final Map<Long, double[]> bestLay  = new ConcurrentHashMap<>();

    // Cumulative OFI values for rolling window
    private final Map<Long, Deque<Double>> ofiWindow = new ConcurrentHashMap<>();
    private final int windowSize;

    public OfiTracker(int windowSize) {
        this.windowSize = windowSize;
    }

    public void onRunnerChange(long selectionId, RunnerChange rc) {
        double ofi = 0.0;

        if (rc.getBatb() != null && !rc.getBatb().isEmpty()) {
            // Best back = first entry in batb (lowest price = best odds for backer)
            List<Double> bestBackLevel = rc.getBatb().get(0);
            double newPrice = bestBackLevel.get(0);
            double newSize  = bestBackLevel.get(1);

            double[] prev = bestBack.get(selectionId);
            if (prev != null) {
                if (newPrice == prev[0]) {
                    // Same price level — size change represents new/cancelled orders
                    ofi += (newSize - prev[1]);
                } else if (newSize > 0) {
                    // New best price — treat as fresh arrival
                    ofi += newSize;
                }
            }
            bestBack.put(selectionId, new double[]{newPrice, newSize});
        }

        if (rc.getBatl() != null && !rc.getBatl().isEmpty()) {
            // Best lay = first entry in batl (lowest lay price)
            List<Double> bestLayLevel = rc.getBatl().get(0);
            double newPrice = bestLayLevel.get(0);
            double newSize  = bestLayLevel.get(1);

            double[] prev = bestLay.get(selectionId);
            if (prev != null) {
                if (newPrice == prev[0]) {
                    ofi -= (newSize - prev[1]); // subtract lay arrivals
                } else if (newSize > 0) {
                    ofi -= newSize;
                }
            }
            bestLay.put(selectionId, new double[]{newPrice, newSize});
        }

        // Add to rolling window
        Deque<Double> window = ofiWindow.computeIfAbsent(
            selectionId, id -> new ArrayDeque<>());
        window.addLast(ofi);
        while (window.size() > windowSize) window.removeFirst();
    }
}

Normalising OFI

Raw OFI values are highly liquidity-dependent. A £500 order flow imbalance in a £500,000 matched market is noise. Normalise by dividing by total size at best prices:

public OptionalDouble normalisedOfi(long selectionId) {
    Deque<Double> window = ofiWindow.get(selectionId);
    if (window == null || window.isEmpty()) return OptionalDouble.empty();

    double cumulativeOfi = window.stream().mapToDouble(Double::doubleValue).sum();

    double[] back = bestBack.get(selectionId);
    double[] lay  = bestLay.get(selectionId);

    if (back == null || lay == null) return OptionalDouble.empty();

    double totalAtBest = back[1] + lay[1];
    if (totalAtBest == 0) return OptionalDouble.empty();

    return OptionalDouble.of(cumulativeOfi / totalAtBest);
}

The normalised OFI ranges from roughly -1 to +1. Values above +0.3 suggest sustained buying pressure; below -0.3 suggest selling pressure. These thresholds are starting points — calibrate against your own recorded data.

Combining OFI with WoM and LTP

OFI, WoM, and LTP velocity are complementary. OFI captures the dynamics at the best price level — the most liquid and price-relevant point of the book. WoM captures the broader depth. LTP captures what’s actually been matched.

public TradingSignal compositeSignal(long selectionId) {
    OptionalDouble ofi  = ofiTracker.normalisedOfi(selectionId);
    WomSignal      wom  = womService.getSignal(selectionId);
    LtpPattern     ltp  = ltpAnalyser.classify(selectionId);

    int steamScore = 0;
    if (ofi.isPresent() && ofi.getAsDouble() > 0.25) steamScore++;
    if (wom == WomSignal.STEAM)                       steamScore++;
    if (ltp == LtpPattern.SUSTAINED_STEAM)            steamScore++;

    int driftScore = 0;
    if (ofi.isPresent() && ofi.getAsDouble() < -0.25) driftScore++;
    if (wom == WomSignal.DRIFT)                        driftScore++;
    if (ltp == LtpPattern.SUSTAINED_DRIFT)             driftScore++;

    if (steamScore >= 2) return TradingSignal.STEAM;
    if (driftScore >= 2) return TradingSignal.DRIFT;
    return TradingSignal.NEUTRAL;
}

Requiring at least two of three signals to agree before acting materially reduces false positives. The cost is missing some genuine moves — an acceptable trade-off if you’re managing a portfolio of positions across multiple races simultaneously.

False Positives in Thin Markets

OFI is least reliable in markets with low liquidity. In a market with £10,000 total matched volume, one trader can create a sustained OFI signal with a £200 resting order. Some filters I apply:

public boolean isMarketSuitableForOfi(String marketId) {
    MarketState state = cache.getMarket(marketId).orElse(null);
    if (state == null) return false;

    double totalMatched = state.getTotalMatched();
    int minutesToOff = state.minutesUntilOff();

    return totalMatched > 50_000       // minimum liquidity
        && minutesToOff <= 30          // close enough to off for signal to matter
        && minutesToOff >= 2;          // not so close that execution is infeasible
}

Pre-race horse racing markets with £100k+ matched volume 10–20 minutes before the off are where OFI has been most reliable in my experience. Very early market formation, niche events, and sports with sporadic betting patterns are where I don’t apply OFI.

ProTips

If you’re looking for a Java contractor who knows this space inside out, get in touch.