How to detect steam moves in Betfair pre-race markets — rapid, sustained price shortening driven by informed money — and how to build a Java signal that distinguishes steam from noise.
A steam move is a rapid, sustained price shortening driven by significant informed money. The price doesn’t drift back up after a steam move — it holds or continues to shorten, reflecting genuine information advantage held by the bettors responsible. Detecting steam reliably in a Betfair pre-race market requires distinguishing it from three things it resembles: normal pre-race price drift toward the off, random noise in thin markets, and spoofed orders that are pulled before matching.
In a liquid market, a steam move typically shows:
False positives look like:
Track the best back price across streaming ticks:
public class PriceSeries {
private final int capacity;
private final Deque<PriceTick> ticks = new ArrayDeque<>();
public record PriceTick(double price, long matchedVolume, Instant time) {}
public PriceSeries(int capacity) {
this.capacity = capacity;
}
public void add(double price, long matchedVolume) {
ticks.addLast(new PriceTick(price, matchedVolume, Instant.now()));
if (ticks.size() > capacity) ticks.removeFirst();
}
public OptionalDouble priceChangeOver(Duration window) {
Instant cutoff = Instant.now().minus(window);
List<PriceTick> recent = ticks.stream()
.filter(t -> t.time().isAfter(cutoff))
.collect(Collectors.toList());
if (recent.size() < 2) return OptionalDouble.empty();
double earliest = recent.getFirst().price();
double latest = recent.getLast().price();
return OptionalDouble.of(latest - earliest); // negative = shortening
}
public OptionalDouble volumeChangeOver(Duration window) {
Instant cutoff = Instant.now().minus(window);
List<PriceTick> recent = ticks.stream()
.filter(t -> t.time().isAfter(cutoff))
.collect(Collectors.toList());
if (recent.size() < 2) return OptionalDouble.empty();
long earliest = recent.getFirst().matchedVolume();
long latest = recent.getLast().matchedVolume();
return OptionalDouble.of(latest - earliest);
}
}
public class SteamDetector {
private static final Duration STEAM_WINDOW = Duration.ofSeconds(30);
private static final double MIN_TICK_MOVE = 0.10; // minimum price drop in window
private static final long MIN_VOLUME = 500; // minimum new matched £ in window
public Optional<SteamSignal> detect(PriceSeries series, double currentWom) {
OptionalDouble priceChange = series.priceChangeOver(STEAM_WINDOW);
OptionalDouble volumeChange = series.volumeChangeOver(STEAM_WINDOW);
if (priceChange.isEmpty() || volumeChange.isEmpty()) return Optional.empty();
double priceDrop = -priceChange.getAsDouble(); // positive = shortening
double matchedInflow = volumeChange.getAsDouble();
boolean hasPriceMove = priceDrop >= MIN_TICK_MOVE;
boolean hasVolumeSpike = matchedInflow >= MIN_VOLUME;
boolean hasLayHeavyWom = currentWom < 0.40; // layers dominating
if (hasPriceMove && hasVolumeSpike && hasLayHeavyWom) {
return Optional.of(new SteamSignal(
priceDrop, matchedInflow, currentWom, Instant.now()));
}
return Optional.empty();
}
}
public record SteamSignal(
double priceDrop, // how much the price shortened in the window
double matchedInflow, // £ matched during the window
double womAtSignal, // WoM when signal fired
Instant detectedAt
) {
public double strength() {
return priceDrop * Math.log1p(matchedInflow);
}
}
A single detection is not enough — the signal is confirmed when the price holds at the new level without reverting:
public class SteamConfirmer {
private SteamSignal pendingSignal;
private double priceAtSignal;
public void onSignalDetected(SteamSignal signal, double currentPrice) {
this.pendingSignal = signal;
this.priceAtSignal = currentPrice;
}
public boolean isConfirmed(double currentPrice) {
if (pendingSignal == null) return false;
Duration age = Duration.between(pendingSignal.detectedAt(), Instant.now());
if (age.getSeconds() < 10) return false; // wait 10s for confirmation
// Price should have held or continued shortening
return currentPrice <= priceAtSignal + 0.02; // allow 2p bounce tolerance
}
public void onPriceReverted() {
pendingSignal = null; // false positive — discard
}
}
public void onPriceTick(String runnerId, double bestBack, long matchedVolume) {
PriceSeries series = priceSeriesMap.get(runnerId);
if (series == null) return;
series.add(bestBack, matchedVolume);
double wom = womCalculator.compute(orderBook.get(runnerId));
detector.detect(series, wom).ifPresent(signal -> {
log.info("Steam detected on {}: dropped {}, £{} matched, WoM {}",
runnerId, signal.priceDrop(), signal.matchedInflow(), signal.womAtSignal());
confirmer.onSignalDetected(signal, bestBack);
});
if (confirmer.isConfirmed(bestBack)) {
strategyEngine.onSteamConfirmed(runnerId, bestBack);
confirmer.onPriceReverted(); // reset after acting
}
}
Steam detection parameters need calibration per market type:
Track signal outcomes over time — how often did a steam signal at a given strength result in a continued price move versus a revert. This data drives threshold calibration.
If you’re building pre-race trading strategies in Java and want help with signal development, get in touch.