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-lines endpoint 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/bookmakers lists 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 hom...