Available 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

  • Never assume price parity across exchanges. The same runner on the same race will have different back and lay prices on each exchange. Your order model must always record which exchange a bet was placed on, and your P&L calculations must use the correct commission rate per exchange.
  • Build a market registry. Each exchange uses a different market ID format (Betfair uses 1.XXXXXXXXX, Betdaq uses an integer, Smarkets uses a UUID). Maintain a mapping table that links the same real-world market across all three. Scraping this from exchange catalogue APIs 24 hours before race time works well.
  • Test each adapter with a small stake strategy before running live. The translation logic between your domain model and each exchange’s native API is where bugs hide. Run each adapter independently with a minimal strategy and verify that placed orders appear correctly in the exchange’s web UI.
  • Log raw exchange responses alongside your parsed model. When something goes wrong in production — and it will — having the raw API response lets you diagnose whether the bug is in your parsing, your translation, or the exchange’s data.

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

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 25+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.