diff --git a/server/src/db/db.ts b/server/src/db/db.ts index 382c6e1..f90549b 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -462,6 +462,10 @@ export function saveSnapshot(fixtureNum: number, late: boolean, model: unknown, .run(fixtureNum, Date.now(), late ? 1 : 0, JSON.stringify(model), market ? JSON.stringify(market) : null); } +export function snapshotFor(fixtureNum: number): SnapshotRow | null { + return (db().prepare('SELECT * FROM prediction_snapshots WHERE fixture_num = ?').get(fixtureNum) as unknown as SnapshotRow) ?? null; +} + export function allSnapshots(): SnapshotRow[] { return db() .prepare('SELECT fixture_num, taken_at, late, model_json, market_json FROM prediction_snapshots ORDER BY fixture_num') diff --git a/server/src/ingest/scheduler.ts b/server/src/ingest/scheduler.ts index 166a42e..587274e 100644 --- a/server/src/ingest/scheduler.ts +++ b/server/src/ingest/scheduler.ts @@ -215,13 +215,17 @@ export function startScheduler( logIngest('fotmob', 'map', false, Date.now() - t0, e instanceof Error ? e.message : String(e)); } } - // details: live fixtures at the live TTL; finished ones until settled + // details: live fixtures at the live TTL; finished ones until settled; + // upcoming ones inside 48h for the predicted lineups (the 1h cache makes + // the extra fetches free on the live cadence). for (const f of state.allFixtures()) { if (stopped) return; const id = getSourceMap(f.num, 'fotmob'); if (!id) continue; const live = f.status === 'live'; - if (!live && f.status !== 'finished') continue; + const k = new Date(f.kickoff).getTime(); + const upcoming = f.status === 'scheduled' && k > now && k - now < 48 * 60 * 60 * 1000; + if (!live && f.status !== 'finished' && !upcoming) continue; if (f.status === 'finished') { const row = getMatchProvider<{ v?: number }>(f.num, 'fotmob'); const settled = row && row.data.v === 2 && row.updatedAt > new Date(f.kickoff).getTime() + FOTMOB_SETTLE_MS; diff --git a/server/src/preview.ts b/server/src/preview.ts index 90cb26c..b2ee155 100644 --- a/server/src/preview.ts +++ b/server/src/preview.ts @@ -1,10 +1,10 @@ import { readFileSync, existsSync } from 'node:fs'; -import { getMatchExt } from './db/db'; +import { getMatchExt, snapshotFor } from './db/db'; import type { TournamentState } from './tournament'; import type { ModelEngine } from './model'; import type { EspnSummary } from './ingest/espnNormalize'; import type { - H2HSummary, KeyPlayer, MatchPreview, StandingRow, TeamProfile, + Fixture, H2HSummary, KeyPlayer, MatchPrediction, MatchPreview, MatchProbs, StandingRow, TeamProfile, } from '../../src/lib/types'; // Assembles a MatchPreview / TeamProfile from three layers: precomputed history @@ -51,6 +51,45 @@ function h2hSummary(home: string, away: string): H2HSummary | null { }; } + +/** Most likely score under independent Poissons — a close stand-in for the + * frozen forecast's scoreline chip (the full DC matrix isn't stored). */ +function poissonTopScore(lh: number, la: number): { home: number; away: number; p: number } { + const pmf = (l: number, k: number): number => { + let f = 1; + for (let i = 2; i <= k; i++) f *= i; + return (Math.exp(-l) * l ** k) / f; + }; + let best = { home: 0, away: 0, p: 0 }; + for (let h = 0; h <= 6; h++) { + for (let a = 0; a <= 6; a++) { + const p = pmf(lh, h) * pmf(la, a); + if (p > best.p) best = { home: h, away: a, p }; + } + } + return { ...best, p: Math.round(best.p * 1000) / 1000 }; +} + +function frozenPrediction(fixture: Fixture): MatchPrediction | null { + if (fixture.status !== 'finished' || !fixture.home.team || !fixture.away.team) return null; + const snap = snapshotFor(fixture.num); + if (!snap) return null; + try { + const m = JSON.parse(snap.model_json) as { probs: MatchProbs; lambdaHome: number; lambdaAway: number }; + return { + num: fixture.num, + home: fixture.home.team, + away: fixture.away.team, + probs: m.probs, + lambdaHome: m.lambdaHome, + lambdaAway: m.lambdaAway, + topScore: poissonTopScore(m.lambdaHome, m.lambdaAway), + }; + } catch { + return null; + } +} + export function buildPreview(num: number, state: TournamentState, model: ModelEngine): MatchPreview | null { const fixture = state.getFixture(num); if (!fixture) return null; @@ -65,7 +104,9 @@ export function buildPreview(num: number, state: TournamentState, model: ModelEn const eh = espnFor(home); const ea = espnFor(away); - const prediction = model.current().matches.find((m) => m.num === num) ?? null; + // The live model drops finished matches — fall back to the forecast frozen + // before kickoff so "how good were we" stays visible after full time. + const prediction = model.current().matches.find((m) => m.num === num) ?? frozenPrediction(fixture); return { num, diff --git a/src/components/MatchCard.tsx b/src/components/MatchCard.tsx index d69d67e..51f8923 100644 --- a/src/components/MatchCard.tsx +++ b/src/components/MatchCard.tsx @@ -20,6 +20,7 @@ function ModelHint({ num }: { num: number }) {
{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}
++ {finished && f.homeScore != null && f.awayScore != null + ? fmt(t.match.predictedVsActual, { + ph: preview.prediction.lambdaHome.toFixed(2), + pa: preview.prediction.lambdaAway.toFixed(2), + ah: f.homeScore, + aa: f.awayScore, + }) + : fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })} +
{t.match.xgAttribution}
+{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}