Detect arbitrage
A 2-way arbitrage exists when the implied probabilities of opposite outcomes — across different bookmakers — sum to less than 1. The total potential profit margin is 1 - (1/p_home + 1/p_away) for a head-to-head market.
This recipe pairs head-to-head markets across bookmakers for the same match and surfaces opportunities as they appear.
Pair same-match markets across bookmakers
Section titled “Pair same-match markets across bookmakers”import { createClient, type Bookmaker, type Market, type SportEvent } from 'realtimeodds'
const client = createClient({ url: 'wss://api.realtimeodds.xyz', apiKey: process.env.REALTIMEODDS_API_KEY!})
// Bucket key for matching: (competition, date, normalised teams)function eventKey(ev: SportEvent): string { const date = ev.startDate?.toISODate() ?? 'unknown' const pair = ev.sport === 'tennis' ? [ev.competitor1, ev.competitor2].map(s => s.toLowerCase()).sort().join('|') : [ev.homeTeam, ev.awayTeam].map(s => s.toLowerCase()).sort().join('|') return `${ev.competition}::${date}::${pair}`}
function findHeadToHeadMarket(ev: SportEvent): Market | undefined { for (const m of ev.markets.values()) { // pick the full-match moneyline / 1X2 — adapt to your sport if (m.kind === 'market:basketball_match.moneyline' && m.period === 'full_match') return m if (m.kind === 'market:tennis_match.moneyline') return m if (m.kind === 'market:football_match.moneyline') return m } return undefined}
function checkArb(): void { // Group all events by (competition, date, teams) const grouped = new Map<string, SportEvent[]>() for (const ev of client.snapshot().sportEvents.values()) { const list = grouped.get(eventKey(ev)) ?? [] list.push(ev) grouped.set(eventKey(ev), list) }
for (const events of grouped.values()) { if (events.length < 2) continue // need at least two bookmakers
// For each pair of bookmakers, check the head-to-head spread for (let i = 0; i < events.length; i++) { for (let j = i + 1; j < events.length; j++) { const a = events[i], b = events[j] const ma = findHeadToHeadMarket(a) const mb = findHeadToHeadMarket(b) if (!ma || !mb) continue
// Take best price for each side across the two sources. // For 2-way (no draw): "home" / "away" (or "competitor1" / "competitor2") const priceFor = (m: Market, result: string) => { for (const s of m.selections.values()) { if (s.result === result) return s.quote?.price } return undefined }
const sideA = a.sport === 'tennis' ? 'competitor1' : 'home' const sideB = a.sport === 'tennis' ? 'competitor2' : 'away'
const bestA = Math.max(priceFor(ma, sideA) ?? 0, priceFor(mb, sideA) ?? 0) const bestB = Math.max(priceFor(ma, sideB) ?? 0, priceFor(mb, sideB) ?? 0) if (!bestA || !bestB) continue
const margin = 1 - (1 / bestA + 1 / bestB) if (margin > 0.005) { console.log( `[ARB] ${a.name}: ${a.bookmaker} ${bestA} / ${b.bookmaker} ${bestB} → margin ${(margin * 100).toFixed(2)}%` ) } } } }}
client.on('odds:changed', checkArb)client.on('sportEvent:added', checkArb)
await client.connect()Why exclude football moneylines without care
Section titled “Why exclude football moneylines without care”Football’s moneyline is 3-way (home / draw / away). The 2-way logic above will silently mis-treat it because it picks any home / away selection regardless of the third outcome. For 3-way arb you need three prices and the math is 1 - (1/h + 1/d + 1/a). Filter to 2-way markets explicitly:
function findHeadToHeadMarket(ev: SportEvent): Market | undefined { for (const m of ev.markets.values()) { if (m.selectionKind === 'home/away' || m.selectionKind === 'competitor1/competitor2') { return m } } return undefined}Considerations beyond the math
Section titled “Considerations beyond the math”- Liquidity: an arb at the top of a thin order book disappears on first execution. Use
quote.size(ororderBooklevels when available) to size your bets to actual depth. - Latency: by the time you’ve placed bet A, the price on B may have moved. Real arbitrage requires colocation, parallel order placement, and tight risk controls.
- Same-event matching is hard: bookmakers spell team names slightly differently (
"L.A. Lakers"vs"Los Angeles Lakers"). Maintain a normalisation table. - Stake limits: each bookmaker imposes per-bet and per-day limits. Track them.
Where to go next
Section titled “Where to go next”- Filtering & performance — how to keep the recompute cheap when running on every event.
- Multi-bookmaker — why same-match matching is non-trivial.