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
|
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 (
|
CREATE TABLE IF NOT EXISTS ingest_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source TEXT NOT NULL,
|
source TEXT NOT NULL,
|
||||||
@@ -135,6 +147,32 @@ export function cacheSet(key: string, value: string, ttl: number): void {
|
|||||||
.run(key, value, Date.now(), ttl);
|
.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
|
/** Nightly consistent snapshot: VACUUM INTO a LOCAL temp file (SQLite needs
|
||||||
* real locking, which the CIFS-mounted archive cannot offer), then a plain
|
* real locking, which the CIFS-mounted archive cannot offer), then a plain
|
||||||
* sequential copy into the archive dir. Keeps the newest `keep` dated copies. */
|
* 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 { ModelEngine } from './model';
|
||||||
import { buildPreview, buildTeamProfile } from './preview';
|
import { buildPreview, buildTeamProfile } from './preview';
|
||||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
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 { loadStatic } from './preview';
|
||||||
import { canonicalTeam } from '../../src/lib/teams';
|
import { canonicalTeam } from '../../src/lib/teams';
|
||||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||||
@@ -56,12 +57,35 @@ export function buildServer() {
|
|||||||
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
|
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) ----
|
// ---- REST: snapshot + predictions (initial load + a no-WS fallback) ----
|
||||||
app.get('/api/snapshot', async () => state.snapshot());
|
app.get('/api/snapshot', async () => state.snapshot());
|
||||||
app.get('/api/predictions', async () => model.current());
|
app.get('/api/predictions', async () => model.current());
|
||||||
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
|
app.get('/api/sources/health', async () => ({ sources: healthAll() }));
|
||||||
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
|
// Bookmaker odds (benchmark for the Model-vs-Market scoreboard; not a model input)
|
||||||
app.get('/api/odds', async () => ({ odds: latestOddsAll() }));
|
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));
|
app.get('/api/scoreboard', async () => buildScoreboard(state));
|
||||||
// The data lake, quantified — counters for the /data page.
|
// The data lake, quantified — counters for the /data page.
|
||||||
app.get('/api/data/stats', async () => {
|
app.get('/api/data/stats', async () => {
|
||||||
@@ -114,6 +138,7 @@ export function buildServer() {
|
|||||||
);
|
);
|
||||||
if (!f) return reply.code(404).send({ error: 'no such fixture' });
|
if (!f) return reply.code(404).send({ error: 'no such fixture' });
|
||||||
snapshotCheck(state, model);
|
snapshotCheck(state, model);
|
||||||
|
recordInplayTick();
|
||||||
broadcast();
|
broadcast();
|
||||||
return { ok: true, fixture: f };
|
return { ok: true, fixture: f };
|
||||||
});
|
});
|
||||||
@@ -149,6 +174,7 @@ export function buildServer() {
|
|||||||
const stopIngest = startScheduler(state, () => broadcast(), () => {
|
const stopIngest = startScheduler(state, () => broadcast(), () => {
|
||||||
const taken = snapshotCheck(state, model);
|
const taken = snapshotCheck(state, model);
|
||||||
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
|
if (taken) console.log(`[scoreboard] froze ${taken} pre-kickoff forecast(s)`);
|
||||||
|
try { recordInplayTick(); } catch { /* non-fatal */ }
|
||||||
});
|
});
|
||||||
app.addHook('onClose', async () => stopIngest());
|
app.addHook('onClose', async () => stopIngest());
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useT } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export interface TimelinePoint {
|
||||||
|
minute: number;
|
||||||
|
p_home: number;
|
||||||
|
p_draw: number;
|
||||||
|
p_away: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stacked 100% area chart of the in-play win probability: home fills from the
|
||||||
|
* bottom (accent), away from the top (info), draw is the gap between. */
|
||||||
|
export function WinProbTimeline({ points, home, away }: { points: TimelinePoint[]; home: string; away: string }) {
|
||||||
|
const t = useT();
|
||||||
|
const W = 600;
|
||||||
|
const H = 180;
|
||||||
|
const PAD = { l: 6, r: 6, t: 6, b: 20 };
|
||||||
|
|
||||||
|
const { homePath, awayPath, ticks, maxMin } = useMemo(() => {
|
||||||
|
const pts = [...points].sort((a, b) => a.minute - b.minute);
|
||||||
|
const maxMin = Math.max(90, pts[pts.length - 1]?.minute ?? 90);
|
||||||
|
const x = (min: number) => PAD.l + (min / maxMin) * (W - PAD.l - PAD.r);
|
||||||
|
const y = (p: number) => PAD.t + (1 - p) * (H - PAD.t - PAD.b);
|
||||||
|
|
||||||
|
// Home area: from the bottom edge up to p_home.
|
||||||
|
const homeTop = pts.map((p) => `${x(p.minute).toFixed(1)},${y(p.p_home).toFixed(1)}`);
|
||||||
|
const homePath = pts.length
|
||||||
|
? `M ${PAD.l},${y(0)} L ${homeTop.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(0)} Z`
|
||||||
|
: '';
|
||||||
|
// Away area: from the top edge down to 1 − p_away.
|
||||||
|
const awayBottom = pts.map((p) => `${x(p.minute).toFixed(1)},${y(1 - p.p_away).toFixed(1)}`);
|
||||||
|
const awayPath = pts.length
|
||||||
|
? `M ${PAD.l},${y(1)} L ${awayBottom.join(' L ')} L ${x(pts[pts.length - 1]!.minute).toFixed(1)},${y(1)} Z`
|
||||||
|
: '';
|
||||||
|
const ticks = [0, 15, 30, 45, 60, 75, 90].filter((m) => m <= maxMin).map((m) => ({ m, px: x(m) }));
|
||||||
|
return { homePath, awayPath, ticks, maxMin };
|
||||||
|
}, [points]);
|
||||||
|
|
||||||
|
if (points.length < 2) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<svg viewBox={`0 0 ${W} ${H}`} className="w-full" role="img" aria-label={`${home} / ${t.common.draw} / ${away}`}>
|
||||||
|
<rect x={PAD.l} y={PAD.t} width={W - PAD.l - PAD.r} height={H - PAD.t - PAD.b} className="fill-[var(--app-elevated)]" rx={4} />
|
||||||
|
<path d={awayPath} className="fill-[var(--app-info)] opacity-70" />
|
||||||
|
<path d={homePath} className="fill-[var(--app-accent)] opacity-80" />
|
||||||
|
{/* 50% guide */}
|
||||||
|
<line x1={PAD.l} x2={W - PAD.r} y1={PAD.t + (H - PAD.t - PAD.b) / 2} y2={PAD.t + (H - PAD.t - PAD.b) / 2} className="stroke-[var(--app-line-strong)]" strokeDasharray="4 4" strokeWidth={1} />
|
||||||
|
{ticks.map(({ m, px }) => (
|
||||||
|
<g key={m}>
|
||||||
|
<line x1={px} x2={px} y1={H - PAD.b} y2={H - PAD.b + 4} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
<text x={px} y={H - 6} textAnchor="middle" className="fill-[var(--app-faint)] text-[10px]">{m}'</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{maxMin > 90 && (
|
||||||
|
<line x1={PAD.l + (90 / maxMin) * (W - PAD.l - PAD.r)} x2={PAD.l + (90 / maxMin) * (W - PAD.l - PAD.r)} y1={PAD.t} y2={H - PAD.b} className="stroke-[var(--app-line-strong)]" strokeWidth={1} />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
<div className="mt-1.5 flex items-center justify-between text-[11px]">
|
||||||
|
<span className="font-semibold text-accent">{home}</span>
|
||||||
|
<span className="text-faint">{t.common.draw}</span>
|
||||||
|
<span className="font-semibold text-info">{away}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Link, useParams } from '@tanstack/react-router';
|
|||||||
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
import { ArrowLeft, ArrowRightLeft, CircleDot, Shield, Shirt, Volleyball } from 'lucide-react';
|
||||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { WinProbBar } from '@/components/WinProbBar';
|
import { WinProbBar } from '@/components/WinProbBar';
|
||||||
|
import { WinProbTimeline, type TimelinePoint } from '@/components/WinProbTimeline';
|
||||||
import { FormChips } from '@/components/FormChips';
|
import { FormChips } from '@/components/FormChips';
|
||||||
import { teamFlag } from '@/lib/teams';
|
import { teamFlag } from '@/lib/teams';
|
||||||
import { fmt, useFormat, useT } from '@/lib/i18n';
|
import { fmt, useFormat, useT } from '@/lib/i18n';
|
||||||
@@ -82,6 +83,7 @@ export function MatchPreviewPage() {
|
|||||||
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
const { num: numParam } = useParams({ strict: false }) as { num: string };
|
||||||
const { t, kickoffDay, kickoffTime } = useFormat();
|
const { t, kickoffDay, kickoffTime } = useFormat();
|
||||||
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
const [preview, setPreview] = useState<MatchPreview | null>(null);
|
||||||
|
const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
// Live score/minute arrive over the WebSocket snapshot — overlay them on the
|
||||||
@@ -92,14 +94,26 @@ export function MatchPreviewPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
|
setTimeline([]);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
const load = () =>
|
const load = () =>
|
||||||
fetch(`/api/preview/${numParam}`)
|
fetch(`/api/preview/${numParam}`)
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
.then((d: MatchPreview) => alive && setPreview(d))
|
.then((d: MatchPreview) => alive && setPreview(d))
|
||||||
.catch(() => alive && setFailed(true));
|
.catch(() => alive && setFailed(true));
|
||||||
|
const loadTimeline = () =>
|
||||||
|
fetch(`/api/inplay/${numParam}`)
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject()))
|
||||||
|
.then((d: { points: TimelinePoint[] }) => alive && setTimeline(d.points))
|
||||||
|
.catch(() => {});
|
||||||
void load();
|
void load();
|
||||||
const id = setInterval(() => { if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') void load(); }, 10_000);
|
void loadTimeline();
|
||||||
|
const id = setInterval(() => {
|
||||||
|
if (useTournamentStore.getState().snapshot?.fixtures.find((f) => f.num === Number(numParam))?.status === 'live') {
|
||||||
|
void load();
|
||||||
|
void loadTimeline();
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
return () => { alive = false; clearInterval(id); };
|
return () => { alive = false; clearInterval(id); };
|
||||||
}, [numParam]);
|
}, [numParam]);
|
||||||
|
|
||||||
@@ -164,11 +178,26 @@ export function MatchPreviewPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
<WinProbBar home={homeName} away={awayName} probs={inPlay} />
|
||||||
|
{timeline.length >= 2 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
<p className="mt-2 text-xs text-faint">{t.match.inPlayHelp}</p>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* FT: how the win probability swung over the match */}
|
||||||
|
{finished && timeline.length >= 2 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader><span className="font-display font-bold text-ink">{t.match.momentum}</span></CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<WinProbTimeline points={timeline} home={homeName} away={awayName} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* LIVE/FT: stats */}
|
{/* LIVE/FT: stats */}
|
||||||
{preview.liveStats && (
|
{preview.liveStats && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export const de: Dict = {
|
|||||||
saves: "Paraden",
|
saves: "Paraden",
|
||||||
},
|
},
|
||||||
timeline: "Spielverlauf",
|
timeline: "Spielverlauf",
|
||||||
|
momentum: "So schwankte die Siegwahrscheinlichkeit",
|
||||||
preMatchModel: "Modell vor Anstoß",
|
preMatchModel: "Modell vor Anstoß",
|
||||||
modelExpectation: "Modell-Prognose",
|
modelExpectation: "Modell-Prognose",
|
||||||
expectedGoalsLine: "Erwartete Tore (xG): {home} – {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung",
|
expectedGoalsLine: "Erwartete Tore (xG): {home} – {away} · Modell-Wahrscheinlichkeiten, keine Wettempfehlung",
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const en = {
|
|||||||
saves: "Saves",
|
saves: "Saves",
|
||||||
},
|
},
|
||||||
timeline: "Timeline",
|
timeline: "Timeline",
|
||||||
|
momentum: "How the win probability swung",
|
||||||
preMatchModel: "Pre-match model",
|
preMatchModel: "Pre-match model",
|
||||||
modelExpectation: "Model expectation",
|
modelExpectation: "Model expectation",
|
||||||
expectedGoalsLine: "Expected goals: {home} – {away} · model probabilities, not betting advice",
|
expectedGoalsLine: "Expected goals: {home} – {away} · model probabilities, not betting advice",
|
||||||
|
|||||||
Reference in New Issue
Block a user