Portfolio project — statistical modeling & backtesting
Value Betting Model22 European leagues, Dixon-Coles
A model that estimates "fair" match probabilities from historical results and compares them against bookmaker odds to flag potential value bets — then backtests that rule against real closing odds, across 22 leagues and 61,412 matches, to see if it would have actually made money.
NO EDGE
"Backtesting against real closing odds is the whole point. A model that looks profitable in-sample is trivial to produce — and means nothing." Working principle for this project
01 — The idea
Four steps, and only one that matters
-
01
Fit a statistical model to historical match results → get a probability for each outcome (home / draw / away).
-
02
Convert bookmaker odds into implied probabilities, removing the overround.
-
03
Where the model's probability exceeds the market's by some margin, that's a theoretical "value" bet.
-
04
Backtest that rule against real historical closing odds to see whether it would have actually made money.the point
02 — The model
Dixon-Coles
Each team gets an attack strength (α) and a defense weakness (β), fit separately for each of the 22 leagues. Expected goals come from a home-advantage-adjusted exponential of the two, plus a global intercept so shrinking a team toward zero means "league-average", not "scores exactly 1.0 goals":
λ_home = exp(μ + α_home − β_away + γ)
λ_away = exp(μ + α_away − β_home)
# μ = league scoring rate, γ = home advantage
A τ correction adjusts the independent-Poisson assumption for low-scoring outcomes (0–0, 1–0, 0–1, 1–1) — the scores plain Poisson gets wrong most often. The model is fit by maximum likelihood (scipy.optimize.minimize, L-BFGS-B) with explicit parameter bounds and an L2 (ridge) penalty on attack/defense strengths, which matters far more on a single thin season than on the full nine-year window.
Home advantage alone doesn't transfer across leagues — fitted γ ranges from 0.147 (Serie A) to 0.269 (La Liga), nearly a factor of two. Every parameter is fit per league; nothing is shared or ported.
score_matrix(home, away) returns the full joint scoreline distribution, from which match_probs() derives 1X2 and markets.py derives over/under, BTTS, and Asian handicaps.
03 — The backtest
Strict walk-forward, no lookahead
For each matchday, the model is refit using only matches before that date — no lookahead, no season-level leakage. Probabilities are compared against the sharpest available closing line (Pinnacle → Bet365 → market average, overround removed). Two staking rules are simulated: every selection where the model's edge clears a fixed threshold (used for totals), and the ten highest-edge selections per matchday per league (used for 1X2, so one busy Saturday can't dominate the sample).
edge = model_prob − market_prob
VALUE_THRESHOLD = 0.05 # 5 percentage points
# 1X2: top 10 by edge, per matchday, per league
# totals: every selection over threshold
Two metrics get reported. Log-loss compares model calibration against the market and is threshold-independent — it's the more reliable signal. ROI depends on the staking rule and is noisier, so every ROI figure below carries a bootstrapped 95% confidence interval (3,000 resamples, resampling bets with replacement).
04 — Results
61,412 matches, 22 leagues, nine seasons
31,625 and 7,090 bets held out on the 2024/25 and 2025/26 seasons — never touched during tuning.
| Market | Bets | ROI | 95% CI | P(profit) |
|---|---|---|---|---|
| 1X2 | 31,625 | −6.8% | [−8.5%, −5.1%] | 0.0% |
| Totals (O/U 2.5) | 7,090 | −5.2% | [−7.8%, −2.8%] | 0.0% |
Both intervals sit entirely below zero across tens of thousands of bets — this isn't a noisy small-sample result, it's a settled one.
Adding 16 lower-division leagues was meant to test whether smaller markets are priced more loosely. They're not — the model's disadvantage to the market grows, not shrinks, down the pyramid, while the commission charged grows with it:
| Tier | Model log-loss gap | Book margin |
|---|---|---|
| 1 — top flights | +0.0266 | 4.4% |
| 2 | +0.0323 | 5.4% |
| 3 | +0.0353 | 7.0% |
| 5 — National League | +0.0443 | 6.6% |
Soft markets are soft because they're hard to model — thin squads, high turnover, more randomness — not because the price is lazy. One of the 22 leagues (Scottish League Two, 359 matches) finished positive at +1.3%. With 22 tested, that's exactly the false-positive rate you'd expect by chance, not a lead.
05 — Why it loses
The selection gap
This is the core finding, and it isn't that the model is bad. It's what happens when you use the model's own errors to decide which bets to place.
Across every match the model prices, it's well calibrated — within about a percentage point almost everywhere:
| Model says | Actually wins | Model error | Market error |
|---|---|---|---|
| 15.9% | 14.8% | +1.1pp | +0.8pp |
| 25.6% | 25.5% | +0.1pp | +0.2pp |
| 34.4% | 33.6% | +0.7pp | +0.2pp |
| 48.5% | 49.1% | −0.6pp | −0.4pp |
Conditioned on the 6,128 selections the betting rule actually bets — where the model's edge over the market exceeds 5 points — that calibration collapses:
| Model says | Actually wins | Model error | Market error |
|---|---|---|---|
| 16.7% | 8.2% | +8.4pp | +1.4pp |
| 25.4% | 15.8% | +9.6pp | +1.5pp |
| 34.8% | 23.8% | +11.0pp | +1.5pp |
| 48.4% | 36.5% | +11.9pp | +1.1pp |
Same model, same matches, same day. The only difference is conditioning on model_prob − market_prob > 5pp. The market stays honest under that filter; the model doesn't, because the filter selects for the model's own errors. Log-loss tells the same story: the model looks better in absolute terms on those rows (0.5568 vs. 0.5778 overall) and is further behind the market there (0.5259 vs. 0.5642 overall).
"A model can be well calibrated on average and lose money on every bet it recommends. Overall calibration is not evidence for a betting strategy." The one sentence worth remembering from this project
What this means
- Bigger claimed edges are worse, not better — bucketed by edge size, ROI runs −5.7% / −11.0% / −11.0% / −12.9% / −1.8% from smallest to largest. No monotone payoff, and the large-edge buckets are where the model is most wrong.
- Improving the model's average calibration doesn't obviously fix this. The gap is a variance problem under selection, not a bias problem — which is a different, harder thing to solve.
06 — Reading this honestly
This is a valid result, not a bug
This is the expected outcome for a goals-only model up against markets priced by professional trading desks — now confirmed across 22 leagues and 61,412 matches rather than one partial season.
Taking the best available price across a panel of books, instead of one sharp book's price, is worth about three points in every market — larger than any edge this model ever claimed, and it needs no model at all. But it was found by scanning price buckets after the fact, the exact mistake this project warns against everywhere else. It's a lead, not a finding, until it's tested on data it wasn't found on.
The model uses goals only — no xG, injuries, form, rest days, or lineups. Adding features to a model whose own bet selection is the weak point doesn't obviously help; that's not where this goes next.
07 — Open leads
What's actually left to try
- Best-price shopping — the only live lead, and it isn't a model. Switching from one sharp book's closing price to the best available across a panel is worth +2.84 points at 1X2, +2.27 at totals, +1.66 on Asian handicaps, measured across all 22 leagues. Needs its own held-out test before it's trusted.
- Favourite–longshot bias is real here too — backing every favourite returns −2.7% against −7.4% for longshots. It doesn't make money; it's a way to lose more slowly, and it's already priced in.
- BTTS is still unpriced —
markets.pyalready computes it, but neither ESPN nor football-data's standard columns carry the odds. Needs a different feed before it can be tested at all. - Features — xG, rest days, lineup strength — only after there's an answer to the selection gap. A model whose own bet selection is its weak point doesn't obviously improve by getting more inputs.
Traps to avoid
- Don't tune the threshold after seeing results — that's how you manufacture a backtest that looks profitable and isn't.
- Don't re-use a holdout season once you've looked at it — from then on, it's a tuning set.
- Don't chase the best of N tests. A Premier League totals lead that looked like +6% reverted to −6% on 2.3× the untouched data. Scottish League Two's lone positive result is the same shape.
- Don't trust overall calibration — see "Why it loses" above.
08 — Files & setup
Reproduce it
| File | Purpose |
|---|---|
| boet.py | Single entry point — fetch, leagues, bets, validate, diagnose |
| dc_model.py | Current model — ridge, intercept, per-league fit, bounds |
| loader.py | Multi-league loader; picks the sharpest closing line automatically |
| validate.py | Walk-forward validation, any leagues, any seasons |
| diagnose.py | Reproduces the calibration tables above from saved rows, no refit |
| markets.py | Derives over/under, BTTS, and Asian handicaps from the scoreline matrix |
| leagues.py | Registry — 22 leagues, tiers, groups |
Where it started: dixon_coles.py and backtest.py — the original single-league, single-season model. Kept in the repo for reference; dc_model.py and validate.py replaced them once the small-sample failure mode became clear.
Data comes from football-data.co.uk — nine seasons, 22 leagues, the sharpest available closing line per season (Pinnacle where available).
python3 boet.py fetch
python3 boet.py validate all