A model that looks profitable in a spreadsheet can still lose money live. The usual cause is bad data, not bad math. If your backtest uses information the market didn’t have at kickoff, or prices no bookmaker actually offered, your results are fiction.
This guide shows how to build a trustworthy backtest using a historical sports data API: which data you need, how to avoid the common traps, and how to judge a model honestly.
What Data You Need Before You Start
A credible backtest needs more than final scores. These are the building blocks:
- Fixtures and results: the ground truth for every match, with the exact kickoff time.
- Team and player statistics: the raw material for features such as form, shots and xG.
- Lineups: who actually played, so you can test whether lineup news matters.
- Match events: goals, cards and substitutions, useful for in-play models.
- Closing odds: the market’s final price, which is the benchmark every serious model is measured against.
Orbistats provides multi-season historical archives across 13 sports, including results, statistics, lineups and closing odds, so one source can cover the full pipeline.
| Data typeWhat it gives youTypical use in a backtest | ||
| Results | Final scores and outcomes | Labels for training and evaluation |
| Statistics | Team and player metrics | Feature engineering |
| Lineups | Starting XI and substitutes | Availability and rotation features |
| Closing odds | Final market prices | Benchmark and CLV calculation |
Step 1: Pull Historical Data
Start by fetching fixtures and results for the seasons you want to test. The snippet below is illustrative. Check the API documentation for exact endpoints, parameters and field names.
python
import requests
import pandas as pd
API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.orbistats.com/v1" # confirm in the docs
def fetch_fixtures(sport, season):
resp = requests.get(
f"{BASE_URL}/{sport}/fixtures",
params={"season": season},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=30,
)
resp.raise_for_status()
return pd.DataFrame(resp.json()["data"])
frames = [fetch_football_fixtures := fetch_fixtures("football", s) for s in range(2018, 2026)]
matches = pd.concat(frames, ignore_index=True)
matches["kickoff"] = pd.to_datetime(matches["kickoff"], utc=True)
matches = matches.sort_values("kickoff")
Sort by kickoff time immediately. Everything that follows depends on strict chronological order.
Step 2: Build Point-in-Time Features
A feature must only use data available before the match started. This is the single most important rule in backtesting.
- Use rolling windows that end before kickoff: for example, a team’s average goals over the last five matches, excluding the match being predicted.
- Never use season-end aggregates: a “season total” for a match played in October quietly includes data from May.
- Respect lineup timing: only use lineup data if you would genuinely have had it at your decision time.
python
matches["home_form_5"] = (
matches.groupby("home_team")["home_goals"]
.transform(lambda s: s.shift(1).rolling(5).mean())
)
The shift(1) is what keeps the current match out of its own feature.
Step 3: Simulate Bets Against Real Prices
Now compare your model’s probabilities with the market. A bet only has value when your estimated probability beats the implied probability of the odds.
python
def implied_prob(decimal_odds):
return 1 / decimal_odds
matches["edge"] = matches["model_prob_home"] - matches["home_odds"].map(implied_prob)
bets = matches[matches["edge"] > 0.03].copy() # require a 3% edge
bets["profit"] = bets.apply(
lambda r: (r["home_odds"] - 1) if r["home_win"] else -1, axis=1
)
print("ROI:", bets["profit"].sum() / len(bets))
Use a fixed flat stake first. Fancy staking plans can hide a weak model behind lucky variance.
Step 4: Judge the Model with Closing Line Value
Profit alone is noisy. Over a few hundred bets, luck dominates. Closing line value (CLV) is a steadier signal: it measures whether you consistently got better odds than the final market price.
- Positive CLV over a large sample suggests your model finds real edge.
- Profit with negative CLV usually means you were lucky.
- This is why closing odds matter: without them in your dataset, you cannot calculate CLV at all.
Compare against historical and live odds data so your pre-match prices and closing prices come from the same normalized schema.
Common Backtesting Pitfalls
| SymptomLikely causeFix | ||
| Unrealistically high ROI (30%+) | Lookahead bias in features | Shift all rolling features by one match and audit every column’s timestamp |
| Great backtest, poor live results | Overfitting to past seasons | Use walk-forward validation: train on past seasons, test on the next one |
| Profit disappears on closing odds | Edge was only against stale prices | Measure CLV and test against closing odds, not just opening lines |
| Results change between runs | Unsorted data or random splits | Sort by kickoff and never shuffle time-series data |
| Missing matches in some seasons | Incomplete or inconsistent source data | Use one backfilled provider and check match counts per season |
| Bets on markets you can’t actually get | Ignoring limits and margin | Subtract bookmaker margin and cap stake sizes in the simulation |
Frequently Asked Questions
How many seasons of data do I need?
As many as you can get while keeping the data consistent. For football, 5 to 8 seasons gives a reasonable sample. More matters most for niche markets with few events per season.
Should I test on opening or closing odds?
Both. Opening odds show whether you can find value early. Closing odds show whether you beat the sharpest version of the market.
Is a backtest enough to start betting?
No. Treat it as a filter, not proof. Follow it with a small live or paper-trading phase to check execution and pricing.
Start Backtesting with Orbistats
Good models start with clean, complete, point-in-time data. Orbistats offers a free tier so you can explore the API, and historical access is included in the Growth plan. Read the documentation, grab an API key, and run your first backtest this week.
