353 lines
15 KiB
TypeScript
353 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()]
|
|||
|
|
.sort((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.participantExpectedValues.participantId,
|
|||
|
|
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
|||
|
|
})
|
|||
|
|
.from(schema.participantExpectedValues)
|
|||
|
|
.where(eq(schema.participantExpectedValues.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! }));
|
|||
|
|
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?.sourceOdds != null ? 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.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);
|
|||
|
|
if (qfLoserCounts.has(loser)) {
|
|||
|
|
qfLoserCounts.set(loser, qfLoserCounts.get(loser)! + 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);
|
|||
|
|
if (sfLoserCounts.has(loser)) {
|
|||
|
|
sfLoserCounts.set(loser, sfLoserCounts.get(loser)! + 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]));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (championCounts.has(champion)) {
|
|||
|
|
championCounts.set(champion, championCounts.get(champion)! + 1);
|
|||
|
|
}
|
|||
|
|
if (finalistCounts.has(finalist)) {
|
|||
|
|
finalistCounts.set(finalist, finalistCounts.get(finalist)! + 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)!;
|
|||
|
|
const f = finalistCounts.get(participantId)!;
|
|||
|
|
const sf = sfLoserCounts.get(participantId)!;
|
|||
|
|
const qf = qfLoserCounts.get(participantId)!;
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|