diff --git a/.gitignore b/.gitignore index 66ae466..9b2eff6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ public/data/ratings.json public/data/h2h.json public/data/scorers.json public/data/backtest.json +public/data/dcTrain.json public/pwa-192x192.png public/pwa-512x512.png public/favicon.svg diff --git a/scripts/buildBacktest.ts b/scripts/buildBacktest.ts index 9b60944..3813459 100644 --- a/scripts/buildBacktest.ts +++ b/scripts/buildBacktest.ts @@ -1,14 +1,21 @@ -// v3 bake-off backtest with a strict three-way split — the evidence behind the +// v4 bake-off backtest with a strict three-way split — the evidence behind the // shipped model. NO information leaks forward: // params (pre-2018) : Elo→goals calibration, Dixon-Coles rho -// validate (2018–2022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w +// validate (2018–2022) : tune Team-DC decay ξ, shrinkage k, ensemble weight w, +// plus the v4 recency candidates — a fast-Elo blend +// (second walk at K×m, blended in with weight β) and a +// recent-form goal multiplier exp(γ·form), where form is +// each side's mean Elo surprise (actual − expected) over +// its last 10 non-friendly matches // test (2022–2026) : untouched final numbers for every variant + baselines +// Decision rule: a recency candidate ships only if it beats the previous shipped +// config by ≥ MIN_VAL_GAIN RPS on validation. Test is evaluated once, for honesty. // Output → public/data/backtest.json (Methodology page). import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { canonicalTeam } from '../src/lib/teams'; -import { HOME_ADV_ELO, importanceWeight, eloDelta } from '../src/lib/model/elo'; +import { HOME_ADV_ELO, importanceWeight, eloDelta, expectedHome } from '../src/lib/model/elo'; import { lambdasFromElo, scoreMatrix, outcomeProbs, poissonPmf, type ModelParams } from '../src/lib/model/poisson'; import { fitTeamDc, teamDcLambdas, poolMatrices, type DcMatch, type TeamDcParams } from '../src/lib/model/teamDc'; @@ -24,8 +31,15 @@ const TEST_TO = '2026-06-01'; const REFIT_DAYS = 60; const FIT_WINDOW_YEARS = 15; -const XI_GRID = [0.5, 1, 1.5, 2, 2.5]; -const K_GRID = [5, 10, 20]; +const XI_GRID = [0.25, 0.5, 0.75, 1, 1.5, 2, 2.5]; +const K_GRID = [3, 5, 10, 20]; +const M_GRID = [2, 3]; // fast-Elo K multiplier +const BETA_GRID = [0, 0.1, 0.2, 0.3]; // fast-Elo blend weight (0 = control) +const GAMMA_GRID = [0, 0.05, 0.1, 0.15]; // recent-form goal multiplier (0 = control) +const FORM_WINDOW = 10; // last N non-friendly matches feeding the form signal +const MIN_VAL_GAIN = 0.0005; // candidate must beat the previous config by this much +/** the previously shipped v3 config — the bar every candidate has to clear */ +const PREV = { xi: 0.5, k: 5, w: 0.75, m: 2, beta: 0, gamma: 0 }; interface Row { date: string; home: string; away: string; hs: number; as: number; tournament: string; neutral: boolean; t: number } type Probs = { h: number; d: number; a: number }; @@ -89,13 +103,25 @@ function main(): void { const tTestFrom = new Date(TEST_FROM).getTime(); const tTestTo = new Date(TEST_TO).getTime(); - // ---------- pass 1: Elo walk + Elo-DC param fit on pre-2018 ---------- + // ---------- pass 1: Elo walks + form signal + Elo-DC param fit on pre-2018 ---------- const elo = new Map(); const get = (t: string) => elo.get(t) ?? START_ELO; let sumTotal = 0, nCal = 0, sxy = 0, sxx = 0; const calib: { d: number; h: number; a: number }[] = []; /** pre-match Elo per row index (so later passes never see post-match info) */ const preElo: { eh: number; ea: number }[] = new Array(rows.length); + /** pre-match fast Elo (same walk at K×m) per multiplier, for the β blend */ + const eloFast = new Map>(M_GRID.map((m) => [m, new Map()])); + const preEloFast = new Map(M_GRID.map((m) => [m, new Array(rows.length)])); + /** pre-match recent form per side: mean Elo surprise (s − E) over the last + * FORM_WINDOW non-friendly matches — strictly pre-match, like preElo */ + const formArr = new Map(); + const formH = new Float64Array(rows.length); + const formA = new Float64Array(rows.length); + const formMean = (t: string): number => { + const a = formArr.get(t); + return a && a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; + }; for (let i = 0; i < rows.length; i++) { const r = rows[i]!; @@ -107,6 +133,27 @@ function main(): void { sumTotal += r.hs + r.as; nCal++; sxy += eff * (r.hs - r.as); sxx += eff * eff; calib.push({ d: eff, h: r.hs, a: r.as }); } + for (const m of M_GRID) { + const fm = eloFast.get(m)!; + const feh = fm.get(r.home) ?? START_ELO, fea = fm.get(r.away) ?? START_ELO; + preEloFast.get(m)![i] = { eh: feh, ea: fea }; + const fd = eloDelta(r.hs, r.as, feh, fea, importanceWeight(r.tournament) * m, homeAdv); + fm.set(r.home, feh + fd); fm.set(r.away, fea - fd); + } + formH[i] = formMean(r.home); + formA[i] = formMean(r.away); + if (r.tournament !== 'Friendly') { + const eHome = expectedHome(eh, ea, homeAdv); + const sHome = r.hs > r.as ? 1 : r.hs < r.as ? 0 : 0.5; + const push = (t: string, v: number): void => { + const a = formArr.get(t) ?? []; + a.push(v); + if (a.length > FORM_WINDOW) a.shift(); + formArr.set(t, a); + }; + push(r.home, sHome - eHome); + push(r.away, (1 - sHome) - (1 - eHome)); + } const d = eloDelta(r.hs, r.as, eh, ea, importanceWeight(r.tournament), homeAdv); elo.set(r.home, eh + d); elo.set(r.away, ea - d); } @@ -126,15 +173,26 @@ function main(): void { } const params: ModelParams = { goalsPerElo, avgGoals, rho, homeAdvElo: HOME_ADV_ELO }; - const eloMatrix = (i: number): number[][] => { - const r = rows[i]!; - const { lambdaHome, lambdaAway } = lambdasFromElo(preElo[i]!.eh, preElo[i]!.ea, params, r.neutral ? 0 : HOME_ADV_ELO); - return scoreMatrix(lambdaHome, lambdaAway, rho); + // ---------- prediction helpers (all read strictly pre-match state) ---------- + /** Elo-member lambdas, with optional fast-Elo blend R = (1−β)·slow + β·fast. */ + const eloLambdas = (i: number, beta: number, m: number): { lh: number; la: number } => { + const slow = preElo[i]!; + const fast = beta > 0 ? preEloFast.get(m)![i]! : slow; + const eh = (1 - beta) * slow.eh + beta * fast.eh; + const ea = (1 - beta) * slow.ea + beta * fast.ea; + const { lambdaHome, lambdaAway } = lambdasFromElo(eh, ea, params, rows[i]!.neutral ? 0 : HOME_ADV_ELO); + return { lh: lambdaHome, la: lambdaAway }; }; + const clampL = (x: number): number => Math.min(8, Math.max(0.1, x)); + /** Recent-form goal multiplier exp(γ·form), applied to both ensemble members. */ + const withForm = (lh: number, la: number, i: number, gamma: number): { lh: number; la: number } => + gamma === 0 + ? { lh, la } + : { lh: clampL(lh * Math.exp(gamma * formH[i]!)), la: clampL(la * Math.exp(gamma * formA[i]!)) }; - // ---------- walk-forward Team-DC over a window, collecting matrices ---------- - type Pred = { i: number; mDc: number[][] }; - const walkTeamDc = (xi: number, k: number, fromT: number, toT: number): Pred[] => { + // ---------- walk-forward Team-DC over a window, collecting lambdas ---------- + type Pred = { i: number; lDcH: number; lDcA: number }; + const walkTeamDc = (xi: number, k: number, fromT: number, toT: number, refitDays = REFIT_DAYS): Pred[] => { const preds: Pred[] = []; let fit: TeamDcParams | null = null; let fitAt = -Infinity; @@ -143,7 +201,7 @@ function main(): void { const r = rows[i]!; if (r.t < fromT) continue; if (r.t >= toT) break; - if (r.t - fitAt > REFIT_DAYS * DAY) { + if (r.t - fitAt > refitDays * DAY) { const train: DcMatch[] = []; for (let j = 0; j < i; j++) { const m = rows[j]!; @@ -157,36 +215,96 @@ function main(): void { fitAt = r.t; } const { lambdaHome, lambdaAway } = teamDcLambdas(fit!, r.home, r.away, r.neutral); - preds.push({ i, mDc: scoreMatrix(lambdaHome, lambdaAway, rho) }); + preds.push({ i, lDcH: lambdaHome, lDcA: lambdaAway }); } return preds; }; - // ---------- validation: tune ξ, k, then w ---------- - console.log('tuning on validation (2018–2022)…'); - let best = { xi: 1, k: 10, w: 0.5, rps: Infinity }; + /** Sweep the ensemble weight w for a fixed (β, m, γ) over cached walk preds. */ + const W_STEPS: number[] = []; + for (let w = 0; w <= 1.0001; w += 0.05) W_STEPS.push(+w.toFixed(2)); + const sweepW = (preds: Pred[], beta: number, m: number, gamma: number): { w: number; rps: number }[] => { + const acc = new Float64Array(W_STEPS.length); + for (const p of preds) { + const o = outcomeOf(rows[p.i]!.hs, rows[p.i]!.as); + const el = eloLambdas(p.i, beta, m); + const ef = withForm(el.lh, el.la, p.i, gamma); + const df = withForm(p.lDcH, p.lDcA, p.i, gamma); + const mE = scoreMatrix(ef.lh, ef.la, rho); + const mD = scoreMatrix(df.lh, df.la, rho); + for (let wi = 0; wi < W_STEPS.length; wi++) { + const w = W_STEPS[wi]!; + const pooled = w === 0 ? mD : w === 1 ? mE : poolMatrices(mE, mD, w); + acc[wi] += rps(probsOf(pooled), o); + } + } + return W_STEPS.map((w, wi) => ({ w, rps: acc[wi]! / preds.length })); + }; + + // ---------- validation stage 1: tune ξ, k, w (β=0, γ=0) ---------- + console.log('validation stage 1 — ξ/k/w grid (β=0, γ=0)…'); + const valWalks = new Map(); + let best = { xi: PREV.xi, k: PREV.k, w: PREV.w, m: PREV.m, beta: 0, gamma: 0, rps: Infinity }; + let prevValRps = Infinity; // validation RPS of the previously shipped config for (const xi of XI_GRID) { for (const k of K_GRID) { const preds = walkTeamDc(xi, k, tValFrom, tTestFrom); - // pure Team-DC score (w=0 candidate) and weight sweep against Elo - for (let w = 0; w <= 1.0001; w += 0.05) { - let r = 0; - for (const p of preds) { - const pooled = w === 0 ? p.mDc : w === 1 ? eloMatrix(p.i) : poolMatrices(eloMatrix(p.i), p.mDc, w); - r += rps(probsOf(pooled), outcomeOf(rows[p.i]!.hs, rows[p.i]!.as)); - } - r /= preds.length; - if (r < best.rps) best = { xi, k, w: +w.toFixed(2), rps: r }; + valWalks.set(`${xi}|${k}`, preds); + for (const { w, rps: r } of sweepW(preds, 0, PREV.m, 0)) { + if (xi === PREV.xi && k === PREV.k && w === PREV.w) prevValRps = r; + if (r < best.rps) best = { xi, k, w, m: PREV.m, beta: 0, gamma: 0, rps: r }; } console.log(` ξ=${xi} k=${k} done (best so far: ξ=${best.xi} k=${best.k} w=${best.w} rps=${best.rps.toFixed(4)})`); } } - // ---------- test window: final scores for every variant ---------- - console.log(`testing on ${TEST_FROM}–${TEST_TO} with ξ=${best.xi} k=${best.k} w=${best.w}…`); - const testPreds = walkTeamDc(best.xi, best.k, tTestFrom, tTestTo); + // ---------- validation stage 2: fast-Elo blend (m, β) at the stage-1 ξ/k ---------- + console.log('validation stage 2 — fast-Elo blend (m, β)…'); + const stagePreds = valWalks.get(`${best.xi}|${best.k}`)!; + for (const m of M_GRID) { + for (const beta of BETA_GRID) { + if (beta === 0) continue; // control already covered by stage 1 + for (const { w, rps: r } of sweepW(stagePreds, beta, m, 0)) { + if (r < best.rps) best = { ...best, m, beta, w, rps: r }; + } + } + console.log(` m=${m} swept (best: β=${best.beta} m=${best.m} w=${best.w} rps=${best.rps.toFixed(4)})`); + } + + // ---------- validation stage 3: recent-form multiplier γ at the winners ---------- + console.log('validation stage 3 — form multiplier γ…'); + for (const gamma of GAMMA_GRID) { + if (gamma === 0) continue; // control covered above + for (const { w, rps: r } of sweepW(stagePreds, best.beta, best.m, gamma)) { + if (r < best.rps) best = { ...best, gamma, w, rps: r }; + } + } + console.log(` champion: ξ=${best.xi} k=${best.k} w=${best.w} β=${best.beta} m=${best.m} γ=${best.gamma} rps=${best.rps.toFixed(4)}`); + + // ---------- decision rule: ship only a validated improvement ---------- + const gain = prevValRps - best.rps; + const shipChange = gain >= MIN_VAL_GAIN; + const shipped = shipChange ? best : { ...PREV, rps: prevValRps }; + console.log(`previous config validation rps=${prevValRps.toFixed(4)} | champion gain=${gain.toFixed(4)} → ${shipChange ? 'SHIP candidate' : 'KEEP previous (bar not met)'}`); + + // ---------- in-tournament refit cadence check (validation only) ---------- + console.log('validating 7-day Team-DC refit cadence…'); + const preds7 = walkTeamDc(shipped.xi, shipped.k, tValFrom, tTestFrom, 7); + const refit7Rps = sweepW(preds7, shipped.beta, shipped.m, shipped.gamma).find((x) => x.w === shipped.w)!.rps; + const refit7Ok = refit7Rps <= shipped.rps + MIN_VAL_GAIN; + console.log(` refit=7d rps=${refit7Rps.toFixed(4)} vs ${REFIT_DAYS}d rps=${shipped.rps.toFixed(4)} → nightly refit ${refit7Ok ? 'ENABLED' : 'disabled'}`); + + // ---------- test window: shipped + candidate/previous + members + baselines ---------- + console.log(`testing on ${TEST_FROM}–${TEST_TO} with shipped ξ=${shipped.xi} k=${shipped.k} w=${shipped.w} β=${shipped.beta} m=${shipped.m} γ=${shipped.gamma}…`); + const testPreds = walkTeamDc(shipped.xi, shipped.k, tTestFrom, tTestTo); + const testPredsAlt = shipped.xi === PREV.xi && shipped.k === PREV.k + ? testPreds + : walkTeamDc(shipChange ? PREV.xi : best.xi, shipChange ? PREV.k : best.k, tTestFrom, tTestTo); + const altCfg = shipChange ? { ...PREV } : { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma }; + const altByIdx = new Map(testPredsAlt.map((p) => [p.i, p])); const setEnsemble: { p: Probs; o: Outcome }[] = []; + const setAlt: { p: Probs; o: Outcome }[] = []; const setElo: { p: Probs; o: Outcome }[] = []; const setDc: { p: Probs; o: Outcome }[] = []; const setUniform: { p: Probs; o: Outcome }[] = []; @@ -202,12 +320,25 @@ function main(): void { } const rate: Probs = { h: th / tn, d: td / tn, a: ta / tn }; + /** pooled outcome probs for one test row under a full config */ + const pooledProbs = (p: Pred, cfg: { w: number; m: number; beta: number; gamma: number }): Probs => { + const el = eloLambdas(p.i, cfg.beta, cfg.m); + const ef = withForm(el.lh, el.la, p.i, cfg.gamma); + const df = withForm(p.lDcH, p.lDcA, p.i, cfg.gamma); + const mE = scoreMatrix(ef.lh, ef.la, rho); + const mD = scoreMatrix(df.lh, df.la, rho); + return probsOf(cfg.w === 0 ? mD : cfg.w === 1 ? mE : poolMatrices(mE, mD, cfg.w)); + }; + for (const p of testPreds) { const r = rows[p.i]!; const o = outcomeOf(r.hs, r.as); - const mE = eloMatrix(p.i); - const pooled = poolMatrices(mE, p.mDc, best.w); - const pe = probsOf(mE), pd = probsOf(p.mDc), pp = probsOf(pooled); + const el = eloLambdas(p.i, 0, PREV.m); + const pe = probsOf(scoreMatrix(el.lh, el.la, rho)); + const pd = probsOf(scoreMatrix(p.lDcH, p.lDcA, rho)); + const pp = pooledProbs(p, shipped); + const alt = altByIdx.get(p.i); + if (alt) setAlt.push({ p: pooledProbs(alt, altCfg), o }); setElo.push({ p: pe, o }); setDc.push({ p: pd, o }); setEnsemble.push({ p: pp, o }); @@ -228,11 +359,20 @@ function main(): void { const totalN = reliabilityRaw.length; const ece = +bins.reduce((s, b) => s + (b.n ? (b.n / totalN) * Math.abs(b.sp / b.n - b.sy / b.n) : 0), 0).toFixed(3); + const altName = shipChange + ? 'Previous ensemble (v3)' + : 'Recency candidate (failed validation bar)'; const out = { generatedAt: new Date().toISOString(), paramFrom: PARAM_FROM, trainEnd: VAL_FROM, - validation: { from: VAL_FROM, to: TEST_FROM, tuned: { xi: best.xi, shrinkK: best.k, ensembleW: best.w } }, + validation: { + from: VAL_FROM, to: TEST_FROM, + tuned: { + xi: shipped.xi, shrinkK: shipped.k, ensembleW: shipped.w, + fastM: shipped.m, fastBeta: shipped.beta, formGamma: shipped.gamma, + }, + }, testFrom: TEST_FROM, testTo: TEST_TO, tested: testPreds.length, @@ -240,6 +380,7 @@ function main(): void { model: scoreSet(setEnsemble), // the SHIPPED model = ensemble variants: [ { name: 'Ensemble (shipped)', ...scoreSet(setEnsemble) }, + { name: altName, ...scoreSet(setAlt) }, { name: 'Elo–Dixon-Coles', ...scoreSet(setElo) }, { name: 'Team Dixon-Coles', ...scoreSet(setDc) }, ], @@ -247,6 +388,14 @@ function main(): void { reliability, ece, worldCup: { tested: wcN, accuracy: wcN ? +(wcCorrect / wcN).toFixed(4) : 0 }, + // the v4 recency decision, recorded for honesty + reproducibility + recency: { + minValGain: MIN_VAL_GAIN, + previous: { ...PREV, valRps: +prevValRps.toFixed(4) }, + champion: { xi: best.xi, k: best.k, w: best.w, m: best.m, beta: best.beta, gamma: best.gamma, valRps: +best.rps.toFixed(4) }, + shippedChange: shipChange, + nightlyRefit: { refitDays: 7, valRps: +refit7Rps.toFixed(4), baselineValRps: +shipped.rps.toFixed(4), enabled: refit7Ok }, + }, }; mkdirSync(OUT_DIR, { recursive: true }); diff --git a/scripts/buildRatings.ts b/scripts/buildRatings.ts index f4214f5..fe6736a 100644 --- a/scripts/buildRatings.ts +++ b/scripts/buildRatings.ts @@ -13,9 +13,13 @@ import { fitTeamDc, type DcMatch } from '../src/lib/model/teamDc'; // Ensemble hyper-params — tuned on the 2018–2022 validation window and verified // out-of-sample on 2022–2026 by scripts/buildBacktest.ts. Keep in sync with it. -const TEAMDC_XI = 0.5; -const TEAMDC_SHRINK_K = 5; -const ENSEMBLE_W = 0.75; // log-pool weight on the Elo-DC member +// v4 recency config: more ensemble weight on Team-DC, plus a fast Elo walk +// (K×3) blended into the Elo member at β=0.3, so recent results count harder. +const TEAMDC_XI = 0.25; +const TEAMDC_SHRINK_K = 3; +const ENSEMBLE_W = 0.45; // log-pool weight on the Elo-DC member +const FAST_M = 3; // fast-Elo K multiplier +const FAST_BETA = 0.3; // blend weight on the fast walk const FIT_WINDOW_YEARS = 15; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); @@ -62,6 +66,8 @@ function main(): void { const rows = parseCsv(readFileSync(CSV, 'utf8')); const elo = new Map(); const get = (t: string) => elo.get(t) ?? START_ELO; + const eloFast = new Map(); + const getFast = (t: string) => eloFast.get(t) ?? START_ELO; // Calibration accumulators (modern era only). let sumTotal = 0, nCalib = 0; @@ -88,6 +94,10 @@ function main(): void { const delta = eloDelta(r.hs, r.as, eh, ea, k, homeAdv); elo.set(home, eh + delta); elo.set(away, ea - delta); + const feh = getFast(home), fea = getFast(away); + const fastDelta = eloDelta(r.hs, r.as, feh, fea, k * FAST_M, homeAdv); + eloFast.set(home, feh + fastDelta); + eloFast.set(away, fea - fastDelta); lastDate = r.date; } @@ -126,6 +136,8 @@ function main(): void { } // Ensure every WC team has a rating (default for any never seen). for (const t of ALL_TEAMS) if (!(t in ratings)) ratings[t] = START_ELO; + const ratingsFast: Record = {}; + for (const t of Object.keys(ratings)) ratingsFast[t] = Math.round(getFast(t) * 10) / 10; // ---- Team-DC ensemble member: fit on the recent window through today ---- const nowT = new Date(lastDate).getTime(); @@ -162,12 +174,24 @@ function main(): void { }, teamDc, ensembleW: ENSEMBLE_W, + ratingsFast, + fastBeta: FAST_BETA, + fastM: FAST_M, + teamDcFit: { xi: TEAMDC_XI, shrinkK: TEAMDC_SHRINK_K, windowYears: FIT_WINDOW_YEARS }, ratings, }; mkdirSync(OUT_DIR, { recursive: true }); writeFileSync(join(OUT_DIR, 'ratings.json'), JSON.stringify(out)); + // Compact training rows for the server's nightly in-tournament Team-DC refit + // (absolute dates, so daysAgo can be recomputed as the tournament progresses). + const dcRows = rows + .filter((r) => nowT - new Date(r.date).getTime() <= windowMs) + .map((r) => [r.date, canonicalTeam(r.home), canonicalTeam(r.away), r.hs, r.as, r.neutral ? 1 : 0]); + writeFileSync(join(OUT_DIR, 'dcTrain.json'), JSON.stringify({ asOf: lastDate, rows: dcRows })); + console.log(`dcTrain → public/data/dcTrain.json (${dcRows.length} rows, ${FIT_WINDOW_YEARS}y window)`); + const top = Object.entries(ratings).slice(0, 12).map(([t, r]) => `${t} ${Math.round(r)}`).join(', '); console.log(`ratings → public/data/ratings.json (${rows.length} matches, asOf ${lastDate})`); console.log(`params: goalsPerElo=${out.params.goalsPerElo} avgGoals=${out.params.avgGoals} rho=${rho} homeAdvElo=${HOME_ADV_ELO}`); diff --git a/server/src/index.ts b/server/src/index.ts index 7571acb..8f1b5b9 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -35,6 +35,7 @@ export function buildServer() { db(); // open + migrate SQLite before anything reads it const state = new TournamentState(STATIC_DIR); const model = new ModelEngine(STATIC_DIR); + model.refitTeamDc(state.allFixtures()); // current Team-DC from day one (covers restarts mid-tournament) model.recompute(state.allFixtures()); const clients = new Set(); @@ -151,6 +152,12 @@ export function buildServer() { }); app.addHook('onClose', async () => stopIngest()); + // ---- nightly Team-DC refit (backtest-validated): learn from this tournament ---- + const refitTimer = setInterval(() => { + if (model.refitTeamDc(state.allFixtures())) broadcast(); + }, 24 * 60 * 60 * 1000); + app.addHook('onClose', async () => clearInterval(refitTimer)); + // ---- static SPA (prod) with history fallback ---- if (existsSync(STATIC_DIR)) { void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false }); diff --git a/server/src/model.ts b/server/src/model.ts index b4b069b..94b8647 100644 Binary files a/server/src/model.ts and b/server/src/model.ts differ diff --git a/server/src/refit.test.ts b/server/src/refit.test.ts new file mode 100644 index 0000000..8405b07 --- /dev/null +++ b/server/src/refit.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { refitTrainingSet, type DcRow } from './refit'; +import type { Fixture } from '../../src/lib/types'; + +const slot = (team: string | null) => ({ team, placeholder: null, label: team ?? '?' }); +const fx = (over: Partial): Fixture => ({ + num: 1, stage: 'group', group: 'A', round: 'Matchday 1', groupRound: 1, + kickoff: '2026-06-14T18:00:00Z', venue: 'Boston (Foxborough)', + home: slot('Spain'), away: slot('Japan'), + status: 'finished', homeScore: 2, awayScore: 0, minute: null, + ...over, +} as Fixture); + +const base = { + asOf: '2026-06-10', + rows: [ + ['2026-06-01', 'Spain', 'France', 1, 1, 1], + ['2010-01-01', 'Spain', 'France', 3, 0, 0], // outside a 15y window from 2026 + ] as DcRow[], +}; + +describe('refitTrainingSet', () => { + it('appends finished fixtures and advances asOf to the newest match', () => { + const { train, asOf } = refitTrainingSet(base, [fx({})], 70); + expect(asOf).toBe('2026-06-14'); + const wc = train.find((m) => m.daysAgo === 0); + expect(wc).toMatchObject({ home: 'Spain', away: 'Japan', homeGoals: 2, awayGoals: 0, neutral: true }); + }); + + it('recomputes daysAgo relative to the new asOf', () => { + const { train } = refitTrainingSet(base, [fx({})], 70); + const old = train.find((m) => m.home === 'Spain' && m.away === 'France'); + expect(old?.daysAgo).toBe(13); // 2026-06-01 → 2026-06-14 + }); + + it('drops rows that fall outside the window', () => { + const { train } = refitTrainingSet(base, [fx({})], 70); + expect(train.some((m) => m.homeGoals === 3)).toBe(false); + }); + + it('treats a host playing at home as non-neutral', () => { + const { train } = refitTrainingSet(base, [fx({ home: slot('USA'), venue: 'Los Angeles (Inglewood)' })], 70); + const wc = train.find((m) => m.daysAgo === 0); + expect(wc).toMatchObject({ home: 'USA', neutral: false }); + }); + + it('flips orientation when the away side is the host at home', () => { + const { train } = refitTrainingSet(base, [fx({ away: slot('Mexico'), venue: 'Mexico City' })], 70); + const wc = train.find((m) => m.daysAgo === 0); + expect(wc).toMatchObject({ home: 'Mexico', away: 'Spain', homeGoals: 0, awayGoals: 2, neutral: false }); + }); + + it('skips fixtures without teams or scores', () => { + const { train } = refitTrainingSet(base, [fx({ homeScore: null })], 70); + expect(train.every((m) => m.daysAgo > 0)).toBe(true); + }); +}); diff --git a/server/src/refit.ts b/server/src/refit.ts new file mode 100644 index 0000000..251db89 --- /dev/null +++ b/server/src/refit.ts @@ -0,0 +1,51 @@ +// Pure helpers for the nightly in-tournament Team-DC refit (no fs/db imports, +// so vitest can load them). The training base comes from dcTrain.json — compact +// [date, home, away, hs, as, neutral01] rows with absolute dates — plus every +// finished World Cup fixture so far. +import { hostAdvantage } from '../../src/lib/model/hosts'; +import type { DcMatch } from '../../src/lib/model/teamDc'; +import type { Fixture } from '../../src/lib/types'; + +export type DcRow = [string, string, string, number, number, number]; + +const DAY = 24 * 60 * 60 * 1000; + +/** Build the refit training set, with daysAgo relative to the newest match in + * the combined data. Host-nation fixtures are oriented so the host is the + * DC home side (the CSV convention for non-neutral rows). */ +export function refitTrainingSet( + base: { asOf: string; rows: DcRow[] }, + finished: Fixture[], + homeAdvElo: number, + windowYears = 15, +): { train: DcMatch[]; asOf: string } { + let asOf = base.asOf; + const extra: { date: string; home: string; away: string; hs: number; as: number; neutral: boolean }[] = []; + for (const f of finished) { + if (!f.home.team || !f.away.team || f.homeScore == null || f.awayScore == null) continue; + const date = f.kickoff.slice(0, 10); + if (date > asOf) asOf = date; + const adv = hostAdvantage(f.home.team, f.away.team, f.venue, homeAdvElo); + if (adv < 0) { + extra.push({ date, home: f.away.team, away: f.home.team, hs: f.awayScore, as: f.homeScore, neutral: false }); + } else { + extra.push({ date, home: f.home.team, away: f.away.team, hs: f.homeScore, as: f.awayScore, neutral: adv === 0 }); + } + } + + const nowT = new Date(asOf).getTime(); + const windowMs = windowYears * 365 * DAY; + const train: DcMatch[] = []; + for (const [date, home, away, hs, as, neutral] of base.rows) { + const t = new Date(date).getTime(); + if (nowT - t > windowMs) continue; + train.push({ daysAgo: (nowT - t) / DAY, home, away, homeGoals: hs, awayGoals: as, neutral: neutral === 1 }); + } + for (const m of extra) { + train.push({ + daysAgo: (nowT - new Date(m.date).getTime()) / DAY, + home: m.home, away: m.away, homeGoals: m.hs, awayGoals: m.as, neutral: m.neutral, + }); + } + return { train, asOf }; +} diff --git a/src/lib/model/model.test.ts b/src/lib/model/model.test.ts index 7329d02..2f2b7ae 100644 --- a/src/lib/model/model.test.ts +++ b/src/lib/model/model.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { expectedHome, eloDelta, goalDiffMultiplier } from './elo'; import { poissonPmf, scoreMatrix, outcomeProbs, lambdasFromElo, type ModelParams } from './poisson'; -import { predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict'; +import { blendedRating, predictMatch, knockoutAdvanceProb, type RatingsModel } from './predict'; const PARAMS: ModelParams = { goalsPerElo: 0.0057, avgGoals: 2.73, rho: -0.05, homeAdvElo: 70 }; @@ -82,3 +82,35 @@ describe('predictMatch', () => { expect(adv).toBeLessThan(1); }); }); + +describe('fast-Elo blend (v4 recency)', () => { + const base: RatingsModel = { + asOf: '2026-06-10', + params: PARAMS, + ratings: { Strong: 2050, Weak: 1650 }, + }; + + it('absent fast fields ⇒ bit-identical to the slow model (golden regression)', () => { + const withNoise: RatingsModel = { ...base, ratingsFast: { Strong: 2300, Weak: 1400 }, fastBeta: 0 }; + const a = predictMatch('Strong', 'Weak', base); + const b = predictMatch('Strong', 'Weak', withNoise); + expect(b.probs).toEqual(a.probs); + expect(b.lambdaHome).toBe(a.lambdaHome); + }); + + it('blends toward the fast rating with β', () => { + expect(blendedRating({ ...base, ratingsFast: { Strong: 2150, Weak: 1650 }, fastBeta: 0.3 }, 'Strong')) + .toBeCloseTo(0.7 * 2050 + 0.3 * 2150, 9); + }); + + it('a hot fast rating raises the win probability', () => { + const hot: RatingsModel = { ...base, ratingsFast: { Strong: 2250, Weak: 1650 }, fastBeta: 0.3 }; + expect(predictMatch('Strong', 'Weak', hot).probs.home) + .toBeGreaterThan(predictMatch('Strong', 'Weak', base).probs.home); + }); + + it('falls back to DEFAULT_ELO for unknown teams in the fast walk', () => { + const m: RatingsModel = { ...base, ratingsFast: {}, fastBeta: 0.3 }; + expect(blendedRating(m, 'Strong')).toBeCloseTo(0.7 * 2050 + 0.3 * 1500, 9); + }); +}); diff --git a/src/lib/model/predict.ts b/src/lib/model/predict.ts index f1d5016..f5582c1 100644 --- a/src/lib/model/predict.ts +++ b/src/lib/model/predict.ts @@ -19,6 +19,14 @@ export interface RatingsModel { teamDc?: TeamDcParams; /** Log-pool weight on the Elo-DC member (backtest-tuned). */ ensembleW?: number; + /** Fast Elo member — the same walk at K×fastM, so recent results move it + * harder. Blended into the Elo member as (1−β)·slow + β·fast. All optional: + * an older ratings file behaves exactly as before. */ + ratingsFast?: Record; + fastBeta?: number; + fastM?: number; + /** Team-DC fit hyper-params, shipped so the server can refit in-tournament. */ + teamDcFit?: { xi: number; shrinkK: number; windowYears: number }; } export const DEFAULT_ELO = 1500; @@ -27,6 +35,15 @@ export function ratingOf(model: RatingsModel, team: string): number { return model.ratings[team] ?? DEFAULT_ELO; } +/** The rating that drives probabilities: slow Elo blended with the fast walk + * when the model ships one (backtest-validated recency component). */ +export function blendedRating(model: RatingsModel, team: string): number { + const slow = model.ratings[team] ?? DEFAULT_ELO; + const beta = model.fastBeta ?? 0; + if (beta <= 0 || !model.ratingsFast) return slow; + return (1 - beta) * slow + beta * (model.ratingsFast[team] ?? DEFAULT_ELO); +} + export interface MatchPrediction { home: string; away: string; @@ -52,7 +69,7 @@ export function matchMatrix( homeAdv = 0, ): number[][] { const { lambdaHome, lambdaAway } = lambdasFromElo( - ratingOf(model, home), ratingOf(model, away), model.params, homeAdv, + blendedRating(model, home), blendedRating(model, away), model.params, homeAdv, ); const mElo = scoreMatrix(lambdaHome, lambdaAway, model.params.rho); if (!model.teamDc || model.ensembleW == null || model.ensembleW >= 1) return mElo; diff --git a/vite.config.ts b/vite.config.ts index ae4a330..7b59280 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,8 @@ export default defineConfig({ }, workbox: { globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'], + // server-only refit training data (~1MB) — keep it off every client + globIgnores: ['**/dcTrain.json'], maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, skipWaiting: true, clientsClaim: true,