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 <noreply@anthropic.com>
This commit is contained in:
parent
80bdbbc3bb
commit
70c22d03fc
7 changed files with 745 additions and 2 deletions
|
|
@ -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
|
|||
<SelectItem value="indycar_standings">IndyCar Standings Model</SelectItem>
|
||||
<SelectItem value="golf_qualifying_points">Golf Qualifying Points Model</SelectItem>
|
||||
<SelectItem value="playoff_bracket">Bracket Monte Carlo</SelectItem>
|
||||
<SelectItem value="ucl_bracket">UCL Bracket Monte Carlo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
|
|
|||
375
app/services/simulations/__tests__/ucl-simulator.test.ts
Normal file
375
app/services/simulations/__tests__/ucl-simulator.test.ts
Normal file
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SimulatorType, { info: SimulatorInfo; create: () => 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 {
|
||||
|
|
|
|||
352
app/services/simulations/ucl-simulator.ts
Normal file
352
app/services/simulations/ucl-simulator.ts
Normal file
|
|
@ -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<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;
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"indycar_standings",
|
||||
"golf_qualifying_points",
|
||||
"playoff_bracket",
|
||||
"ucl_bracket",
|
||||
]);
|
||||
|
||||
export const leagues = pgTable("leagues", {
|
||||
|
|
|
|||
1
drizzle/0039_add_ucl_bracket_simulator_type.sql
Normal file
1
drizzle/0039_add_ucl_bracket_simulator_type.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TYPE "public"."simulator_type" ADD VALUE 'ucl_bracket';
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue