How to use matched volume from the Betfair Streaming API as a market confidence signal — tracking cumulative volume, rate of change, and per-runner distribution to inform pre-race trading decisions in Java.
Matched volume is the most direct signal of market confidence available on Betfair. Price alone tells you where the market thinks the probability sits. Volume tells you how certain the market is about that view. A favourite at 2.0 with £50,000 matched is a very different trading proposition to the same favourite at 2.0 with £5,000 matched — the price might be right in both cases, but the first market has far more information embedded in it.
Understanding how to read and react to volume data from the Streaming API is a practical trading edge that most participants ignore.
The Streaming API delivers market data as a sequence of change messages. For matched volume, the relevant fields per runner are:
trd — traded volume: a list of [price, size] pairs showing the volume matched at each price pointbatb / batl — best available to back/lay (not volume, but context for the volume you’re seeing)At the market level:
tv — total value matched across the entire marketThese come as delta updates: the API sends only changed values, not the full state every time. Your model needs to maintain cumulative totals.
Start with a model that accumulates volume per runner from the stream deltas:
public class RunnerVolumeModel {
private final long selectionId;
private final NavigableMap<Double, BigDecimal> volumeByPrice = new TreeMap<>();
private BigDecimal totalMatched = BigDecimal.ZERO;
private final List<VolumeSnapshot> history = new ArrayList<>();
public void applyTradedVolume(List<PriceSize> tradedPrices, Instant timestamp) {
for (var ps : tradedPrices) {
volumeByPrice.merge(
ps.price(),
ps.size(),
BigDecimal::add
);
}
totalMatched = volumeByPrice.values().stream()
.reduce(BigDecimal.ZERO, BigDecimal::add);
history.add(new VolumeSnapshot(totalMatched, timestamp));
}
public BigDecimal getTotalMatched() {
return totalMatched;
}
public BigDecimal getVolumeAtPrice(double price) {
return volumeByPrice.getOrDefault(price, BigDecimal.ZERO);
}
public record VolumeSnapshot(BigDecimal totalMatched, Instant timestamp) {}
}
This gives you per-price volume distribution and a snapshot history you can use to compute rate of change.
The absolute matched volume is useful; the rate at which it’s growing is often more useful. A market where volume is accelerating in the 10 minutes before the off is a market where new information is being incorporated. A market where volume has stalled despite the race being 5 minutes away may have a liquidity problem.
Compute the volume velocity over a sliding window:
public class VolumeVelocityCalculator {
public BigDecimal computeVelocity(
List<RunnerVolumeModel.VolumeSnapshot> history,
Duration window) {
if (history.size() < 2) return BigDecimal.ZERO;
Instant cutoff = Instant.now().minus(window);
var recentSnapshots = history.stream()
.filter(s -> s.timestamp().isAfter(cutoff))
.toList();
if (recentSnapshots.isEmpty()) return BigDecimal.ZERO;
var earliest = recentSnapshots.getFirst();
var latest = recentSnapshots.getLast();
BigDecimal volumeGain = latest.totalMatched().subtract(earliest.totalMatched());
long secondsElapsed = Duration.between(earliest.timestamp(), latest.timestamp()).toSeconds();
if (secondsElapsed == 0) return BigDecimal.ZERO;
return volumeGain.divide(
BigDecimal.valueOf(secondsElapsed),
4, RoundingMode.HALF_UP
);
}
}
A positive, increasing velocity in the last minute before off suggests the market is being informed — potentially by betting syndicates, on-course professionals, or automated systems that have edge on the race outcome.
How volume is distributed across price points tells you something about market conviction. A market where 80% of the matched volume is at a single price has very different dynamics to one where volume is spread evenly across a range.
public Map<Double, Double> getVolumeConcentration(RunnerVolumeModel model) {
if (model.getTotalMatched().compareTo(BigDecimal.ZERO) == 0) {
return Map.of();
}
return model.getVolumeByPrice().entrySet().stream()
.collect(toMap(
Map.Entry::getKey,
entry -> entry.getValue()
.divide(model.getTotalMatched(), 4, RoundingMode.HALF_UP)
.doubleValue()
));
}
Concentration at the top two or three prices in the book is normal for an efficient market. Scattered volume across many prices, including some that are far from the current best available, may indicate earlier trading at stale prices — or that the market is still finding its level.
For a multi-runner race, relative volume between runners is as informative as absolute volume. A runner attracting 40% of total market volume is telling you something regardless of its price:
public class MarketVolumeAnalyser {
public Map<Long, Double> computeVolumeShares(
Map<Long, RunnerVolumeModel> runnerModels,
BigDecimal totalMarketVolume) {
if (totalMarketVolume.compareTo(BigDecimal.ZERO) == 0) {
return Map.of();
}
return runnerModels.entrySet().stream()
.collect(toMap(
Map.Entry::getKey,
entry -> entry.getValue().getTotalMatched()
.divide(totalMarketVolume, 4, RoundingMode.HALF_UP)
.doubleValue()
));
}
public Optional<Long> findVolumeLeader(Map<Long, RunnerVolumeModel> runnerModels) {
return runnerModels.entrySet().stream()
.max(Comparator.comparing(e -> e.getValue().getTotalMatched()))
.map(Map.Entry::getKey);
}
}
A runner drawing significantly more volume than its price-implied probability would suggest is often a sign that informed money has a view. Whether that view is correct is another question — but knowing which runner the market’s attention is on helps frame where to look.
Wiring the volume model to the Streaming API delta feed:
public class MarketVolumeHandler implements MarketChangeListener {
private final Map<Long, RunnerVolumeModel> runnerModels = new ConcurrentHashMap<>();
private final AtomicReference<BigDecimal> totalMarketVolume = new AtomicReference<>(BigDecimal.ZERO);
@Override
public void onMarketChange(MarketChange change) {
if (change.getTv() != null) {
totalMarketVolume.set(BigDecimal.valueOf(change.getTv()));
}
if (change.getRc() == null) return;
Instant now = Instant.now();
for (RunnerChange rc : change.getRc()) {
var model = runnerModels.computeIfAbsent(
rc.getId(),
id -> new RunnerVolumeModel(id)
);
if (rc.getTrd() != null && !rc.getTrd().isEmpty()) {
model.applyTradedVolume(rc.getTrd(), now);
}
}
}
public BigDecimal getTotalMarketVolume() {
return totalMarketVolume.get();
}
public Optional<RunnerVolumeModel> getRunnerModel(long selectionId) {
return Optional.ofNullable(runnerModels.get(selectionId));
}
}
Keep in mind that the trd field is cumulative by default in the Streaming API — Betfair sends the complete price/size traded at a price point, not just the increment since the last update. Your accumulation logic should handle this correctly for the merge operation.
Define volume thresholds that your strategy can react to:
public record VolumeThreshold(
BigDecimal minimumMarketVolume,
BigDecimal minimumRunnerVolume,
BigDecimal minimumVelocityPerSecond
) {
public static VolumeThreshold racing() {
return new VolumeThreshold(
new BigDecimal("50000"), // £50k market minimum
new BigDecimal("5000"), // £5k runner minimum
new BigDecimal("100") // £100/sec velocity minimum
);
}
}
public boolean isMarketConfident(
String marketId,
VolumeThreshold threshold) {
var marketVolume = getTotalMarketVolume();
if (marketVolume.compareTo(threshold.minimumMarketVolume()) < 0) return false;
var velocityCalc = new VolumeVelocityCalculator();
return runnerModels.values().stream()
.allMatch(model -> {
if (model.getTotalMatched().compareTo(threshold.minimumRunnerVolume()) < 0)
return false;
var velocity = velocityCalc.computeVelocity(
model.getHistory(), Duration.ofMinutes(2));
return velocity.compareTo(threshold.minimumVelocityPerSecond()) >= 0;
});
}
Use these checks as gates before entering a position rather than as the trading signal itself — volume confidence is a filter, not a direction.
If you’re building a pre-race trading system and want to talk through how volume analysis fits into your strategy architecture, get in touch.