How market making works on a betting exchange, what makes it different from financial markets, and how to implement a practical market making strategy in Java against the Betfair API.
Market making in financial markets means quoting a bid and an offer simultaneously, profiting from the spread when someone takes the other side of each. On a betting exchange it works the same way in principle: back at a high price (lay the bet mentally), lay at a low price (take the role of bookmaker), and earn the spread when both sides fill. The mechanics are different enough that financial market making literature doesn’t translate cleanly — the exchange’s two-sided structure, the overround, and the way liquidity forms around key prices all require a different approach.
This post covers what makes exchange market making viable, where it breaks down, and how to implement the core loop in Java against the Betfair API.
On Betfair, every market has a back side (backers who want to win if the outcome occurs) and a lay side (layers who accept liability if it does). Unlike a traditional bookmaker, Betfair doesn’t take positions itself — it matches bettors against each other and takes commission.
This creates a continuous order book. For a horse racing market at 10 minutes to post, you might see:
Back (want to back) Lay (willing to lay)
£420 @ 3.85 £310 @ 3.90
£180 @ 3.80 £540 @ 3.95
£900 @ 3.75 £220 @ 4.00
A market maker sits on both sides: laying at 3.85 (accepting liability if the horse wins) and backing at 3.90 (getting paid out if it wins). If both sides fill at £100, the maker earns roughly £4–5 on the spread depending on the commission rate and the final price. The risk: if the true probability shifts significantly between the two fills, one side is filled at a bad price.
No continuous two-sided quotes. Financial exchanges quote in microseconds. Betfair markets trade for hours before an event and then move violently in-play. A pre-race market-making strategy must account for the different liquidity regime: thick and slow before the off, thin and fast once racing starts.
Overround exists but is diffuse. A bookmaker’s margin is built in. On Betfair, the overround is whatever the market collectively implies — and it changes as money moves. A market maker doesn’t have a structural edge from overround; the edge comes from correctly estimating where the fair price is relative to the current spread.
Commission is per-matched-bet, not per-spread. Betfair charges 2–5% commission on net winnings per market. This has to be modelled explicitly — a strategy that earns 2% on the spread before commission is not profitable after a 5% commission rate.
In-play risk is asymmetric and fast. If you have open positions when the event starts, market depth collapses and spreads widen. Most pre-race market-making strategies hedge or close all positions before the off. Failing to do so is a common and expensive mistake.
The simplest viable market-making approach on Betfair is to fade short-term price moves — assuming prices revert to a fair value and that any short-term drift is noise rather than signal.
The strategy:
The critical parameter is how aggressively to hedge on partial fills. Hedging too eagerly turns every half-fill into a loss; not hedging enough creates directional exposure that market making wasn’t designed to carry.
public record MarketMakerState(
String selectionId,
double fairValue,
double backQuote,
double layQuote,
double backExposure,
double layExposure,
double netPosition,
Instant lastUpdated) {
public boolean hasOpenPosition() {
return Math.abs(netPosition) > 0.01;
}
public double spreadEarned() {
return layQuote - backQuote;
}
}
@Component
public class FairValueEstimator {
private static final double WOM_WEIGHT = 0.5;
private static final double LTP_WEIGHT = 0.3;
private static final double VWAP_WEIGHT = 0.2;
public double estimate(SelectionSnapshot snapshot) {
double womImplied = womanImpliedPrice(snapshot);
double ltpPrice = snapshot.lastTradedPrice();
double vwap = snapshot.volumeWeightedAvgPrice();
return (WOM_WEIGHT * womImplied)
+ (LTP_WEIGHT * ltpPrice)
+ (VWAP_WEIGHT * vwap);
}
private double womanImpliedPrice(SelectionSnapshot s) {
double totalBack = s.availableToBack().stream()
.mapToDouble(PriceSize::size).sum();
double totalLay = s.availableToLay().stream()
.mapToDouble(PriceSize::size).sum();
if (totalBack + totalLay == 0) return s.lastTradedPrice();
double womRatio = totalBack / (totalBack + totalLay);
// More back money → price should drift in, more lay money → price should drift out
return s.lastTradedPrice() * (1.0 + (0.5 - womRatio) * 0.05);
}
}
Weight of Money is a noisy signal on its own — large orders can be placed and cancelled to manipulate it (a practice known as steaming and spoofing). Blend it with LTP and VWAP rather than relying on it in isolation.
@Component
public class QuoteCalculator {
private static final double MIN_SPREAD = 0.02; // minimum tick-adjusted spread
private static final double TARGET_SPREAD = 0.05;
private static final double MAX_EXPOSURE = 50.0;
private static final double COMMISSION_RATE = 0.05;
public Quote calculate(double fairValue, MarketMakerState state) {
double halfSpread = Math.max(MIN_SPREAD, TARGET_SPREAD / 2.0);
double backQuote = roundToTick(fairValue + halfSpread);
double layQuote = roundToTick(fairValue - halfSpread);
// Commission must be earned by the spread
double netSpread = (layQuote - backQuote) * (1.0 - COMMISSION_RATE);
if (netSpread <= 0) {
return Quote.none();
}
double safeStake = Math.min(10.0, MAX_EXPOSURE - Math.abs(state.netPosition()));
return new Quote(backQuote, layQuote, safeStake);
}
private double roundToTick(double price) {
// Betfair tick increments vary by price range
if (price < 2.0) return Math.round(price / 0.01) * 0.01;
if (price < 3.0) return Math.round(price / 0.02) * 0.02;
if (price < 4.0) return Math.round(price / 0.05) * 0.05;
if (price < 6.0) return Math.round(price / 0.1) * 0.1;
if (price < 10.0) return Math.round(price / 0.2) * 0.2;
if (price < 20.0) return Math.round(price / 0.5) * 0.5;
return Math.round(price);
}
}
Tick rounding is non-negotiable — Betfair rejects orders at prices not on the valid tick ladder. The increment varies by price range: below 2.0 the increment is 0.01; above 10.0 it’s 0.5; above 20.0 it’s 1.0.
@Component
public class MarketMaker {
private static final Duration PRE_OFF_CLOSE_WINDOW = Duration.ofMinutes(2);
private final BetfairOrderClient orderClient;
private final FairValueEstimator fairValueEstimator;
private final QuoteCalculator quoteCalculator;
private final PositionHedger hedger;
public void onMarketUpdate(MarketSnapshot market) {
if (isWithinCloseWindow(market)) {
closeAllPositions(market);
return;
}
for (SelectionSnapshot selection : market.runners()) {
MarketMakerState state = getCurrentState(selection.id());
double fairValue = fairValueEstimator.estimate(selection);
Quote quote = quoteCalculator.calculate(fairValue, state);
if (quote.isNone()) continue;
cancelStaleQuotes(selection.id(), quote);
placeQuotes(selection.id(), quote, market.marketId());
hedgeIfNeeded(state, fairValue);
}
}
private boolean isWithinCloseWindow(MarketSnapshot market) {
return market.timeToOff().compareTo(PRE_OFF_CLOSE_WINDOW) < 0;
}
private void hedgeIfNeeded(MarketMakerState state, double fairValue) {
if (!state.hasOpenPosition()) return;
double priceMove = Math.abs(fairValue - state.fairValue());
if (priceMove > 0.10) {
hedger.hedge(state);
}
}
}
Cancel-and-replace is the most expensive part of the loop. Every cancelled order and every new order generates API calls, and Betfair’s API has rate limits. The practical approach is to cancel only quotes that are more than one tick away from the target price — avoid churning quotes on every update if the price hasn’t moved meaningfully.
private void cancelStaleQuotes(String selectionId, Quote newQuote) {
List<BetOrder> existing = activeOrders.getOrDefault(selectionId, List.of());
List<String> toCancel = existing.stream()
.filter(order -> isMeaningfullyStale(order, newQuote))
.map(BetOrder::betId)
.toList();
if (!toCancel.isEmpty()) {
orderClient.cancelOrders(toCancel);
}
}
private boolean isMeaningfullyStale(BetOrder order, Quote newQuote) {
double targetPrice = order.side() == Side.BACK
? newQuote.backPrice()
: newQuote.layPrice();
return Math.abs(order.price() - targetPrice) > 0.051; // more than one tick at typical prices
}
Market making without hard limits is not a strategy — it’s a path to a large loss. The minimum controls:
@Component
public class MarketMakerRiskController {
private static final double MAX_SELECTION_EXPOSURE = 100.0;
private static final double MAX_MARKET_EXPOSURE = 500.0;
private static final double MAX_DAILY_LOSS = 200.0;
public boolean allowNewQuote(String selectionId, String marketId, double stakeSize) {
if (selectionExposure(selectionId) + stakeSize > MAX_SELECTION_EXPOSURE) {
log.warn("Selection exposure limit reached for {}", selectionId);
return false;
}
if (marketExposure(marketId) + stakeSize > MAX_MARKET_EXPOSURE) {
log.warn("Market exposure limit reached for {}", marketId);
return false;
}
if (dailyLoss() > MAX_DAILY_LOSS) {
log.error("Daily loss limit reached — halting market making");
haltAll();
return false;
}
return true;
}
}
Hard-stop the strategy when the daily loss limit is breached. Don’t make this a soft warning — if you’re losing at a rate that triggers the daily limit, something is wrong with the fair value estimation or the market regime has changed, and continuing without human review will make it worse.
Informed flow. When a significant backer or layer with genuine information (an owner, a stable connection) enters a market, price moves persistently in one direction. Quoting against persistent moves is the market maker’s core risk. WOM-based fair value estimation doesn’t distinguish informed from noise — you need a flow filter that detects sustained directional pressure and pauses quoting.
Illiquid markets. Small-field or niche markets have wide spreads and infrequent matching. The commission wipes out the spread at lower volumes and the hedging options are poor. Market making works best in deep, liquid markets — big fields at major meetings.
Steam moves. A sudden, large, coordinated back or lay — a “steam” — moves a market fast. The strategy’s cancel-and-replace loop is unlikely to be fast enough to avoid being filled on the wrong side of a steam. Detection (sustained volume spike at a single price) and automatic pause are essential.
If you’re building a serious market-making or algorithmic trading framework for Betfair and want to get the architecture right, get in touch.