Backtesting Framework¶
Summary¶
A backtesting framework systematically evaluates a sports betting model by simulating how it would have performed on historical data. The framework must mimic real deployment conditions: using only information available at prediction time, tracking realistic transaction costs (vig), computing proper Kelly stakes, and measuring not just ROI but also CLV, calibration, and risk metrics.
The client's spec requires walk-forward validation across 2010, 2014, 2018, and 2022 World Cups — training on all prior data before each tournament and generating predictions before each match. This gives an honest picture of how the model performs out-of-sample.
Key metrics for a sports betting backtest:
1. ROI on staked: Total profit/total staked. Accounts for varying stake sizes.
2. Hit rate: % of bets that win. Alone, this is misleading without odds context.
3. CLV: Whether the model beats the closing line. Higher standard than just ROI.
4. Brier score: Probability calibration quality.
5. Max drawdown: Worst peak-to-trough decline. Critical for bankroll management.
6. Sharpe ratio: Risk-adjusted return. Allows comparison across strategies.
Framework Architecture¶
class SportsBettingBacktester:
def __init__(self, bankroll=10000, kelly_fraction=0.5, min_edge=0.03):
self.bankroll = bankroll
self.kelly_fraction = kelly_fraction
self.min_edge = min_edge
self.history = []
def run_tournament(self, model, fixtures_df, closing_odds_df):
"""
Run backtest for a single World Cup tournament.
Args:
model: trained prediction model
fixtures_df: match dates, teams, home/away
closing_odds_df: Pinnacle closing odds for all matches
"""
results = []
for _, match in fixtures_df.iterrows():
# 1. Generate prediction
pred = model.predict(match)
prob_home = pred['home_win_prob']
prob_draw = pred['draw_prob']
prob_away = pred['away_win_prob']
# 2. Get bookmaker odds (The Odds API or historical data)
bookie_odds = self.get_odds(match)
# 3. De-vig odds
fair_probs = devig_multiplicative(bookie_odds)
# 4. Compute EV for each outcome
ev_home = prob_home * bookie_odds['home'] - 1
ev_draw = prob_draw * bookie_odds['draw'] - 1
ev_away = prob_away * bookie_odds['away'] - 1
# 5. Find best EV bet
evs = {'home': ev_home, 'draw': ev_draw, 'away': ev_away}
best_bet = max(evs, key=evs.get)
best_ev = evs[best_bet]
if best_ev < self.min_edge:
continue # no bet
# 6. Compute Kelly stake
b = bookie_odds[best_bet] - 1
p = pred[f'{best_bet}_prob']
f_full = max((p * b - (1-p)) / b, 0)
stake = self.bankroll * f_full * self.kelly_fraction
# 7. Record bet (before outcome is known)
self.history.append({
'match': match['id'],
'date': match['date'],
'bet_on': best_bet,
'odds': bookie_odds[best_bet],
'model_prob': p,
'stake': stake,
'close_odds': closing_odds_df.loc[match['id'], best_bet],
'ev': best_ev
})
# 8. Simulate result and update bankroll
result = self.settle_bet(match, best_bet)
self.update_bankroll(result, stake, bookie_odds[best_bet])
return self.compute_metrics()
def compute_metrics(self):
"""Compute all backtest metrics."""
df = pd.DataFrame(self.history)
metrics = {
'n_bets': len(df),
'n_wins': (df['result'] == 1).sum() if 'result' in df.columns else None,
'hit_rate': (df['result'] == 1).mean() if 'result' in df.columns else None,
'roi_on_staked': df['pnl'].sum() / df['stake'].sum() if 'pnl' in df.columns else None,
'total_pnl': df['pnl'].sum() if 'pnl' in df.columns else None,
'final_bankroll': df['bankroll'].iloc[-1] if 'bankroll' in df.columns and len(df) > 0 else self.bankroll,
'max_drawdown': self.max_drawdown(df),
'clv_mean': ((df['odds'] - df['close_odds']) / df['close_odds']).mean(),
'brier_score': ((df['model_prob'] - df['result'])**2).mean()
}
return metrics
def max_drawdown(self, df):
"""Calculate maximum drawdown."""
if 'bankroll' not in df.columns:
return 0
peak = df['bankroll'].cummax()
dd = (df['bankroll'] - peak) / peak
return dd.min()
Key Metrics Definitions¶
| Metric | Formula | Target |
|---|---|---|
| ROI on Staked | total_pnl / total_staked | > 0% (positive edge) |
| Hit Rate | wins / total_bets | Varies by odds |
| CLV Mean | mean((bet_odds - close_odds) / close_odds) | > 0% |
| Brier Score | mean((pred_prob - result)²) | < 0.20 |
| Max Drawdown | max(peak - trough) / peak | < 30% |
| Sharpe Ratio | mean(pnl/stake) / std(pnl/stake) × sqrt(252) | > 1.0 |
Notes¶
- A backtest without CLV tracking is incomplete: even profitable strategies can be worse than just betting the closing line
- The client specifically requires walk-forward validation across 2010-2022 World Cups — implement this as the core validation framework
- Simulating Kelly staking introduces bankroll dependency: early losses reduce later stake sizes, which is realistic but means backtest results are path-dependent
- Transaction costs: always account for vig (use de-vigged odds for decision-making, actual odds for P&L)