Hire Me
← All Writing Betfair

In-Play Trading Architecture — How Everything Changes at the Off

How the architecture of an automated Betfair trading system must change when transitioning from pre-race to in-play — latency requirements, signal sources, order management constraints, and the state machine that keeps it safe.

Pre-race and in-play trading are not the same problem with different data. They are fundamentally different problems that happen to share an exchange. The signals are different, the latency requirements are different, the order management constraints are different, and the risk profile is different. A system that handles pre-race well will fail in-play if you haven’t designed explicitly for what changes at the off.

I’ve built systems that trade both, and the architectural decisions that make in-play work reliably are worth spelling out in detail.

What Changes at the Off

Before the off, markets are liquid, prices move relatively slowly, and you have seconds to react to signals and execute orders. The Betfair Streaming API delivers market data every 50–200ms in active markets, which is fast enough to act on pre-race signals without specialised latency optimisation.

In-play, everything accelerates. Prices move in ticks within milliseconds of a goal, a fall, or a race development. Liquidity spikes and collapses. The Streaming API continues at the same update rate, but the price you see when you decide to act may be several ticks away from the price you get when the order reaches the exchange. The market suspends briefly after major events, then reopens. Position management becomes critical — an unhedged in-play position can move violently against you before you can react.

The system needs to know, precisely, when the market has gone in-play, and transition its behaviour accordingly.

Detecting the Transition

The Streaming API signals the in-play transition via the inPlay field on MarketDefinition:

public class MarketStateManager {

    private volatile boolean inPlay = false;
    private final List<InPlayTransitionListener> listeners = new CopyOnWriteArrayList<>();

    public void onMarketChange(MarketChange change) {
        if (change.getMarketDefinition() == null) return;

        boolean wasInPlay = this.inPlay;
        boolean nowInPlay = Boolean.TRUE.equals(change.getMarketDefinition().getInPlay());

        this.inPlay = nowInPlay;

        if (!wasInPlay && nowInPlay) {
            log.info("Market {} has gone in-play", change.getId());
            listeners.forEach(l -> l.onInPlayTransition(change.getId()));
        }
    }

    public boolean isInPlay() { return inPlay; }
}

The transition fires exactly once per market, and it’s the trigger for switching the entire strategy engine into in-play mode. Do not poll isInPlay() in a loop — register a listener and react to the event.

The Strategy State Machine

Pre-race and in-play logic should be modelled as distinct states in an explicit state machine, not as if/else branches scattered through a single strategy class:

public enum TradingPhase {
    PRE_RACE,
    GOING_IN_PLAY,   // transition window — close pre-race positions
    IN_PLAY,
    CLOSING,         // market approaching close — reduce exposure
    CLOSED
}

@Component
public class StrategyStateMachine implements InPlayTransitionListener {

    private final AtomicReference<TradingPhase> phase =
        new AtomicReference<>(TradingPhase.PRE_RACE);

    private final PreRaceStrategy preRaceStrategy;
    private final InPlayStrategy inPlayStrategy;
    private final PositionManager positionManager;

    @Override
    public void onInPlayTransition(String marketId) {
        phase.set(TradingPhase.GOING_IN_PLAY);

        // Close all pre-race positions before accepting in-play signals
        positionManager.closeAllPositions(marketId, () -> {
            phase.set(TradingPhase.IN_PLAY);
            log.info("Market {} positions closed — entering in-play mode", marketId);
        });
    }

    public void onRunnerChange(String marketId, RunnerChange rc) {
        switch (phase.get()) {
            case PRE_RACE       -> preRaceStrategy.evaluate(marketId, rc);
            case IN_PLAY        -> inPlayStrategy.evaluate(marketId, rc);
            case GOING_IN_PLAY  -> {} // stand aside during position close
            case CLOSING        -> inPlayStrategy.evaluateClosingOnly(marketId, rc);
            case CLOSED         -> {} // no action
        }
    }
}

The GOING_IN_PLAY phase is important. From the moment the off is detected to the moment all pre-race positions are confirmed closed (or expired), the system should neither open new pre-race positions nor enter in-play positions. This window is typically a few hundred milliseconds to a couple of seconds.

In-Play Signal Sources

Pre-race signals — Weight of Money, price velocity, order flow imbalance — are less useful in-play. WoM in particular becomes misleading: the order book dynamics are completely different when the event is live. In-play signals come from a different set of sources:

LTP momentum — the rate of price change is still relevant in-play, but the velocity thresholds need recalibration. In-play prices move much faster; a velocity that would signal strong steam pre-race may be noise in-play.

Score/event data — for football, Betfair’s score data (available via the Streaming API for selected markets) is the primary signal source. A goal triggers a market suspension followed by a large, predictable price movement. Systems that can act within milliseconds of the suspension lifting have a structural edge.

Traded volume acceleration — a spike in matched volume often precedes or accompanies a significant in-play event. Monitoring volume rate of change (not just cumulative volume) can give a leading signal.

Back-to-lay spread — in-play markets often have wider spreads than pre-race. A narrowing spread in a market where one selection is heavily traded indicates consolidation, potentially before a breakout.

public class InPlaySignalService {

    private final PriceVelocityCalculator velocityCalc;
    private final VolumeAccelerationTracker volumeTracker;

    // Shorter window for in-play — market moves faster
    private static final Duration IN_PLAY_VELOCITY_WINDOW = Duration.ofSeconds(10);

    public InPlaySignal evaluate(RunnerChange rc, MarketSnapshot snapshot) {
        if (rc.getLtp() != null) {
            velocityCalc.record(rc.getLtp());
        }
        if (rc.getTv() != null) {
            volumeTracker.record(rc.getTv());
        }

        OptionalDouble velocity   = velocityCalc.velocity();
        OptionalDouble volAccel   = volumeTracker.acceleration();

        if (velocity.isEmpty()) return InPlaySignal.INSUFFICIENT_DATA;

        // High velocity + accelerating volume = strong directional signal
        if (Math.abs(velocity.getAsDouble()) > 0.08
                && volAccel.orElse(0) > 0) {
            return velocity.getAsDouble() < 0
                ? InPlaySignal.STRONG_STEAM
                : InPlaySignal.STRONG_DRIFT;
        }

        return InPlaySignal.NEUTRAL;
    }
}

Latency Constraints and Order Placement

In-play order placement has a latency constraint that doesn’t apply pre-race. Between a signal firing and an order reaching the exchange, the price may have moved several ticks. At short odds (1.1 to 1.5), even a single tick is a large percentage move. You need to know, before placing an order, what the acceptable execution range is.

public class InPlayOrderExecutor {

    private final BetfairOrderService orderService;

    // Maximum ticks of slippage we'll accept
    private static final int MAX_SLIPPAGE_TICKS = 2;

    public Optional<BetPlaced> placeWithSlippageControl(
            String marketId,
            long selectionId,
            Side side,
            double signalPrice,
            double stake) {

        double worstAcceptablePrice = calculateWorstPrice(signalPrice, side, MAX_SLIPPAGE_TICKS);

        PlaceInstruction instruction = PlaceInstruction.builder()
            .selectionId(selectionId)
            .side(side)
            .orderType(OrderType.LIMIT)
            .limitOrder(LimitOrder.builder()
                .size(stake)
                .price(worstAcceptablePrice)
                .persistenceType(PersistenceType.LAPSE) // lapse unmatched — never keep open orders in-play
                .build())
            .build();

        PlaceExecutionReport report = orderService.placeOrders(marketId, List.of(instruction));

        if (report.getStatus() == ExecutionReportStatus.SUCCESS) {
            return report.getInstructionReports().stream()
                .filter(r -> r.getStatus() == InstructionReportStatus.SUCCESS)
                .map(r -> new BetPlaced(r.getBetId(), r.getAveragePriceMatched()))
                .findFirst();
        }

        return Optional.empty();
    }

    private double calculateWorstPrice(double price, Side side, int ticks) {
        // For backs: accept up to N ticks shorter (worse price = shorter odds)
        // For lays:  accept up to N ticks longer (worse price = longer odds)
        return side == Side.BACK
            ? PriceLadder.ticksAway(price, -ticks)
            : PriceLadder.ticksAway(price, ticks);
    }
}

PersistenceType.LAPSE is critical in-play. Never use PERSIST on in-play orders — an unmatched order that persists to the next market suspension will be sitting at a price that no longer reflects reality when the market reopens.

Position Limits and Exposure Management

In-play risk is different from pre-race risk. Pre-race, you can usually exit a position before the off if a trade goes against you. In-play, a position can move a long way against you before the next opportunity to exit.

Define hard position limits per market and enforce them in the strategy engine:

@Component
public class InPlayRiskGuard {

    private final Map<String, Double> currentExposure = new ConcurrentHashMap<>();

    private static final double MAX_IN_PLAY_EXPOSURE = 50.0; // £50 max exposure per market

    public boolean canPlaceOrder(String marketId, double additionalExposure) {
        double current = currentExposure.getOrDefault(marketId, 0.0);
        if (current + additionalExposure > MAX_IN_PLAY_EXPOSURE) {
            log.warn("In-play exposure limit reached for market {} (current=£{}, requested=£{})",
                marketId, current, additionalExposure);
            return false;
        }
        return true;
    }

    public void onOrderMatched(String marketId, double exposure) {
        currentExposure.merge(marketId, exposure, Double::sum);
    }

    public void onPositionClosed(String marketId, double exposureReleased) {
        currentExposure.merge(marketId, -exposureReleased, Double::sum);
    }
}

Keep in-play position limits tighter than pre-race limits, at least until you have a significant body of logged results that justifies increasing them.

Handling In-Play Suspensions

Markets suspend frequently in-play — every goal in football, every fall in jump racing. The suspension handling from the reconnection post applies here, but with an additional in-play concern: when the market resumes, the price will often have jumped significantly. Don’t assume your pre-suspension positions are still correctly priced on resume.

On resumption from an in-play suspension, always reconcile open positions and re-evaluate signals from the current market state — don’t continue executing against a signal that was generated before the suspension.

Knowing When Not to Trade In-Play

Not all markets are worth trading in-play. The structural edges available in pre-race trading (WoM, velocity, order flow) are well-understood and widely used. In-play edges are harder to find, require lower latency, and carry higher risk. For most automated systems, the right approach is:

A system that knows when not to trade is more valuable than one that trades in every phase.

If you’re extending an automated trading system to handle in-play and need an engineer who understands what changes at the off, get in touch.