How to handle Betfair market suspension events correctly in a Java trading system — state machines, safe position management, and re-entry logic after resumption.
Market suspension is not an edge case. It’s a normal part of the horse racing lifecycle, and for in-play traders it can happen mid-race at the worst possible moment. A horse gets withdrawn minutes before the off. A false start is called. An objection is lodged. The market freezes, your unmatched orders are stranded, and your strategy engine keeps generating signals based on prices that are no longer valid.
I’ve run automated trading systems through hundreds of race days. Handling suspension correctly is the difference between a system that recovers cleanly and one that enters an undefined state and starts making poor decisions when the market resumes. Here’s how I model it.
Suspension arrives in the Streaming API as a MarketDefinition change on the MarketChange message. The status field on the definition moves from OPEN to SUSPENDED. When the market resumes, it transitions back to OPEN. Closed markets transition to CLOSED and never reopen.
The critical field to watch is not just status — it’s also inPlay. A market can be OPEN and not inPlay (pre-race), OPEN and inPlay (in-running), SUSPENDED and not inPlay (pre-race suspension), or SUSPENDED and inPlay (in-running suspension). These combinations require different responses from your strategy.
public record MarketLifecycleState(MarketStatus status, boolean inPlay) {
public static MarketLifecycleState from(MarketDefinition def) {
return new MarketLifecycleState(
MarketStatus.from(def.getStatus()),
Boolean.TRUE.equals(def.getInPlay())
);
}
public boolean isTradeable() {
return status == MarketStatus.OPEN;
}
public boolean isSuspended() {
return status == MarketStatus.SUSPENDED;
}
}
Model the market lifecycle as an explicit state machine. Implicit state management — a boolean flag here, a null check there — will miss transitions and leave your system in undefined states.
public enum MarketPhase {
UNKNOWN,
PRE_RACE_OPEN,
PRE_RACE_SUSPENDED,
IN_PLAY_OPEN,
IN_PLAY_SUSPENDED,
CLOSED
}
@Component
@Slf4j
public class MarketLifecycleFsm {
private final Map<String, MarketPhase> phases = new ConcurrentHashMap<>();
private final List<MarketLifecycleListener> listeners;
public MarketLifecycleFsm(List<MarketLifecycleListener> listeners) {
this.listeners = listeners;
}
public MarketPhase getPhase(String marketId) {
return phases.getOrDefault(marketId, MarketPhase.UNKNOWN);
}
public void onMarketDefinitionChange(String marketId, MarketDefinition def) {
MarketPhase previous = phases.getOrDefault(marketId, MarketPhase.UNKNOWN);
MarketPhase next = resolve(def);
if (previous == next) return;
phases.put(marketId, next);
log.info("Market {} phase transition: {} -> {}", marketId, previous, next);
notifyListeners(marketId, previous, next);
}
private MarketPhase resolve(MarketDefinition def) {
boolean inPlay = Boolean.TRUE.equals(def.getInPlay());
boolean suspended = "SUSPENDED".equals(def.getStatus());
boolean closed = "CLOSED".equals(def.getStatus());
if (closed) return MarketPhase.CLOSED;
if (inPlay && suspended) return MarketPhase.IN_PLAY_SUSPENDED;
if (inPlay) return MarketPhase.IN_PLAY_OPEN;
if (suspended) return MarketPhase.PRE_RACE_SUSPENDED;
return MarketPhase.PRE_RACE_OPEN;
}
private void notifyListeners(String marketId, MarketPhase from, MarketPhase to) {
for (MarketLifecycleListener listener : listeners) {
try {
listener.onPhaseTransition(marketId, from, to);
} catch (Exception e) {
log.error("Listener {} threw on phase transition", listener.getClass().getSimpleName(), e);
}
}
}
}
Catching exceptions from listeners is not optional — a buggy listener must not prevent other listeners from receiving the transition notification.
The dangerous question when suspension hits: what is my exposure? You need to know your matched positions at the moment of suspension, because you may not be able to exit them until the market resumes — or at all, if the race is abandoned.
@Component
@RequiredArgsConstructor
@Slf4j
public class PositionSuspensionGuard implements MarketLifecycleListener {
private final OrderStateCache orderStateCache;
private final StrategyController strategyController;
@Override
public void onPhaseTransition(String marketId, MarketPhase from, MarketPhase to) {
if (to == MarketPhase.PRE_RACE_SUSPENDED || to == MarketPhase.IN_PLAY_SUSPENDED) {
onSuspended(marketId, to);
}
if ((from == MarketPhase.PRE_RACE_SUSPENDED || from == MarketPhase.IN_PLAY_SUSPENDED)
&& (to == MarketPhase.PRE_RACE_OPEN || to == MarketPhase.IN_PLAY_OPEN)) {
onResumed(marketId, from, to);
}
}
private void onSuspended(String marketId, MarketPhase phase) {
strategyController.halt(marketId);
PositionSnapshot snapshot = orderStateCache.snapshotMatchedPositions(marketId);
log.warn("Market {} suspended ({}) — matched exposure: back={}, lay={}",
marketId, phase, snapshot.totalBackedStake(), snapshot.totalLayedLiability());
// Cancel all unmatched orders — they cannot fill during suspension
orderStateCache.cancelUnmatched(marketId);
}
private void onResumed(String marketId, MarketPhase from, MarketPhase to) {
log.info("Market {} resumed: {} -> {}", marketId, from, to);
boolean crossedIntoInPlay = from == MarketPhase.PRE_RACE_SUSPENDED
&& to == MarketPhase.IN_PLAY_OPEN;
if (crossedIntoInPlay) {
log.warn("Market {} went in-play via suspension — reviewing pre-race positions", marketId);
strategyController.reviewPositionsForInPlay(marketId);
} else {
strategyController.resume(marketId);
}
}
}
The in-play transition via suspension deserves special attention. Markets can go from pre-race suspension directly into in-play (PRE_RACE_SUSPENDED → IN_PLAY_OPEN) — a pattern seen when the race starts immediately after a delayed start. If your pre-race strategy was building a position expecting to exit before in-play, you’ve just been carried in-play involuntarily. Your system must detect this specifically and handle it — don’t resume normal pre-race strategy logic when you’re now in-running.
Resuming after suspension isn’t simply a matter of toggling a flag. The market may have moved significantly. Your pre-suspension prices are stale. The best-available prices on the book immediately after resumption are often volatile as liquidity rebuilds.
@Component
@RequiredArgsConstructor
@Slf4j
public class PostSuspensionReEntryStrategy {
private static final Duration SETTLE_PERIOD = Duration.ofSeconds(5);
private final MarketDataCache dataCache;
private final StrategyController strategyController;
private final ScheduledExecutorService scheduler;
public void onResumed(String marketId) {
// Wait for liquidity to stabilise before re-enabling strategy
scheduler.schedule(
() -> evaluateReEntry(marketId),
SETTLE_PERIOD.toMillis(),
TimeUnit.MILLISECONDS
);
}
private void evaluateReEntry(String marketId) {
MarketSnapshot snapshot = dataCache.getSnapshot(marketId);
if (!snapshot.hasSufficientLiquidity()) {
log.info("Market {} post-suspension liquidity insufficient — delaying re-entry", marketId);
scheduler.schedule(() -> evaluateReEntry(marketId), 2_000, TimeUnit.MILLISECONDS);
return;
}
double spreadPercent = snapshot.bestBackOdds() / snapshot.bestLayOdds() - 1.0;
if (spreadPercent > 0.05) {
log.info("Market {} spread too wide post-suspension ({:.1f}%) — waiting", marketId, spreadPercent * 100);
scheduler.schedule(() -> evaluateReEntry(marketId), 2_000, TimeUnit.MILLISECONDS);
return;
}
log.info("Market {} post-suspension conditions acceptable — resuming strategy", marketId);
strategyController.resume(marketId);
}
}
The settle period and spread threshold are tunable per strategy. For scalping strategies operating on tight margins, a 5% spread means the edge has evaporated — waiting is the right call. For longer-horizon strategies, you might accept wider spreads.
In-play suspensions follow predictable patterns that you can use to calibrate your response. Brief suspensions (under 10 seconds) in a horse race are typically automated — the exchange suspended on an in-running flag and resumed immediately. Longer suspensions (over 30 seconds) usually indicate stewards’ enquiries, photo finishes, or abandoned races.
@Component
@Slf4j
public class SuspensionDurationMonitor {
private final Map<String, Instant> suspendedAt = new ConcurrentHashMap<>();
public void onSuspended(String marketId) {
suspendedAt.put(marketId, Instant.now());
}
public void onResumed(String marketId) {
Instant start = suspendedAt.remove(marketId);
if (start == null) return;
Duration duration = Duration.between(start, Instant.now());
log.info("Market {} suspension lasted {}ms", marketId, duration.toMillis());
if (duration.toSeconds() > 60) {
log.warn("Market {} suspension exceeded 60s — elevated abandonment risk", marketId);
}
}
}
Tracking suspension duration doesn’t directly change your trading decisions, but it feeds into risk management — a system that knows a market has been suspended for two minutes can reduce position size on re-entry until the situation is clearer.
The common failure mode in suspension handling is a race condition between the suspension notification and an order placement that was already in flight. You need a clear gate:
@Component
@Slf4j
public class OrderGate {
private final Set<String> haltedMarkets = ConcurrentHashMap.newKeySet();
public void halt(String marketId) {
haltedMarkets.add(marketId);
log.warn("Order gate CLOSED for market {}", marketId);
}
public void open(String marketId) {
haltedMarkets.remove(marketId);
log.info("Order gate OPEN for market {}", marketId);
}
public void checkPermitted(String marketId) {
if (haltedMarkets.contains(marketId)) {
throw new MarketHaltedException("Orders not permitted for suspended market: " + marketId);
}
}
}
Every order submission must pass through checkPermitted(). The order attempt in-flight when suspension is signalled will throw, get caught by your error handler, and be discarded. The alternative — letting it hit the Betfair API — results in a rejected order, a wasted API call, and potentially a rate limit penalty.
Suspension handling is one of the areas where production experience makes the most difference. The state machine above has been tested through genuinely unusual scenarios — abandoned races, walkover events, voids — and each one exposed an edge case that wasn’t obvious from reading the API documentation.
If you’re building a Betfair trading system and want to talk through resilience architecture, get in touch.