Phase 2: predictive model — Elo + Dixon-Coles + Monte Carlo
- buildRatings.ts: walks 49k historical results → World-Football-Elo per team, data-calibrated goals model (goals-per-Elo slope, mean goals) + MLE-fit Dixon-Coles rho. Top: Spain/Argentina/France (the real 2026 favourites) - src/lib/model: elo, poisson/dixon-coles, predict, host-advantage, monteCarlo (full 48-team sim — group sampling, best-third bipartite allocation, knockout advance probs). 20 vitest cases incl. exact per-round count invariants - Server ModelEngine: live Elo re-rating after each result, per-match W/D/L, 20k-sim odds, odds-over-time history; broadcast on finished-result changes - Client: championship board, heat-shaded odds table, lazy-loaded title-race chart (Recharts split to its own chunk), match-prediction bars, bracket advance overlay - Verified: odds render, chart populates as injected results re-rate teams live Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+15
-4
@@ -7,6 +7,7 @@ import fastifyStatic from '@fastify/static';
|
||||
import type { WebSocket } from 'ws';
|
||||
import { TournamentState } from './tournament';
|
||||
import { startLiveLoop } from './liveLoop';
|
||||
import { ModelEngine } from './model';
|
||||
import type { MatchStatus, ServerMessage } from '../../src/lib/types';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8787);
|
||||
@@ -27,20 +28,30 @@ export function buildServer() {
|
||||
});
|
||||
|
||||
const state = new TournamentState(STATIC_DIR);
|
||||
const model = new ModelEngine(STATIC_DIR);
|
||||
model.recompute(state.allFixtures());
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
const snapshotMessage = (): string =>
|
||||
JSON.stringify({ t: 'snapshot', snapshot: state.snapshot() } satisfies ServerMessage);
|
||||
const predictionsMessage = (): string =>
|
||||
JSON.stringify({ t: 'predictions', model: model.current() } satisfies ServerMessage);
|
||||
|
||||
const broadcast = (): void => {
|
||||
const msg = snapshotMessage();
|
||||
const sendAll = (msg: string): void => {
|
||||
for (const ws of clients) {
|
||||
try { ws.send(msg); } catch { /* closed */ }
|
||||
}
|
||||
};
|
||||
|
||||
// ---- REST: current snapshot (initial load + a no-WS fallback) ----
|
||||
/** Re-push the snapshot, and predictions too if the finished-result set moved. */
|
||||
const broadcast = (): void => {
|
||||
sendAll(snapshotMessage());
|
||||
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
|
||||
};
|
||||
|
||||
// ---- 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('/healthz', async () => ({ ok: true }));
|
||||
|
||||
// ---- dev-only: inject a score to exercise the live → standings → WS loop ----
|
||||
@@ -76,7 +87,7 @@ export function buildServer() {
|
||||
}
|
||||
if (clients.size >= MAX_WS) { socket.close(1013, 'busy'); return; }
|
||||
clients.add(socket);
|
||||
try { socket.send(snapshotMessage()); } catch { /* closed */ }
|
||||
try { socket.send(snapshotMessage()); socket.send(predictionsMessage()); } catch { /* closed */ }
|
||||
|
||||
let alive = true;
|
||||
socket.on('pong', () => { alive = true; });
|
||||
|
||||
Binary file not shown.
@@ -63,6 +63,11 @@ export class TournamentState {
|
||||
return this.byNum.get(num);
|
||||
}
|
||||
|
||||
/** The live fixtures array (for the model engine). */
|
||||
allFixtures(): Fixture[] {
|
||||
return this.fixtures;
|
||||
}
|
||||
|
||||
/** True if any match is live or kicks off within `windowMs` of now — used to
|
||||
* poll the APIs often during match windows and rarely when nothing is on. */
|
||||
hasLiveActivity(windowMs: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user