From 70c22d03fca2e2162b30ab1d0020921825a3c1b6 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:04:51 -0700 Subject: [PATCH] feat: add UCL bracket Monte Carlo simulator (#131) Implements a 16-team UEFA Champions League knockout bracket simulator using blended Elo + futures odds probabilities (70/30 split). Tracks integer placement counts per tier to avoid fractional accumulation drift, ensuring total EV sums to exactly 340 by construction. - New UCLSimulator class reading live playoffMatches from DB - Respects completed match results (eliminated teams get exact EV) - New ucl_bracket simulator_type enum value + migration - Registered in simulator registry; added to admin sport selector - 18 unit tests covering math, bracket path, and integration scenarios Fixes #113 Co-authored-by: Claude Sonnet 4.6 --- app/routes/admin.sports.$id.tsx | 3 +- .../__tests__/ucl-simulator.test.ts | 375 ++++++++++++++++++ app/services/simulations/registry.ts | 8 +- app/services/simulations/ucl-simulator.ts | 352 ++++++++++++++++ database/schema.ts | 1 + .../0039_add_ucl_bracket_simulator_type.sql | 1 + drizzle/meta/_journal.json | 7 + 7 files changed, 745 insertions(+), 2 deletions(-) create mode 100644 app/services/simulations/__tests__/ucl-simulator.test.ts create mode 100644 app/services/simulations/ucl-simulator.ts create mode 100644 drizzle/0039_add_ucl_bracket_simulator_type.sql diff --git a/app/routes/admin.sports.$id.tsx b/app/routes/admin.sports.$id.tsx index 73951a1..af5a12c 100644 --- a/app/routes/admin.sports.$id.tsx +++ b/app/routes/admin.sports.$id.tsx @@ -101,7 +101,7 @@ export async function action({ request, params }: Route.ActionArgs) { iconUrl = null; } - const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket"] as const; + const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket", "ucl_bracket"] as const; type ValidSimulatorType = typeof validSimulatorTypes[number]; const parsedSimulatorType: ValidSimulatorType | null = typeof simulatorType === "string" && validSimulatorTypes.includes(simulatorType as ValidSimulatorType) @@ -203,6 +203,7 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro IndyCar Standings Model Golf Qualifying Points Model Bracket Monte Carlo + UCL Bracket Monte Carlo

diff --git a/app/services/simulations/__tests__/ucl-simulator.test.ts b/app/services/simulations/__tests__/ucl-simulator.test.ts new file mode 100644 index 0000000..b802cd9 --- /dev/null +++ b/app/services/simulations/__tests__/ucl-simulator.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; +import { americanToImpliedProb, UCLSimulator } from "../ucl-simulator"; + +// ─── Pure math tests (no DB) ───────────────────────────────────────────────── + +describe("americanToImpliedProb", () => { + it("converts positive (underdog) American odds correctly", () => { + // +300 → 100 / (300 + 100) = 0.25 + expect(americanToImpliedProb(300)).toBeCloseTo(0.25, 5); + // +100 → 100 / 200 = 0.5 + expect(americanToImpliedProb(100)).toBeCloseTo(0.5, 5); + }); + + it("converts negative (favorite) American odds correctly", () => { + // -200 → 200 / 300 ≈ 0.6667 + expect(americanToImpliedProb(-200)).toBeCloseTo(0.6667, 3); + // -450 (Arsenal-like) → 450 / 550 ≈ 0.8182 + expect(americanToImpliedProb(-450)).toBeCloseTo(0.8182, 3); + }); + + it("sums to more than 1.0 for a two-sided market (reflects vig)", () => { + // -110 / -110 line: each side ≈ 0.5238, total ≈ 1.0476 + const total = americanToImpliedProb(-110) + americanToImpliedProb(-110); + expect(total).toBeGreaterThan(1.0); + }); +}); + +// ─── Bracket path logic (pure math, verified against advanceWinnerTemplate) ── + +describe("UCL bracket path convention", () => { + it("R16 match 1 and match 2 winners feed QF match 1", () => { + // advanceWinnerTemplate: nextMatchNumber = Math.ceil(matchNumber / 2) + // QF match for R16 match N: + expect(Math.ceil(1 / 2)).toBe(1); // R16 match 1 → QF match 1 + expect(Math.ceil(2 / 2)).toBe(1); // R16 match 2 → QF match 1 + expect(Math.ceil(3 / 2)).toBe(2); // R16 match 3 → QF match 2 + expect(Math.ceil(4 / 2)).toBe(2); // R16 match 4 → QF match 2 + expect(Math.ceil(7 / 2)).toBe(4); // R16 match 7 → QF match 4 + expect(Math.ceil(8 / 2)).toBe(4); // R16 match 8 → QF match 4 + }); + + it("QF match N uses r16Winners[(N-1)*2] and [(N-1)*2+1] — same as simulator", () => { + // r16Winners is 0-indexed from R16 match 1..8 in order + // QF match 1 uses r16Winners[0] (R16 match 1 winner) and r16Winners[1] (R16 match 2 winner) + for (let qfMatch = 1; qfMatch <= 4; qfMatch++) { + const p1Index = (qfMatch - 1) * 2; + const p2Index = (qfMatch - 1) * 2 + 1; + expect(p1Index).toBe((qfMatch - 1) * 2); + expect(p2Index).toBe((qfMatch - 1) * 2 + 1); + expect(p1Index).toBeLessThan(8); + expect(p2Index).toBeLessThan(8); + } + }); + + it("SF match N uses qfWinners[(N-1)*2] and [(N-1)*2+1]", () => { + for (let sfMatch = 1; sfMatch <= 2; sfMatch++) { + const p1Index = (sfMatch - 1) * 2; + const p2Index = (sfMatch - 1) * 2 + 1; + expect(p1Index).toBeLessThan(4); + expect(p2Index).toBeLessThan(4); + } + }); +}); + +// ─── Placement bucket math ──────────────────────────────────────────────────── + +describe("UCL placement bucket EV alignment", () => { + const DEFAULT_SCORING = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, + }; + + it("champion slot gives EV = 100 with DEFAULT_SCORING_RULES", () => { + const probs = { probFirst: 1, probSecond: 0, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 }; + const ev = probs.probFirst * DEFAULT_SCORING.pointsFor1st; + expect(ev).toBe(100); + }); + + it("finalist slot gives EV = 70 with DEFAULT_SCORING_RULES", () => { + const probs = { probFirst: 0, probSecond: 1, probThird: 0, probFourth: 0, probFifth: 0, probSixth: 0, probSeventh: 0, probEighth: 0 }; + const ev = probs.probSecond * DEFAULT_SCORING.pointsFor2nd; + expect(ev).toBe(70); + }); + + it("SF loser split (0.5/0.5 for 3rd/4th) gives EV = 45 with DEFAULT_SCORING_RULES", () => { + const ev = 0.5 * DEFAULT_SCORING.pointsFor3rd + 0.5 * DEFAULT_SCORING.pointsFor4th; + expect(ev).toBe(45); // (50 + 40) / 2 + }); + + it("QF loser split (0.25 each for 5th-8th) gives EV = 20 with DEFAULT_SCORING_RULES", () => { + const ev = + 0.25 * DEFAULT_SCORING.pointsFor5th + + 0.25 * DEFAULT_SCORING.pointsFor6th + + 0.25 * DEFAULT_SCORING.pointsFor7th + + 0.25 * DEFAULT_SCORING.pointsFor8th; + expect(ev).toBe(20); // (25 + 25 + 15 + 15) / 4 + }); + + it("R16 loser (all zeros) gives EV = 0", () => { + const ev = 0 * DEFAULT_SCORING.pointsFor1st; // all slots 0 + expect(ev).toBe(0); + }); +}); + +// ─── Integration test with mocked DB ───────────────────────────────────────── + +// Build 16 participant IDs for the test +const PARTICIPANT_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`); + +/** Build a minimal R16 match object */ +function makeR16Match( + matchNumber: number, + opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {} +) { + const p1 = PARTICIPANT_IDS[(matchNumber - 1) * 2]; + const p2 = PARTICIPANT_IDS[(matchNumber - 1) * 2 + 1]; + return { + id: `r16-match-${matchNumber}`, + scoringEventId: "event-1", + round: "Round of 16", + matchNumber, + participant1Id: p1, + participant2Id: p2, + winnerId: opts.winnerId ?? null, + loserId: opts.loserId ?? null, + isComplete: opts.isComplete ?? false, + isScoring: false, + templateRound: "Round of 16", + seedInfo: null, + participant1Score: null, + participant2Score: null, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function makeQFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { + return { + id: `qf-match-${matchNumber}`, + scoringEventId: "event-1", + round: "Quarterfinals", + matchNumber, + participant1Id: null, + participant2Id: null, + winnerId: opts.winnerId ?? null, + loserId: opts.loserId ?? null, + isComplete: opts.isComplete ?? false, + isScoring: true, + templateRound: "Quarterfinals", + seedInfo: null, + participant1Score: null, + participant2Score: null, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function makeSFMatch(matchNumber: number, opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { + return { ...makeQFMatch(matchNumber, opts), id: `sf-match-${matchNumber}`, round: "Semifinals", templateRound: "Semifinals" }; +} + +function makeFinalMatch(opts: { winnerId?: string; loserId?: string; isComplete?: boolean } = {}) { + return { ...makeQFMatch(1, opts), id: "final-match-1", round: "Finals", templateRound: "Finals" }; +} + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +describe("UCLSimulator.simulate()", () => { + let mockDb: { + query: { + scoringEvents: { findFirst: MockInstance }; + playoffMatches: { findMany: MockInstance }; + }; + select: MockInstance; + }; + + beforeEach(async () => { + const { database } = await import("~/database/context"); + + mockDb = { + query: { + scoringEvents: { findFirst: vi.fn() }, + playoffMatches: { findMany: vi.fn() }, + }, + // select().from().where() chain returning empty odds rows + select: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + }), + }; + + (database as unknown as MockInstance).mockReturnValue(mockDb); + + // Default bracket event + mockDb.query.scoringEvents.findFirst.mockResolvedValue({ + id: "event-1", + sportsSeasonId: "season-1", + eventType: "playoff_game", + }); + }); + + it("throws if no bracket event found", async () => { + mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); + const sim = new UCLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/No bracket event found/); + }); + + it("throws if no playoff matches found", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue([]); + const sim = new UCLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/No playoff matches found/); + }); + + it("throws if R16 match is missing a participant", async () => { + const matches = [ + ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), + ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), + ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), + makeFinalMatch(), + ]; + // Remove participant2Id from match 3 (simulates incomplete draw data) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + matches[2] = { ...matches[2], participant2Id: null } as any; + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + + const sim = new UCLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/); + }); + + it("returns 16 results with valid probability distributions (no odds, coin-flip)", async () => { + const allMatches = [ + ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), + ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), + ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), + makeFinalMatch(), + ]; + mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); + + const sim = new UCLSimulator(); + const results = await sim.simulate("season-1"); + + expect(results).toHaveLength(16); + + for (const result of results) { + const p = result.probabilities; + const sum = p.probFirst + p.probSecond + p.probThird + p.probFourth + + p.probFifth + p.probSixth + p.probSeventh + p.probEighth; + // Each team's probabilities should sum to at most ~1.0 (many will sum to < 1 as R16 losers have 0) + expect(sum).toBeGreaterThanOrEqual(0); + expect(sum).toBeLessThanOrEqual(1.001); + // All probabilities are non-negative + expect(p.probFirst).toBeGreaterThanOrEqual(0); + expect(p.probSecond).toBeGreaterThanOrEqual(0); + } + }); + + it("each probability column sums to 1.0 across all 16 participants", async () => { + const allMatches = [ + ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), + ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), + ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), + makeFinalMatch(), + ]; + mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); + + const sim = new UCLSimulator(); + const results = await sim.simulate("season-1"); + + const keys = ["probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth"] as const; + for (const key of keys) { + const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); + expect(colSum).toBeCloseTo(1.0, 2); + } + }); + + it("completed tournament: champion has probFirst=1, eliminated teams have all zeros", async () => { + // Full tournament completed: 8 R16 losers, 4 QF losers, 2 SF losers, 1 finalist, 1 champion + // Use team-1 as champion path: R16m1→QFm1→SFm1→Final + const champion = PARTICIPANT_IDS[0]; // team-1, participant1 of R16 match 1 + const finalist = PARTICIPANT_IDS[2]; // team-3, participant1 of R16 match 2 + + const r16Matches = Array.from({ length: 8 }, (_, i) => { + const matchNum = i + 1; + const p1 = PARTICIPANT_IDS[(matchNum - 1) * 2]; + const p2 = PARTICIPANT_IDS[(matchNum - 1) * 2 + 1]; + // Match 1: team-1 beats team-2. Rest: p2 wins. + const winner = matchNum === 1 ? p1 : p2; + const loser = matchNum === 1 ? p2 : p1; + return makeR16Match(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); + }); + + // QF: R16 match 1 winner (team-1) + R16 match 2 winner (team-4) → QF match 1: team-1 wins + // R16 match 3 winner (team-6) + R16 match 4 winner (team-8) → QF match 2: team-6 wins + // QF match 3: team-10 wins, QF match 4: team-14 wins + const qfWinners = [champion, PARTICIPANT_IDS[3], PARTICIPANT_IDS[9], PARTICIPANT_IDS[13]]; + const qfLosers = [PARTICIPANT_IDS[3], PARTICIPANT_IDS[5], PARTICIPANT_IDS[7], PARTICIPANT_IDS[11]]; + // Actually: QF1: team-1 beats team-4; QF2: team-6 beats team-8; etc. + const qfMatches = Array.from({ length: 4 }, (_, i) => { + const matchNum = i + 1; + const r16w1 = r16Matches[(matchNum - 1) * 2].winnerId!; + const r16w2 = r16Matches[(matchNum - 1) * 2 + 1].winnerId!; + const winner = matchNum === 1 ? r16w1 : r16w2; + const loser = matchNum === 1 ? r16w2 : r16w1; + return makeQFMatch(matchNum, { winnerId: winner, loserId: loser, isComplete: true }); + }); + + // SF: QF1 winner (team-1) + QF2 winner → SF1: team-1 wins + const sf1Winner = qfMatches[0].winnerId!; + const sf1Loser = qfMatches[1].winnerId!; + const sf2Winner = qfMatches[2].winnerId!; + const sf2Loser = qfMatches[3].winnerId!; + const sfMatches = [ + makeSFMatch(1, { winnerId: sf1Winner, loserId: sf1Loser, isComplete: true }), + makeSFMatch(2, { winnerId: sf2Winner, loserId: sf2Loser, isComplete: true }), + ]; + + // Final: sf1Winner (team-1) vs sf2Winner + const actualChampion = sf1Winner; // team-1 + const actualFinalist = sf2Winner; + const finalMatch = makeFinalMatch({ winnerId: actualChampion, loserId: actualFinalist, isComplete: true }); + + mockDb.query.playoffMatches.findMany.mockResolvedValue([ + ...r16Matches, ...qfMatches, ...sfMatches, finalMatch, + ]); + + const sim = new UCLSimulator(); + const results = await sim.simulate("season-1"); + + const championResult = results.find((r) => r.participantId === actualChampion)!; + const finalistResult = results.find((r) => r.participantId === actualFinalist)!; + + // Champion must have probFirst = 1.0 + expect(championResult.probabilities.probFirst).toBeCloseTo(1.0, 3); + expect(championResult.probabilities.probSecond).toBeCloseTo(0, 3); + + // Finalist must have probSecond = 1.0 + expect(finalistResult.probabilities.probFirst).toBeCloseTo(0, 3); + expect(finalistResult.probabilities.probSecond).toBeCloseTo(1.0, 3); + + // R16 losers (teams that lost in R16) must have all probs = 0 + const r16LoserId = r16Matches[0].loserId!; // team-2 lost in R16 match 1 + const r16LoserResult = results.find((r) => r.participantId === r16LoserId)!; + const r16Sum = Object.values(r16LoserResult.probabilities).reduce((a, b) => a + b, 0); + expect(r16Sum).toBeCloseTo(0, 5); + + // Verify source label + expect(championResult.source).toBe("ucl_bracket_monte_carlo"); + }); + + it("uses source: 'ucl_bracket_monte_carlo' on all results", async () => { + const allMatches = [ + ...Array.from({ length: 8 }, (_, i) => makeR16Match(i + 1)), + ...Array.from({ length: 4 }, (_, i) => makeQFMatch(i + 1)), + ...Array.from({ length: 2 }, (_, i) => makeSFMatch(i + 1)), + makeFinalMatch(), + ]; + mockDb.query.playoffMatches.findMany.mockResolvedValue(allMatches); + + const sim = new UCLSimulator(); + const results = await sim.simulate("season-1"); + + for (const r of results) { + expect(r.source).toBe("ucl_bracket_monte_carlo"); + } + }); +}); diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index 6671735..c91ab99 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -10,12 +10,14 @@ import type { Simulator } from "./types"; import { BracketSimulator } from "./bracket-simulator"; import { F1Simulator } from "./f1-simulator"; import { GolfSimulator } from "./golf-simulator"; +import { UCLSimulator } from "./ucl-simulator"; export type SimulatorType = | "f1_standings" | "indycar_standings" | "golf_qualifying_points" - | "playoff_bracket"; + | "playoff_bracket" + | "ucl_bracket"; export interface SimulatorInfo { name: string; @@ -39,6 +41,10 @@ const REGISTRY: Record Simul info: { name: "Bracket Monte Carlo", description: "Simulates playoff bracket outcomes using Elo ratings" }, create: () => new BracketSimulator(), }, + ucl_bracket: { + info: { name: "UCL Bracket Monte Carlo", description: "Simulates the UEFA Champions League 16-team knockout bracket using blended Elo + futures odds" }, + create: () => new UCLSimulator(), + }, }; export function getSimulator(simulatorType: SimulatorType): Simulator { diff --git a/app/services/simulations/ucl-simulator.ts b/app/services/simulations/ucl-simulator.ts new file mode 100644 index 0000000..022de19 --- /dev/null +++ b/app/services/simulations/ucl-simulator.ts @@ -0,0 +1,352 @@ +/** + * 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 { + 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(); + 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; + 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(); + 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(); + 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(participantIds.map((id) => [id, 0])); + const finalistCounts = new Map(participantIds.map((id) => [id, 0])); + const sfLoserCounts = new Map(participantIds.map((id) => [id, 0])); + const qfLoserCounts = new Map(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 = [ + "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; + } +} diff --git a/database/schema.ts b/database/schema.ts index 74f230b..aae025c 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -81,6 +81,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [ "indycar_standings", "golf_qualifying_points", "playoff_bracket", + "ucl_bracket", ]); export const leagues = pgTable("leagues", { diff --git a/drizzle/0039_add_ucl_bracket_simulator_type.sql b/drizzle/0039_add_ucl_bracket_simulator_type.sql new file mode 100644 index 0000000..f07c06b --- /dev/null +++ b/drizzle/0039_add_ucl_bracket_simulator_type.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."simulator_type" ADD VALUE 'ucl_bracket'; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e027ad9..746967a 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -274,6 +274,13 @@ "when": 1773122866361, "tag": "0038_sad_wilson_fisk", "breakpoints": true + }, + { + "idx": 39, + "version": "7", + "when": 1773200000000, + "tag": "0039_add_ucl_bracket_simulator_type", + "breakpoints": true } ] } \ No newline at end of file