{"id":2552,"date":"2026-04-01T10:00:00","date_gmt":"2026-04-01T10:00:00","guid":{"rendered":"https:\/\/oddspapi.io\/blog\/?p=2552"},"modified":"2026-03-25T13:27:04","modified_gmt":"2026-03-25T13:27:04","slug":"backtest-betting-model-free-historical-odds","status":"publish","type":"post","link":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/","title":{"rendered":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)"},"content":{"rendered":"<h2>Your Betting Model Looks Great on Paper. But Would It Actually Make Money?<\/h2>\n<p>You&#8217;ve built a model. It predicts match outcomes, finds value, maybe even spots arbitrage. But here&#8217;s the question you can&#8217;t answer without historical data: <strong>would it have been profitable over the last 500 games?<\/strong><\/p>\n<p>That&#8217;s backtesting \u2014 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 <strong>free tier<\/strong> \u2014 timestamped odds from 350+ bookmakers including sharps like Pinnacle, Singbet, and SBOBet.<\/p>\n<p>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.<\/p>\n<h2>Why Backtest? (And Why Most Bettors Skip It)<\/h2>\n<p>Backtesting is standard practice in quant finance. You&#8217;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.<\/p>\n<p>A proper backtest tells you three things your model can&#8217;t: <strong>1)<\/strong> Whether your edge survives the vig. <strong>2)<\/strong> How bad the drawdowns get. <strong>3)<\/strong> Whether you&#8217;re overfitting to noise. Without it, you&#8217;re just guessing with extra steps.<\/p>\n<h2>Historical Odds Data: Old Way vs OddsPapi<\/h2>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Feature<\/th>\n<th>Scraping \/ CSV Files<\/th>\n<th>The Odds API<\/th>\n<th>OddsPapi<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>Historical data<\/strong><\/td>\n<td>DIY \u2014 unreliable<\/td>\n<td>$79\/mo add-on<\/td>\n<td>Free tier included<\/td>\n<\/tr>\n<tr>\n<td><strong>Bookmakers<\/strong><\/td>\n<td>1-2 you scrape<\/td>\n<td>~40<\/td>\n<td>350+<\/td>\n<\/tr>\n<tr>\n<td><strong>Sharps included<\/strong><\/td>\n<td>No<\/td>\n<td>No<\/td>\n<td>Pinnacle, Singbet, SBOBet<\/td>\n<\/tr>\n<tr>\n<td><strong>Closing line data<\/strong><\/td>\n<td>Unreliable timestamps<\/td>\n<td>Yes<\/td>\n<td>Yes (timestamped snapshots)<\/td>\n<\/tr>\n<tr>\n<td><strong>Line movement history<\/strong><\/td>\n<td>No<\/td>\n<td>Limited<\/td>\n<td>Full opening \u2192 closing timeline<\/td>\n<\/tr>\n<tr>\n<td><strong>Authentication<\/strong><\/td>\n<td>N\/A<\/td>\n<td>Header-based<\/td>\n<td>Simple query parameter<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>The Backtest Pipeline: 6 Steps in Python<\/h2>\n<p>We&#8217;ll backtest three simple strategies on Premier League matches using Pinnacle closing odds as our benchmark. Here&#8217;s the full pipeline.<\/p>\n<h3>Step 1: Setup and Authentication<\/h3>\n<pre class=\"wp-block-code\"><code>import requests\nimport pandas as pd\nfrom datetime import datetime\n\nAPI_KEY = \"YOUR_API_KEY\"\nBASE_URL = \"https:\/\/api.oddspapi.io\/v4\"\n\ndef api_get(endpoint, params=None):\n    \"\"\"Helper to make authenticated API calls.\"\"\"\n    if params is None:\n        params = {}\n    params[\"apiKey\"] = API_KEY\n    response = requests.get(f\"{BASE_URL}\/{endpoint}\", params=params)\n    response.raise_for_status()\n    return response.json()\n\n# Test authentication\nsports = api_get(\"sports\")\nprint(f\"Connected \u2014 {len(sports)} sports available\")<\/code><\/pre>\n<h3>Step 2: Get Completed Fixtures<\/h3>\n<p>Fetch Premier League fixtures from the current season. We filter to matches that have already been played (<code>statusId=2<\/code> means completed).<\/p>\n<pre class=\"wp-block-code\"><code># Fetch Premier League fixtures (tournamentId=17)\nfixtures = api_get(\"fixtures\", {\n    \"sportId\": 10,\n    \"tournamentId\": 17,\n    \"per_page\": 100\n})\n\n# Filter to completed matches\ncompleted = [f for f in fixtures if f.get(\"statusId\") == 2]\nprint(f\"Found {len(completed)} completed Premier League fixtures\")\n\n# Preview\nfor f in completed[:3]:\n    print(f\"  {f['participant1Name']} vs {f['participant2Name']} \u2014 {f['startTime'][:10]}\")<\/code><\/pre>\n<h3>Step 3: Get Match Results<\/h3>\n<p>For each completed fixture, fetch the final score and determine the outcome (home win, draw, or away win).<\/p>\n<pre class=\"wp-block-code\"><code>import time\n\ndef get_result(fixture_id):\n    \"\"\"Fetch score and return outcome: 'home', 'draw', or 'away'.\"\"\"\n    try:\n        data = api_get(\"scores\", {\"fixtureId\": fixture_id})\n        result = data[\"scores\"][\"periods\"][\"result\"]\n        home = result[\"participant1Score\"]\n        away = result[\"participant2Score\"]\n        if home > away:\n            return \"home\"\n        elif home < away:\n            return \"away\"\n        else:\n            return \"draw\"\n    except Exception:\n        return None\n\n# Collect results (respect rate limits)\nresults = {}\nfor f in completed:\n    fid = f[\"fixtureId\"]\n    results[fid] = get_result(fid)\n    time.sleep(1)  # Rate limit: ~1 request\/second\n\nprint(f\"Got results for {len([r for r in results.values() if r])} matches\")<\/code><\/pre>\n<h3>Step 4: Get Historical (Closing) Odds from Pinnacle<\/h3>\n<p>This is the core of the backtest. We fetch timestamped odds snapshots from Pinnacle and extract the <strong>pre-match closing line<\/strong> \u2014 the last price before kickoff. Pinnacle's closing line is the industry benchmark for market efficiency.<\/p>\n<pre class=\"wp-block-code\"><code>def get_closing_odds(fixture_id, start_time):\n    \"\"\"\n    Fetch historical odds and extract pre-match closing prices.\n    Returns dict: {'home': price, 'draw': price, 'away': price}\n    \"\"\"\n    try:\n        data = api_get(\"historical-odds\", {\n            \"fixtureId\": fixture_id,\n            \"bookmakers\": \"pinnacle\",\n            \"marketId\": 101  # Full Time Result (1X2)\n        })\n    except Exception:\n        return None\n\n    markets = data[\"bookmakers\"][\"pinnacle\"][\"markets\"][\"101\"]\n    kickoff = datetime.fromisoformat(start_time.replace(\"Z\", \"+00:00\"))\n\n    closing = {}\n    outcome_map = {\"101\": \"home\", \"102\": \"draw\", \"103\": \"away\"}\n\n    for outcome_id, label in outcome_map.items():\n        snapshots = markets[\"outcomes\"][outcome_id][\"players\"][\"0\"]\n\n        # Filter to pre-match snapshots only\n        pre_match = [\n            s for s in snapshots\n            if datetime.fromisoformat(s[\"createdAt\"]) < kickoff\n        ]\n\n        if not pre_match:\n            return None\n\n        # Closing line = most recent pre-match snapshot\n        # Snapshots are ordered newest-first\n        closing[label] = pre_match[0][\"price\"]\n\n    return closing\n\n# Collect closing odds for all completed fixtures\nodds_data = {}\nfor f in completed:\n    fid = f[\"fixtureId\"]\n    odds = get_closing_odds(fid, f[\"startTime\"])\n    if odds:\n        odds_data[fid] = odds\n    time.sleep(5)  # Historical endpoint has stricter rate limits\n\nprint(f\"Got closing odds for {len(odds_data)} matches\")<\/code><\/pre>\n<h3>Step 5: Build a Backtest Dataset<\/h3>\n<p>Combine fixtures, results, and closing odds into a single DataFrame \u2014 the foundation for all our strategy tests.<\/p>\n<pre class=\"wp-block-code\"><code># Build the dataset\nrows = []\nfor f in completed:\n    fid = f[\"fixtureId\"]\n    if fid in odds_data and results.get(fid):\n        odds = odds_data[fid]\n        rows.append({\n            \"fixture_id\": fid,\n            \"home_team\": f[\"participant1Name\"],\n            \"away_team\": f[\"participant2Name\"],\n            \"date\": f[\"startTime\"][:10],\n            \"result\": results[fid],\n            \"home_odds\": odds[\"home\"],\n            \"draw_odds\": odds[\"draw\"],\n            \"away_odds\": odds[\"away\"],\n            # Implied probabilities (removing vig)\n            \"home_prob\": 1 \/ odds[\"home\"],\n            \"draw_prob\": 1 \/ odds[\"draw\"],\n            \"away_prob\": 1 \/ odds[\"away\"],\n        })\n\ndf = pd.DataFrame(rows)\nprint(f\"Backtest dataset: {len(df)} matches\")\nprint(df[[\"home_team\", \"away_team\", \"result\", \"home_odds\", \"draw_odds\", \"away_odds\"]].head())<\/code><\/pre>\n<h3>Step 6: Run Three Strategies<\/h3>\n<p>Now the fun part. We'll test three strategies and measure their performance with real metrics.<\/p>\n<h3>Strategy 1: Always Bet the Favourite<\/h3>\n<p>The simplest possible strategy \u2014 always bet on the outcome with the lowest odds (highest implied probability).<\/p>\n<pre class=\"wp-block-code\"><code>def backtest_favourite(df):\n    \"\"\"Bet 1 unit on the favourite (lowest odds) every match.\"\"\"\n    bets = []\n    for _, row in df.iterrows():\n        # Find the favourite\n        outcomes = {\"home\": row[\"home_odds\"], \"draw\": row[\"draw_odds\"], \"away\": row[\"away_odds\"]}\n        favourite = min(outcomes, key=outcomes.get)\n        odds = outcomes[favourite]\n\n        # Did it win?\n        won = row[\"result\"] == favourite\n        profit = (odds - 1) if won else -1\n        bets.append({\"match\": f\"{row['home_team']} vs {row['away_team']}\",\n                      \"bet\": favourite, \"odds\": odds, \"won\": won, \"profit\": profit})\n\n    return pd.DataFrame(bets)\n\nfav_results = backtest_favourite(df)\ntotal_staked = len(fav_results)\ntotal_profit = fav_results[\"profit\"].sum()\nyield_pct = (total_profit \/ total_staked) * 100\nhit_rate = fav_results[\"won\"].mean() * 100\n\nprint(f\"=== STRATEGY 1: Always Bet the Favourite ===\")\nprint(f\"Bets placed:  {total_staked}\")\nprint(f\"Hit rate:     {hit_rate:.1f}%\")\nprint(f\"Total profit: {total_profit:.2f} units\")\nprint(f\"Yield:        {yield_pct:.2f}%\")\nprint(f\"Max drawdown: {fav_results['profit'].cumsum().min():.2f} units\")<\/code><\/pre>\n<h3>Strategy 2: Closing Line Value (CLV)<\/h3>\n<p>A smarter approach: only bet when <strong>your model's probability exceeds Pinnacle's implied probability<\/strong>. This is the gold standard for professional bettors. If you consistently beat the closing line, you have an edge.<\/p>\n<pre class=\"wp-block-code\"><code>def backtest_clv(df, model_edge=0.05):\n    \"\"\"\n    Simulate a model that's slightly better than the market.\n    Bet when your model's implied probability exceeds Pinnacle's by 'model_edge'.\n\n    In practice, replace this with YOUR model's predictions.\n    \"\"\"\n    bets = []\n    for _, row in df.iterrows():\n        outcomes = {\n            \"home\": (row[\"home_odds\"], row[\"home_prob\"]),\n            \"draw\": (row[\"draw_odds\"], row[\"draw_prob\"]),\n            \"away\": (row[\"away_odds\"], row[\"away_prob\"]),\n        }\n\n        for outcome, (odds, implied_prob) in outcomes.items():\n            # Simulate: your model thinks probability is 5% higher than market\n            # Replace this with your actual model predictions\n            model_prob = implied_prob * (1 + model_edge)\n\n            # Only bet if model probability > implied probability (value bet)\n            if model_prob > implied_prob and odds > 1.5:  # Min odds filter\n                won = row[\"result\"] == outcome\n                profit = (odds - 1) if won else -1\n                bets.append({\n                    \"match\": f\"{row['home_team']} vs {row['away_team']}\",\n                    \"bet\": outcome, \"odds\": odds,\n                    \"model_prob\": model_prob, \"market_prob\": implied_prob,\n                    \"won\": won, \"profit\": profit\n                })\n\n    return pd.DataFrame(bets)\n\nclv_results = backtest_clv(df, model_edge=0.05)\nif len(clv_results) > 0:\n    print(f\"=== STRATEGY 2: Closing Line Value (5% Edge) ===\")\n    print(f\"Bets placed:  {len(clv_results)}\")\n    print(f\"Hit rate:     {clv_results['won'].mean() * 100:.1f}%\")\n    print(f\"Total profit: {clv_results['profit'].sum():.2f} units\")\n    print(f\"Yield:        {(clv_results['profit'].sum() \/ len(clv_results)) * 100:.2f}%\")\n    print(f\"Avg odds:     {clv_results['odds'].mean():.2f}\")<\/code><\/pre>\n<h3>Strategy 3: Fade the Public (Sharp vs Soft Line Divergence)<\/h3>\n<p>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.<\/p>\n<pre class=\"wp-block-code\"><code>def get_closing_odds_multi(fixture_id, start_time, bookmakers=\"pinnacle,bet365\"):\n    \"\"\"Fetch closing odds from multiple bookmakers for comparison.\"\"\"\n    try:\n        data = api_get(\"historical-odds\", {\n            \"fixtureId\": fixture_id,\n            \"bookmakers\": bookmakers,\n            \"marketId\": 101\n        })\n    except Exception:\n        return None\n\n    kickoff = datetime.fromisoformat(start_time.replace(\"Z\", \"+00:00\"))\n    result = {}\n    outcome_map = {\"101\": \"home\", \"102\": \"draw\", \"103\": \"away\"}\n\n    for bookie_slug, bookie_data in data[\"bookmakers\"].items():\n        result[bookie_slug] = {}\n        markets = bookie_data[\"markets\"][\"101\"]\n        for outcome_id, label in outcome_map.items():\n            snapshots = markets[\"outcomes\"][outcome_id][\"players\"][\"0\"]\n            pre_match = [s for s in snapshots if datetime.fromisoformat(s[\"createdAt\"]) < kickoff]\n            if pre_match:\n                result[bookie_slug][label] = pre_match[0][\"price\"]\n\n    return result\n\ndef backtest_fade_public(df, completed_fixtures, threshold=0.10):\n    \"\"\"\n    Bet when Bet365 (soft) and Pinnacle (sharp) disagree by > threshold.\n    Bet on the Pinnacle side \u2014 sharps are usually right.\n    \"\"\"\n    bets = []\n    for f in completed_fixtures:\n        fid = f[\"fixtureId\"]\n        row = df[df[\"fixture_id\"] == fid]\n        if row.empty:\n            continue\n        row = row.iloc[0]\n\n        multi_odds = get_closing_odds_multi(fid, f[\"startTime\"])\n        time.sleep(5)\n\n        if not multi_odds or \"pinnacle\" not in multi_odds or \"bet365\" not in multi_odds:\n            continue\n\n        pin = multi_odds[\"pinnacle\"]\n        b365 = multi_odds[\"bet365\"]\n\n        for outcome in [\"home\", \"draw\", \"away\"]:\n            if outcome not in pin or outcome not in b365:\n                continue\n            pin_prob = 1 \/ pin[outcome]\n            b365_prob = 1 \/ b365[outcome]\n\n            # Pinnacle thinks it's MORE likely than Bet365 does\n            if pin_prob - b365_prob > threshold:\n                won = row[\"result\"] == outcome\n                profit = (b365[outcome] - 1) if won else -1  # Bet at Bet365 price\n                bets.append({\n                    \"match\": f\"{row['home_team']} vs {row['away_team']}\",\n                    \"bet\": outcome,\n                    \"pinnacle_odds\": pin[outcome],\n                    \"bet365_odds\": b365[outcome],\n                    \"divergence\": f\"{(pin_prob - b365_prob)*100:.1f}%\",\n                    \"won\": won, \"profit\": profit\n                })\n\n    return pd.DataFrame(bets)\n\n# Note: This strategy makes additional API calls per fixture\n# fade_results = backtest_fade_public(df, completed, threshold=0.10)<\/code><\/pre>\n<h3>Putting It All Together: Performance Summary<\/h3>\n<pre class=\"wp-block-code\"><code>def print_summary(name, results_df):\n    \"\"\"Print a clean performance summary for any strategy.\"\"\"\n    if results_df.empty:\n        print(f\"{name}: No bets placed\")\n        return\n\n    n = len(results_df)\n    profit = results_df[\"profit\"].sum()\n    cumulative = results_df[\"profit\"].cumsum()\n\n    print(f\"\\n{'='*50}\")\n    print(f\"  {name}\")\n    print(f\"{'='*50}\")\n    print(f\"  Bets placed:    {n}\")\n    print(f\"  Won:            {results_df['won'].sum()} ({results_df['won'].mean()*100:.1f}%)\")\n    print(f\"  Total profit:   {profit:+.2f} units\")\n    print(f\"  Yield:          {(profit\/n)*100:+.2f}%\")\n    print(f\"  Best streak:    {max_streak(results_df, True)} wins\")\n    print(f\"  Worst streak:   {max_streak(results_df, False)} losses\")\n    print(f\"  Max drawdown:   {(cumulative - cumulative.cummax()).min():.2f} units\")\n\ndef max_streak(df, winning=True):\n    \"\"\"Calculate longest consecutive win or loss streak.\"\"\"\n    streak = 0\n    max_s = 0\n    for won in df[\"won\"]:\n        if won == winning:\n            streak += 1\n            max_s = max(max_s, streak)\n        else:\n            streak = 0\n    return max_s\n\nprint_summary(\"Always Bet the Favourite\", fav_results)\nprint_summary(\"Closing Line Value (5% Edge)\", clv_results)<\/code><\/pre>\n<h2>5 Backtesting Mistakes That Will Blow Up Your Results<\/h2>\n<p>A backtest is only as good as its methodology. Here are the traps that catch even experienced quants:<\/p>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Mistake<\/th>\n<th>What Happens<\/th>\n<th>How to Avoid It<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>Look-ahead bias<\/strong><\/td>\n<td>You use closing odds to decide bets you'd have placed at opening<\/td>\n<td>Only use data available at decision time. If you bet pre-match, use opening odds for selection and closing for settlement.<\/td>\n<\/tr>\n<tr>\n<td><strong>Overfitting<\/strong><\/td>\n<td>Strategy has 10 parameters tuned to past data, fails on new data<\/td>\n<td>Keep strategies simple. If your edge disappears when you change one parameter by 5%, it's not real.<\/td>\n<\/tr>\n<tr>\n<td><strong>Survivorship bias<\/strong><\/td>\n<td>You only test markets where you found data, ignoring gaps<\/td>\n<td>Include fixtures where odds were unavailable. A strategy that only works on 30% of matches is fragile.<\/td>\n<\/tr>\n<tr>\n<td><strong>Ignoring the vig<\/strong><\/td>\n<td>Your model shows +EV but the margin eats the edge<\/td>\n<td>Always calculate yield after the vig. Pinnacle's 2-3% margin is the best case \u2014 soft books take 5-8%.<\/td>\n<\/tr>\n<tr>\n<td><strong>Sample size<\/strong><\/td>\n<td>50-game backtest shows +20% yield, you bet the house<\/td>\n<td>Minimum 500 bets for statistical significance. A 55% hit rate needs 1,000+ bets to confirm it's not luck.<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>What Makes OddsPapi Different for Backtesting<\/h2>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Feature<\/th>\n<th>Why It Matters for Backtesting<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td><strong>350+ bookmakers<\/strong><\/td>\n<td>Compare sharp vs soft lines across the full market \u2014 not just 40 books<\/td>\n<\/tr>\n<tr>\n<td><strong>Timestamped snapshots<\/strong><\/td>\n<td>See exact opening \u2192 closing line movement, not just final price<\/td>\n<\/tr>\n<tr>\n<td><strong>Sharp bookmakers<\/strong><\/td>\n<td>Pinnacle, Singbet, SBOBet closing lines \u2014 the true market benchmark<\/td>\n<\/tr>\n<tr>\n<td><strong>Free tier<\/strong><\/td>\n<td>Historical data included at no cost. Competitors charge $79+\/month.<\/td>\n<\/tr>\n<tr>\n<td><strong>59 sports<\/strong><\/td>\n<td>Backtest across soccer, NBA, MLB, esports, tennis \u2014 same API, same structure<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>Next Steps: Take Your Backtest Further<\/h2>\n<p>This tutorial gives you the foundation. Here's where to go next:<\/p>\n<ul>\n<li><strong>Add more bookmakers:<\/strong> Compare Pinnacle vs Bet365 vs DraftKings closing lines to find which soft books are slowest to adjust<\/li>\n<li><strong>Test different sports:<\/strong> Change <code>sportId<\/code> and <code>tournamentId<\/code> to backtest NBA (<code>sportId=11<\/code>), MLB (<code>sportId=13<\/code>), or NFL (<code>sportId=14<\/code>)<\/li>\n<li><strong>Plug in your model:<\/strong> Replace the simulated CLV strategy with your actual predictions<\/li>\n<li><strong>Track line movement:<\/strong> Use the full snapshot history to measure opening-to-closing movement and find bookmakers that move last<\/li>\n<\/ul>\n<p>Related guides:<\/p>\n<ul>\n<li><a href=\"https:\/\/oddspapi.io\/blog\/prediction-market-accuracy-backtest-polymarket-sportsbooks\/\">Prediction Market Accuracy: Backtest Polymarket vs Sportsbooks<\/a> \u2014 Advanced backtest with Brier scores and calibration curves<\/li>\n<li><a href=\"https:\/\/oddspapi.io\/blog\/bet365-historical-odds-guide-the-data-apis-and-strategy\/\">Bet365 Historical Odds Guide<\/a> \u2014 Deep dive on Bet365 historical data sources<\/li>\n<li><a href=\"https:\/\/oddspapi.io\/blog\/arbitrage-betting-bot-python\/\">How to Build an Arbitrage Betting Bot<\/a> \u2014 Live arb scanner with Python<\/li>\n<\/ul>\n<h2>Frequently Asked Questions<\/h2>\n<h3>Is historical odds data really free?<\/h3>\n<p>Yes. OddsPapi includes historical odds on the free tier \u2014 250 requests\/month with full access to timestamped snapshots from 350+ bookmakers. Competitors like The Odds API charge $79\/month for historical data access.<\/p>\n<h3>How far back does historical data go?<\/h3>\n<p>Historical odds are available for completed fixtures within the current and previous seasons. The exact coverage depends on the sport and tournament \u2014 Premier League fixtures typically have data going back to the start of the current season.<\/p>\n<h3>Can I backtest player props and other markets?<\/h3>\n<p>Yes. Change the <code>marketId<\/code> parameter to target any supported market. For example, Over\/Under 2.5 goals is <code>marketId=1010<\/code>, Asian Handicap -0.5 is <code>marketId=1068<\/code>. Each market has its own outcome structure.<\/p>\n<h3>What's the best bookmaker for closing line accuracy?<\/h3>\n<p>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 \u2014 regardless of whether individual bets win or lose.<\/p>\n<h3>How many bets do I need for a meaningful backtest?<\/h3>\n<p>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.<\/p>\n<p><script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Is historical odds data really free?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Yes. OddsPapi includes historical odds on the free tier \u2014 250 requests\/month with full access to timestamped snapshots from 350+ bookmakers. Competitors like The Odds API charge $79\/month for historical data access.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How far back does historical data go?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Historical odds are available for completed fixtures within the current and previous seasons. The exact coverage depends on the sport and tournament.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I backtest player props and other markets?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"What's the best bookmaker for closing line accuracy?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How many bets do I need for a meaningful backtest?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"At minimum 300-500 bets for basic signal detection. For strategies with lower hit rates, you need 1,000+ bets.\"\n      }\n    }\n  ]\n}\n<\/script><\/p>\n<h2>Stop Guessing. Start Backtesting.<\/h2>\n<p>Every serious bettor backtests. The data is free, the code is above, and you can have your first backtest running in 15 minutes. <a href=\"https:\/\/oddspapi.io\/en\/register\">Get your free API key<\/a> and find out if your model actually works \u2014 before you find out the expensive way.<\/p>\n<p><!--\nFocus Keyphrase: backtest betting model\nSEO Title: How to Backtest a Betting Model with Free Historical Odds (Python)\nMeta Description: Backtest betting models against Pinnacle closing odds from 350+ bookmakers. Free Python tutorial with yield, ROI, and drawdown calculations.\nSlug: backtest-betting-model-free-historical-odds\n--><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Backtest betting models against Pinnacle closing odds from 350+ bookmakers. Free Python tutorial with yield, ROI, and drawdown calculations.<\/p>\n","protected":false},"author":2,"featured_media":2554,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[12,8,4,9,11],"class_list":["post-2552","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to-guides","tag-betting-data","tag-free-api","tag-historical-odds","tag-odds-api","tag-python"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v26.4 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog\" \/>\n<meta property=\"og:description\" content=\"Backtest betting models against Pinnacle closing odds from 350+ bookmakers. Free Python tutorial with yield, ROI, and drawdown calculations.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\" \/>\n<meta property=\"og:site_name\" content=\"Odds API Development Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-04-01T10:00:00+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp\" \/>\n\t<meta property=\"og:image:width\" content=\"2560\" \/>\n\t<meta property=\"og:image:height\" content=\"1429\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/webp\" \/>\n<meta name=\"author\" content=\"Odds API Writer\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:creator\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:site\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Odds API Writer\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"12 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#article\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\"},\"author\":{\"name\":\"Odds API Writer\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\"},\"headline\":\"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)\",\"datePublished\":\"2026-04-01T10:00:00+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\"},\"wordCount\":1187,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp\",\"keywords\":[\"Betting Data\",\"Free API\",\"historical odds\",\"Odds API\",\"Python\"],\"articleSection\":[\"How To Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\",\"name\":\"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp\",\"datePublished\":\"2026-04-01T10:00:00+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp\",\"width\":2560,\"height\":1429,\"caption\":\"Backtest Betting Model - OddsPapi API Blog\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/oddspapi.io\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"name\":\"OddsPapi\",\"description\":\"Sports Odds APIs Tutorials &amp; Guides\",\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"alternateName\":\"Odds Papi\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/oddspapi.io\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\",\"name\":\"OddsPapi\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"width\":135,\"height\":135,\"caption\":\"OddsPapi\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\"},\"sameAs\":[\"https:\/\/x.com\/oddspapiapi\"]},{\"@type\":\"Person\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\",\"name\":\"Odds API Writer\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"caption\":\"Odds API Writer\"},\"url\":\"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/","og_locale":"en_US","og_type":"article","og_title":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog","og_description":"Backtest betting models against Pinnacle closing odds from 350+ bookmakers. Free Python tutorial with yield, ROI, and drawdown calculations.","og_url":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/","og_site_name":"Odds API Development Blog","article_published_time":"2026-04-01T10:00:00+00:00","og_image":[{"width":2560,"height":1429,"url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp","type":"image\/webp"}],"author":"Odds API Writer","twitter_card":"summary_large_image","twitter_creator":"@oddspapiapi","twitter_site":"@oddspapiapi","twitter_misc":{"Written by":"Odds API Writer","Est. reading time":"12 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#article","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/"},"author":{"name":"Odds API Writer","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13"},"headline":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)","datePublished":"2026-04-01T10:00:00+00:00","mainEntityOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/"},"wordCount":1187,"commentCount":0,"publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp","keywords":["Betting Data","Free API","historical odds","Odds API","Python"],"articleSection":["How To Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/","url":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/","name":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial) | Odds API Development Blog","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp","datePublished":"2026-04-01T10:00:00+00:00","breadcrumb":{"@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#primaryimage","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/03\/backtest-betting-model-free-historical-odds-scaled.webp","width":2560,"height":1429,"caption":"Backtest Betting Model - OddsPapi API Blog"},{"@type":"BreadcrumbList","@id":"https:\/\/oddspapi.io\/blog\/backtest-betting-model-free-historical-odds\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/oddspapi.io\/blog\/"},{"@type":"ListItem","position":2,"name":"How to Backtest a Betting Model with Free Historical Odds (Python Tutorial)"}]},{"@type":"WebSite","@id":"https:\/\/oddspapi.io\/blog\/#website","url":"https:\/\/oddspapi.io\/blog\/","name":"OddsPapi","description":"Sports Odds APIs Tutorials &amp; Guides","publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"alternateName":"Odds Papi","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/oddspapi.io\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/oddspapi.io\/blog\/#organization","name":"OddsPapi","url":"https:\/\/oddspapi.io\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","width":135,"height":135,"caption":"OddsPapi"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/x.com\/oddspapiapi"]},{"@type":"Person","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13","name":"Odds API Writer","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","caption":"Odds API Writer"},"url":"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/"}]}},"_links":{"self":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2552","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/comments?post=2552"}],"version-history":[{"count":1,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2552\/revisions"}],"predecessor-version":[{"id":2553,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/2552\/revisions\/2553"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media\/2554"}],"wp:attachment":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media?parent=2552"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/categories?post=2552"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/tags?post=2552"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}