How to integrate live sports data feeds alongside Betfair market data in Java — correlating events to markets, consuming score signals, and meeting in-play latency requirements.
The Betfair Streaming API tells you what the market is doing. A sports data feed tells you what the sport is doing. The real edge — if there is one — sits in the gap between those two streams: acting on a score event, a red card, or a wicket before the market has fully repriced. That window is small and getting smaller, but it exists, and building a Java system that can exploit it requires getting both streams right and correlating them cleanly.
This post covers the landscape of data providers, the mechanics of correlating sports events to Betfair market IDs, the latency requirements for in-play systems, and how to build a resilient consumer that won’t fall over mid-match.
The commercial options worth knowing about:
Sportradar is the market leader for European football, tennis, and most major sports. Coverage is deep, reliability is high, latency is typically 2–8 seconds behind real-world events (the human input delay). Their Live Odds and Live Data feeds are the standard choice for professional trading operations. Cost reflects that — annual contracts are not cheap.
Stats Perform (formerly Opta/STATS) has exceptional football data, particularly for lower leagues and international competitions that Sportradar covers less comprehensively. The Perform Data API is broadly similar in structure. Worth evaluating if your strategy relies on lower-tier competitions.
Open/free alternatives are generally unsuitable for production in-play systems. Football-data.org and similar are useful for backtesting and pre-race strategy validation but their in-play latency is too unpredictable for live execution. I’ve used them for historical data analysis, never for live trading.
If your focus is horse racing — where Betfair’s in-play action is most active — the picture is different. There is no equivalent of Sportradar for racing. You’re working with Betfair’s own data plus the Racing Post / PA Sport feed for runners, going, and form, and the in-running signals come from the market itself.
This is the first real engineering problem. Sportradar calls a match “Man City v Arsenal, 2026-04-05”. Betfair calls it something that resolves to marketId: 1.234567890. Getting these to agree requires a lookup layer.
The Betfair API’s listMarketCatalogue is your starting point:
@Service
@RequiredArgsConstructor
public class MarketCorrelationService {
private final BetfairApiClient betfairClient;
// Key: "HOME_TEAM:AWAY_TEAM:DATE" -> Betfair marketId
private final Map<String, String> correlationCache = new ConcurrentHashMap<>();
public Optional<String> findMatchOddsMarketId(
String homeTeam, String awayTeam, LocalDate matchDate) {
String cacheKey = homeTeam + ":" + awayTeam + ":" + matchDate;
String cached = correlationCache.get(cacheKey);
if (cached != null) return Optional.of(cached);
MarketFilter filter = new MarketFilter();
filter.setEventTypeIds(Set.of("1")); // 1 = Soccer
filter.setMarketCountries(Set.of("GB"));
filter.setMarketTypeCodes(Set.of("MATCH_ODDS"));
TimeRange timeRange = new TimeRange();
timeRange.setFrom(matchDate.atStartOfDay().toInstant(ZoneOffset.UTC));
timeRange.setTo(matchDate.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC));
filter.setMarketStartTime(timeRange);
List<MarketCatalogue> markets = betfairClient.listMarketCatalogue(
filter,
List.of(MarketProjection.EVENT, MarketProjection.RUNNER_DESCRIPTION),
null, 20
);
return markets.stream()
.filter(m -> isMatchingEvent(m, homeTeam, awayTeam))
.map(MarketCatalogue::getMarketId)
.findFirst()
.map(id -> {
correlationCache.put(cacheKey, id);
return id;
});
}
private boolean isMatchingEvent(MarketCatalogue market, String home, String away) {
if (market.getEvent() == null) return false;
String eventName = market.getEvent().getName().toLowerCase();
return eventName.contains(home.toLowerCase())
&& eventName.contains(away.toLowerCase());
}
}
Build the correlation cache at startup — load all matches with marketStartTime in the next 24 hours and correlate them before the first in-play event fires. Doing this lazily during live trading adds unnecessary latency and failure modes.
Once you have the correlation, wire the sports data feed into an event pipeline. The pattern I use treats score events as commands that the strategy can react to:
public sealed interface SportEvent permits GoalEvent, CardEvent, MatchStatusEvent {}
public record GoalEvent(
String matchId,
String betfairMarketId,
String scoringTeam,
int homeScore,
int awayScore,
int minuteOfMatch,
Instant receivedAt
) implements SportEvent {}
public record CardEvent(
String matchId,
String betfairMarketId,
String team,
CardType cardType,
int minuteOfMatch,
Instant receivedAt
) implements SportEvent {}
public enum CardType { YELLOW, RED, SECOND_YELLOW }
Using sealed interfaces here is deliberate — the strategy engine can exhaustively switch over event types and the compiler will flag any unhandled case as you add new event types to the hierarchy. I introduced this pattern at Mosaic Smart Data for analytics event pipelines and it transfers cleanly to trading systems.
The Sportradar webhook or WebSocket callback populates these records and publishes them via an internal event bus:
@Component
@RequiredArgsConstructor
public class SportradarEventAdapter {
private final ApplicationEventPublisher eventPublisher;
private final MarketCorrelationService correlationService;
public void onSportEventReceived(SportradarPushEvent raw) {
String matchId = raw.getMatchId();
Optional<String> marketId = correlationService.getMarketIdForMatch(matchId);
if (marketId.isEmpty()) {
log.warn("No Betfair market found for Sportradar matchId={}", matchId);
return;
}
SportEvent event = switch (raw.getEventType()) {
case "score.change" -> new GoalEvent(
matchId, marketId.get(),
raw.getTeam(), raw.getHomeScore(), raw.getAwayScore(),
raw.getMinute(), Instant.now()
);
case "card" -> new CardEvent(
matchId, marketId.get(),
raw.getTeam(), CardType.valueOf(raw.getCardType().toUpperCase()),
raw.getMinute(), Instant.now()
);
default -> null;
};
if (event != null) eventPublisher.publishEvent(event);
}
}
The human input delay for sports data is typically 2–8 seconds. Sportradar push events for football goals arrive within that window. Your processing pipeline needs to be well inside 100ms — which is achievable in Java if you avoid blocking I/O on the critical path.
The danger is bet placement latency. When a goal event fires, the sequence is:
The market reprices continuously during steps 2 and 3. If you’re placing based on a price seen in step 2, you may get a worse fill or a rejection at step 3. The solution is to use persistenceType: LAPSE with a price limit that reflects your strategy’s acceptable slippage, not to try to outsmart the market on the same tick.
ProTip: Build a resilient sports data consumer. WebSocket feeds from data providers drop. Reconnect logic must be automatic, and you need a gap-fill mechanism. Subscribe to a REST polling endpoint as a fallback — lower frequency, but it keeps your state consistent during a reconnect. Log every event with a monotonic sequence number from the provider so you can detect and fill gaps when the stream resumes. I learned this the hard way: a silently dropped connection in the second half meant the system was trading on stale score state. The fix was a heartbeat check — if no event arrives within 30 seconds, assume the connection is dead and reconnect.
If you’re building live in-play trading systems and need an engineer who has worked through these integration patterns in production, get in touch.