Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
345 lines
15 KiB
TypeScript
345 lines
15 KiB
TypeScript
/**
|
||
* UCL Bracket Simulator
|
||
*
|
||
* Monte Carlo simulation of the UEFA Champions League 16-team knockout bracket.
|
||
*
|
||
* Algorithm:
|
||
* 1. Load the bracket scoring event and all playoff matches from DB
|
||
* 2. Load futures odds (American format) from participantExpectedValues
|
||
* 3. Build two probability signals per team:
|
||
* a. Elo — derived from futures via convertFuturesToElo() (long-run team strength)
|
||
* b. Normalized odds — vig-removed implied win probability from the same futures
|
||
* 4. Per-match win probability = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
|
||
* 5. Simulate 50,000 tournaments, respecting already-completed matches
|
||
* 6. Track integer placement counts per tier (champion / finalist / SF loser / QF loser).
|
||
* At conversion, exact denominators guarantee column sums of 1.0 by construction:
|
||
* - probFirst = champion / N
|
||
* - probSecond = finalist / N
|
||
* - probThird/probFourth = sfLoserCount / (2*N) — 2 SF losers per sim
|
||
* - probFifth–probEighth = qfLoserCount / (4*N) — 4 QF losers per sim
|
||
* - R16 losers → all 0 (score 0 points per scoring rules)
|
||
*
|
||
* Bracket path follows the same matchNumber pairing used by advanceWinnerTemplate():
|
||
* nextMatchNumber = Math.ceil(matchNumber / 2)
|
||
* i.e. R16 match 1 + R16 match 2 → QF match 1, R16 match 3 + R16 match 4 → QF match 2, …
|
||
*
|
||
* In-progress handling:
|
||
* - Completed matches (isComplete + winnerId + loserId set) use the actual result in
|
||
* every simulation — giving eliminated teams an exact EV equal to their scored points.
|
||
* - Incomplete matches are simulated using the blended probability.
|
||
*
|
||
* Notes:
|
||
* - Requires futures odds in sourceOdds (American format) to be imported first.
|
||
* - Falls back to uniform probability (coin flip) when no odds are stored.
|
||
* - ELO_WEIGHT and ODDS_WEIGHT can be tuned here; 0.7/0.3 matches the Python calibration.
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq, and } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50000;
|
||
|
||
/**
|
||
* Weight given to the Elo-based win probability (derived from futures).
|
||
* Remaining weight (1 - ELO_WEIGHT) goes to the normalized futures odds component.
|
||
*/
|
||
const ELO_WEIGHT = 0.7;
|
||
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
||
|
||
// ─── Odds helper ──────────────────────────────────────────────────────────────
|
||
|
||
/** Convert American odds to implied probability (with vig). Exported for testing. */
|
||
export function americanToImpliedProb(odds: number): number {
|
||
if (odds > 0) return 100 / (odds + 100);
|
||
return Math.abs(odds) / (Math.abs(odds) + 100);
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class UCLSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// 1. Find the bracket scoring event for this sports season.
|
||
// UCL has exactly one playoff_game event per season.
|
||
const bracketEvent = await db.query.scoringEvents.findFirst({
|
||
where: and(
|
||
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
|
||
eq(schema.scoringEvents.eventType, "playoff_game")
|
||
),
|
||
});
|
||
|
||
if (!bracketEvent) {
|
||
throw new Error(
|
||
`No bracket event found for sports season ${sportsSeasonId}. ` +
|
||
`Create a playoff_game scoring event and set up the bracket first.`
|
||
);
|
||
}
|
||
|
||
// 2. Load all playoff matches for this bracket event.
|
||
const allMatches = await db.query.playoffMatches.findMany({
|
||
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
|
||
orderBy: (m, { asc }) => [asc(m.matchNumber)],
|
||
});
|
||
|
||
if (allMatches.length === 0) {
|
||
throw new Error(
|
||
`No playoff matches found for the bracket event. ` +
|
||
`Generate the bracket from the admin panel first.`
|
||
);
|
||
}
|
||
|
||
// 3. Group matches by round, ordered by number of matches descending.
|
||
// Round of 16 (8) → Quarterfinals (4) → Semifinals (2) → Finals (1)
|
||
const byRound = new Map<string, typeof allMatches>();
|
||
for (const m of allMatches) {
|
||
if (!byRound.has(m.round)) byRound.set(m.round, []);
|
||
byRound.get(m.round)?.push(m);
|
||
}
|
||
|
||
const sortedRoundMatches = [...byRound.values()]
|
||
.toSorted((a, b) => b.length - a.length)
|
||
.map((matches) => matches.sort((a, b) => a.matchNumber - b.matchNumber));
|
||
|
||
if (sortedRoundMatches.length < 4) {
|
||
throw new Error(
|
||
`Expected 4 rounds (R16, Quarterfinals, Semifinals, Finals), ` +
|
||
`found ${sortedRoundMatches.length}. Check the bracket structure.`
|
||
);
|
||
}
|
||
|
||
const r16Matches = sortedRoundMatches[0]; // 8 matches
|
||
const qfMatches = sortedRoundMatches[1]; // 4 matches
|
||
const sfMatches = sortedRoundMatches[2]; // 2 matches
|
||
const finalMatches = sortedRoundMatches[3]; // 1 match
|
||
|
||
if (r16Matches.length !== 8) {
|
||
throw new Error(
|
||
`Expected 8 Round of 16 matches, found ${r16Matches.length}. ` +
|
||
`This simulator only supports the standard UCL 16-team format.`
|
||
);
|
||
}
|
||
|
||
// Validate all R16 matches have participants (the draw must be entered).
|
||
for (const m of r16Matches) {
|
||
if (!m.participant1Id || !m.participant2Id) {
|
||
throw new Error(
|
||
`Round of 16 match ${m.matchNumber} is missing participants. ` +
|
||
`Assign all 16 teams to the bracket before running simulation.`
|
||
);
|
||
}
|
||
}
|
||
|
||
// 4. Collect all 16 participant IDs from the R16 draw (order matters for pairings).
|
||
// r16Matches is sorted by matchNumber, so participantIds[0..1] = match 1, [2..3] = match 2, …
|
||
const participantIds: string[] = [];
|
||
for (const m of r16Matches) {
|
||
participantIds.push(m.participant1Id ?? "", m.participant2Id ?? "");
|
||
}
|
||
const participantSet = new Set(participantIds);
|
||
const fallbackProb = 1 / participantIds.length;
|
||
|
||
// 5. Load futures odds from participantExpectedValues.
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
const evMap = new Map(evRows.map((r) => [r.participantId, r]));
|
||
|
||
// 6. Build Elo map via the futures → Elo pipeline.
|
||
const hasOdds = evRows.some(
|
||
(r) => r.sourceOdds !== null && participantSet.has(r.participantId)
|
||
);
|
||
|
||
let eloMap: Map<string, number>;
|
||
if (hasOdds) {
|
||
const oddsInput = evRows
|
||
.filter((r) => r.sourceOdds !== null && participantSet.has(r.participantId))
|
||
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
|
||
eloMap = convertFuturesToElo(oddsInput, "american");
|
||
} else {
|
||
// No odds stored — all teams get equal Elo (coin-flip bracket)
|
||
eloMap = new Map(participantIds.map((id) => [id, 1500]));
|
||
}
|
||
|
||
// 7. Build normalized futures win-probability map (vig removed).
|
||
// Used as the second signal in the blended per-match probability.
|
||
const rawProbs = new Map<string, number>();
|
||
for (const id of participantIds) {
|
||
const ev = evMap.get(id);
|
||
rawProbs.set(
|
||
id,
|
||
ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb
|
||
);
|
||
}
|
||
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
|
||
const normalizedOddsMap = new Map<string, number>();
|
||
for (const [id, prob] of rawProbs) {
|
||
normalizedOddsMap.set(id, prob / rawSum);
|
||
}
|
||
|
||
// 8. Build per-round lookup maps keyed by matchNumber for O(1) access in the hot loop.
|
||
const r16ByNum = new Map(r16Matches.map((m) => [m.matchNumber, m]));
|
||
const qfByNum = new Map(qfMatches.map((m) => [m.matchNumber, m]));
|
||
const sfByNum = new Map(sfMatches.map((m) => [m.matchNumber, m]));
|
||
const finalMatch = finalMatches[0];
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/** Blended Elo + normalized-odds win probability for p1 vs p2. */
|
||
const blendedWinProb = (p1: string, p2: string): number => {
|
||
const elo1 = eloMap.get(p1) ?? 1500;
|
||
const elo2 = eloMap.get(p2) ?? 1500;
|
||
const eloProb = eloWinProbability(elo1, elo2);
|
||
|
||
const o1 = normalizedOddsMap.get(p1) ?? fallbackProb;
|
||
const o2 = normalizedOddsMap.get(p2) ?? fallbackProb;
|
||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
||
|
||
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
||
};
|
||
|
||
const simMatch = (p1: string, p2: string): { winner: string; loser: string } => {
|
||
const w = Math.random() < blendedWinProb(p1, p2) ? p1 : p2;
|
||
return { winner: w, loser: w === p1 ? p2 : p1 };
|
||
};
|
||
|
||
// 9. Integer placement counts per tier.
|
||
// Using separate integer maps avoids fractional accumulation error (e.g. += 0.25 × 50k).
|
||
// R16 losers are never counted → all probs stay 0 → EV = 0.
|
||
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const sfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const qfLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
|
||
// 10. Run Monte Carlo simulations.
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
// ── Round of 16 ──────────────────────────────────────────────────────
|
||
// R16 losers: no count added (0 points per scoring rules)
|
||
const r16Winners: string[] = [];
|
||
for (let i = 1; i <= 8; i++) {
|
||
const m = r16ByNum.get(i);
|
||
if (!m) continue;
|
||
if (m.isComplete && m.winnerId) {
|
||
r16Winners.push(m.winnerId);
|
||
} else {
|
||
const { winner } = simMatch(m.participant1Id ?? "", m.participant2Id ?? "");
|
||
r16Winners.push(winner);
|
||
}
|
||
}
|
||
|
||
// ── Quarterfinals ─────────────────────────────────────────────────────
|
||
// Bracket path: QF match N gets winner of R16 match (2N-1) and (2N).
|
||
// r16Winners is 0-indexed: [0,1] = R16 matches 1,2 → QF match 1, etc.
|
||
const qfWinners: string[] = [];
|
||
for (let i = 1; i <= 4; i++) {
|
||
const dbMatch = qfByNum.get(i);
|
||
let winner: string;
|
||
let loser: string;
|
||
|
||
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
|
||
winner = dbMatch.winnerId;
|
||
loser = dbMatch.loserId;
|
||
} else {
|
||
const p1 = r16Winners[(i - 1) * 2];
|
||
const p2 = r16Winners[(i - 1) * 2 + 1];
|
||
({ winner, loser } = simMatch(p1, p2));
|
||
}
|
||
|
||
qfWinners.push(winner);
|
||
qfLoserCounts.set(loser, (qfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// ── Semifinals ───────────────────────────────────────────────────────
|
||
// SF match N gets winner of QF match (2N-1) and (2N).
|
||
const sfWinners: string[] = [];
|
||
for (let i = 1; i <= 2; i++) {
|
||
const dbMatch = sfByNum.get(i);
|
||
let winner: string;
|
||
let loser: string;
|
||
|
||
if (dbMatch?.isComplete && dbMatch.winnerId && dbMatch.loserId) {
|
||
winner = dbMatch.winnerId;
|
||
loser = dbMatch.loserId;
|
||
} else {
|
||
const p1 = qfWinners[(i - 1) * 2];
|
||
const p2 = qfWinners[(i - 1) * 2 + 1];
|
||
({ winner, loser } = simMatch(p1, p2));
|
||
}
|
||
|
||
sfWinners.push(winner);
|
||
sfLoserCounts.set(loser, (sfLoserCounts.get(loser) ?? 0) + 1);
|
||
}
|
||
|
||
// ── Final ─────────────────────────────────────────────────────────────
|
||
let champion: string;
|
||
let finalist: string;
|
||
|
||
if (finalMatch?.isComplete && finalMatch.winnerId && finalMatch.loserId) {
|
||
champion = finalMatch.winnerId;
|
||
finalist = finalMatch.loserId;
|
||
} else {
|
||
({ winner: champion, loser: finalist } = simMatch(sfWinners[0], sfWinners[1]));
|
||
}
|
||
|
||
championCounts.set(champion, (championCounts.get(champion) ?? 0) + 1);
|
||
finalistCounts.set(finalist, (finalistCounts.get(finalist) ?? 0) + 1);
|
||
}
|
||
|
||
// 11. Convert counts to probability distributions.
|
||
// Exact denominators guarantee each paired column group sums to 1.0 by construction:
|
||
// probFirst/Second → N total (1 per sim)
|
||
// probThird/Fourth → sfLoserCounts / (2*N) — 2 SF losers per sim
|
||
// probFifth–Eighth → qfLoserCounts / (4*N) — 4 QF losers per sim
|
||
const N = NUM_SIMULATIONS;
|
||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||
const c = championCounts.get(participantId) ?? 0;
|
||
const f = finalistCounts.get(participantId) ?? 0;
|
||
const sf = sfLoserCounts.get(participantId) ?? 0;
|
||
const qf = qfLoserCounts.get(participantId) ?? 0;
|
||
return {
|
||
participantId,
|
||
probabilities: {
|
||
probFirst: c / N,
|
||
probSecond: f / N,
|
||
probThird: sf / (2 * N),
|
||
probFourth: sf / (2 * N),
|
||
probFifth: qf / (4 * N),
|
||
probSixth: qf / (4 * N),
|
||
probSeventh: qf / (4 * N),
|
||
probEighth: qf / (4 * N),
|
||
},
|
||
source: "ucl_bracket_monte_carlo",
|
||
};
|
||
});
|
||
|
||
// 12. Per-position normalization — belt-and-suspenders safety net for floating-point
|
||
// division residuals. Columns are already near-exactly 1.0 after step 11, but this
|
||
// guarantees the invariant before probabilities are persisted.
|
||
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
|
||
"probFirst", "probSecond", "probThird", "probFourth",
|
||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||
];
|
||
for (const key of positionKeys) {
|
||
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
|
||
const residual = 1.0 - colSum;
|
||
if (residual !== 0) {
|
||
const maxResult = results.reduce((best, r) =>
|
||
r.probabilities[key] > best.probabilities[key] ? r : best
|
||
);
|
||
maxResult.probabilities[key] += residual;
|
||
}
|
||
}
|
||
|
||
return results;
|
||
}
|
||
}
|