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:
@@ -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')
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user