How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)

Backtest Betting Model - OddsPapi API Blog
How To Guides April 1, 2026

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 sportId and tournamentId to 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:

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.