Predicted lineups, predicted-vs-actual goals, and goals on upcoming cards

- Scheduler now captures FotMob details for fixtures inside 48h, so the
  projected XIs exist before kickoff; the Lineups tab shows the pitch
  pre-match under a 'Predicted lineups' header with an honest note
  (official lineups ~1h before kickoff), switching to the confirmed
  view at kickoff
- Finished matches get their forecast back: the preview falls back to
  the prediction frozen <=15min before kickoff (the scoreboard's
  no-hindsight snapshot), with the most-likely-score chip rebuilt from
  the frozen expected goals; the model card now reads 'Model's
  pre-match expected goals: 1.60 - 1.02 · final score 2-1', and the
  market-movement chart returns to finished matches too
- Upcoming match cards append the model's expected goals to the hint:
  'Model: Brazil 64% · 1.3-0.9 goals'

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 13:04:13 +02:00
parent b552bc8ba9
commit e85053aa0a
7 changed files with 78 additions and 11 deletions
+4
View File
@@ -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')
+6 -2
View File
@@ -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;
+44 -3
View File
@@ -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,
+1
View File
@@ -20,6 +20,7 @@ function ModelHint({ num }: { num: number }) {
<div className="mt-1.5 text-center text-[11px] text-faint">
{t.match.modelPrefix} {side ? <>{teamFlag(side)} {side} </> : `${t.common.draw} `}
<span className="font-semibold text-muted">{Math.round(p * 100)}%</span>
<span className="tnum"> · {pred.lambdaHome.toFixed(1)}{pred.lambdaAway.toFixed(1)} {t.match.goalsShort}</span>
</div>
);
}
+15 -6
View File
@@ -213,7 +213,16 @@ export function MatchPreviewPage() {
<CardHeader><span className="font-display font-bold text-ink">{hasScore ? t.match.preMatchModel : t.match.modelExpectation}</span></CardHeader>
<CardBody className="space-y-2">
<WinProbBar home={preview.prediction.home} away={preview.prediction.away} probs={preview.prediction.probs} topScore={preview.prediction.topScore} />
<p className="text-xs text-faint">{fmt(t.match.expectedGoalsLine, { home: preview.prediction.lambdaHome.toFixed(2), away: preview.prediction.lambdaAway.toFixed(2) })}</p>
<p className="text-xs text-faint">
{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) })}
</p>
</CardBody>
</Card>
);
@@ -419,14 +428,14 @@ export function MatchPreviewPage() {
{/* ── Lineups ── */}
{tab === 'lineups' && (
<div className="space-y-4">
{/* FotMob serves predicted XIs before kickoff — only draw the pitch
once the match has started or ESPN confirms the lineups. */}
{rich?.lineup && (hasScore || preview.lineups) ? (
{/* Before kickoff the pitch shows FotMob's projected XIs, clearly
labeled as predicted; from kickoff it's the confirmed lineup. */}
{rich?.lineup ? (
<Card>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{t.match.lineups}</span></CardHeader>
<CardHeader className="flex items-center gap-2"><Shirt size={16} className="text-accent" /><span className="font-display font-bold text-ink">{hasScore ? t.match.lineups : t.match.predictedLineups}</span></CardHeader>
<CardBody>
<LineupPitch lineup={rich.lineup} home={homeName} away={awayName} events={preview.events} />
<p className="mt-2 text-xs text-faint">{t.match.xgAttribution}</p>
<p className="mt-2 text-xs text-faint">{hasScore ? t.match.xgAttribution : t.match.predictedLineupsNote}</p>
</CardBody>
</Card>
) : (
+4
View File
@@ -130,6 +130,10 @@ export const de: Dict = {
assist: "Vorlage · {name}",
noGoalsYet: "Noch keine Tore.",
subbedOff: "Ausgewechselt",
predictedLineups: "Voraussichtliche Aufstellungen",
predictedLineupsNote: "Voraussichtliche Startelf laut FotMob — die offiziellen Aufstellungen kommen meist rund eine Stunde vor Anstoß.",
predictedVsActual: "Erwartete Tore des Modells vor Anstoß: {ph} {pa} · Endstand {ah}{aa}",
goalsShort: "Tore",
chips: {
att: "Zusch.",
ref: "SR",
+4
View File
@@ -129,6 +129,10 @@ export const en = {
assist: "Assist · {name}",
noGoalsYet: "No goals yet.",
subbedOff: "Subbed off",
predictedLineups: "Predicted lineups",
predictedLineupsNote: "FotMob's projected starting XIs — the official lineups usually arrive about an hour before kickoff.",
predictedVsActual: "Model's pre-match expected goals: {ph} {pa} · final score {ah}{aa}",
goalsShort: "goals",
chips: {
att: "Att",
ref: "Ref",