Hire Me
← All Writing Betfair

Multi-Exchange Abstraction — Betfair, Betdaq, and Smarkets in One Framework

How to design a Java trading framework that targets Betfair, Betdaq, and Smarkets through a common abstraction — covering adapters, data normalisation, and unified order management.

Running a trading strategy on a single exchange leaves money on the table. Betfair has the deepest liquidity for UK horse racing, but Betdaq often offers better odds on the same selection at the same time, and Smarkets charges lower commission on winning bets. Once your strategy is profitable on one exchange, the natural next question is: can I express the same strategy across all three without rewriting it for each API? I spent a significant amount of time building exactly this — a framework where strategies are written once against an abstraction, and the exchange-specific implementation is a deployment-time configuration choice.

The Exchange Adapter Interface

The core of the design is an interface that every exchange adapter must implement. The interface models the operations all three exchanges share: subscribing to market data, reading the order book, placing orders, and querying positions.

public interface ExchangeAdapter {

    String exchangeId();

    // Market data
    void subscribeToMarket(String marketId, MarketDataHandler handler);
    void unsubscribeFromMarket(String marketId);

    // Order book (normalised across exchanges)
    Optional<MarketSnapshot> getMarketSnapshot(String marketId);

    // Order management
    PlaceOrderResult placeOrder(PlaceOrderRequest request);
    CancelOrderResult cancelOrder(String marketId, String betId);
    List<OpenOrder> getOpenOrders(String marketId);

    // Lifecycle
    void connect();
    void disconnect();
    boolean isConnected();
}

This is deliberately minimal. If you try to expose every exchange-specific capability through the interface, you end up with a leaky abstraction full of optional methods and exchange-specific flags. Start with the operations your strategies actually need.

Normalising Market Data

Each exchange represents the order book differently. Betfair uses decimal odds and structures the ladder as batb/batl arrays. Betdaq uses decimal odds but structures data differently in its API responses. Smarkets uses decimal odds with a separate REST API for market state.

The normalised model sits in the domain layer and knows nothing about any specific exchange:

public record MarketSnapshot(
    String marketId,
    String exchangeId,
    Instant capturedAt,
    List<RunnerSnapshot> runners
) {}

public record RunnerSnapshot(
    long selectionId,
    String runnerName,
    List<PriceLevel> backLadder,   // sorted: best (lowest) price first
    List<PriceLevel> layLadder,    // sorted: best (lowest) price first
    double tradedVolume,
    OptionalDouble lastTradedPrice
) {}

public record PriceLevel(double price, double size) {}

Each exchange adapter is responsible for translating its native representation into this model. The Betfair adapter maps batb arrays to PriceLevel lists. The Smarkets adapter converts its back_offers and lay_offers structures. Your strategy code never sees the raw exchange format.

@Component
public class BetfairAdapter implements ExchangeAdapter {

    @Override
    public Optional<MarketSnapshot> getMarketSnapshot(String marketId) {
        MarketState state = cache.getMarket(marketId).orElse(null);
        if (state == null) return Optional.empty();

        List<RunnerSnapshot> runners = state.getRunners().stream()
            .map(this::toRunnerSnapshot)
            .collect(toList());

        return Optional.of(new MarketSnapshot(
            marketId, exchangeId(), Instant.now(), runners));
    }

    private RunnerSnapshot toRunnerSnapshot(RunnerState r) {
        return new RunnerSnapshot(
            r.getSelectionId(),
            r.getRunnerName(),
            r.getBatb().stream()
                .map(level -> new PriceLevel(level.get(0), level.get(1)))
                .collect(toList()),
            r.getBatl().stream()
                .map(level -> new PriceLevel(level.get(0), level.get(1)))
                .collect(toList()),
            r.getTradedVolume(),
            r.getLastTradedPrice()
        );
    }
}

Unified Order Model

Placing an order requires normalising the request and translating the response. The key challenge is that bet IDs are exchange-specific strings — a Betfair bet ID cannot be used against Betdaq. The framework tracks which exchange a bet originated from:

public record PlaceOrderRequest(
    String marketId,
    long selectionId,
    Side side,         // BACK or LAY
    double price,
    double size,
    OrderType orderType  // LIMIT or LIMIT_ON_CLOSE
) {}

public record PlaceOrderResult(
    boolean success,
    String betId,       // exchange-native bet ID
    String exchangeId,  // which exchange this bet lives on
    String errorCode,
    double matchedSize,
    double unmatchedSize
) {}

The strategy layer constructs a PlaceOrderRequest and passes it to whichever adapter it wants to use. The adapters handle the exchange-specific API call:

@Component
public class SmarketsAdapter implements ExchangeAdapter {

    @Override
    public PlaceOrderResult placeOrder(PlaceOrderRequest request) {
        SmarketsOrderRequest smarketsReq = SmarketsOrderRequest.builder()
            .contractId(resolveContractId(request.marketId(), request.selectionId()))
            .side(request.side() == Side.BACK ? "buy" : "sell")
            .price(toSmarketsPrice(request.price()))  // Smarkets uses basis points
            .quantity(toQuantity(request.size()))
            .build();

        SmarketsOrderResponse response = smarketsClient.placeOrder(smarketsReq);

        return PlaceOrderResult.builder()
            .success(response.isAccepted())
            .betId(String.valueOf(response.getOrderId()))
            .exchangeId(exchangeId())
            .matchedSize(response.getMatchedQuantity())
            .unmatchedSize(response.getUnmatchedQuantity())
            .build();
    }
}

Note the toSmarketsPrice() call: Smarkets expresses prices in basis points (e.g., odds of 3.00 = 30000 basis points). This translation must be exact — a rounding error here is a direct financial loss.

Arbitrage Opportunity Detection

With normalised snapshots from multiple exchanges, detecting arbitrage between them becomes straightforward. A back-lay arb exists when the best back price on exchange A exceeds the best lay price on exchange B:

@Service
public class ArbitrageScanner {

    private final Map<String, ExchangeAdapter> adapters;
    private final double minEdgePercent;

    public List<ArbOpportunity> scan(String normalizedMarketId) {
        List<ArbOpportunity> opportunities = new ArrayList<>();

        // Build market snapshot from each exchange that has this market
        Map<String, MarketSnapshot> snapshots = adapters.entrySet().stream()
            .filter(e -> marketRegistry.knowsMarket(e.getKey(), normalizedMarketId))
            .collect(toMap(
                Map.Entry::getKey,
                e -> e.getValue().getMarketSnapshot(
                    marketRegistry.exchangeMarketId(e.getKey(), normalizedMarketId))
                    .orElseThrow()
            ));

        // Check all exchange pairs for each runner
        for (String selectionKey : getSharedSelections(snapshots)) {
            for (String backExchange : snapshots.keySet()) {
                for (String layExchange : snapshots.keySet()) {
                    if (backExchange.equals(layExchange)) continue;

                    double bestBack = getBestBackPrice(snapshots.get(backExchange), selectionKey);
                    double bestLay  = getBestLayPrice(snapshots.get(layExchange), selectionKey);

                    if (bestBack > 0 && bestLay > 0) {
                        double edgePercent = ((bestBack / bestLay) - 1.0) * 100.0;
                        if (edgePercent >= minEdgePercent) {
                            opportunities.add(new ArbOpportunity(
                                selectionKey, backExchange, layExchange,
                                bestBack, bestLay, edgePercent));
                        }
                    }
                }
            }
        }

        return opportunities;
    }
}

In practice, pure arbitrage at Betfair/Betdaq/Smarkets liquidity levels is rare and often illusory once you account for commission. The more useful application is comparative analysis — consistently finding one exchange offers better lay prices for your target selections and routing orders accordingly.

Deployment Architecture

Each adapter runs on a separate thread pool. Betfair’s streaming API is TCP-based and long-running. Betdaq and Smarkets are polled via HTTP. The framework uses a ScheduledExecutorService per adapter for polling, while the Betfair adapter’s streaming connection runs on its own dedicated thread:

@Configuration
public class AdapterConfig {

    @Bean
    public Map<String, ExchangeAdapter> exchangeAdapters(
            BetfairAdapter betfair,
            BetdaqAdapter betdaq,
            SmarketsAdapter smarkets) {

        Map<String, ExchangeAdapter> adapters = new LinkedHashMap<>();
        adapters.put("BETFAIR",  betfair);
        adapters.put("BETDAQ",   betdaq);
        adapters.put("SMARKETS", smarkets);
        return Collections.unmodifiableMap(adapters);
    }
}

Health checks run per adapter. If Betdaq’s API goes down, the framework marks that adapter as unavailable and routes order placement to Betfair only — degraded but not dead. This resilience pattern is essential: exchange APIs go down during peak racing periods, often exactly when you most want to be active.

ProTips

If you’re designing a multi-exchange trading framework and want to talk architecture, get in touch.