Skip to content

Commit 98c6fe3

Browse files
valvesssclaude
andcommitted
feat(worldcup): goals forecast (xG + scoreline) + knockout penalties/advance
The card now answers "what's the score, and who goes through?": - expected goals per side + most likely scoreline, from a fast iterative attack/defence Poisson on recent internationals (neutral-aware home advantage; a global statsmodels GLM over ~200 nations was too slow for the cron). - knockout: chance to advance (win in 90'/ET, or win the shootout) + the penalty split (shrunk toward a coin-flip — shootouts are near-random). - fixes an off-by-one date bug (UTC parse rolled fixtures back a day). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bca8bb0 commit 98c6fe3

4 files changed

Lines changed: 248 additions & 23 deletions

File tree

site/src/data/wc_live.json

Lines changed: 128 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@
2323
"p_draw": 0.252,
2424
"p_away": 0.192,
2525
"home_flag": "br",
26-
"away_flag": "jp"
26+
"away_flag": "jp",
27+
"adv_home": 0.7066,
28+
"adv_away": 0.2934,
29+
"pen_home": 0.5973,
30+
"xg_home": 1.82,
31+
"xg_away": 0.56,
32+
"score_home": 1,
33+
"score_away": 0
2734
},
2835
"upcoming": [
2936
{
@@ -43,7 +50,14 @@
4350
"p_draw": 0.252,
4451
"p_away": 0.192,
4552
"home_flag": "br",
46-
"away_flag": "jp"
53+
"away_flag": "jp",
54+
"adv_home": 0.7066,
55+
"adv_away": 0.2934,
56+
"pen_home": 0.5973,
57+
"xg_home": 1.82,
58+
"xg_away": 0.56,
59+
"score_home": 1,
60+
"score_away": 0
4761
},
4862
{
4963
"match_id": "2026-06-29|Germany|Paraguay",
@@ -62,7 +76,14 @@
6276
"p_draw": 0.2285,
6377
"p_away": 0.1483,
6478
"home_flag": "de",
65-
"away_flag": "py"
79+
"away_flag": "py",
80+
"adv_home": 0.7656,
81+
"adv_away": 0.2344,
82+
"pen_home": 0.6231,
83+
"xg_home": 1.48,
84+
"xg_away": 0.73,
85+
"score_home": 1,
86+
"score_away": 0
6687
},
6788
{
6889
"match_id": "2026-06-29|Netherlands|Morocco",
@@ -81,7 +102,14 @@
81102
"p_draw": 0.2712,
82103
"p_away": 0.2479,
83104
"home_flag": "nl",
84-
"away_flag": "ma"
105+
"away_flag": "ma",
106+
"adv_home": 0.6338,
107+
"adv_away": 0.3662,
108+
"pen_home": 0.5639,
109+
"xg_home": 1.0,
110+
"xg_away": 0.8,
111+
"score_home": 1,
112+
"score_away": 0
85113
},
86114
{
87115
"match_id": "2026-06-30|France|Sweden",
@@ -100,7 +128,14 @@
100128
"p_draw": 0.154,
101129
"p_away": 0.0683,
102130
"home_flag": "fr",
103-
"away_flag": "se"
131+
"away_flag": "se",
132+
"adv_home": 0.8805,
133+
"adv_away": 0.1195,
134+
"pen_home": 0.6677,
135+
"xg_home": 1.5,
136+
"xg_away": 0.74,
137+
"score_home": 1,
138+
"score_away": 0
104139
},
105140
{
106141
"match_id": "2026-06-30|Ivory Coast|Norway",
@@ -119,7 +154,14 @@
119154
"p_draw": 0.2809,
120155
"p_away": 0.4014,
121156
"home_flag": "ci",
122-
"away_flag": "no"
157+
"away_flag": "no",
158+
"adv_home": 0.4516,
159+
"adv_away": 0.5484,
160+
"pen_home": 0.4767,
161+
"xg_home": 0.96,
162+
"xg_away": 1.03,
163+
"score_home": 0,
164+
"score_away": 1
123165
},
124166
{
125167
"match_id": "2026-06-30|Mexico|Ecuador",
@@ -138,7 +180,14 @@
138180
"p_draw": 0.267,
139181
"p_away": 0.2326,
140182
"home_flag": "mx",
141-
"away_flag": "ec"
183+
"away_flag": "ec",
184+
"adv_home": 0.6534,
185+
"adv_away": 0.3466,
186+
"pen_home": 0.5731,
187+
"xg_home": 1.51,
188+
"xg_away": 1.05,
189+
"score_home": 1,
190+
"score_away": 1
142191
},
143192
{
144193
"match_id": "2026-07-01|Belgium|Senegal",
@@ -157,7 +206,14 @@
157206
"p_draw": 0.2535,
158207
"p_away": 0.1955,
159208
"home_flag": "be",
160-
"away_flag": "sn"
209+
"away_flag": "sn",
210+
"adv_home": 0.7019,
211+
"adv_away": 0.2981,
212+
"pen_home": 0.5953,
213+
"xg_home": 1.27,
214+
"xg_away": 0.82,
215+
"score_home": 1,
216+
"score_away": 0
161217
},
162218
{
163219
"match_id": "2026-07-01|England|DR Congo",
@@ -176,7 +232,14 @@
176232
"p_draw": 0.1521,
177233
"p_away": 0.0669,
178234
"home_flag": "gb-eng",
179-
"away_flag": "cd"
235+
"away_flag": "cd",
236+
"adv_home": 0.8827,
237+
"adv_away": 0.1173,
238+
"pen_home": 0.6685,
239+
"xg_home": 1.63,
240+
"xg_away": 0.46,
241+
"score_home": 1,
242+
"score_away": 0
180243
},
181244
{
182245
"match_id": "2026-07-01|United States|Bosnia and Herzegovina",
@@ -195,7 +258,14 @@
195258
"p_draw": 0.1738,
196259
"p_away": 0.0846,
197260
"home_flag": "us",
198-
"away_flag": "ba"
261+
"away_flag": "ba",
262+
"adv_home": 0.8561,
263+
"adv_away": 0.1439,
264+
"pen_home": 0.659,
265+
"xg_home": 1.84,
266+
"xg_away": 0.85,
267+
"score_home": 1,
268+
"score_away": 0
199269
},
200270
{
201271
"match_id": "2026-07-02|Portugal|Croatia",
@@ -214,7 +284,14 @@
214284
"p_draw": 0.2573,
215285
"p_away": 0.2046,
216286
"home_flag": "pt",
217-
"away_flag": "hr"
287+
"away_flag": "hr",
288+
"adv_home": 0.6899,
289+
"adv_away": 0.3101,
290+
"pen_home": 0.5898,
291+
"xg_home": 1.28,
292+
"xg_away": 0.83,
293+
"score_home": 1,
294+
"score_away": 0
218295
},
219296
{
220297
"match_id": "2026-07-02|Spain|Austria",
@@ -233,7 +310,14 @@
233310
"p_draw": 0.1866,
234311
"p_away": 0.0966,
235312
"home_flag": "es",
236-
"away_flag": "at"
313+
"away_flag": "at",
314+
"adv_home": 0.8385,
315+
"adv_away": 0.1615,
316+
"pen_home": 0.6525,
317+
"xg_home": 1.63,
318+
"xg_away": 0.65,
319+
"score_home": 1,
320+
"score_away": 0
237321
},
238322
{
239323
"match_id": "2026-07-02|Switzerland|Algeria",
@@ -252,7 +336,14 @@
252336
"p_draw": 0.2712,
253337
"p_away": 0.2482,
254338
"home_flag": "ch",
255-
"away_flag": "dz"
339+
"away_flag": "dz",
340+
"adv_home": 0.6335,
341+
"adv_away": 0.3665,
342+
"pen_home": 0.5638,
343+
"xg_home": 1.33,
344+
"xg_away": 1.07,
345+
"score_home": 1,
346+
"score_away": 1
256347
},
257348
{
258349
"match_id": "2026-07-03|Argentina|Cape Verde",
@@ -271,7 +362,14 @@
271362
"p_draw": 0.0691,
272363
"p_away": 0.0205,
273364
"home_flag": "ar",
274-
"away_flag": "cv"
365+
"away_flag": "cv",
366+
"adv_home": 0.9581,
367+
"adv_away": 0.0419,
368+
"pen_home": 0.6912,
369+
"xg_home": 1.99,
370+
"xg_away": 0.34,
371+
"score_home": 1,
372+
"score_away": 0
275373
},
276374
{
277375
"match_id": "2026-07-03|Australia|Egypt",
@@ -290,7 +388,14 @@
290388
"p_draw": 0.268,
291389
"p_away": 0.2362,
292390
"home_flag": "au",
293-
"away_flag": "eg"
391+
"away_flag": "eg",
392+
"adv_home": 0.6488,
393+
"adv_away": 0.3512,
394+
"pen_home": 0.5709,
395+
"xg_home": 0.79,
396+
"xg_away": 0.83,
397+
"score_home": 0,
398+
"score_away": 0
294399
},
295400
{
296401
"match_id": "2026-07-03|Colombia|Ghana",
@@ -309,7 +414,14 @@
309414
"p_draw": 0.1139,
310415
"p_away": 0.042,
311416
"home_flag": "co",
312-
"away_flag": "gh"
417+
"away_flag": "gh",
418+
"adv_home": 0.9217,
419+
"adv_away": 0.0783,
420+
"pen_home": 0.6811,
421+
"xg_home": 1.67,
422+
"xg_away": 0.5,
423+
"score_home": 1,
424+
"score_away": 0
313425
}
314426
],
315427
"backtest2026": {

site/src/pages/worldcup.astro

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ const pct = (x) => (x * 100).toFixed(0) + '%';
103103
</div>
104104
</div>
105105

106+
<div class="nm-forecast" id="nm-forecast">
107+
<div class="nm-fc-card" id="nm-fc-goals">
108+
<div class="nm-fc-label">Predicted scoreline</div>
109+
<div class="nm-fc-score"><span id="nm-sc-h">–</span><i>:</i><span id="nm-sc-a">–</span></div>
110+
<div class="nm-fc-xg">expected goals <b id="nm-xg-h"></b> – <b id="nm-xg-a"></b></div>
111+
</div>
112+
<div class="nm-fc-card">
113+
<div class="nm-fc-label">Knockout — chance to advance</div>
114+
<div class="nm-adv-bar"><span class="adv-h" id="nm-adv-h"></span><span class="adv-a" id="nm-adv-a"></span></div>
115+
<div class="nm-adv-key">
116+
<span><b id="nm-advh"></b> <span id="nm-advhn"></span></span>
117+
<span><span id="nm-advan"></span> <b id="nm-adva"></b></span>
118+
</div>
119+
<div class="nm-pens" id="nm-pens"></div>
120+
</div>
121+
</div>
122+
106123
<div class="nm-bet">
107124
<div class="nm-bet-line">Bet on the favourite — <b id="nm-fav"></b></div>
108125
<div class="nm-verdict" id="nm-verdict"></div>
@@ -312,9 +329,23 @@ python analysis/export_worldcup_data.py # regenerate this page's data</code></
312329
$('nm-side-a').classList.toggle('fav', favA);
313330
setFlag($('nm-flag-h'), f.home_flag, f.home);
314331
setFlag($('nm-flag-a'), f.away_flag, f.away);
315-
$('nm-date').textContent = new Date(f.date).toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' });
332+
$('nm-date').textContent = new Date(f.date + 'T12:00:00').toLocaleDateString('en-GB', { day:'numeric', month:'short', year:'numeric' });
316333
$('nm-venue').textContent = f.neutral ? 'neutral venue' : `${f.home} hosting`;
317334

335+
// goals forecast (Poisson) + knockout / penalties
336+
const hasG = f.xg_home != null;
337+
$('nm-fc-goals').style.display = hasG ? '' : 'none';
338+
if (hasG) {
339+
$('nm-sc-h').textContent = f.score_home; $('nm-sc-a').textContent = f.score_away;
340+
$('nm-xg-h').textContent = f.xg_home.toFixed(2); $('nm-xg-a').textContent = f.xg_away.toFixed(2);
341+
}
342+
const ah = f.adv_home ?? f.p_fav, aa = f.adv_away ?? (1 - (f.adv_home ?? f.p_fav));
343+
$('nm-adv-h').style.width = (ah*100)+'%'; $('nm-adv-a').style.width = (aa*100)+'%';
344+
$('nm-advh').textContent = pc(ah); $('nm-advhn').textContent = f.home;
345+
$('nm-adva').textContent = pc(aa); $('nm-advan').textContent = f.away;
346+
const pen = f.pen_home ?? 0.5;
347+
$('nm-pens').innerHTML = `<span>🥅 if it goes to penalties:</span> <b>${f.home} ${pc(pen)}</b> · <b>${f.away} ${pc(1-pen)}</b> <em>— shootouts are near coin-flips</em>`;
348+
318349
// 3-way W/D/L bar
319350
const ph = f.p_home ?? f.p_fav, pd = f.p_draw ?? 0, pa = f.p_away ?? 0;
320351
$('nm-wph').textContent = pc(ph);
@@ -423,6 +454,21 @@ python analysis/export_worldcup_data.py # regenerate this page's data</code></
423454
.nm-wdl-key b { color: var(--ink); font-family: var(--mono); }
424455
.nm-wdl-key .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
425456
.dot-h { background: var(--accent); } .dot-d { background: var(--ink-faint); } .dot-a { background: var(--navy); }
457+
/* goals forecast + knockout */
458+
.nm-forecast { display: grid; grid-template-columns: 1fr; gap: 14px; margin-top: 18px; }
459+
@media (min-width: 620px) { .nm-forecast { grid-template-columns: 1fr 1.3fr; } }
460+
.nm-fc-card { border: 1px solid var(--line-soft); border-radius: 12px; padding: 14px 16px; background: var(--surface); }
461+
.nm-fc-label { font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-faint); margin-bottom: 8px; }
462+
.nm-fc-score { font-family: var(--mono); font-size: 2.2rem; font-weight: 650; color: var(--ink); text-align: center; line-height: 1; }
463+
.nm-fc-score i { color: var(--ink-faint); margin: 0 8px; font-style: normal; }
464+
.nm-fc-xg { text-align: center; font-size: .8rem; color: var(--ink-faint); margin-top: 6px; }
465+
.nm-fc-xg b { color: var(--ink-soft); font-family: var(--mono); }
466+
.nm-adv-bar { display: flex; height: 14px; border-radius: 7px; overflow: hidden; background: var(--surface-2); }
467+
.nm-adv-bar .adv-h { background: var(--accent); } .nm-adv-bar .adv-a { background: var(--navy); }
468+
.nm-adv-key { display: flex; justify-content: space-between; margin-top: 7px; font-size: .82rem; color: var(--ink-soft); }
469+
.nm-adv-key b { font-family: var(--mono); color: var(--ink); }
470+
.nm-pens { margin-top: 10px; font-size: .82rem; color: var(--ink-soft); text-align: center; }
471+
.nm-pens b { color: var(--ink); } .nm-pens em { color: var(--ink-faint); font-size: .76rem; }
426472
/* the bet */
427473
.nm-bet { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line-soft); }
428474
.nm-bet-line { text-align: center; font-size: .95rem; color: var(--ink-soft); }

study/analysis/predict_worldcup.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,25 @@ def main():
4343
preds = wc.predict_upcoming(intl, fixtures) if len(fixtures) else fixtures
4444
asof = fp["date_max"]
4545

46-
# per-fixture display extras (3-way probs + flags) — cosmetic, keyed by match_id
47-
extras = {r.match_id: {
48-
"p_home": round(float(r.pH_elo), 4), "p_draw": round(float(r.pD_elo), 4),
49-
"p_away": round(float(r.pA_elo), 4),
50-
"home_flag": wc.flag_iso(r.HomeTeam), "away_flag": wc.flag_iso(r.AwayTeam),
51-
} for r in preds.itertuples()} if len(preds) else {}
46+
# per-fixture display extras (3-way probs, flags, goals forecast, penalties)
47+
extras = {}
48+
gmodel, gteams = wc.fit_intl_goals(intl) if len(preds) else (None, set())
49+
for r in (preds.itertuples() if len(preds) else []):
50+
pH, pD, pA = float(r.pH_elo), float(r.pD_elo), float(r.pA_elo)
51+
cond = pH / (pH + pA) if (pH + pA) > 0 else 0.5
52+
s_home = 0.5 + 0.4 * (cond - 0.5) # shootout win prob (shrunk to ~coin-flip)
53+
adv_home = pH + pD * s_home # advance: win in 90'/ET, or win the shootout
54+
ex = {"p_home": round(pH, 4), "p_draw": round(pD, 4), "p_away": round(pA, 4),
55+
"home_flag": wc.flag_iso(r.HomeTeam), "away_flag": wc.flag_iso(r.AwayTeam),
56+
"adv_home": round(adv_home, 4), "adv_away": round(1 - adv_home, 4),
57+
"pen_home": round(s_home, 4)}
58+
g = wc.predict_match_goals(gmodel, gteams, r.HomeTeam, r.AwayTeam, bool(r.neutral))
59+
if g:
60+
lh, la = g
61+
i, j, _ = wc.top_scoreline(lh, la)
62+
ex.update({"xg_home": round(float(lh), 2), "xg_away": round(float(la), 2),
63+
"score_home": i, "score_away": j})
64+
extras[r.match_id] = ex
5265

5366
# ── append-only ledger: freeze new predictions, reconcile resolved ones ──
5467
ledger = json.loads(LEDGER.read_text()) if LEDGER.exists() else {"predictions": []}

0 commit comments

Comments
 (0)