Most betting models look good on the data they were built on. The test is how their picks compare with the closing market on matches the model has not seen. This guide backtests a small Elo model for the Premier League in Python against historical closing odds from the Odds-API.io v3 API. It reports three numbers for every strategy: hit rate, return on a 1 unit stake, and closing line value (CLV).
The script was run against the live API on 12 November 2026, and the output below is from that run. It covers 821 Premier League matches from September 2024 to October 2026. Ten bookmakers were requested, and 316 matches since December 2025 have closing odds from at least five of them.
If you want the live side of this, logging the price you take and grading it against the close later, read Value Bets API: Track Closing Line Value in Python. For pulling closing odds into pandas without a model, see Oddsportal API Alternative: Football Odds History in Python.
What you need
- Python 3.8 or newer and the official SDK:
pip install odds-api-io. - An API key on a paid plan, set as
ODDS_API_KEY. The bulk/v3/historical/closing-linesendpoint is paid. The per-event historical endpoints work on every plan. - The bookmakers you request must be selected on your key. Bookmaker names are case-sensitive, so use them exactly as
/v3/bookmakerslists them.
Step 1: Pull closing lines month by month
/v3/historical/closing-lines returns every settled match in a league and date range, with the final score and each bookmaker's last price before kick-off. One request can span up to 366 days and returns at most 100 matches, so a two-season window needs more than one call either way. The script loops over calendar months and caches each month to a JSON file, in a folder named after the league and bookmaker list. A rerun reads from disk, and when you extend the window only the new months hit the API. If a month was still in progress when you fetched it, delete that month's JSON file. The SDK's iter_closing_lines handles the 100-match pages.
Premier League closing odds in the archive start in December 2025, and results go back to September 2024. Matches before December come back with scores and an empty bookmakers object. That is useful here: the Elo ratings warm up on 15 months of results before the first bet.
Step 2: Build a fair closing price
A single bookmaker's close includes its margin. To get a fair price, the script takes every book with a full home, draw and away close, removes the margin from each one by scaling the implied probabilities to sum to 1, and averages the result. Matches need at least five books to count, and most of the 316 have seven to nine.
Before that it drops bad rows. In a few December 2025 matches one book's close sat far from every other book. In one, two books closed an away side at 7.00 while the other seven priced it between 1.53 and 1.60. Any book with a price more than 20% from the median for that outcome is removed from the match. Some removed prices may have been real, but a few bad ones can move an average a long way.
Step 3: The model
The model is a plain Elo rating. Every team starts at 1500, the home side gets 60 rating points, and ratings move by up to 20 points after each result. Sides promoted for 2025/26 and 2026/27 start at the average of the three lowest-rated sides from the season before, roughly where the relegated sides finished. Elo gives an expected score, so the script takes the draw rate seen so far and subtracts half of it to get a win probability for each side.
The betting rule: for each match from 8 December 2025, take the best closing price across the ten books for home and away. Pick the side where the model's probability times that price is higher. If that number is above 1.05, record a 1 unit bet on it. That is at most one bet per match. As a control, the script also backs every home side at the best price.
The script predicts every match on a date before it applies any result from that date, so no prediction uses a result from the same day. K, the home bonus and the 5% threshold were set before the first run and not changed to improve these results.
The script
import hashlib
import json
import math
import os
import statistics
from datetime import date
from itertools import groupby
from pathlib import Path
from odds_api import OddsAPIClient
LEAGUE = "england-premier-league"
BOOKS = ["Bet365", "Unibet", "William Hill", "Bwin", "888Sport",
"Paddy Power", "Ladbrokes", "Betway", "SingBet", "Sbobet"]
FIRST_MONTH, LAST_MONTH = date(2024, 9, 1), date(2026, 10, 1)
BET_FROM = "2025-12-08"
MIN_BOOKS = 5
MIN_EDGE = 0.05
MAX_GAP = 0.20
K, HOME_ADV = 20, 60
MARKETS = ["ML"]
SIDES = ("home", "draw", "away")
CACHE = Path("cache") / hashlib.sha1(json.dumps([LEAGUE, MARKETS, BOOKS]).encode()).hexdigest()[:10]
def month_windows(first, last):
d = first
while d <= last:
nxt = date(d.year + d.month // 12, d.month % 12 + 1, 1)
yield f"{d}T00:00:00Z", f"{date.fromordinal(nxt.toordinal() - 1)}T23:59:59Z"
d = nxt
def fetch_month(client, start, end):
path = CACHE / f"{start[:7]}.json"
if path.exists():
return json.loads(path.read_text())
rows = list(client.iter_closing_lines(
sport="football", leagues=[LEAGUE], from_=start, to=end,
markets=MARKETS, bookmakers=BOOKS))
CACHE.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(rows))
return rows
def final_score(event):
scores = event.get("scores") or {}
home, away = scores.get("home"), scores.get("away")
if isinstance(home, (int, float)) and isinstance(away, (int, float)):
return home, away
return None
def closing_prices(event):
prices = {}
for book, markets in (event.get("bookmakers") or {}).items():
for m in markets:
if m.get("name") == "ML" and m.get("odds"):
try:
p = {s: float(m["odds"][0][s]) for s in SIDES}
except (KeyError, TypeError, ValueError):
continue
if all(math.isfinite(v) and v > 1 for v in p.values()):
prices[book] = p
return drop_outliers(prices)
def drop_outliers(prices):
if len(prices) < 3:
return prices
median = {s: statistics.median(p[s] for p in prices.values()) for s in SIDES}
return {b: p for b, p in prices.items()
if all(abs(p[s] / median[s] - 1) <= MAX_GAP for s in SIDES)}
def fair_probs(prices):
devigged = []
for p in prices.values():
total = sum(1 / p[s] for s in SIDES)
devigged.append({s: (1 / p[s]) / total for s in SIDES})
return {s: sum(d[s] for d in devigged) / len(devigged) for s in SIDES}
def season_of(kickoff):
year, month = int(kickoff[:4]), int(kickoff[5:7])
return year if month >= 7 else year - 1
def elo_expected(r_home, r_away):
return 1 / (1 + 10 ** ((r_away - r_home - HOME_ADV) / 400))
def summarise(name, bets):
n = len(bets)
print(f"\n{name}")
if n < 2:
print(f" {n} bets, too few to summarise")
return
pnl = [b["odds"] - 1 if b["won"] else -1 for b in bets]
roi = sum(pnl) / n
half = 1.96 * statistics.stdev(pnl) / math.sqrt(n)
clv = [b["odds"] / b["fair_odds"] - 1 for b in bets]
wins = sum(b["won"] for b in bets)
print(f" bets {n}, won {wins} ({wins / n:.1%}), market expected {sum(1 / b['fair_odds'] for b in bets):.1f} wins")
print(f" average entry odds {sum(b['odds'] for b in bets) / n:.2f}")
print(f" return per 1 unit at best close {roi:+.1%} (approx. 95% range {roi - half:+.1%} to {roi + half:+.1%})")
b365 = [b for b in bets if b["b365"]]
if b365:
roi_b365 = sum(b["b365"] - 1 if b["won"] else -1 for b in b365) / len(b365)
print(f" return per 1 unit at Bet365 close {roi_b365:+.1%} ({len(b365)} bets)")
if "edge" in bets[0]:
print(f" edge the model claimed: average {sum(b['edge'] for b in bets) / n:+.1%}")
print(f" CLV vs fair close: average {statistics.mean(clv):+.2%} (approx. 95% range"
f" {statistics.mean(clv) - 1.96 * statistics.stdev(clv) / math.sqrt(n):+.2%} to"
f" {statistics.mean(clv) + 1.96 * statistics.stdev(clv) / math.sqrt(n):+.2%}),"
f" positive on {sum(c > 0 for c in clv) / n:.0%} of bets")
def main():
client = OddsAPIClient(api_key=os.environ["ODDS_API_KEY"])
events = []
for start, end in month_windows(FIRST_MONTH, LAST_MONTH):
events += fetch_month(client, start, end)
events = sorted((e for e in events if final_score(e)), key=lambda e: e["date"])
if not events:
print("no settled matches with scores")
return
ratings, results, newcomer_base, teams_by_season = {}, [], {}, {}
model_bets, home_bets, priced = [], [], 0
for _, day in groupby(events, key=lambda e: e["date"][:10]):
day = list(day)
draw_rate = results.count(0.5) / len(results) if results else 0.25
for e in day:
season = season_of(e["date"])
if season not in newcomer_base and season - 1 in teams_by_season:
previous = sorted(ratings[t] for t in teams_by_season[season - 1])
newcomer_base[season] = statistics.mean(previous[:3])
for team in (e["home"], e["away"]):
if team not in ratings:
ratings[team] = newcomer_base.get(season, 1500)
teams_by_season.setdefault(season, set()).add(team)
updates = []
for e in day:
exp_home = elo_expected(ratings[e["home"]], ratings[e["away"]])
hs, as_ = final_score(e)
outcome = "home" if hs > as_ else "away" if as_ > hs else "draw"
updates.append((e, exp_home, outcome))
prices = closing_prices(e)
if e["date"] < BET_FROM or len(prices) < MIN_BOOKS:
continue
priced += 1
clamped = min(max(exp_home, draw_rate / 2), 1 - draw_rate / 2)
model = {"home": clamped - draw_rate / 2, "away": 1 - clamped - draw_rate / 2}
fair = fair_probs(prices)
best = {s: max(p[s] for p in prices.values()) for s in ("home", "away")}
def bet(side):
return {"date": e["date"][:10], "match": f"{e['home']} v {e['away']}", "side": side,
"odds": best[side], "fair_odds": 1 / fair[side],
"b365": prices.get("Bet365", {}).get(side), "won": outcome == side}
home_bets.append(bet("home"))
edge = {s: model[s] * best[s] - 1 for s in ("home", "away")}
side = max(edge, key=edge.get)
if edge[side] > MIN_EDGE:
model_bets.append({**bet(side), "edge": edge[side]})
for e, exp_home, outcome in updates:
score = {"home": 1, "draw": 0.5, "away": 0}[outcome]
ratings[e["home"]] += K * (score - exp_home)
ratings[e["away"]] -= K * (score - exp_home)
results.append(score)
print(f"{len(events)} matches, {priced} from {BET_FROM} with closing odds from {MIN_BOOKS}+ books")
summarise("Elo model, bet when model edge > 5% at the best closing price", model_bets)
summarise("Baseline, back every home side at the best closing price", home_bets)
print("\nlast five model bets")
for b in model_bets[-5:]:
print(f" {b['date']} {b['match']:<40} {b['side']:<4} {b['odds']:.2f} vs fair {b['fair_odds']:.2f}"
f" {'won' if b['won'] else 'lost'}")
if __name__ == "__main__":
main()
Save it as backtest.py, set ODDS_API_KEY and run python3 backtest.py from a folder you can write to. This run used SDK version 1.2.2.
The output
821 matches, 316 from 2025-12-08 with closing odds from 5+ books
Elo model, bet when model edge > 5% at the best closing price
bets 232, won 61 (26.3%), market expected 70.1 wins
average entry odds 4.00
return per 1 unit at best close -16.6% (approx. 95% range -36.9% to +3.7%)
return per 1 unit at Bet365 close -19.9% (232 bets)
edge the model claimed: average +28.1%
CLV vs fair close: average -0.90% (approx. 95% range -1.30% to -0.51%), positive on 27% of bets
Baseline, back every home side at the best closing price
bets 316, won 122 (38.6%), market expected 136.1 wins
average entry odds 2.80
return per 1 unit at best close -15.1% (approx. 95% range -29.1% to -1.1%)
return per 1 unit at Bet365 close -17.7% (316 bets)
CLV vs fair close: average -2.33% (approx. 95% range -2.60% to -2.06%), positive on 13% of bets
last five model bets
2026-10-31 Coventry City v Sunderland AFC home 3.25 vs fair 3.16 won
2026-10-31 Hull City v Ipswich Town home 3.00 vs fair 3.09 lost
2026-10-31 Manchester City v Brighton & Hove Albion home 1.73 vs fair 1.80 lost
2026-10-31 Tottenham Hotspur v Crystal Palace away 5.10 vs fair 5.23 lost
2026-10-31 Aston Villa v Fulham FC home 2.01 vs fair 2.08 lost
Reading the results
The model claimed an average edge of 28.1% on its 232 bets. The results say it lost 16.6% per unit staked. CLV says the prices it took were 0.90% worse than the fair close on average.
If the closing market is right about the probabilities, the expected return of a bet at price P is P divided by the fair closing price, minus one. That is the CLV. So the closing market expected these 232 bets to return about -1%. The model expected +28.1%. The results came in at -16.6%, with 61 winners where the market expected 70.1.
The approximate 95% range on the result runs from -36.9% to +3.7%. That is too wide to tell a -1% strategy from a -15% one. The home-side baseline shows the same thing. It had CLV of -2.33% and returned -15.1%, with 122 home wins where the market expected 136.1.
A gap that large between the model's estimate and the market's points to a badly calibrated model. A naive Elo disagrees with the market on most matches, mostly by liking outsiders more than the books do. 216 of its 232 bets were on sides with a fair closing price above 2.00, and the average entry price was 4.00.
Why CLV matters more than short-run results
On the model's bets, the return per 1 unit bet had a standard deviation of about 1.6 units. Narrowing the 95% range on the return to plus or minus 2% would take about 24,000 bets like these, assuming each bet is independent.
CLV does not depend on who scored. Per bet it had a standard deviation of about 3%, against 158% for the return. On the same 232 bets, its 95% range is -1.30% to -0.51%, narrow enough to say the prices were worse than the fair close. That is the reason to track it. It still depends on the fair price being a good estimate, and on the sample being more than a few dozen bets.
Caveats
- Entry price. The archive stores closing prices, so this backtest assumes you bet at the close at the best of ten books. CLV here measures the gap between the best close and the average fair close. In live use you bet earlier and log the price you took, then compare it with the close in the same way.
- Which close. The fair price here is an average of five to ten books after margin removal. Grading against one book's close gives different numbers. Betting at the Bet365 close alone turned -16.6% into -19.9%, because the Bet365 prices on the same picks paid less than the best of ten.
- Margin removal. Scaling probabilities proportionally is the simplest method. It shrinks every implied probability by the same factor, while books tend to put more of their margin on outsiders. On a strategy that backs outsiders, a different method would move the fair prices and the CLV.
- Sample size. 316 matches in one league is a small test. The closing-lines endpoint takes up to 10 leagues per request, so adding leagues is the simplest way to get more bets.
- Data cleaning. Check the extremes before trusting any average. Before the outlier filter, a handful of bad December prices pushed the model's average CLV to +3.7%.
Next steps
Swap in your own model inside the loop. It needs a probability for home and away before each match, and the evaluation stays the same. For goals models, add Totals to the markets list and write the over and under probability and settlement for each line. For a bigger sample, run more league slugs from /v3/leagues?sport=football, one league at a time or with ratings and draw rates kept per league. The historical odds guide lists every parameter and limit, and the SDK guide covers the Python and Node.js clients.
I run Odds-API.io, so I'm biased toward the API used here. Originally published on the Odds-API.io blog.
Top comments (0)