Skip to content

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.

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
}
  • Liquidity: an arb at the top of a thin order book disappears on first execution. Use quote.size (or orderBook levels 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.