Hire Me
← All Writing Betfair

Steam Moves — Detecting Rapid Price Change as a Trading Signal in Java

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.

What a steam move looks like

In a liquid market, a steam move typically shows:

  1. Best back price drops 3–5+ ticks in quick succession
  2. Matched volume spikes — the orders are being filled, not just placed
  3. WoM shifts lay-heavy as layers scramble to cover
  4. The new price holds — no bounce back toward the pre-steam level

False positives look like:

Price tick tracking

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);
    }
}

Steam detection logic

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);
    }
}

Confirming the move holds

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
    }
}

Strategy integration

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
    }
}

Tuning the parameters

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.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.