How to detect large orders and sudden liquidity changes in the Betfair Exchange ladder, what the signals mean for price movement, and how to implement the detection logic in Java.
The full price ladder on Betfair carries information that the best-available prices do not. Large blocks of money sitting at specific price levels act as support and resistance — their presence, movement, and sudden removal are among the most reliable short-term signals in liquid horse racing markets. Detecting them programmatically requires working with bdatb and bdatl (the full display ladder) rather than the condensed batb/batl fields.
The streaming API sends bdatb and bdatl as [index, price, size] arrays covering up to 10 price levels on each side. A large order appears as a disproportionate size at a single level relative to the surrounding book:
bdatb: [[0, 2.5, 120.0], [1, 2.48, 85.0], [2, 2.46, 1250.0], ...]
Index 2 at 2.46 holds 1250 — roughly 10× the levels around it. This suggests a large lay order sitting as a back limit, or a participant who wants to buy a significant quantity at 2.46 but not at 2.48 or above.
In liquid markets, large back orders at a price level tend to attract the best lay price — if there is £1,000 available to back at 2.46, layers will fill against it before offering 2.44. This creates a temporary floor. Conversely, a large lay order creates a ceiling.
When a large order is consumed or cancelled:
Tracking the difference between consumption and cancellation requires comparing size changes across consecutive updates.
Store the full ladder per runner per side:
public class LadderBook {
private final TreeMap<Integer, PriceLevel> backLadder = new TreeMap<>();
private final TreeMap<Integer, PriceLevel> layLadder = new TreeMap<>();
public void applyBackDelta(List<List<Object>> delta) {
applyDelta(backLadder, delta);
}
public void applyLayDelta(List<List<Object>> delta) {
applyDelta(layLadder, delta);
}
private void applyDelta(TreeMap<Integer, PriceLevel> book, List<List<Object>> delta) {
for (List<Object> raw : delta) {
int idx = ((Number) raw.get(0)).intValue();
double prc = ((Number) raw.get(1)).doubleValue();
double sz = ((Number) raw.get(2)).doubleValue();
if (sz == 0.0) {
book.remove(idx);
} else {
book.put(idx, new PriceLevel(idx, prc, sz));
}
}
}
public List<PriceLevel> backLevels() {
return List.copyOf(backLadder.values());
}
public List<PriceLevel> layLevels() {
return List.copyOf(layLadder.values());
}
}
Define a threshold relative to the average level size. An absolute threshold (e.g., £500) misses context — in a thin £2,000 market, £200 is a whale; in a £200,000 market, it is noise.
public class LargeOrderDetector {
private final double multiplierThreshold; // e.g. 5.0 = 5× average
public LargeOrderDetector(double multiplierThreshold) {
this.multiplierThreshold = multiplierThreshold;
}
public List<PriceLevel> detectLargeOrders(List<PriceLevel> levels) {
if (levels.size() < 3) return List.of();
double avg = levels.stream()
.mapToDouble(PriceLevel::size)
.average()
.orElse(0.0);
double threshold = avg * multiplierThreshold;
return levels.stream()
.filter(l -> l.size() >= threshold)
.collect(Collectors.toList());
}
}
This gives you the levels that are significantly larger than the surrounding book — candidates for genuine block orders.
Maintain a snapshot of the previous ladder state on each tick to distinguish consumption from cancellation:
public class LadderChangeAnalyser {
private Map<Integer, PriceLevel> previousBackLevels = new HashMap<>();
public LadderChangeEvent analyse(List<PriceLevel> currentLevels) {
Map<Integer, PriceLevel> currentMap = currentLevels.stream()
.collect(Collectors.toMap(PriceLevel::index, l -> l));
List<LevelChange> changes = new ArrayList<>();
for (Map.Entry<Integer, PriceLevel> prev : previousBackLevels.entrySet()) {
int idx = prev.getKey();
PriceLevel current = currentMap.get(idx);
if (current == null) {
changes.add(new LevelChange(prev.getValue(), null, ChangeType.REMOVED));
} else if (current.size() < prev.getValue().size() * 0.1) {
changes.add(new LevelChange(prev.getValue(), current, ChangeType.MOSTLY_CONSUMED));
} else if (current.size() > prev.getValue().size() * 1.5) {
changes.add(new LevelChange(prev.getValue(), current, ChangeType.SIZE_INCREASED));
}
}
for (Map.Entry<Integer, PriceLevel> curr : currentMap.entrySet()) {
if (!previousBackLevels.containsKey(curr.getKey())) {
changes.add(new LevelChange(null, curr.getValue(), ChangeType.APPEARED));
}
}
previousBackLevels = currentMap;
return new LadderChangeEvent(changes);
}
}
enum ChangeType { APPEARED, REMOVED, MOSTLY_CONSUMED, SIZE_INCREASED }
REMOVED means the level disappeared from the book entirely — either cancelled or consumed. MOSTLY_CONSUMED means most of the size was taken — the order was genuine and price is likely to move through. Distinguishing the two requires correlating the change with a trade — if the best back price changed simultaneously with the removal, it was consumed.
Use the large order positions as dynamic support/resistance levels in your signal logic:
public OptionalDouble findNearestBackSupport(LadderBook book, double currentPrice) {
return book.backLevels().stream()
.filter(l -> l.price() < currentPrice)
.filter(l -> l.size() > LARGE_ORDER_THRESHOLD)
.mapToDouble(PriceLevel::price)
.max(); // highest price below current = nearest support
}
If the current best back is 2.50 and there is a large order at 2.44, that level is support — price is unlikely to breach it without the order being consumed or withdrawn.
A single large order can dominate the Weight of Money calculation, making the naive WoM signal misleading. Compute WoM both with and without outlier levels to detect distortion:
public double robustWoM(LadderBook book) {
double backTotal = trimmedTotal(book.backLevels());
double layTotal = trimmedTotal(book.layLevels());
double total = backTotal + layTotal;
return total == 0.0 ? 0.5 : backTotal / total;
}
private double trimmedTotal(List<PriceLevel> levels) {
if (levels.isEmpty()) return 0.0;
double avg = levels.stream().mapToDouble(PriceLevel::size).average().orElse(0.0);
double cap = avg * 4.0;
return levels.stream()
.mapToDouble(l -> Math.min(l.size(), cap))
.sum();
}
Capping each level at 4× the average prevents a single block order from swamping the WoM signal while still including genuine large participation in proportion.
The streaming API can deliver dozens of updates per second in active pre-race markets. Running the full ladder analysis on every tick is unnecessary and wasteful. Throttle to the last update within a sliding window:
private Instant lastAnalysis = Instant.MIN;
private static final Duration ANALYSIS_INTERVAL = Duration.ofMillis(500);
public void onLadderUpdate(LadderBook book) {
Instant now = Instant.now();
if (Duration.between(lastAnalysis, now).compareTo(ANALYSIS_INTERVAL) < 0) return;
lastAnalysis = now;
List<PriceLevel> large = detector.detectLargeOrders(book.backLevels());
// process signals...
}
500ms is usually enough resolution for pre-race signals — prices move slower than the raw update rate.
With large order detection wired into your streaming pipeline, the ladder stops being raw numbers and starts being structured signal: where the money is, how it is moving, and what it implies about near-term price direction.
If you’re building Betfair trading systems in Java and want help with ladder signal extraction, get in touch.