How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)
Your Betting Model Looks Great on Paper. But Would It Actually Make Money?
You’ve built a model. It predicts match outcomes, finds value, maybe even spots arbitrage. But here’s the question you can’t answer without historical data: would it have been profitable over the last 500 games?
That’s backtesting — running your strategy against real past odds to measure actual performance before you risk real money. The problem? Most APIs charge $79+/month for historical data. OddsPapi includes it on the free tier — timestamped odds from 350+ bookmakers including sharps like Pinnacle, Singbet, and SBOBet.
This tutorial walks you through a complete Python backtesting pipeline: fetch historical odds, get match results, run three strategies, and calculate yield, ROI, and drawdown. Every line of code is tested against live data.
Why Backtest? (And Why Most Bettors Skip It)
Backtesting is standard practice in quant finance. You’d never deploy a trading algorithm without it. But in sports betting, most people skip straight to live betting because historical odds data is either expensive or hard to get.
A proper backtest tells you three things your model can’t: 1) Whether your edge survives the vig. 2) How bad the drawdowns get. 3) Whether you’re overfitting to noise. Without it, you’re just guessing with extra steps.
Historical Odds Data: Old Way vs OddsPapi
| Feature | Scraping / CSV Files | The Odds API | OddsPapi |
|---|---|---|---|
| Historical data | DIY — unreliable | $79/mo add-on | Free tier included |
| Bookmakers | 1-2 you scrape | ~40 | 350+ |
| Sharps included | No | No | Pinnacle, Singbet, SBOBet |
| Closing line data | Unreliable timestamps | Yes | Yes (timestamped snapshots) |
| Line movement history | No | Limited | Full opening → closing timeline |
| Authentication | N/A | Header-based | Simple query parameter |
The Backtest Pipeline: 6 Steps in Python
We’ll backtest three simple strategies on Premier League matches using Pinnacle closing odds as our benchmark. Here’s the full pipeline.
Step 1: Setup and Authentication
import requests
import pandas as pd
from datetime import datetime
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"
def api_get(endpoint, params=None):
"""Helper to make authenticated API calls."""
if params is None:
params = {}
params["apiKey"] = API_KEY
response = requests.get(f"{BASE_URL}/{endpoint}", params=params)
response.raise_for_status()
return response.json()
# Test authentication
sports = api_get("sports")
print(f"Connected — {len(sports)} sports available")
Step 2: Get Completed Fixtures
Fetch Premier League fixtures from the current season. We filter to matches that have already been played (statusId=2 means completed).
# Fetch Premier League fixtures (tournamentId=17)
fixtures = api_get("fixtures", {
"sportId": 10,
"tournamentId": 17,
"per_page": 100
})
# Filter to completed matches
completed = [f for f in fixtures if f.get("statusId") == 2]
print(f"Found {len(completed)} completed Premier League fixtures")
# Preview
for f in completed[:3]:
print(f" {f['participant1Name']} vs {f['participant2Name']} — {f['startTime'][:10]}")
Step 3: Get Match Results
For each completed fixture, fetch the final score and determine the outcome (home win, draw, or away win).
import time
def get_result(fixture_id):
"""Fetch score and return outcome: 'home', 'draw', or 'away'."""
try:
data = api_get("scores", {"fixtureId": fixture_id})
result = data["scores"]["periods"]["result"]
home = result["participant1Score"]
away = result["participant2Score"]
if home > away:
return "home"
elif home < away:
return "away"
else:
return "draw"
except Exception:
return None
# Collect results (respect rate limits)
results = {}
for f in completed:
fid = f["fixtureId"]
results[fid] = get_result(fid)
time.sleep(1) # Rate limit: ~1 request/second
print(f"Got results for {len([r for r in results.values() if r])} matches")
Step 4: Get Historical (Closing) Odds from Pinnacle
This is the core of the backtest. We fetch timestamped odds snapshots from Pinnacle and extract the pre-match closing line — the last price before kickoff. Pinnacle's closing line is the industry benchmark for market efficiency.
def get_closing_odds(fixture_id, start_time):
"""
Fetch historical odds and extract pre-match closing prices.
Returns dict: {'home': price, 'draw': price, 'away': price}
"""
try:
data = api_get("historical-odds", {
"fixtureId": fixture_id,
"bookmakers": "pinnacle",
"marketId": 101 # Full Time Result (1X2)
})
except Exception:
return None
markets = data["bookmakers"]["pinnacle"]["markets"]["101"]
kickoff = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
closing = {}
outcome_map = {"101": "home", "102": "draw", "103": "away"}
for outcome_id, label in outcome_map.items():
snapshots = markets["outcomes"][outcome_id]["players"]["0"]
# Filter to pre-match snapshots only
pre_match = [
s for s in snapshots
if datetime.fromisoformat(s["createdAt"]) < kickoff
]
if not pre_match:
return None
# Closing line = most recent pre-match snapshot
# Snapshots are ordered newest-first
closing[label] = pre_match[0]["price"]
return closing
# Collect closing odds for all completed fixtures
odds_data = {}
for f in completed:
fid = f["fixtureId"]
odds = get_closing_odds(fid, f["startTime"])
if odds:
odds_data[fid] = odds
time.sleep(5) # Historical endpoint has stricter rate limits
print(f"Got closing odds for {len(odds_data)} matches")
Step 5: Build a Backtest Dataset
Combine fixtures, results, and closing odds into a single DataFrame — the foundation for all our strategy tests.
# Build the dataset
rows = []
for f in completed:
fid = f["fixtureId"]
if fid in odds_data and results.get(fid):
odds = odds_data[fid]
rows.append({
"fixture_id": fid,
"home_team": f["participant1Name"],
"away_team": f["participant2Name"],
"date": f["startTime"][:10],
"result": results[fid],
"home_odds": odds["home"],
"draw_odds": odds["draw"],
"away_odds": odds["away"],
# Implied probabilities (removing vig)
"home_prob": 1 / odds["home"],
"draw_prob": 1 / odds["draw"],
"away_prob": 1 / odds["away"],
})
df = pd.DataFrame(rows)
print(f"Backtest dataset: {len(df)} matches")
print(df[["home_team", "away_team", "result", "home_odds", "draw_odds", "away_odds"]].head())
Step 6: Run Three Strategies
Now the fun part. We'll test three strategies and measure their performance with real metrics.
Strategy 1: Always Bet the Favourite
The simplest possible strategy — always bet on the outcome with the lowest odds (highest implied probability).
def backtest_favourite(df):
"""Bet 1 unit on the favourite (lowest odds) every match."""
bets = []
for _, row in df.iterrows():
# Find the favourite
outcomes = {"home": row["home_odds"], "draw": row["draw_odds"], "away": row["away_odds"]}
favourite = min(outcomes, key=outcomes.get)
odds = outcomes[favourite]
# Did it win?
won = row["result"] == favourite
profit = (odds - 1) if won else -1
bets.append({"match": f"{row['home_team']} vs {row['away_team']}",
"bet": favourite, "odds": odds, "won": won, "profit": profit})
return pd.DataFrame(bets)
fav_results = backtest_favourite(df)
total_staked = len(fav_results)
total_profit = fav_results["profit"].sum()
yield_pct = (total_profit / total_staked) * 100
hit_rate = fav_results["won"].mean() * 100
print(f"=== STRATEGY 1: Always Bet the Favourite ===")
print(f"Bets placed: {total_staked}")
print(f"Hit rate: {hit_rate:.1f}%")
print(f"Total profit: {total_profit:.2f} units")
print(f"Yield: {yield_pct:.2f}%")
print(f"Max drawdown: {fav_results['profit'].cumsum().min():.2f} units")
Strategy 2: Closing Line Value (CLV)
A smarter approach: only bet when your model's probability exceeds Pinnacle's implied probability. This is the gold standard for professional bettors. If you consistently beat the closing line, you have an edge.
def backtest_clv(df, model_edge=0.05):
"""
Simulate a model that's slightly better than the market.
Bet when your model's implied probability exceeds Pinnacle's by 'model_edge'.
In practice, replace this with YOUR model's predictions.
"""
bets = []
for _, row in df.iterrows():
outcomes = {
"home": (row["home_odds"], row["home_prob"]),
"draw": (row["draw_odds"], row["draw_prob"]),
"away": (row["away_odds"], row["away_prob"]),
}
for outcome, (odds, implied_prob) in outcomes.items():
# Simulate: your model thinks probability is 5% higher than market
# Replace this with your actual model predictions
model_prob = implied_prob * (1 + model_edge)
# Only bet if model probability > implied probability (value bet)
if model_prob > implied_prob and odds > 1.5: # Min odds filter
won = row["result"] == outcome
profit = (odds - 1) if won else -1
bets.append({
"match": f"{row['home_team']} vs {row['away_team']}",
"bet": outcome, "odds": odds,
"model_prob": model_prob, "market_prob": implied_prob,
"won": won, "profit": profit
})
return pd.DataFrame(bets)
clv_results = backtest_clv(df, model_edge=0.05)
if len(clv_results) > 0:
print(f"=== STRATEGY 2: Closing Line Value (5% Edge) ===")
print(f"Bets placed: {len(clv_results)}")
print(f"Hit rate: {clv_results['won'].mean() * 100:.1f}%")
print(f"Total profit: {clv_results['profit'].sum():.2f} units")
print(f"Yield: {(clv_results['profit'].sum() / len(clv_results)) * 100:.2f}%")
print(f"Avg odds: {clv_results['odds'].mean():.2f}")
Strategy 3: Fade the Public (Sharp vs Soft Line Divergence)
Compare Pinnacle (sharp) odds to a soft bookmaker like Bet365. When lines diverge significantly, the sharp book is usually right. This strategy bets with Pinnacle when Bet365 disagrees.
def get_closing_odds_multi(fixture_id, start_time, bookmakers="pinnacle,bet365"):
"""Fetch closing odds from multiple bookmakers for comparison."""
try:
data = api_get("historical-odds", {
"fixtureId": fixture_id,
"bookmakers": bookmakers,
"marketId": 101
})
except Exception:
return None
kickoff = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
result = {}
outcome_map = {"101": "home", "102": "draw", "103": "away"}
for bookie_slug, bookie_data in data["bookmakers"].items():
result[bookie_slug] = {}
markets = bookie_data["markets"]["101"]
for outcome_id, label in outcome_map.items():
snapshots = markets["outcomes"][outcome_id]["players"]["0"]
pre_match = [s for s in snapshots if datetime.fromisoformat(s["createdAt"]) < kickoff]
if pre_match:
result[bookie_slug][label] = pre_match[0]["price"]
return result
def backtest_fade_public(df, completed_fixtures, threshold=0.10):
"""
Bet when Bet365 (soft) and Pinnacle (sharp) disagree by > threshold.
Bet on the Pinnacle side — sharps are usually right.
"""
bets = []
for f in completed_fixtures:
fid = f["fixtureId"]
row = df[df["fixture_id"] == fid]
if row.empty:
continue
row = row.iloc[0]
multi_odds = get_closing_odds_multi(fid, f["startTime"])
time.sleep(5)
if not multi_odds or "pinnacle" not in multi_odds or "bet365" not in multi_odds:
continue
pin = multi_odds["pinnacle"]
b365 = multi_odds["bet365"]
for outcome in ["home", "draw", "away"]:
if outcome not in pin or outcome not in b365:
continue
pin_prob = 1 / pin[outcome]
b365_prob = 1 / b365[outcome]
# Pinnacle thinks it's MORE likely than Bet365 does
if pin_prob - b365_prob > threshold:
won = row["result"] == outcome
profit = (b365[outcome] - 1) if won else -1 # Bet at Bet365 price
bets.append({
"match": f"{row['home_team']} vs {row['away_team']}",
"bet": outcome,
"pinnacle_odds": pin[outcome],
"bet365_odds": b365[outcome],
"divergence": f"{(pin_prob - b365_prob)*100:.1f}%",
"won": won, "profit": profit
})
return pd.DataFrame(bets)
# Note: This strategy makes additional API calls per fixture
# fade_results = backtest_fade_public(df, completed, threshold=0.10)
Putting It All Together: Performance Summary
def print_summary(name, results_df):
"""Print a clean performance summary for any strategy."""
if results_df.empty:
print(f"{name}: No bets placed")
return
n = len(results_df)
profit = results_df["profit"].sum()
cumulative = results_df["profit"].cumsum()
print(f"\n{'='*50}")
print(f" {name}")
print(f"{'='*50}")
print(f" Bets placed: {n}")
print(f" Won: {results_df['won'].sum()} ({results_df['won'].mean()*100:.1f}%)")
print(f" Total profit: {profit:+.2f} units")
print(f" Yield: {(profit/n)*100:+.2f}%")
print(f" Best streak: {max_streak(results_df, True)} wins")
print(f" Worst streak: {max_streak(results_df, False)} losses")
print(f" Max drawdown: {(cumulative - cumulative.cummax()).min():.2f} units")
def max_streak(df, winning=True):
"""Calculate longest consecutive win or loss streak."""
streak = 0
max_s = 0
for won in df["won"]:
if won == winning:
streak += 1
max_s = max(max_s, streak)
else:
streak = 0
return max_s
print_summary("Always Bet the Favourite", fav_results)
print_summary("Closing Line Value (5% Edge)", clv_results)
5 Backtesting Mistakes That Will Blow Up Your Results
A backtest is only as good as its methodology. Here are the traps that catch even experienced quants:
| Mistake | What Happens | How to Avoid It |
|---|---|---|
| Look-ahead bias | You use closing odds to decide bets you'd have placed at opening | Only use data available at decision time. If you bet pre-match, use opening odds for selection and closing for settlement. |
| Overfitting | Strategy has 10 parameters tuned to past data, fails on new data | Keep strategies simple. If your edge disappears when you change one parameter by 5%, it's not real. |
| Survivorship bias | You only test markets where you found data, ignoring gaps | Include fixtures where odds were unavailable. A strategy that only works on 30% of matches is fragile. |
| Ignoring the vig | Your model shows +EV but the margin eats the edge | Always calculate yield after the vig. Pinnacle's 2-3% margin is the best case — soft books take 5-8%. |
| Sample size | 50-game backtest shows +20% yield, you bet the house | Minimum 500 bets for statistical significance. A 55% hit rate needs 1,000+ bets to confirm it's not luck. |
What Makes OddsPapi Different for Backtesting
| Feature | Why It Matters for Backtesting |
|---|---|
| 350+ bookmakers | Compare sharp vs soft lines across the full market — not just 40 books |
| Timestamped snapshots | See exact opening → closing line movement, not just final price |
| Sharp bookmakers | Pinnacle, Singbet, SBOBet closing lines — the true market benchmark |
| Free tier | Historical data included at no cost. Competitors charge $79+/month. |
| 59 sports | Backtest across soccer, NBA, MLB, esports, tennis — same API, same structure |
Next Steps: Take Your Backtest Further
This tutorial gives you the foundation. Here's where to go next:
- Add more bookmakers: Compare Pinnacle vs Bet365 vs DraftKings closing lines to find which soft books are slowest to adjust
- Test different sports: Change
sportIdandtournamentIdto backtest NBA (sportId=11), MLB (sportId=13), or NFL (sportId=14) - Plug in your model: Replace the simulated CLV strategy with your actual predictions
- Track line movement: Use the full snapshot history to measure opening-to-closing movement and find bookmakers that move last
Related guides:
- Prediction Market Accuracy: Backtest Polymarket vs Sportsbooks — Advanced backtest with Brier scores and calibration curves
- Bet365 Historical Odds Guide — Deep dive on Bet365 historical data sources
- How to Build an Arbitrage Betting Bot — Live arb scanner with Python
Frequently Asked Questions
Is historical odds data really free?
Yes. OddsPapi includes historical odds on the free tier — 250 requests/month with full access to timestamped snapshots from 350+ bookmakers. Competitors like The Odds API charge $79/month for historical data access.
How far back does historical data go?
Historical odds are available for completed fixtures within the current and previous seasons. The exact coverage depends on the sport and tournament — Premier League fixtures typically have data going back to the start of the current season.
Can I backtest player props and other markets?
Yes. Change the marketId parameter to target any supported market. For example, Over/Under 2.5 goals is marketId=1010, Asian Handicap -0.5 is marketId=1068. Each market has its own outcome structure.
What's the best bookmaker for closing line accuracy?
Pinnacle. Their closing line is the industry benchmark because they accept the highest limits from sharp bettors. If your model consistently beats Pinnacle's closing line, you have a genuine edge — regardless of whether individual bets win or lose.
How many bets do I need for a meaningful backtest?
At minimum 300-500 bets for basic signal detection. For strategies with lower hit rates (e.g., betting underdogs at 3.0+ odds), you need 1,000+ bets. A useful rule: if your yield changes by more than 5% when you remove 10% of the sample, your sample is too small.
Stop Guessing. Start Backtesting.
Every serious bettor backtests. The data is free, the code is above, and you can have your first backtest running in 15 minutes. Get your free API key and find out if your model actually works — before you find out the expensive way.