Sportsbook Arb Detection: Catch Sharp Traffic Before It Hits Your Book
Your trader sets the World Cup line. A sharp backs it within ninety seconds. By the time anyone notices, you’ve taken five figures at a price the rest of the market has already left behind. That’s not bad luck, it’s a structural blind spot: you can see your own prices, but you can’t see the other 350+ books your line is being arbitraged against.
Most “arbitrage” content is written for the bettor: how to find arbs and bet them. This guide flips it. We’ll build a Python scanner that watches your book from the sharp side of the table, comparing your quoted prices against a real-time consensus from 350+ bookmakers, and flags the exact outcome where you’ve become the soft leg in a market-wide arb. Every number below is pulled live from the OddsPapi API.
Why operators are blind to their own exposure
An arbitrage exists when the best available price on every outcome of a market, summed as implied probability, falls below 100%. When that happens, a bettor can stake all outcomes and lock a guaranteed profit no matter the result. The catch for an operator: one of those “best prices” is usually yours. You are the book quoting the outlier, and sharp money flows straight to it.
The reason this goes undetected is tooling. A trading desk watches a handful of competitor screens and its own ticket flow. It does not watch the full market in real time, and it definitely does not watch the sharp exchanges and Asian books where the smart line forms first. By the time a stale price shows up in your settled-bet report, the damage is done.
| The Old Way | With OddsPapi |
|---|---|
| Watch 4–5 competitor screens manually | Scan 350+ books in one JSON call |
| Find out you were arbed in the settlement report | Flag the soft leg in real time, before the next stake lands |
| No sharp benchmark, just gut feel on “is my price off?” | De-vig Pinnacle/sharp consensus as an objective fair line |
| Scrape competitor sites, fight bot defenses | One authenticated endpoint, structured prices |
| React after exposure builds | WebSocket-driven suspension triggers (push, not poll) |
This is the same data the arbitrage betting bot uses to attack books. We’re pointing it the other way: defense.
Step 1: Authenticate and pull the market grid
OddsPapi authenticates with an apiKey query parameter, not a header. Grab the live odds for a fixture and build a grid of every book’s price on each outcome. We filter on active so suspended or stale outcomes never poison the consensus.
import requests, statistics
API_KEY = "YOUR_API_KEY"
BASE = "https://api.oddspapi.io/v4"
SHARP = "pinnacle" # sharp anchor for the fair line
def fetch_grid(fixture_id, market_id="101", outcomes=("101", "102", "103")):
"""Return {slug: {outcome_id: price}} for active prices only."""
r = requests.get(f"{BASE}/odds",
params={"apiKey": API_KEY, "fixtureId": fixture_id})
books = r.json().get("bookmakerOdds", {})
grid = {}
for slug, data in books.items():
m = data.get("markets", {}).get(market_id)
if not m:
continue
prices = {}
for oc in outcomes:
node = m.get("outcomes", {}).get(oc)
if not node:
continue
p0 = node.get("players", {}).get("0")
if p0 and p0.get("active") and p0.get("price"):
prices[oc] = p0["price"]
if len(prices) == len(outcomes):
grid[slug] = prices
return grid
Market 101 is the soccer Full Time Result (1X2); outcomes 101/102/103 are home/draw/away. Don’t hardcode the rest, look them up live with /v4/markets?sportId=10 and build your own {marketId: marketName} map.
Step 2: De-vig the sharp line into a fair benchmark
You can’t judge whether your price is “off” without a reference for the true probability. Pinnacle’s margin is thin and its line is sharp, so we strip its vig with a simple proportional de-vig to get fair decimal odds per outcome.
def devig(prices):
"""Proportional de-vig -> fair decimal odds per outcome."""
inv = {oc: 1 / p for oc, p in prices.items()}
overround = sum(inv.values())
return {oc: overround / i for oc, i in inv.items()}, overround - 1
For deeper treatment of consensus fair value across the whole book set (not just one sharp), see the consensus odds calculator. A single sharp anchor is enough for a risk signal; the consensus is your validator when sharps disagree.
Step 3: The operator risk scan
Here’s the core. For every outcome, compare your quoted price against the sharp fair line and against the market-best price. When your price is both above the sharp fair value and the highest in the entire market, you are the leg a sharp will hammer.
def operator_risk_scan(grid, my_book, outcomes, names, vig_floor=0.02):
"""Flag where MY quoted price is exploitable vs the sharp fair line."""
fair, sharp_vig = devig(grid[SHARP])
mine = grid[my_book]
market_best = {oc: max(g[oc] for g in grid.values()) for oc in outcomes}
print(f"Sharp ({SHARP}) overround: {sharp_vig*100:.2f}%\n")
for oc in outcomes:
edge = mine[oc] / fair[oc] - 1 # the bettor's edge vs sharp fair
is_market_best = mine[oc] >= market_best[oc] - 1e-9
flag = ""
if edge > vig_floor and is_market_best:
flag = " <-- EXPLOITABLE: market-best leg, above sharp fair"
print(f"{names[oc]:>9}: you {mine[oc]:>6} | fair {fair[oc]:>6.3f} "
f"| mkt-best {market_best[oc]:>6} | bettor edge {edge*100:+6.2f}%{flag}")
Step 4: Run it on a live fixture
We ran this against Scotland v Brazil (World Cup, fixture id1000001666456936), pulling 16 books with a complete 1X2 market. We cast HardRock Bet as the operator, since it happened to be holding the longest price on the home side.
fid = "id1000001666456936" # Scotland v Brazil
names = {"101": "Scotland", "102": "Draw", "103": "Brazil"}
grid = fetch_grid(fid)
operator_risk_scan(grid, my_book="hardrockbet",
outcomes=("101", "102", "103"), names=names)
Live output:
books with full 1X2: 16
Sharp (pinnacle) overround: 4.47%
Scotland: you 8.5 | fair 7.062 | mkt-best 8.5 | bettor edge +20.36% <-- EXPLOITABLE: market-best leg, above sharp fair
Draw: you 5.25 | fair 5.600 | mkt-best 5.556 | bettor edge -6.25%
Brazil: you 1.364 | fair 1.471 | mkt-best 1.364 | bettor edge -7.27%
The signal is unambiguous. Pinnacle’s de-vigged fair price on Scotland is 7.06. The operator is quoting 8.50, which is the single best price in the entire market and sits 20.36% above the sharp fair value. Every sharp running a line-shopping scan sees this price and routes their Scotland stake here. The Draw and Brazil legs are safely below fair, so they attract no sharp interest. The exposure is concentrated entirely on one outcome, and now you know which one, while you can still move it.
Step 5: Confirm the arb is real, market-wide
The risk scan tells you you’re the soft leg. The market-wide arb sum confirms a bettor can actually lock a profit using your price. Sum the inverse of the best available price on every outcome; below 1.0 means a guaranteed-profit arb exists.
def market_arb_sum(grid, outcomes):
best = {oc: max(g[oc] for g in grid.values()) for oc in outcomes}
inv = sum(1 / best[oc] for oc in outcomes)
verdict = f"ARB {(1/inv - 1)*100:+.2f}%" if inv < 1 else "no arb"
print(f"Best-price inverse sum: {inv:.4f} ({verdict})")
# Best-price inverse sum: 0.9974 (ARB +0.26%)
The inverse sum is 0.9974, a live 0.26% arb. Concretely, a bettor splitting a 1,000 bankroll backs Scotland @ 8.50 (with you), the Draw @ 5.556, and Brazil @ 1.429, staking 117.95 / 180.45 / 701.60. Whatever the result, the return is 1,002.58, a risk-free 0.26%. Your 8.50 on Scotland is the leg that makes the whole arb solvent. Pull it, and the arb collapses.
| Outcome | Your price | Sharp fair | Bettor edge | Action |
|---|---|---|---|---|
| Scotland | 8.50 | 7.06 | +20.36% | Suspend / trim to ~7.2 |
| Draw | 5.25 | 5.60 | −6.25% | Hold |
| Brazil | 1.364 | 1.471 | −7.27% | Hold |
From signal to action: automate the suspension trigger
A scan you run by hand is a report, not a defense. Wire it into a loop over your live slate and fire a suspension trigger the moment an outcome flips to exploitable. Because OddsPapi pushes updates over WebSocket rather than forcing you to poll, you can react to the move that creates the exposure instead of discovering it a cycle later.
def scan_slate(fixtures, my_book, market_id="101",
outcomes=("101", "102", "103"), names=None):
alerts = []
for fid in fixtures:
grid = fetch_grid(fid, market_id, outcomes)
if my_book not in grid or SHARP not in grid:
continue
fair, _ = devig(grid[SHARP])
best = {oc: max(g[oc] for g in grid.values()) for oc in outcomes}
for oc in outcomes:
mine = grid[my_book][oc]
if mine / fair[oc] - 1 > 0.02 and mine >= best[oc] - 1e-9:
alerts.append((fid, names[oc] if names else oc,
mine, round(fair[oc], 3)))
return alerts # feed each alert straight into your suspension queue
Same idea as a bettor's line-shopping scan, inverted into a liability monitor. Pair it with a post-hoc closing-line-value audit to measure how often your desk was the soft leg over a week, and free historical odds give you the full price trail to reconstruct exactly when each line drifted out of range, an audit trail you don't have to pay extra for.
Why 350+ books matters here specifically
A consensus built from four soft competitors will tell you you're fine right up until a sharp Asian book or an exchange moves and you don't see it. The arb above only surfaced because the book set included Pinnacle, SBOBet, Kalshi and Polymarket alongside the US retail books. The sharp and exchange prices are where the true line forms; the soft books, including possibly yours, lag it. Detecting your exposure requires watching the books that move first, which is the entire point of aggregating 350+ of them through one feed. For the architecture behind a real-time operator pipeline, see the WebSocket-first pricing pipeline.
Stop finding out in the settlement report
The bettors arbing your book already have this data. A real-time consensus from 350+ bookmakers, a sharp fair benchmark, and free historical odds for the audit trail close the gap, on a free tier you can test today. Get your free OddsPapi API key and point the scan at your own lines.
Frequently Asked Questions
How is this different from an arbitrage betting bot?
An arb bot scans for arbs and bets them, the bettor's side. This scanner runs the same math from the operator's side: it identifies when your quoted price is the soft leg that makes a market-wide arb solvent, so you can suspend or reprice before sharp money floods in.
Which bookmaker should I use as the sharp anchor?
Pinnacle is the default because its margin is low and its line is sharp. SBOBet and Singbet are strong alternatives, especially on Asian Handicap markets. When sharps disagree, fall back to a de-vigged consensus median across the full book set as your validator.
Does the 2% edge threshold need tuning?
Yes. The vig_floor of 0.02 is a starting point. Tighten it for low-margin markets where even a small overshoot draws sharp traffic, and loosen it on high-margin props where you carry more cushion. Calibrate it against your own settled-bet data.
Can I run this on markets other than soccer 1X2?
Yes. Pass any market_id and its outcome IDs. Look them up live via /v4/markets?sportId=X for moneylines, totals, spreads and player props across all 69 sports in the catalog, then feed them into the same scan.
How fresh is the data?
OddsPapi pushes updates over WebSocket rather than relying on polling, so you react to the price move that creates your exposure in near real time instead of catching it on the next poll cycle. Each outcome also carries changedAt timestamps so you can detect and discount stale prices.