Hire Me
← All Writing Betfair

In-Play Betting Automation — Latency, Execution, and Survival

The reality of automated in-play betting on Betfair — API vs Streaming API latency, market suspension cascades, hedging open positions, and building a Java system that survives the chaos.

In-play trading on Betfair is a different discipline to pre-race trading. Pre-race, the market moves in relatively predictable ways — liquidity builds, steam arrives, prices shorten. You have time to think. In-play, a goal, wicket, or red card can move a price 20 ticks in under a second, suspend the market immediately afterwards, and leave you holding an unhedged position if your system wasn’t designed to handle it. I’ve run automated in-play systems on horse racing and football for several years. This post is about the engineering that keeps them alive.

The In-Play Delay: What the Rules Actually Say

Betfair imposes a delay on in-play bet placement that varies by market. The standard delay for most in-play markets is around 5 seconds — your bet request is queued and only submitted to the order book 5 seconds after it’s received. This is Betfair’s anti-abuse mechanism: it prevents systems with a split-second data advantage from consistently taking money from slower participants.

However, the delay is not uniform. For certain exchange markets (notably some horse racing markets at certain UK tracks), the delay can be different or the market can be designated “in-play but no delay” (very rare). Check the MarketCatalogue description.turnInPlayEnabled and description.inPlayEnabled fields when building your strategy — and verify the actual delay is what you expect by testing with small stakes.

The practical implication: if you’re placing an in-play back bet in response to a price signal, the bet will be submitted to the order book 5 seconds later than you intended. The price you saw may not exist at submission time. Use persistenceType: LAPSE — you want the bet to lapse immediately if it can’t be matched at your price, not hang in the market as an unmatched bet catching whatever price happens to be available next.

API vs Streaming API Latency

The REST API is unsuitable for in-play execution monitoring. Polling listMarketBook every second is Betfair’s documented limit for most API users, and even at that frequency, you’re getting state that is already up to a second old. A market can suspend and reopen between polls.

The Streaming API is the only viable choice for in-play systems. It pushes market changes to you as they happen, typically within 100–200ms of the exchange processing the change. Market suspension events arrive via the status field on the MarketChange:

@Component
@RequiredArgsConstructor
public class InPlayStreamHandler implements StreamListener {

    private final InPlayStateManager stateManager;
    private final EmergencyExitService emergencyExitService;

    @Override
    public void onMarketChange(MarketChange change) {
        if (change.getStatus() != null) {
            MarketStatus status = MarketStatus.valueOf(change.getStatus());
            stateManager.updateStatus(change.getId(), status);

            if (status == MarketStatus.SUSPENDED) {
                emergencyExitService.onMarketSuspended(change.getId());
            }
        }

        // Price updates follow on the same change object
        if (change.getRc() != null) {
            stateManager.updatePrices(change.getId(), change.getRc());
        }
    }
}

The suspension event and the price update can arrive in the same MarketChange message. Process the status first — if the market is suspended, any price data in the same message is from before the suspension and should be treated as stale.

Network Location Considerations

Your network location relative to Betfair’s matching engine matters at the margins. Betfair’s servers are hosted in Slough (UK). If your system is running on a home broadband connection in Manchester, your round-trip time for API calls is approximately 20–40ms. On a London datacenter with a good provider, it’s 2–5ms. For pre-race trading that gap is largely irrelevant. For in-play execution, where you’re racing the market repricing after an event, it’s worth considering.

I run my primary in-play system on a VPS in a UK datacenter. The cost is modest (£10–20/month for a basic instance) and the latency improvement over home broadband is real and consistent. It also removes the risk of a home broadband outage mid-race — which is the more significant practical benefit.

Market Suspension Cascades

In-play markets don’t suspend in isolation. When a goal is scored in football, multiple related markets suspend near-simultaneously: Match Odds, Both Teams to Score, Correct Score, Next Team to Score. If you’re monitoring multiple related markets for a single event, you’ll receive a burst of suspension events within a few hundred milliseconds.

Design your suspension handler to be idempotent and to batch-process related markets:

@Service
@RequiredArgsConstructor
public class EmergencyExitService {

    private final BetfairOrderClient orderClient;
    private final OpenPositionStore openPositions;

    // Debounce: collect suspension events for 200ms, then process together
    private final Map<String, ScheduledFuture<?>> pendingExits = new ConcurrentHashMap<>();
    private final ScheduledExecutorService scheduler =
        Executors.newSingleThreadScheduledExecutor();

    public void onMarketSuspended(String marketId) {
        List<OpenPosition> positions = openPositions.getPositions(marketId);
        if (positions.isEmpty()) return;

        log.warn("Market {} suspended with {} open positions", marketId, positions.size());

        // Cancel any pending orders on this market immediately
        cancelPendingOrders(marketId);

        // Schedule hedge attempt for when the market reopens
        pendingExits.computeIfAbsent(marketId, id ->
            scheduler.schedule(() -> attemptHedgeOnReopen(id), 6, TimeUnit.SECONDS)
        );
    }

    private void cancelPendingOrders(String marketId) {
        try {
            orderClient.cancelOrders(marketId, null, null);
        } catch (Exception e) {
            log.error("Failed to cancel pending orders on suspension for {}", marketId, e);
        }
    }

    private void attemptHedgeOnReopen(String marketId) {
        List<OpenPosition> positions = openPositions.getPositions(marketId);
        positions.forEach(pos -> hedgePosition(marketId, pos));
        pendingExits.remove(marketId);
    }
}

The 6-second delay before attempting to hedge is intentional. After a suspension triggered by a goal, Betfair typically reopens the market within 5–10 seconds with new prices reflecting the score change. Attempting to hedge immediately on suspension will fail — the market is not accepting bets. Waiting 6 seconds gives the market time to reopen and provides a cleaner hedge opportunity.

Hedging Open Positions on Suspension

When the market reopens after a suspension, your previously held position is exposed to the new post-event price. The hedge decision depends on your strategy’s risk tolerance, but the mechanics are standard:

public class PositionHedger {

    private final BetfairOrderClient orderClient;

    /**
     * Hedges a back bet by placing a lay at the current best lay price.
     * Uses BEST_PRICE execution to accept the market's current offer.
     */
    public void hedgeBackPosition(String marketId, OpenPosition position) {
        // If we backed at 3.0 with £100 stake, we're liable for £200 profit on win.
        // To hedge, we lay the runner at current market price.
        double hedgeStake = calculateHedgeStake(position);
        double currentLayPrice = getCurrentBestLayPrice(marketId, position.selectionId());

        PlaceInstruction hedge = new PlaceInstruction();
        hedge.setOrderType(OrderType.LIMIT);
        hedge.setSelectionId(position.selectionId());
        hedge.setSide(Side.LAY);

        LimitOrder limitOrder = new LimitOrder();
        limitOrder.setSize(hedgeStake);
        limitOrder.setPrice(currentLayPrice);
        limitOrder.setPersistenceType(PersistenceType.LAPSE);
        limitOrder.setTimeInForce(TimeInForce.FILL_OR_KILL);
        hedge.setLimitOrder(limitOrder);

        try {
            PlaceExecutionReport report = orderClient.placeOrders(
                marketId, List.of(hedge), null);
            log.info("Hedge placed for market {} selection {}: {}",
                marketId, position.selectionId(), report.getStatus());
        } catch (Exception e) {
            log.error("Hedge failed for market {} — MANUAL INTERVENTION REQUIRED",
                marketId, e);
            alertService.sendUrgentAlert(marketId, position, e);
        }
    }

    private double calculateHedgeStake(OpenPosition position) {
        // Simplified: full liability hedge
        // In production, partial hedges based on in-play score/state are common
        return (position.backedStake() * position.backedPrice()) / getCurrentLayPrice();
    }
}

TimeInForce.FILL_OR_KILL is critical here. If the hedge can’t be matched immediately, you don’t want it sitting as an unmatched order — you want to know it failed and handle it. An unmatched hedge order that gets matched later at a different price is not a hedge, it’s a new position.

Emergency Exit Logic

Every in-play system needs a kill switch. Mine is a SUSPENDED state flag checked at the start of every order placement:

@Component
public class InPlaySafetyGuard {

    private volatile boolean killSwitchActive = false;
    private volatile Instant killSwitchActivatedAt;

    public void activateKillSwitch(String reason) {
        killSwitchActive = true;
        killSwitchActivatedAt = Instant.now();
        log.error("KILL SWITCH ACTIVATED: {}", reason);
        // Send alert — SMS/email/Slack
    }

    public void requireSafe() {
        if (killSwitchActive) {
            throw new KillSwitchActiveException(
                "Kill switch active since " + killSwitchActivatedAt +
                " — no new orders permitted");
        }
    }
}

Conditions that activate the kill switch automatically in my system: stream disconnection lasting more than 10 seconds, any NullPointerException or uncaught exception in the order placement path, daily P&L drawdown exceeding a configurable threshold, and any order placement returning TRANSACTION_THROTTLE_EXCEEDED. You can reset it manually once you’ve investigated.

In-play automation is where the gap between “it works in testing” and “it works in production” is widest. The market doesn’t care that your exception handler has a bug. Design for the failure cases first.

If you’re building in-play automated trading systems and want an engineer who has shipped these in production, get in touch.