Historical Odds Data: Export to CSV or Excel for Backtesting (Free API)

Historical Odds Data - OddsPapi API Blog
Historical Odds April 13, 2026

You need historical odds for backtesting. The options look bleak: OddsJam charges $299/month, SportsGameOdds walls it behind their Enterprise plan, and The Odds API historical endpoint costs a premium tier just to query one sport at a time. So you give up and try to scrape Bet365’s archive into an Excel file. That dies within a week.

Here’s the workaround: OddsPapi’s /historical-odds endpoint is free, covers every sport, and returns the full price history for each bookmaker — every line move, timestamped. This tutorial shows you how to pull it, flatten the nested JSON, and dump it straight to CSV or Excel so you can load it into pandas, a notebook, or even a spreadsheet and start backtesting.

All code below was tested against the live API on April 11, 2026.

Why Historical Odds Data Is So Hard to Get

Three problems keep killing backtest projects before they start:

  1. Paywalls. The big historical providers treat the data as a premium product. $299/month to OddsJam or $500+/month to Sportradar-backed feeds just to answer the question “what did Pinnacle close at last Tuesday?”
  2. Per-sport gatekeeping. Even when you pay, you usually pay per sport. Want NFL + Premier League + ATP tennis in one dataset? That’s three subscriptions.
  3. Nested JSON hell. The providers that do give you historical data hand it back as deeply nested objects — bookmakers → markets → outcomes → players → price_history — and leave you to unpack it before you can even load a DataFrame.

OddsPapi solves all three in one call. Free historical data is on the free tier. One endpoint covers every sport we ingest (soccer, NFL, tennis, esports, cricket, volleyball — 59 sports). And with ~30 lines of Python below, you’ll flatten the nested response into a clean tabular CSV or Excel file.

Historical Odds Providers Compared (2026)

Provider Historical Cost Sports Covered Export Format Price History
OddsPapi Free 59 sports JSON (flatten to CSV/Excel with Python) ✅ Every line move, timestamped
The Odds API $119/mo+ (historical endpoint, paid tier) Per-sport plans JSON Single snapshots per timestamp
SportsGameOdds Enterprise plan only Major US sports JSON Limited
OddsJam $299/mo+ Major sports CSV export (built-in) ✅ Available
Bet365 archive scraping “Free” (until they block you) Bet365 only HTML (good luck) ❌ Not exposed

If you already have an OddsJam subscription and just want a CSV export, the built-in tool is fine. If you want free historical data across every sport with every price tick, read on.

Step 1: Set Up the API and Discover Sports

Authentication is a query parameter (apiKey), not a header. Grab a free key at oddspapi.io and the rest is three imports.

import requests
import pandas as pd

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.oddspapi.io/v4"

# Discover every sport available
resp = requests.get(f"{BASE_URL}/sports", params={"apiKey": API_KEY})
sports = resp.json()

for s in sports[:10]:
    print(f"{s['sportId']:>3}  {s['sportName']}")

Output (partial):

 10  Soccer
 11  Basketball
 12  Tennis
 13  Baseball
 14  American Football
 15  Ice Hockey
 16  ESport Dota
 17  ESport Counter-Strike
 20  MMA
 23  Volleyball

Every one of those sports has historical data available. Pick a sportId and move on.

Step 2: Fetch Historical Fixtures for a Date Range

The /fixtures endpoint takes from and to parameters (max 10 days apart) and returns every fixture played or scheduled in that window. For a backtest, point it at a past date range and pull the full fixture list.

# Fetch all soccer fixtures from April 5–6, 2026
params = {
    "apiKey": API_KEY,
    "sportId": 10,          # 10 = Soccer
    "from": "2026-04-05",
    "to": "2026-04-06",
}

resp = requests.get(f"{BASE_URL}/fixtures", params=params)
fixtures = resp.json()

print(f"Fetched {len(fixtures)} fixtures")

Output:

Fetched 985 fixtures

985 soccer fixtures in a single 48-hour window, across every league OddsPapi ingests. Filter by tournamentName if you only care about a specific league (e.g. "Premier League", "La Liga", "MLS").

Filter to the league you care about

target_tournaments = ["Premier League", "La Liga", "Serie A", "Bundesliga", "Ligue 1"]

top_leagues = [
    f for f in fixtures
    if f.get("tournamentName") in target_tournaments
]

print(f"{len(top_leagues)} fixtures across top leagues")

Step 3: Pull Historical Odds for a Fixture

The /historical-odds endpoint takes a fixtureId and up to three bookmakers. Each call returns the full price history for every market and outcome offered by those books — not a single snapshot, but the complete list of line moves with timestamps.

# Pick a fixture (any from the filtered list)
fixture = top_leagues[0]
fixture_id = fixture["fixtureId"]

# Fetch historical odds from 3 sharp/soft books
params = {
    "apiKey": API_KEY,
    "fixtureId": fixture_id,
    "bookmakers": "pinnacle,bet365,singbet",
}
resp = requests.get(f"{BASE_URL}/historical-odds", params=params)
data = resp.json()

print("Bookmakers returned:", list(data["bookmakers"].keys()))

Output:

Bookmakers returned: ['bet365', 'pinnacle', 'singbet']

Note: The API enforces a max of 3 bookmakers per call. To pull more, loop the request with different bookmaker combinations and merge the responses — the code in Step 4 handles that naturally.

The nested response structure

Here’s the shape of what you get back:

{
  "fixtureId": "id1000119163078643",
  "bookmakers": {
    "pinnacle": {
      "markets": {
        "101": {                   # marketId = Full Time Result (1X2)
          "outcomes": {
            "101": {                 # outcomeId = Home win
              "players": {
                "0": [
                  {"createdAt": "2026-04-05T14:58:17Z", "price": 1.85, "limit": null, ...},
                  {"createdAt": "2026-04-05T13:02:18Z", "price": 1.92, "limit": null, ...},
                  {"createdAt": "2026-04-05T11:41:42Z", "price": 1.95, "limit": null, ...}
                ]
              }
            },
            "102": { ... X price history ... },
            "103": { ... away price history ... }
          }
        },
        "1010": { ... Over/Under 2.5 price history ... }
      }
    },
    "bet365": { ... }
  }
}

Every entry in the inner list is one line move. Timestamped. Free. This is the data OddsJam charges $299/month for.

Step 4: Flatten the Nested JSON into Tabular Rows

To get this into a CSV or Excel file, we need one row per bookmaker × market × outcome × timestamp. We also want human-readable market and outcome names, not just IDs, so we fetch the market catalog once and build a lookup table.

# Fetch the market catalog for the sport (maps marketId/outcomeId to names)
resp = requests.get(f"{BASE_URL}/markets", params={"apiKey": API_KEY, "sportId": 10})
markets_catalog = resp.json()

market_names = {m["marketId"]: m["marketName"] for m in markets_catalog}
outcome_names = {
    (m["marketId"], o["outcomeId"]): o["outcomeName"]
    for m in markets_catalog
    for o in m.get("outcomes", [])
}

def flatten_historical_odds(data, fixture):
    """Flatten the nested historical-odds response into a list of flat dicts."""
    rows = []
    home = fixture.get("participant1Name")
    away = fixture.get("participant2Name")
    start = fixture.get("startTime")
    fid = fixture["fixtureId"]

    for bm, bdata in data.get("bookmakers", {}).items():
        for mid_str, mdata in bdata.get("markets", {}).items():
            mid = int(mid_str)
            for oid_str, odata in mdata.get("outcomes", {}).items():
                oid = int(oid_str)
                for _player, price_history in odata.get("players", {}).items():
                    for snap in price_history:
                        rows.append({
                            "fixture_id": fid,
                            "start_time": start,
                            "home": home,
                            "away": away,
                            "bookmaker": bm,
                            "market_id": mid,
                            "market_name": market_names.get(mid, f"Market {mid}"),
                            "outcome_id": oid,
                            "outcome_name": outcome_names.get((mid, oid), f"Outcome {oid}"),
                            "price": snap.get("price"),
                            "limit": snap.get("limit"),
                            "recorded_at": snap.get("createdAt"),
                        })
    return rows

rows = flatten_historical_odds(data, fixture)
df = pd.DataFrame(rows)
print(df.shape)
print(df.head())

Output:

(8881, 12)
           fixture_id                home          away bookmaker  market_id       market_name outcome_name  price              recorded_at
0  id1000119163078643  Berekum Chelsea  Aduana Stars    bet365        101  Full Time Result            1   1.18 2026-04-05T09:38:17+00:00
1  id1000119163078643  Berekum Chelsea  Aduana Stars    bet365        101  Full Time Result            1   1.10 2026-04-04T12:03:18+00:00
2  id1000119163078643  Berekum Chelsea  Aduana Stars    bet365        101  Full Time Result            1   1.12 2026-04-04T10:41:42+00:00
3  id1000119163078643  Berekum Chelsea  Aduana Stars    bet365        101  Full Time Result            X   4.75 2026-04-05T09:38:17+00:00
4  id1000119163078643  Berekum Chelsea  Aduana Stars    bet365        101  Full Time Result            X   7.00 2026-04-04T12:03:18+00:00

8,881 rows from a single fixture with two bookmakers. That’s thousands of timestamped line moves across every market they offered — 1X2, Over/Under, Asian Handicaps, Both Teams To Score, Correct Score, the whole book. Multiply by hundreds of fixtures for a full backtest dataset.

Step 5: Export to CSV and Excel

With the rows flattened into a DataFrame, the export is one line.

# Export to CSV — the universal option
df.to_csv("historical_odds.csv", index=False)

# Export to Excel — requires openpyxl: pip install openpyxl
df.to_excel("historical_odds.xlsx", index=False, sheet_name="Historical Odds")

print(f"Exported {len(df)} rows to historical_odds.csv and historical_odds.xlsx")

Output:

Exported 8881 rows to historical_odds.csv and historical_odds.xlsx

Scaling up: loop over an entire season

To build a full season dataset, loop over fixtures and append each flattened batch to a single DataFrame. Keep in mind rate limits — a small time.sleep(0.2) between calls keeps you safe on the free tier.

import time

all_rows = []
for fx in top_leagues[:50]:        # first 50 filtered fixtures
    r = requests.get(
        f"{BASE_URL}/historical-odds",
        params={
            "apiKey": API_KEY,
            "fixtureId": fx["fixtureId"],
            "bookmakers": "pinnacle,bet365,singbet",
        },
    )
    if r.status_code == 200:
        all_rows.extend(flatten_historical_odds(r.json(), fx))
    time.sleep(0.2)

season_df = pd.DataFrame(all_rows)
season_df.to_csv("season_historical.csv", index=False)
print(f"{len(season_df):,} rows exported")

Step 6: Backtest Teaser — Compute Closing Line Value

Once the CSV is on disk, you can load it back and start doing real analysis. Here’s the simplest thing: compute the closing line (the last price recorded before kickoff) for every market on every book, then compare a hypothetical “bet” against it.

df = pd.read_csv("historical_odds.csv", parse_dates=["recorded_at", "start_time"])

# Only keep rows recorded BEFORE kickoff
pre_match = df[df["recorded_at"] < df["start_time"]]

# The "closing line" is the last price per bookmaker × market × outcome
closing = (
    pre_match
    .sort_values("recorded_at")
    .groupby(["fixture_id", "bookmaker", "market_id", "outcome_id"])
    .tail(1)
    .rename(columns={"price": "closing_price"})
)

# Pinnacle's 1X2 home closing lines — your backtest benchmark
pinnacle_home_closes = closing[
    (closing["bookmaker"] == "pinnacle") &
    (closing["market_name"] == "Full Time Result") &
    (closing["outcome_name"] == "1")
]

print(pinnacle_home_closes[["home", "away", "closing_price"]].head())

That’s the foundation of every serious betting model: use the closing price from a sharp book (Pinnacle is the industry benchmark) as ground truth, compare your model’s predictions against it, and measure whether you’re beating the closing line. If you want the full walkthrough with staking strategy and Kelly sizing, see our betting model backtest tutorial.

What You Can Build Once the Data Is in a CSV

Project What you need from the CSV
Model training (ML features) Closing lines, opening lines, line movement velocity, consensus vs Pinnacle disagreement
CLV tracker Your bet’s price vs the Pinnacle closing price at the same timestamp
Line movement analysis Full recorded_at timeline per market — plot with matplotlib or Plotly
Sharp vs soft divergence Pinnacle/Singbet vs Bet365/DraftKings on the same fixtures
Arbitrage historical research Cross-book pivot: was there ever a real arb on this fixture?
Steam move detector price.diff() per bookmaker per market, flagged when > N%

None of this requires a $299/month plan. It requires a free API key, Python, and 30 lines of flattening code.

FAQ

How far back does OddsPapi’s free historical data go?

Historical odds are available from our archive ingestion start date and are included on the free tier for all sports. Price history is stored per line move, so a fixture may have dozens or hundreds of snapshots depending on book activity.

Can I export player props historical data?

Yes. Player prop markets (NFL QB passing yards, NBA points, soccer shots on target) appear in the same /historical-odds response under their own market IDs. The flattening function above handles them identically — no special case needed.

Why does the API limit me to 3 bookmakers per call?

Each bookmaker adds a full price history payload, and some fixtures have 8,000+ line moves per book. The 3-bookmaker cap keeps response sizes manageable. To pull more books, just loop the request with different bookmaker combinations — the flatten function in Step 4 merges them cleanly.

Is there a row count or request limit?

The free tier has per-minute rate limits but no hard row cap. A time.sleep(0.2) between requests keeps you well within the limit for most backtesting workflows. For heavy batch jobs or production backtest pipelines, the Pro tier lifts the rate limits.

Can I schedule automated exports?

Yes. Wrap the loop in a cron job or a GitHub Actions workflow, write the CSV to S3 or Google Cloud Storage, and you have a nightly historical odds pipeline for free. No paid “data export” add-on required.

Does the CSV include closed or settled fixtures?

Yes. The /fixtures endpoint returns fixtures regardless of status — closed, settled, in-play, or upcoming. Filter by statusId if you only want settled fixtures for a backtest (“did the bet win?”) calculation.

Stop Paying for Data You Need Once

Historical odds should be a commodity. You need it once — to backtest a model or research a strategy — and then you’re done. Paying $299/month for a one-time data pull is an extraction tax, not a product.

OddsPapi gives historical data away free because we want developers to build on our API, not bounce off a paywall. Grab an API key, run the code above, and you’ll have a CSV in your working directory in about five minutes.

Get your free OddsPapi API key →

Related reading: Bet365 Historical Odds Guide · Backtest a Betting Model with Free Historical Odds · Build a Value Betting Scanner in Python · Free Odds API: 350+ Bookmakers