How Many Bookmakers Does Your Backtest Need? A 176-Book Study
The /historical-odds endpoint takes three bookmakers per call. Ask for four and it returns HTTP 400 with TOO_MANY_BOOKMAKERS. So most backtests get built the obvious way: Pinnacle, one sharp-ish book, one retail book, and whatever those three closed at becomes “the closing line”.
I wanted to know what that shortcut costs. So I pulled the full closing board for three Premier League fixtures played on 23 and 24 August 2026, then asked how far a 3-book consensus sits from a 176-book one. If you have not run a backtest against historical odds before, start with the walkthrough and come back to this once your pipeline works.
The short answer: a random 3-book closing line misses the full-board fair probability by 0.67 percentage points on average and 5.18pp at worst. Grade your bets against Pinnacle, Bet365 and DraftKings instead of the whole board and up to 9.7% of your closing-line verdicts flip. The curve flattens around 25 books, well past the 3 the endpoint hands you in one call.
How the study was built
Three fixtures, all finished: Manchester City v Bournemouth, Newcastle v Liverpool and Fulham v Chelsea. Finished fixtures return hasOdds: false from /fixtures because live prices get dropped, but their price history stays queryable, which is the whole reason this measurement is possible on a free key.
For each fixture I walked the entire bookmaker catalogue three slugs at a time, took the Full Time Result market (ID 101), and defined the closing price as the last snapshot before kick-off that still carried active: true. Then I de-vigged each book proportionally and averaged the fair probabilities.
| Fixture | Books with a closing 1X2 | Complete and active | Independent price tuples | Dedupe collapse |
|---|---|---|---|---|
| Man City v Bournemouth | 180 | 176 | 108 | 38.6% |
| Newcastle v Liverpool | 180 | 176 | 112 | 36.4% |
| Fulham v Chelsea | 179 | 175 | 104 | 40.6% |
Median snapshot depth ran 212 to 251 per book per fixture, and Kalshi alone logged 44,507 snapshots on one outcome. Pinnacle opened all three fixtures on 19 June, roughly nine weeks out. Retention runs deeper than one season, which is why a Bet365 price history from months ago is still queryable today.
The curve: error against book count
For each sample size I drew 600 random subsets per fixture, built the consensus, and measured the gap to the full-board number on the worst of the three outcomes.
| Books sampled | Mean error | 95th percentile | Within 0.5pp | Median independent feeds |
|---|---|---|---|---|
| 1 | 1.07pp | 2.71pp | 22% | 1.0 |
| 2 | 0.79pp | 1.74pp | 34% | 2.0 |
| 3 | 0.67pp | 1.50pp | 43% | 3.0 |
| 5 | 0.52pp | 1.31pp | 58% | 5.0 |
| 10 | 0.37pp | 0.84pp | 77% | 10.0 |
| 25 | 0.23pp | 0.47pp | 96% | 22.0 |
| 50 | 0.15pp | 0.30pp | 100% | 39.0 |
| 100 | 0.08pp | 0.17pp | 100% | 69.0 |
Read the fourth column first. At three books, 43% of your fixtures land within half a percentage point of the real consensus. At 25 books, 96% do. That is where the curve stops paying you back, and it costs nine calls rather than one.
The last column carries a second warning. Sample 50 slugs and you get 39 distinct prices, because 36% to 41% of the board is one feed wearing several brands. Sample 100 and you get 69. Your effective sample size stays smaller than your book count.
Which three books, if you only get three
Random sampling is the honest way to measure the penalty, but nobody picks books at random. So I tested the combinations you would reach for.
| Three-book set | Man City | Newcastle | Fulham |
|---|---|---|---|
| pinnacle + draftkings + fanduel | 0.21pp | 0.22pp | 0.35pp |
| draftkings + fanduel + betmgm | 0.19pp | 0.54pp | 0.60pp |
| pinnacle + bet365 + draftkings | 0.31pp | 0.27pp | 1.08pp |
| bet365 + draftkings + fanduel | 0.43pp | 0.58pp | 0.92pp |
| pinnacle + bet365 + fanduel | 0.37pp | 0.84pp | 1.29pp |
| pinnacle + kalshi + bet365 | 0.65pp | 0.78pp | 1.35pp |
Pinnacle with the two big US retail books lands at 0.21pp to 0.35pp, which matches a random draw of 25 books. Choosing well buys most of what breadth buys.
Swapping FanDuel for Bet365 costs you accuracy on all three fixtures, and on Fulham v Chelsea it triples the error. Bet365 closed that match at 3.90 / 3.70 / 1.86 for a 6.43% margin, 110th of 175 books, and it pulls the consensus toward the away side. A wide book does not average out against two tight ones. It drags.
What this does to your CLV numbers
Fair probability error is abstract. Bet grading is not. So I took every book’s closing price on each fixture and asked whether it beat the consensus, once against the full board and once against a 3-book proxy.
| Proxy closing line | Man City | Fulham | Newcastle |
|---|---|---|---|
| pinnacle + bet365 + draftkings | 51 of 528 (9.7%) | 40 of 525 (7.6%) | 19 of 528 (3.6%) |
| draftkings + fanduel + betmgm | 1 of 528 (0.2%) | 14 of 525 (2.7%) | 4 of 528 (0.8%) |
One in ten verdicts flipping is enough to turn a losing strategy into a winning one on paper. If you are measuring closing line value to decide whether a model is worth funding, the proxy you grade against matters as much as the model.
Line shopping is a different question with a different answer
Consensus needs breadth for accuracy. Best price needs breadth for money, and it converges far more slowly.
| Books sampled | Man City | Newcastle | Fulham |
|---|---|---|---|
| 1 | 85.7% | 85.0% | 83.9% |
| 3 | 89.5% | 86.8% | 87.4% |
| 10 | 92.8% | 88.6% | 89.7% |
| 25 | 94.2% | 89.1% | 92.0% |
| 50 | 95.6% | 94.1% | 95.8% |
| 100 | 99.0% | 97.6% | 97.7% |
Each cell is the median share of the best available price you capture on your worst outcome. Three books get you to roughly 88%. Fifty books get you past 94%. On Newcastle v Liverpool the best home price came from 1xbet at 4.24 against Pinnacle’s 3.81, a gap of 11.3%, and no amount of careful de-vigging recovers that if the book sits outside your pull.
Size the pull to the job. Twenty-five books settle the fair price. Line shopping keeps paying you well past a hundred.
Four traps that cost me books
1. One unavailable slug fails the whole batch
Batch three slugs and the call returns everything or nothing. On every fixture, 25 of my 77 batches failed because a single slug in them sat outside my plan. That cost 74 slugs per fixture until I retried them one at a time, which took each board from 139 books to 180. Retry failed batches individually and you recover 41 books for free.
2. Bet365 closes with active set to false
On Newcastle v Liverpool, Bet365’s final pre-kickoff snapshot landed at 15:11 with active: false against a 15:30 kick-off. Its last live price was 4.10 at 15:00, the same number. Take the last snapshot and demand active in one step and you drop Bet365 plus eight regional skins from that fixture. Walking back to the last active price recovered 11 books on Newcastle and 8 on Fulham v Chelsea, including Caesars and William Hill.
3. Roughly 40% of the board is duplicate feeds
On Newcastle v Liverpool, ten slugs closed at an identical 4.00 / 3.95 / 1.90, including atg.se, paf, svenskaspel and four Unibet regionals. Nine more shared 3.60 / 3.70 / 1.88 across the Entain brands. None of them carries a cloneOf flag. Dedupe on the price tuple before you average, or one trading desk votes ten times.
4. A margin screen trims the tail and does not fix the sample
Bovada closed Fulham v Chelsea at 2.15 / 3.90 / 1.444, a 41.4% margin and 8.70pp away from consensus on its own. Dropping every book above a 15% margin removes it, along with the three Betway slugs and bwin.fr. The gain is small: mean error at three books moves from 0.66pp to 0.63pp, and the worst case does not improve.
Screen anyway. One stale quote in a three-book sample moves your fair line by a full point. Filtering will not substitute for coverage.
The old way versus the OddsPapi way
| Scraping or a 3-book feed | OddsPapi /historical-odds |
|
|---|---|---|
| Books on a closing line | 3 to 10 | 176 usable on a Premier League fixture |
| Cost of history | Paid add-on at most providers | Free tier |
| Snapshot depth | Whatever you captured live | 212 to 251 per book, back to 19 June |
| Sharps and exchanges | Rarely included | Pinnacle, SBOBet, Betfair, SX Bet, Kalshi, Polymarket |
| Limits on the closing price | Not exposed | limit field on Pinnacle and exchanges |
| Backfill for a season you did not record | Impossible | Query it today |
Build it: a closing-line puller that survives the 3-book cap
Step 1: authenticate and find a played fixture
The API key is a query parameter, never a header.
import requests, time
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
# Premier League is tournamentId 17. Pick a window that has already been played.
r = requests.get(f"{BASE_URL}/fixtures", params={
"apiKey": API_KEY, "sportId": 10, "tournamentId": 17,
"from": "2026-08-22", "to": "2026-08-25",
})
for f in r.json():
print(f["fixtureId"], f["startTime"], f["statusName"])
Finished fixtures report hasOdds: false. Ignore it. That flag describes live prices, and the history is still there.
Step 2: pull the closing line, three slugs at a time
Three defences matter here. Honour the retryMs the API hands back on a 429, retry a failed batch slug by slug, and walk backwards to the last active snapshot.
FTR_MARKET = "101" # Full Time Result
OUTCOMES = {"101": "home", "102": "draw", "103": "away"}
def fetch(fixture_id, batch, tries=4):
"""One call, honouring the 429 retryMs the API hands back."""
for _ in range(tries):
r = requests.get(f"{BASE_URL}/historical-odds", params={
"apiKey": API_KEY, "fixtureId": fixture_id,
"bookmakers": ",".join(batch),
})
if r.status_code == 429:
wait = r.json()["error"].get("retryMs", 3000) / 1000
time.sleep(wait + 0.5)
continue
time.sleep(4.6) # cooldown between successful calls
return r
return r
def parse(payload, kickoff, board):
for slug, book in (payload.get("bookmakers") or {}).items():
market = (book.get("markets") or {}).get(FTR_MARKET)
if not market:
continue
prices = {}
for oid, label in OUTCOMES.items():
outcome = (market.get("outcomes") or {}).get(oid)
if not outcome:
continue
snaps = (outcome.get("players") or {}).get("0") or []
pre = [s for s in snaps if s["createdAt"] < kickoff]
live = [s for s in pre if s.get("active")]
if live: # walk back past a suspended close
prices[label] = live[-1]["price"]
if len(prices) == 3:
board[slug] = prices
def closing_line(fixture_id, kickoff, slugs):
board, unavailable = {}, []
for i in range(0, len(slugs), 3):
batch = slugs[i:i + 3]
r = fetch(fixture_id, batch)
if r.status_code == 200:
parse(r.json(), kickoff, board)
continue
# One unavailable slug fails the whole batch. Retry each slug alone.
for slug in batch:
single = fetch(fixture_id, [slug])
if single.status_code == 200:
parse(single.json(), kickoff, board)
else:
unavailable.append(slug)
return board, unavailable
Retry the slug once and give up. An early version of this called itself on the failed slug and blew the stack after 973 frames, because a book outside your plan fails every time you ask.
The /historical-odds cooldown runs near 4.5 seconds because the payloads are large. Three books on one Premier League fixture returned 30 MB. Sleep 4.6 seconds after each success and a full board runs with almost no 429s.
Step 3: dedupe, screen, then average
def fair_probabilities(board, max_margin=0.15):
unique = {}
for slug, prices in board.items():
tup = (prices["home"], prices["draw"], prices["away"])
margin = sum(1 / p for p in tup) - 1
if margin <= 0 or margin > max_margin:
continue # partial market, or a stale quote
unique[tup] = slug # identical feeds collapse to one vote
devigged = []
for tup in unique:
inv = [1 / p for p in tup]
total = sum(inv)
devigged.append([i / total for i in inv])
n = len(devigged)
return {label: sum(d[i] for d in devigged) / n
for i, label in enumerate(("home", "draw", "away"))}, n
The margin <= 0 guard is not decoration. A single book never offers you a free arbitrage, so a quote whose inverse prices sum below 1.0 is a partial market rather than an opportunity.
Proportional de-vigging is the simplest of the three common methods and it serves a consensus well, because averaging across dozens of books washes out most of the difference between them. To find the fair price of one book on its own, the power and Shin methods handle favourite-longshot bias better.
Step 4: run it on a real fixture
KICKOFF = "2026-08-24T19:00:00.000Z" # Fulham v Chelsea
FIXTURE = "id1000001772221172"
slugs = [b["slug"] for b in requests.get(
f"{BASE_URL}/bookmakers", params={"apiKey": API_KEY}).json()
if not b.get("cloneOf") and not b["slug"].startswith("pinnacle+")]
board, unavailable = closing_line(FIXTURE, KICKOFF, slugs)
fair, feeds = fair_probabilities(board)
print(f"{len(board)} books, {feeds} independent feeds")
print({k: round(100 * v, 2) for k, v in fair.items()})
Filtering out cloneOf slugs and the pinnacle+ test feeds before you start saves 120 wasted calls. Clone-flagged brands never ship their own payload, and the test feeds quote an identical price to Pinnacle, so they would count the sharpest book on the board three times.
On that fixture the full board returned 179 books, 104 independent price tuples, and a fair line of 25.89% home, 26.83% draw, 47.28% away. Pinnacle alone closed 3.86 / 3.59 / 2.029 at a 3.05% margin, which ranked it 23rd of 175 books. Twenty books priced it tighter. Eight of those are exchanges or prediction markets, led by sx.bet at 0.86% and kalshi at 1.01%, and another eight are the Unibet family quoting one shared price.
Run the same script against Pinnacle, DraftKings, FanDuel and BetMGM alone and it returns 25.91 / 27.12 / 46.96, which sits 0.29pp from the full board. Four chosen books get close. Four random ones do not.
Store the output rather than re-pulling it. A full board across a season moves a lot of bandwidth, and SQLite handles it well. To hand the same board to a spreadsheet or a teammate, export it to CSV instead.
So how many bookmakers do you need?
| What you are doing | Books | Calls | Why |
|---|---|---|---|
| Sanity-checking a model in development | 3, chosen deliberately | 1 | Pinnacle plus two tight retail books lands within 0.4pp |
| Grading CLV on real money | 25 | 9 | 96% of fixtures within 0.5pp, and verdict flips fall to noise |
| Publishing a fair-odds number | 50 | 17 | Every sampled fixture within 0.5pp |
| Line shopping or arbitrage | All of them | 60+ | Best price is still climbing at 100 books |
Three books is defensible when you pick them on purpose. Three random books gives you worse than a coin flip on landing within half a point of the market. The consensus-odds method is unchanged either way. Only the sample underneath it moves.
The reason most backtests stop at three is that history costs money almost everywhere else. It does not here. Get a free API key, point the script at a fixture your model already has an opinion about, and find out which side of 0.67pp you have been living on.
Frequently asked questions
How many bookmakers can I request from /historical-odds at once?
Three. A fourth returns HTTP 400 with the code TOO_MANY_BOOKMAKERS. Loop the call with different combinations and merge the responses.
Does a bigger sample of bookmakers actually change the closing line?
Yes, by about 0.67 percentage points on average between 3 books and 176, and by up to 5.18pp on a bad draw. The accuracy curve flattens near 25 books.
Why did my closing-line script return fewer books than expected?
Three likely causes. A batch containing one unavailable slug returns nothing at all, so retry failed batches individually. Some books post their final snapshot with active: false, so walk back to the last active price. And 36% to 41% of a soccer board is duplicate feeds that collapse on the price tuple.
Can I get historical odds for free?
Yes. OddsPapi ships /historical-odds on the free tier across 350+ bookmakers and 69 sports, including Pinnacle, SBOBet and the exchanges. Competitors charge for history or gate it behind an enterprise plan.
Which three bookmakers should I use if I can only pull three?
Pinnacle, DraftKings and FanDuel landed within 0.21pp to 0.35pp of the full-board consensus on all three fixtures measured. Sets containing Bet365 performed worse, reaching 1.35pp, because Bet365 closed at a 6.43% margin and skewed the average.
How long does a full-board historical pull take?
Around eight minutes per fixture. The endpoint cools down for roughly 4.5 seconds between calls and payloads reach 30 MB for three books, so about 60 calls covers a 180-book board.