How to read and analyse full ladder depth from the Betfair Streaming API in Java — representing the full order book, detecting iceberg orders, measuring liquidity distribution, and using depth signals that the top 3 levels miss.
Most Betfair trading implementations use only the top 3 levels of the order book — best back, second back, third back, and the same on the lay side. It’s a reasonable starting point, and for Weight of Money calculations it’s usually sufficient. But the full ladder tells a more complete story. The shape of liquidity beyond the top 3 levels reveals where traders are positioning for future price movements, where large orders are sitting in wait, and — if you know what to look for — where possible spoofing or layering activity is occurring.
The Betfair Streaming API provides the full 10-level ladder via batb (available to back) and batl (available to lay). This post covers how to represent, analyse, and extract signals from the complete order book.
The Streaming API delivers batb and batl as lists of [price, size] pairs, sorted best-first. batb is sorted ascending by price (lowest price = shortest odds = best back price at the top). batl is sorted ascending by price (lowest lay price = best lay price at the top).
A complete ladder snapshot looks like:
Back side (batb): Lay side (batl):
[2.00, £1,200] ←best [2.02, £800] ←best
[1.98, £3,400] [2.04, £2,100]
[1.96, £8,700] [2.06, £5,500]
[1.94, £2,200] [2.08, £1,800]
[1.92, £4,100] [2.10, £9,200]
...up to 10 levels ...up to 10 levels
Model the ladder as an immutable snapshot that gets replaced on each update:
public class OrderBookSnapshot {
private final List<PriceLevel> backLevels; // sorted best-first (ascending price)
private final List<PriceLevel> layLevels; // sorted best-first (ascending price)
private final Instant capturedAt;
public record PriceLevel(double price, double size) {}
public static OrderBookSnapshot from(RunnerChange rc, Instant now) {
List<PriceLevel> backs = rc.getBatb() == null ? List.of() :
rc.getBatb().stream()
.map(pair -> new PriceLevel(pair.get(0), pair.get(1)))
.sorted(Comparator.comparingDouble(PriceLevel::price))
.toList();
List<PriceLevel> lays = rc.getBatl() == null ? List.of() :
rc.getBatl().stream()
.map(pair -> new PriceLevel(pair.get(0), pair.get(1)))
.sorted(Comparator.comparingDouble(PriceLevel::price))
.toList();
return new OrderBookSnapshot(backs, lays, now);
}
public OptionalDouble bestBackPrice() {
return backLevels.isEmpty()
? OptionalDouble.empty()
: OptionalDouble.of(backLevels.get(0).price());
}
public OptionalDouble bestLayPrice() {
return layLevels.isEmpty()
? OptionalDouble.empty()
: OptionalDouble.of(layLevels.get(0).price());
}
public double totalBackLiquidity(int levels) {
return backLevels.stream().limit(levels)
.mapToDouble(PriceLevel::size).sum();
}
public double totalLayLiquidity(int levels) {
return layLevels.stream().limit(levels)
.mapToDouble(PriceLevel::size).sum();
}
}
Uniform liquidity distribution — similar amounts at each price level — indicates a market in equilibrium. Skewed distribution tells a different story.
Back-heavy deep levels — significant money sitting several ticks behind the best back price indicates traders are positioned for the price to drift out. They’re not willing to back at current prices but are ready if the price lengthens.
Lay-heavy deep levels — a wall of lay money several ticks above the best lay price suggests the market expects the price to shorten. Layers are sitting in wait for the price to come to them.
public class LiquidityDistributionAnalyser {
public LiquidityProfile analyse(OrderBookSnapshot snapshot) {
double top3BackLiquidity = snapshot.totalBackLiquidity(3);
double top3LayLiquidity = snapshot.totalLayLiquidity(3);
double full10BackLiquidity = snapshot.totalBackLiquidity(10);
double full10LayLiquidity = snapshot.totalLayLiquidity(10);
// Proportion of liquidity sitting in levels 4-10 (the "deep" book)
double deepBackRatio = full10BackLiquidity > 0
? (full10BackLiquidity - top3BackLiquidity) / full10BackLiquidity
: 0;
double deepLayRatio = full10LayLiquidity > 0
? (full10LayLiquidity - top3LayLiquidity) / full10LayLiquidity
: 0;
return new LiquidityProfile(
top3BackLiquidity, top3LayLiquidity,
full10BackLiquidity, full10LayLiquidity,
deepBackRatio, deepLayRatio);
}
public record LiquidityProfile(
double top3Back, double top3Lay,
double fullBack, double fullLay,
double deepBackRatio, double deepLayRatio
) {
/** True if significant liquidity is concentrated in deep back levels */
public boolean isDriftPositioned() { return deepBackRatio > 0.5; }
/** True if significant liquidity is concentrated in deep lay levels */
public boolean isSteamPositioned() { return deepLayRatio > 0.5; }
}
}
A deepLayRatio > 0.5 means more than half the lay-side liquidity is sitting in levels 4–10. That’s a lot of traders positioned to lay at higher prices — a signal that the market collectively expects shortening. This complements WoM (which measures the top of the book) with a view of where the broader market is positioned.
An iceberg order is a large order that appears as a normal-sized order in the visible book but is refreshed repeatedly as it gets matched. In practice on Betfair, you see this as a price level that maintains a stable size despite frequent trading — the size doesn’t deplete as you’d expect.
public class IcebergDetector {
// Snapshots keyed by price, tracking size over time
private final Map<Double, Deque<Double>> backSizeHistory = new ConcurrentHashMap<>();
private static final int HISTORY_SIZE = 20;
private static final double STABILITY_THRESHOLD = 0.15; // <15% variation = stable
public void record(OrderBookSnapshot snapshot) {
for (OrderBookSnapshot.PriceLevel level : snapshot.backLevels()) {
backSizeHistory.computeIfAbsent(level.price(), p -> new ArrayDeque<>())
.addLast(level.size());
Deque<Double> history = backSizeHistory.get(level.price());
while (history.size() > HISTORY_SIZE) history.removeFirst();
}
}
public boolean isPossibleIceberg(double price) {
Deque<Double> history = backSizeHistory.get(price);
if (history == null || history.size() < HISTORY_SIZE) return false;
DoubleSummaryStatistics stats = history.stream()
.mapToDouble(Double::doubleValue)
.summaryStatistics();
double coefficientOfVariation = (stats.getMax() - stats.getMin()) / stats.getAverage();
return coefficientOfVariation < STABILITY_THRESHOLD;
}
}
A price level that maintains near-constant size across 20 snapshots while the market is actively trading is a strong candidate for an iceberg. The implication: there is more liquidity available at that price than the visible order book suggests. This is a useful signal when assessing whether a back or lay price is likely to hold.
The spread — the gap between best back and best lay — is a direct measure of market liquidity and confidence. A tight spread (one or two ticks) indicates a liquid, well-traded market where participants agree on fair value. A wide spread indicates uncertainty, thin liquidity, or both.
public class SpreadAnalyser {
public OptionalDouble spread(OrderBookSnapshot snapshot) {
if (snapshot.bestBackPrice().isEmpty() || snapshot.bestLayPrice().isEmpty()) {
return OptionalDouble.empty();
}
double spread = snapshot.bestLayPrice().getAsDouble()
- snapshot.bestBackPrice().getAsDouble();
return OptionalDouble.of(spread);
}
public SpreadState classify(double spread, double midpointPrice) {
// Normalise by price — a spread of 0.02 means different things at 1.5 vs 10.0
double relativeSpread = spread / midpointPrice;
if (relativeSpread < 0.01) return SpreadState.VERY_TIGHT;
if (relativeSpread < 0.025) return SpreadState.TIGHT;
if (relativeSpread < 0.05) return SpreadState.NORMAL;
return SpreadState.WIDE;
}
public enum SpreadState { VERY_TIGHT, TIGHT, NORMAL, WIDE }
}
Normalising spread by the midpoint price is important. A spread of 0.02 is one tick at odds of 2.0 (very tight) but also one tick at odds of 1.02 (which is enormous relative to the price). Scale your spread thresholds accordingly.
The full ladder analysis components integrate into the same signal service pattern used for WoM and price velocity:
@Component
@RequiredArgsConstructor
public class LadderSignalService {
private final LiquidityDistributionAnalyser liquidityAnalyser;
private final IcebergDetector icebergDetector;
private final SpreadAnalyser spreadAnalyser;
private final Map<Long, OrderBookSnapshot> latestSnapshots = new ConcurrentHashMap<>();
public void onRunnerChange(long selectionId, RunnerChange rc) {
OrderBookSnapshot snapshot = OrderBookSnapshot.from(rc, Instant.now());
latestSnapshots.put(selectionId, snapshot);
icebergDetector.record(snapshot);
}
public LadderSignals getSignals(long selectionId) {
OrderBookSnapshot snapshot = latestSnapshots.get(selectionId);
if (snapshot == null) return LadderSignals.empty();
LiquidityDistributionAnalyser.LiquidityProfile liquidity =
liquidityAnalyser.analyse(snapshot);
OptionalDouble spread = spreadAnalyser.spread(snapshot);
SpreadAnalyser.SpreadState spreadState = spread
.stream()
.mapToObj(s -> spreadAnalyser.classify(s,
(snapshot.bestBackPrice().orElse(0) + snapshot.bestLayPrice().orElse(0)) / 2))
.findFirst()
.orElse(SpreadAnalyser.SpreadState.WIDE);
return new LadderSignals(liquidity, spreadState);
}
}
The combination of deep liquidity positioning (where the market is expecting the price to go), iceberg detection (hidden liquidity at key levels), and spread state (market confidence) gives your strategy engine a significantly richer picture of market structure than the top 3 levels alone.
Trading without reading ladder depth is like navigating with only the road directly in front of you visible. You can manage — until you can’t.
If you’re building signal engines for Betfair automated systems and want an engineer who works at this level of detail, get in touch.