* Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
352 lines
15 KiB
TypeScript
352 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.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 !== 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.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;
|
||
}
|
||
}
|