How to measure real-time market liquidity on Betfair in Java — available depth, spread analysis, matched volume, liquidity build patterns, and minimum thresholds for safe automated trading.
Thin markets are where automated trading systems come unstuck. A strategy that performs well in a liquid race can generate wildly wrong signals in a market where the entire available-to-back side is two £50 bets. Your order moves the market. Fill rates are terrible. Spreads are wide enough to park a bus in. The signal that looked clean in a liquid market becomes meaningless noise.
I learned this the hard way early on with my Betfair framework. The fix was not a better signal — it was a proper liquidity gate. Before the strategy evaluates, the market liquidity check runs. If the market doesn’t meet minimum depth thresholds, the strategy is simply not asked for an opinion. This post covers how to measure liquidity from Streaming API data in Java and how to use those measurements to decide whether a market is worth participating in.
The Betfair order book has two sides: available-to-back (atb) and available-to-lay (atl). Each side is a list of price/size pairs representing unmatched orders. The Streaming API delivers these as RunnerChange.atb and RunnerChange.atl — full ladder replacements or delta updates depending on the img (image) flag.
Start by reconstructing the available ladder per runner:
public class RunnerLadder {
// Key: price (Betfair decimal odds), Value: available size in £
private final TreeMap<Double, Double> availableToBack = new TreeMap<>(Comparator.reverseOrder());
private final TreeMap<Double, Double> availableToLay = new TreeMap<>();
public void applyAtb(List<List<Double>> atbUpdates) {
for (List<Double> priceSize : atbUpdates) {
double price = priceSize.get(0);
double size = priceSize.get(1);
if (size == 0) {
availableToBack.remove(price);
} else {
availableToBack.put(price, size);
}
}
}
public void applyAtl(List<List<Double>> atlUpdates) {
for (List<Double> priceSize : atlUpdates) {
double price = priceSize.get(0);
double size = priceSize.get(1);
if (size == 0) {
availableToLay.remove(price);
} else {
availableToLay.put(price, size);
}
}
}
public OptionalDouble bestBack() {
return availableToBack.isEmpty()
? OptionalDouble.empty()
: OptionalDouble.of(availableToBack.firstKey());
}
public OptionalDouble bestLay() {
return availableToLay.isEmpty()
? OptionalDouble.empty()
: OptionalDouble.of(availableToLay.firstKey());
}
public double availableToBackDepth(int levels) {
return availableToBack.values().stream()
.limit(levels)
.mapToDouble(Double::doubleValue)
.sum();
}
public double availableToLayDepth(int levels) {
return availableToLay.values().stream()
.limit(levels)
.mapToDouble(Double::doubleValue)
.sum();
}
}
The TreeMap with Comparator.reverseOrder() for the back side gives you the highest-priced back orders first — which is backwards from intuition, but correct: on the back side, the best available price is the highest odds, and we store them so the firstKey() returns that value. For the lay side, lowest odds is best, so natural ordering works.
The spread is the difference between the best available lay price and the best available back price. A tight spread means backers and layers are in close agreement about true price; a wide spread means there is genuine uncertainty or low participation:
public class LiquidityMetrics {
private final RunnerLadder ladder;
public LiquidityMetrics(RunnerLadder ladder) {
this.ladder = ladder;
}
public OptionalDouble spread() {
OptionalDouble bestBack = ladder.bestBack();
OptionalDouble bestLay = ladder.bestLay();
if (bestBack.isEmpty() || bestLay.isEmpty()) {
return OptionalDouble.empty();
}
// On Betfair: lay price is always >= back price (otherwise arb exists)
return OptionalDouble.of(bestLay.getAsDouble() - bestBack.getAsDouble());
}
public OptionalDouble spreadPercentage() {
OptionalDouble bestBack = ladder.bestBack();
OptionalDouble rawSpread = spread();
if (bestBack.isEmpty() || rawSpread.isEmpty() || bestBack.getAsDouble() == 0) {
return OptionalDouble.empty();
}
return OptionalDouble.of(rawSpread.getAsDouble() / bestBack.getAsDouble() * 100);
}
public double backDepth(int levels) {
return ladder.availableToBackDepth(levels);
}
public double layDepth(int levels) {
return ladder.availableToLayDepth(levels);
}
public double totalDepth(int levels) {
return backDepth(levels) + layDepth(levels);
}
}
A spread of 0.02 on a runner priced around 3.0 is tight. A spread of 0.5 on the same runner means the market has very little price discovery happening — participants are posting orders far apart and waiting to see who blinks first.
Available depth tells you what’s currently queued. Total matched volume tells you how much has already traded — a better proxy for overall market quality, because queued orders can be cancelled instantly but matched volume is permanent:
public class MarketLiquidityTracker {
private double totalMatchedVolume = 0;
private final Map<Long, RunnerLadder> runnerLadders = new HashMap<>();
public void onMarketChange(MarketChange change) {
if (change.getTotalMatched() != null) {
totalMatchedVolume = change.getTotalMatched();
}
if (change.getRc() != null) {
for (RunnerChange rc : change.getRc()) {
RunnerLadder ladder = runnerLadders.computeIfAbsent(
rc.getId(), id -> new RunnerLadder());
if (rc.getAtb() != null) ladder.applyAtb(rc.getAtb());
if (rc.getAtl() != null) ladder.applyAtl(rc.getAtl());
}
}
}
public double totalMatchedVolume() {
return totalMatchedVolume;
}
public LiquidityMetrics metricsFor(long selectionId) {
RunnerLadder ladder = runnerLadders.getOrDefault(selectionId, new RunnerLadder());
return new LiquidityMetrics(ladder);
}
}
Pre-race horse racing markets follow a consistent liquidity build pattern: thin participation until 5–10 minutes before the scheduled off, then a rapid build as the big players move in. Understanding this pattern matters for strategy timing — signals generated in the thin early phase have less predictive value than the same signal generated 2 minutes before the off when the market has £500,000 matched.
public class LiquidityProfile {
private final NavigableMap<Long, Double> volumeByTime = new TreeMap<>(); // publishTimeMs -> totalMatched
public void record(long publishTimeMs, double totalMatched) {
volumeByTime.put(publishTimeMs, totalMatched);
}
/** Volume added in the last N seconds */
public double recentVolumeAdded(long nowMs, int lookbackSeconds) {
long cutoff = nowMs - (lookbackSeconds * 1000L);
Map.Entry<Long, Double> fromEntry = volumeByTime.floorEntry(cutoff);
Map.Entry<Long, Double> toEntry = volumeByTime.lastEntry();
if (fromEntry == null || toEntry == null) return 0;
return toEntry.getValue() - fromEntry.getValue();
}
public boolean isLiquidityBuilding(long nowMs) {
double last30s = recentVolumeAdded(nowMs, 30);
double prev30s = recentVolumeAdded(nowMs - 30_000, 30);
return last30s > prev30s * 1.2; // 20% acceleration threshold
}
}
Accelerating matched volume in the final minutes is a signal that the big players are arriving and the market is becoming reliable. I gate my strategy activation on both total matched volume and the acceleration indicator — the combination reduces false signals significantly during the low-activity early phase.
These are the thresholds I use in production for UK horse racing:
public class LiquidityGate {
// Minimum total matched volume before any strategy runs
private static final double MIN_TOTAL_MATCHED = 20_000;
// Minimum available depth across top 3 back price levels
private static final double MIN_BACK_DEPTH_3_LEVELS = 500;
// Minimum available depth across top 3 lay price levels
private static final double MIN_LAY_DEPTH_3_LEVELS = 300;
// Maximum spread percentage (as % of best back price)
private static final double MAX_SPREAD_PCT = 3.0;
private final MarketLiquidityTracker tracker;
public LiquidityGate(MarketLiquidityTracker tracker) {
this.tracker = tracker;
}
public LiquidityStatus evaluate(long selectionId) {
if (tracker.totalMatchedVolume() < MIN_TOTAL_MATCHED) {
return LiquidityStatus.insufficient("Total matched £%.0f below minimum £%.0f"
.formatted(tracker.totalMatchedVolume(), MIN_TOTAL_MATCHED));
}
LiquidityMetrics metrics = tracker.metricsFor(selectionId);
if (metrics.backDepth(3) < MIN_BACK_DEPTH_3_LEVELS) {
return LiquidityStatus.insufficient("Back depth £%.0f below minimum £%.0f"
.formatted(metrics.backDepth(3), MIN_BACK_DEPTH_3_LEVELS));
}
if (metrics.layDepth(3) < MIN_LAY_DEPTH_3_LEVELS) {
return LiquidityStatus.insufficient("Lay depth £%.0f below minimum £%.0f"
.formatted(metrics.layDepth(3), MIN_LAY_DEPTH_3_LEVELS));
}
OptionalDouble spreadPct = metrics.spreadPercentage();
if (spreadPct.isPresent() && spreadPct.getAsDouble() > MAX_SPREAD_PCT) {
return LiquidityStatus.insufficient("Spread %.1f%% exceeds maximum %.1f%%"
.formatted(spreadPct.getAsDouble(), MAX_SPREAD_PCT));
}
return LiquidityStatus.sufficient();
}
}
These thresholds are starting points. Calibrate them against your own logged data — the right floor for a Class 1 flat race at Ascot is different from an evening National Hunt card at a smaller venue.
The most important thing liquidity analysis tells you is when to do nothing. A market where the spread is 10% and total matched volume is £4,000 is not a market you want to be automated in. Your orders move the price. Your fills are poor. Your signals are noise. No strategy compensates for genuinely bad market conditions.
The correct response to a liquidity gate failure is not to retry with lower thresholds — it is to skip the race entirely and wait for the next one. My framework logs every gate failure with the market ID and reason, which gives me post-session analysis data on how frequently each market type passes the gate and whether my thresholds need adjustment.
If you’re building a Java trading system on Betfair and want to talk through the market analysis architecture, get in touch.