v2 Phase 4: live match center + in-play win probability
- src/lib/model/inplay.ts: in-play win probability — remaining goals modelled as time-scaled Poisson processes convolved with the current score; recomputed each tick. 4 vitest cases (locks at FT, lead grows decisive over time, sums to 1). - ESPN summary normalizer extended with the live event timeline (keyEvents); preview now carries liveStats + events. Scheduler refreshes LIVE matches' summaries every live tick (~20s) for fresh stats/timeline. - /match/:num is now a live match center: when live it shows in-play win probability (driven by the WS score/clock), match stats bars and an event timeline, polling while live; preview content for scheduled games, recap when finished. Verified with a simulated live state (94% live vs 81% pre-match). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { inPlayProbs } from './inplay';
|
||||
|
||||
describe('inPlayProbs', () => {
|
||||
it('sums to 1 and roughly matches pre-match at kickoff', () => {
|
||||
const p = inPlayProbs(1.8, 0.9, 0, 0, 0);
|
||||
expect(p.home + p.draw + p.away).toBeCloseTo(1, 6);
|
||||
expect(p.home).toBeGreaterThan(p.away); // stronger side favoured
|
||||
});
|
||||
|
||||
it('locks the result at full time', () => {
|
||||
expect(inPlayProbs(1.5, 1.2, 90, 2, 1)).toEqual({ home: 1, draw: 0, away: 0 });
|
||||
expect(inPlayProbs(1.5, 1.2, 95, 1, 1)).toEqual({ home: 0, draw: 1, away: 0 });
|
||||
expect(inPlayProbs(1.5, 1.2, 90, 0, 2)).toEqual({ home: 0, draw: 0, away: 1 });
|
||||
});
|
||||
|
||||
it('a lead becomes more decisive as time runs down', () => {
|
||||
const early = inPlayProbs(1.4, 1.4, 20, 1, 0);
|
||||
const late = inPlayProbs(1.4, 1.4, 80, 1, 0);
|
||||
expect(late.home).toBeGreaterThan(early.home);
|
||||
expect(late.away).toBeLessThan(early.away);
|
||||
});
|
||||
|
||||
it('chasing team still has hope early but little late', () => {
|
||||
const trailingEarly = inPlayProbs(1.3, 1.3, 30, 0, 1).home;
|
||||
const trailingLate = inPlayProbs(1.3, 1.3, 85, 0, 1).home;
|
||||
expect(trailingEarly).toBeGreaterThan(trailingLate);
|
||||
expect(trailingLate).toBeLessThan(0.1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { poissonPmf, type OutcomeProbs } from './poisson';
|
||||
|
||||
// In-play win probability. Given each side's pre-match goal expectation, the
|
||||
// current score, and the minute, the remaining goals are modelled as independent
|
||||
// Poisson processes scaled to the time left — convolved with the current score to
|
||||
// give live win/draw/loss. Cheap enough to recompute on every tick.
|
||||
|
||||
const REG_MINUTES = 90;
|
||||
const MAX_GOALS = 12;
|
||||
|
||||
export function inPlayProbs(
|
||||
lambdaHome: number,
|
||||
lambdaAway: number,
|
||||
minute: number,
|
||||
scoreHome: number,
|
||||
scoreAway: number,
|
||||
): OutcomeProbs {
|
||||
const remaining = Math.max(0, Math.min(1, (REG_MINUTES - minute) / REG_MINUTES));
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (scoreHome > scoreAway) return { home: 1, draw: 0, away: 0 };
|
||||
if (scoreHome < scoreAway) return { home: 0, draw: 0, away: 1 };
|
||||
return { home: 0, draw: 1, away: 0 };
|
||||
}
|
||||
|
||||
const lh = lambdaHome * remaining;
|
||||
const la = lambdaAway * remaining;
|
||||
const ph = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(lh, k));
|
||||
const pa = Array.from({ length: MAX_GOALS + 1 }, (_, k) => poissonPmf(la, k));
|
||||
|
||||
let home = 0, draw = 0, away = 0;
|
||||
for (let i = 0; i <= MAX_GOALS; i++) {
|
||||
for (let j = 0; j <= MAX_GOALS; j++) {
|
||||
const p = ph[i]! * pa[j]!;
|
||||
const fh = scoreHome + i;
|
||||
const fa = scoreAway + j;
|
||||
if (fh > fa) home += p;
|
||||
else if (fh < fa) away += p;
|
||||
else draw += p;
|
||||
}
|
||||
}
|
||||
const total = home + draw + away;
|
||||
return { home: home / total, draw: draw / total, away: away / total };
|
||||
}
|
||||
@@ -133,6 +133,7 @@ export interface H2HSummary {
|
||||
|
||||
export interface KeyPlayer { name: string; goals: number; pens: number }
|
||||
export interface LineupPlayer { name: string; position: string | null; starter: boolean; number: number | null }
|
||||
export interface MatchLiveEvent { minute: number | null; type: string; text: string; team: 'home' | 'away' | null }
|
||||
|
||||
export interface MatchPreview {
|
||||
num: number;
|
||||
@@ -144,6 +145,10 @@ export interface MatchPreview {
|
||||
h2h: H2HSummary | null;
|
||||
lineups: { home: LineupPlayer[]; away: LineupPlayer[] } | null;
|
||||
keyPlayers: { home: KeyPlayer[]; away: KeyPlayer[] };
|
||||
/** Live/finished team stats (possession, shots, …), null pre-match. */
|
||||
liveStats: { home: Record<string, string>; away: Record<string, string> } | null;
|
||||
/** Live/finished event timeline (goals, cards, subs). */
|
||||
events: MatchLiveEvent[];
|
||||
/** Honest note on which data layers are populated yet. */
|
||||
dataNote: string;
|
||||
updatedAt: number | null;
|
||||
|
||||
Reference in New Issue
Block a user