A statistical soccer prediction engine built around the 2026 FIFA World Cup. Uses the Dixon-Coles Poisson model to forecast match outcomes, calibrates probabilities against historical data, and sizes bets using the Kelly criterion.
Built as a learning project to explore sports modelling and quantitative betting theory.
Fits per-team attack (α) and defense (β) ratings from 15 years of international match data using maximum likelihood estimation. Key features:
- Time decay — recent matches weighted more heavily (2-year half-life)
- Match importance tiers — World Cup finals weighted 4× more than friendlies
- Low-score correction (τ) — fixes Poisson's tendency to underestimate 0-0 and 1-1 results
- Home advantage (γ) — applied only to the host nation (Canada)
- L2 regularisation — prevents overfitting on teams with few matches
- Priors — FIFA-ranked fallbacks for teams with sparse data
Raw model probabilities are temperature-scaled (T ≈ 0.65) to correct a systematic bias discovered during testing: the model underestimates strong favourites by ~6–8 percentage points.
| Probability range | Raw model | After calibration | Actual hit rate |
|---|---|---|---|
| 60–70% | 64.6% | ~70% | 70.4% |
| 70–80% | 74.4% | ~79% | 80.8% |
| 80–90% | 84.4% | ~89% | 89.6% |
Sizes bets to maximise long-run bankroll growth:
- Half-Kelly (50% of full Kelly) to reduce variance
- 2% slippage buffer on market odds
- 2.5% edge floor — ignores bets with thin or negative expected value
- 15% daily cap — total exposure capped at 15% of bankroll per matchday
Runs 100,000 Monte Carlo simulations of the full WC bracket (group stage → R16 → QF → SF → Final), outputting win probabilities for all 32 teams.
pip install -r requirements.txt # numpy, pandas, scipy
python data_fetcher.py # download 15k real international resultsFetch live odds and run bet sheet
python -c "
import json, urllib.request, pandas as pd
req = urllib.request.Request(
'https://site.api.espn.com/apis/site/v2/sports/soccer/fifa.world/scoreboard',
headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=10) as r:
data = json.loads(r.read())
rows = []
for event in data.get('events', []):
for comp in event.get('competitions', []):
comps = comp.get('competitors', [])
if len(comps) < 2: continue
home = comps[0].get('team',{}).get('displayName','')
away = comps[1].get('team',{}).get('displayName','')
od = (comp.get('odds') or [{}])[0] or {}
ml = od.get('moneyline') or {}
def dec(node, key='close'):
v = ((node.get(key) or {}).get('odds') or (node.get('open') or {}).get('odds'))
try:
v = int(str(v).replace('+',''))
return round(v/100+1,3) if v>0 else round(100/abs(v)+1,3)
except: return None
dn = od.get('drawOdds') or {}
h = dec(ml.get('home') or {}); d = dec(dn,'close') or (lambda v: round(int(str(v).replace('+',''))/100+1,3) if v else None)(dn.get('moneyLine')); a = dec(ml.get('away') or {})
if h and d and a:
rows.append({'home_team':home,'away_team':away,'home_odds':h,'draw_odds':d,'away_odds':a})
pd.DataFrame(rows).to_csv('espn_wc2026_odds.csv', index=False)
print(f'{len(rows)} fixtures saved')
"
python main.py --fixtures real_fixtures.csv --ref-date $(date +%Y-%m-%d) --host Canada --no-sim --odds espn_wc2026_odds.csvUpdate bankroll after results
python -c "from kelly import update_bankroll; update_bankroll(-300)" # net P&L in ETBRun full tournament simulation
python main.py --fixtures real_fixtures.csv --host CanadaWalk-forward evaluation across 11,849 matches from 2014–2026. Elo is used as the market proxy.
| Metric | Value |
|---|---|
| Matches tested | 11,849 |
| Total bets placed | 14,713 |
| DC Brier score | 0.549 |
| Elo (market proxy) Brier | 0.555 |
| Edge over market | +1.1% |
| Starting bankroll | 2,000 ETB |
| Final bankroll | 108,841 ETB |
| Win rate | 32.9% |
Win rate is low because the model primarily bets draws and underdogs at high odds — it doesn't need to win often, it needs the wins to pay enough. Backtest is against Elo prices, not a real sharp bookmaker, so real-world performance will be lower.
🟢 LIVE — Real money bets placed on melbet-et.com using automated execution via
morning.py+bet_executor.py.
Starting bankroll: 2,000 ETB | Current bankroll: 2,134 ETB | Overall: +134 ETB (+6.7%)
June 12–19 results are real tracked bets. From June 20 onwards, bets are placed live on melbet-et.com via the automated workflow. Bankroll reflects actual account balance.
Note on sample size: ~31 settled bets is still noise. The model's 1.1% edge only becomes statistically detectable after 200–500 bets. The EPL 2026–27 season (starting August) is where real validation happens — 380 matches per season.
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Canada vs Bosnia and Herzegovina | Canada | 1.87 | 196 ETB | LOST (1–1 draw) | −196 ETB |
| USA vs Paraguay | Paraguay | 3.85 | 104 ETB | LOST (USA 4–1) | −104 ETB |
Day P&L: −300 ETB | Bankroll: 1,700 ETB
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Qatar vs Switzerland | Qatar | 14.0 | 25 ETB | LOST (1–1 draw) | −25 ETB |
| Brazil vs Morocco | Draw | 3.7 | 85 ETB | WON (1–1) | +230 ETB |
| Haiti vs Scotland | Haiti | 6.0 | 48 ETB | LOST (0–1) | −48 ETB |
Day P&L: +157 ETB | Bankroll: 1,857 ETB
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Australia vs Türkiye | Australia | 5.0 | 123 ETB | WON (2–0) | +492 ETB |
| Ivory Coast vs Ecuador | Draw | 2.85 | 23 ETB | LOST (1–0) | −23 ETB |
| Ivory Coast vs Ecuador | Ecuador | 2.45 | 8 ETB | LOST (1–0) | −8 ETB |
| Netherlands vs Japan | Japan | 3.65 | 49 ETB | LOST (2–2 draw) | −49 ETB |
| Sweden vs Tunisia | Tunisia | 4.30 | 50 ETB | LOST (5–1) | −50 ETB |
| Germany vs Curaçao | Draw | 16.0 | 18 ETB | LOST (7–1) | −18 ETB |
| Germany vs Curaçao | Curaçao | 31.0 | 8 ETB | LOST (7–1) | −8 ETB |
Day P&L: +336 ETB | Bankroll: 2,193 ETB
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Spain vs Cape Verde | — | — | — | 0–0 draw (no bet) | — |
| Belgium vs Egypt | Draw | 3.95 | 74 ETB | WON (1–1) | +218 ETB |
| Belgium vs Egypt | Egypt | 5.75 | 104 ETB | LOST (1–1) | −104 ETB |
| Saudi Arabia vs Uruguay | Draw | 4.50 | 75 ETB | WON (1–1) | +263 ETB |
| Saudi Arabia vs Uruguay | Saudi Arabia | 8.00 | 30 ETB | LOST (1–1) | −30 ETB |
| Iran vs New Zealand | Draw | 3.50 | 16 ETB | WON (2–2) | +40 ETB |
| Iran vs New Zealand | New Zealand | 4.70 | 37 ETB | LOST (2–2) | −37 ETB |
Day P&L: +350 ETB | Bankroll: 2,543 ETB
| Match | Result |
|---|---|
| France vs Senegal | France 3–1 |
| Norway vs Iraq | Norway 4–1 |
| Argentina vs Algeria | Argentina 3–0 |
| Match | Result |
|---|---|
| Austria vs Jordan | Austria 3–1 |
| Portugal vs Congo DR | 1–1 draw |
| England vs Croatia | England 4–2 |
| Ghana vs Panama | Ghana 1–0 |
| Colombia vs Uzbekistan | Colombia 3–1 |
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Czechia vs South Africa | South Africa | 4.80 | 130 ETB | LOST (1–1 draw) | −130 ETB |
| Switzerland vs Bosnia-Herzegovina | Switzerland | 1.556 | 142 ETB | WON (4–1) | +79 ETB |
| Canada vs Qatar | Canada | 1.294 | 74 ETB | WON (6–0) | +22 ETB |
Day P&L: −29 ETB | Bankroll: 2,513 ETB
| Match | Result |
|---|---|
| USA vs Australia | USA 2–0 |
| Morocco vs Scotland | Morocco 1–0 |
| Brazil vs Haiti | Brazil 3–0 |
| Türkiye vs Paraguay | Paraguay 1–0 |
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Netherlands vs Sweden | Netherlands | 1.714 | — | MISSED (5–1) | — |
| Germany vs Ivory Coast | Draw | 4.60 | 119 ETB | LOST (2–1) | −119 ETB |
| Germany vs Ivory Coast | Ivory Coast | 6.50 | 59 ETB | LOST (2–1) | −59 ETB |
| Ecuador vs Curaçao | Draw | 8.50 | 24 ETB | WON (0–0) | +180 ETB |
Day P&L: +2 ETB | Bankroll: 2,515 ETB
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Spain vs Saudi Arabia | Draw | 10.5 | 22 ETB | LOST (4–0) | −22 ETB |
| Spain vs Saudi Arabia | Saudi Arabia | 34.0 | 8 ETB | LOST (4–0) | −8 ETB |
| Belgium vs Iran | Draw | 4.52 | 13 ETB | WON (0–0) | +46 ETB |
| Belgium vs Iran | Iran | 7.6 | 51 ETB | LOST (0–0) | −51 ETB |
| Uruguay vs Cape Verde | Uruguay | 1.455 | 203 ETB | LOST (2–2) | −203 ETB |
| New Zealand vs Egypt | New Zealand | 5.75 | 46 ETB | LOST (1–3) | −46 ETB |
| New Zealand vs Egypt | Draw | 4.04 | 33 ETB | LOST (1–3) | −33 ETB |
Day P&L: −317 ETB | Bankroll: 2,197 ETB
Uruguay 2–2 Cape Verde was the decisive blow. The model gave Uruguay 95.5% — one of its highest-confidence calls — and it drew. That's a 1-in-20 outcome by the model's own estimate. Pure variance, but it cost 203 ETB in a single bet.
| Match | Bet | Odds | Stake | Result | P&L |
|---|---|---|---|---|---|
| Argentina vs Austria | Argentina | 1.46 | 182 ETB | WON (2–0) | +83.72 ETB |
| France vs Iraq | Draw | 9.7 | 42 ETB | LOST (France 3–0) | −42 ETB |
| France vs Iraq | Iraq | 28.0 | 26 ETB | LOST (France 3–0) | −26 ETB |
| Norway vs Senegal | Senegal | 3.35 | 17 ETB | LOST (Norway 3–2) | −17 ETB |
| Jordan vs Algeria | Jordan | 6.6 | 62 ETB | LOST (Algeria 2–1) | −62 ETB |
Day P&L: −63.28 ETB | Bankroll: 2,133.99 ETB
Argentina won comfortably but the 4 losing long shots (draw/Iraq/Senegal/Jordan) outweighed it. France 3–0 Iraq and Norway 3–2 Senegal were the model's misses — both went to the stronger team.
| Day | P&L | Bankroll | Note |
|---|---|---|---|
| Start | — | 2,000 ETB | — |
| June 12 | −300 ETB | 1,700 ETB | Canada draw & Paraguay wrong |
| June 13 | +157 ETB | 1,857 ETB | Brazil/Morocco draw hit |
| June 14 | +336 ETB | 2,193 ETB | Australia 5.0 win; 5 losers |
| June 15 | +350 ETB | 2,543 ETB | Three draws hit (Belgium, Saudi/Uruguay, Iran/NZ) |
| June 16 | — | 2,543 ETB | Model not run |
| June 17 | — | 2,543 ETB | Model not run |
| June 18 | −29 ETB | 2,513 ETB | South Africa drew; Switzerland & Canada won |
| June 19 | — | 2,513 ETB | Model not run |
| June 20 | +2 ETB | 2,515 ETB | Ecuador 0–0 draw saved the day; Netherlands missed |
| June 21 | −317 ETB | 2,197 ETB | Uruguay 2–2 Cape Verde at 1.455 odds — 95.5% model call lost |
| June 22 | −63 ETB | 2,134 ETB | Argentina won; France/Iraq, Norway/Senegal, Jordan/Algeria all went to the stronger team |
Overall (settled): +134 ETB (+6.7%) across ~45 settled bets
The model only bets when Expected Value > 0:
EV = (model_probability × payout) − stake
A heavy favourite at short odds (e.g. Switzerland at 1.211) requires winning 83%+ just to break even. If the model says 77%, the EV is negative — you lose money long-term even though Switzerland probably wins.
Draws and underdogs are where bookmakers consistently misprice:
- Draws: bettors dislike them, so bookmakers price them slightly worse than true probability
- Underdogs: public bias toward recognisable teams inflates their implied probability, leaving value on the other side
The core rule: it's not about who wins, it's about whether the odds pay you enough for the risk.
- 50 games is too few: The model's 1.1% edge over the market only becomes statistically meaningful after 500+ bets. A single tournament is dominated by variance.
- Soft market proxy: The backtest beats Elo, not a real bookmaker like Pinnacle. Real-world edge is likely smaller.
- Training data skew: Model is trained on all international matches — WC results may differ from qualifiers and friendlies.
- Not financial advice: This is a demo project. Bet responsibly.
The same Dixon-Coles engine extended to Premier League, La Liga, Bundesliga, Serie A, and Ligue 1 — where 500+ bets per season make edge statistically measurable.
Key difference from the WC model: home advantage (γ) applies to all home teams, not just the tournament host.
# Download 11 seasons of top-5 league data (~19,000 matches, run once):
python club_main.py --fetch
# Generate today's bet sheet for any league:
python club_main.py --league epl
python club_main.py --league laliga
python club_main.py --league bundesliga
python club_main.py --league seriea
python club_main.py --league ligue1
# Provide your own odds CSV:
python club_main.py --league epl --odds my_odds.csv├── main.py # WC entry point — fit model, simulate, generate bet sheet
├── club_main.py # Club football entry point (EPL, La Liga, Bundesliga, etc.)
├── dixon_coles.py # Dixon-Coles Poisson model + temperature calibration
├── simulator.py # Monte Carlo tournament simulator (100k runs)
├── elo.py # Elo model (market proxy for backtesting)
├── kelly.py # Kelly criterion bet sizing
├── backtest.py # Walk-forward backtest (DC vs Elo)
├── calibrate.py # Sweep time-decay parameter PHI
├── data_loader.py # CSV validation and loading
├── data_fetcher.py # Download real international match data from GitHub
├── club_data_fetcher.py # Download club football data from football-data.co.uk
├── odds_scraper.py # Scrape historical odds from ESPN
├── generate_fixtures.py # Generate synthetic test fixtures
├── tests.py # Unit tests
└── requirements.txt
# Download real match data
python main.py --fetch
# Fit model + simulate tournament
python main.py --fixtures real_fixtures.csv --host Canada --n-sims 100000
# Bet sheet from odds file (no simulation)
python main.py --fixtures real_fixtures.csv --no-sim --odds my_odds.csv
# Calibrate time-decay half-life
python calibrate.py --fixtures real_fixtures.csv
# Walk-forward backtest
python backtest.py --fixtures real_fixtures.csv --bankroll 2000