In-play win-probability timeline: the momentum curve on match pages
While a match runs, every poll records the in-play model probability per game state (insert-if-new on minute+score) into inplay_history. The match page draws it as a stacked area chart — home fills from the bottom, away from the top, the gap is the draw — with minute ticks and a 50% guide. Shows live (under the win-probability bar) and after full time as its own card. EN/DE strings included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,18 @@ CREATE TABLE IF NOT EXISTS prediction_snapshots (
|
||||
market_json TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inplay_history (
|
||||
fixture_num INTEGER NOT NULL,
|
||||
minute INTEGER NOT NULL,
|
||||
home_score INTEGER NOT NULL,
|
||||
away_score INTEGER NOT NULL,
|
||||
p_home REAL NOT NULL,
|
||||
p_draw REAL NOT NULL,
|
||||
p_away REAL NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
PRIMARY KEY (fixture_num, minute, home_score, away_score)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ingest_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
@@ -135,6 +147,32 @@ export function cacheSet(key: string, value: string, ttl: number): void {
|
||||
.run(key, value, Date.now(), ttl);
|
||||
}
|
||||
|
||||
// ---- in-play win-probability history (the momentum curve on match pages) ----
|
||||
export interface InplayPoint {
|
||||
minute: number;
|
||||
home_score: number;
|
||||
away_score: number;
|
||||
p_home: number;
|
||||
p_draw: number;
|
||||
p_away: number;
|
||||
}
|
||||
|
||||
/** Insert-if-new keyed on (minute, score) — one point per game state. */
|
||||
export function recordInplay(num: number, p: InplayPoint): void {
|
||||
db()
|
||||
.prepare(`INSERT OR IGNORE INTO inplay_history
|
||||
(fixture_num, minute, home_score, away_score, p_home, p_draw, p_away, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(num, p.minute, p.home_score, p.away_score, p.p_home, p.p_draw, p.p_away, Date.now());
|
||||
}
|
||||
|
||||
export function inplayHistory(num: number): InplayPoint[] {
|
||||
return db()
|
||||
.prepare(`SELECT minute, home_score, away_score, p_home, p_draw, p_away
|
||||
FROM inplay_history WHERE fixture_num = ? ORDER BY minute, home_score + away_score`)
|
||||
.all(num) as unknown as InplayPoint[];
|
||||
}
|
||||
|
||||
/** Nightly consistent snapshot: VACUUM INTO a LOCAL temp file (SQLite needs
|
||||
* real locking, which the CIFS-mounted archive cannot offer), then a plain
|
||||
* sequential copy into the archive dir. Keeps the newest `keep` dated copies. */
|
||||
|
||||
+27
-1
@@ -10,7 +10,8 @@ import { startScheduler } from './ingest/scheduler';
|
||||
import { ModelEngine } from './model';
|
||||
import { buildPreview, buildTeamProfile } from './preview';
|
||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts } from './db/db';
|
||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay } from './db/db';
|
||||
import { inPlayProbs } from '../../src/lib/model/inplay';
|
||||
import { loadStatic } from './preview';
|
||||
import { canonicalTeam } from '../../src/lib/teams';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
@@ -56,12 +57,35 @@ export function buildServer() {
|
||||
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
|
||||
};
|
||||
|
||||
// Record the in-play win probability per game state while matches run —
|
||||
// the momentum curve on match pages. Lambdas are the frozen pre-match
|
||||
// expectation (predictions only recompute when the finished set changes).
|
||||
const recordInplayTick = (): void => {
|
||||
const preds = new Map(model.current().matches.map((m) => [m.num, m]));
|
||||
for (const f of state.allFixtures()) {
|
||||
if (f.status !== 'live' || f.homeScore == null || f.awayScore == null) continue;
|
||||
const pred = preds.get(f.num);
|
||||
if (!pred) continue;
|
||||
const minute = f.minute ?? 0;
|
||||
const p = inPlayProbs(pred.lambdaHome, pred.lambdaAway, minute, f.homeScore, f.awayScore);
|
||||
recordInplay(f.num, {
|
||||
minute, home_score: f.homeScore, away_score: f.awayScore,
|
||||
p_home: +p.home.toFixed(4), p_draw: +p.draw.toFixed(4), p_away: +p.away.toFixed(4),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
|
||||
app.get('/api/snapshot', async () => state.snapshot());
|
||||
app.get('/api/predictions', async () => model.current());
|
||||
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
|
||||
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
|
||||
app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
|
||||
// In-play win-probability history — the momentum curve on match pages.
|
||||
app.get('/api/inplay/:num', async (req) => {
|
||||
const num = Number((req.params as { num: string }).num);
|
||||
return { points: Number.isFinite(num) ? inplayHistory(num) : [] };
|
||||
});
|
||||
app.get('/api/scoreboard', async () => buildScoreboard(state));
|
||||
// The data lake, quantified — counters for the /data page.
|
||||
app.get('/api/data/stats', async () => {
|
||||
@@ -114,6 +138,7 @@ export function buildServer() {
|
||||
);
|
||||
if (!f) return reply.code(404).send({ error: 'no such fixture' });
|
||||
snapshotCheck(state, model);
|
||||
recordInplayTick();
|
||||
broadcast();
|
||||
return { ok: true, fixture: f };
|
||||
});
|
||||
@@ -149,6 +174,7 @@ export function buildServer() {
|
||||
const stopIngest = startScheduler(state, () => broadcast(), () => {
|
||||
const taken = snapshotCheck(state, model);
|
||||
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
|
||||
try { recordInplayTick(); } catch { /* non-fatal */ }
|
||||
});
|
||||
app.addHook('onClose', async () => stopIngest());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user