From 6f5920eb2b62a3638f9daa0f105c7b6d7314d989 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Apr 2026 05:22:11 +0000 Subject: [PATCH] Fix NHL bracket-aware simulation and MLB sourceElo support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NHL: NHLSimulator now checks for a populated playoff_game bracket before falling back to season-projection mode. When a bracket exists (e.g. via simple_16 template), it simulates the actual bracket structure, respecting completed matches and using ELO+odds blending for the rest — matching the pattern used by Snooker, NBA, and UCL simulators. MLB: MLBSimulator now reads sourceElo from participantExpectedValues and converts it to an effective RDif (via eloToRDif), overriding the hardcoded TEAMS_DATA.rdif when present. This wires up the expected-wins admin form (which saves sourceElo) to the simulation engine. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --- .../__tests__/mlb-simulator.test.ts | 27 ++ app/services/simulations/mlb-simulator.ts | 37 ++- app/services/simulations/nhl-simulator.ts | 233 +++++++++++++++++- 3 files changed, 290 insertions(+), 7 deletions(-) diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts index 68f782b..85e59ab 100644 --- a/app/services/simulations/__tests__/mlb-simulator.test.ts +++ b/app/services/simulations/__tests__/mlb-simulator.test.ts @@ -4,6 +4,7 @@ import { getTeamData, winRateFromRDif, rdifWinProbability, + eloToRDif, simBo3, simBo5, simBo7, @@ -237,3 +238,29 @@ describe("simBo7 (LCS / World Series — first to 4 wins)", () => { expect(result.winner).not.toBe(result.loser); }); }); + +// ─── eloToRDif ──────────────────────────────────────────────────────────────── + +describe("eloToRDif", () => { + it("maps Elo 1500 (average) to RDif 0", () => { + expect(eloToRDif(1500)).toBeCloseTo(0, 5); + }); + + it("maps Elo above 1500 to a positive RDif", () => { + expect(eloToRDif(1600)).toBeGreaterThan(0); + }); + + it("maps Elo below 1500 to a negative RDif", () => { + expect(eloToRDif(1400)).toBeLessThan(0); + }); + + it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => { + expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5); + }); + + it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => { + const elo = 1620; + const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400)); + expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4); + }); +}); diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts index 77b7577..a4a45e7 100644 --- a/app/services/simulations/mlb-simulator.ts +++ b/app/services/simulations/mlb-simulator.ts @@ -189,6 +189,17 @@ export function winRateFromRDif(rdif: number): number { return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR)); } +/** + * Convert an Elo rating to an equivalent projected run differential. + * Uses the standard Elo win probability formula (parity factor 400, average Elo 1500), + * then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR. + * Exported for unit testing. + */ +export function eloToRDif(elo: number): number { + const winRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400)); + return (winRate - 0.5) * RDIF_DIVISOR; +} + /** * Bill James log5 head-to-head win probability for team A over team B, * given their projected run differentials. @@ -303,7 +314,8 @@ export function simBo7( * with positive weight). */ function drawLeaguePlayoffField( - leagueTeams: TeamEntry[] + leagueTeams: TeamEntry[], + getRDif: (t: TeamEntry) => number = getEntryRDif ): TeamEntry[] | undefined { // Group by division const divMap = new Map(); @@ -336,10 +348,10 @@ function drawLeaguePlayoffField( } // Rank division winners 1–3 by RDif descending (best RDif = seed 1) - const sortedDivWinners = divisionWinners.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a)); + const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a)); // Rank WC teams 4–6 by RDif descending (best RDif = seed 4) - const sortedWcTeams = wcTeams.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a)); + const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a)); const seeds = [...sortedDivWinners, ...sortedWcTeams]; return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 })); @@ -442,6 +454,7 @@ export class MLBSimulator implements Simulator { .select({ participantId: schema.participantExpectedValues.participantId, sourceOdds: schema.participantExpectedValues.sourceOdds, + sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); @@ -463,14 +476,26 @@ export class MLBSimulator implements Simulator { }); } + // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif). + const sourceEloRDifMap = new Map(); + for (const r of evRows) { + if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) { + sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo)); + } + } + // ─── Helpers ────────────────────────────────────────────────────────────── + /** Effective RDif: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */ + const effectiveRDif = (entry: TeamEntry): number => + sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry); + /** * Blended per-game win probability for team A over team B. * When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head. */ const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { - const rdifProb = rdifWinProbability(getEntryRDif(a), getEntryRDif(b)); + const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b)); if (!hasOdds) return rdifProb; const o1 = normalizedOddsMap.get(a.id); @@ -494,8 +519,8 @@ export class MLBSimulator implements Simulator { let effectiveN = 0; for (let s = 0; s < NUM_SIMULATIONS; s++) { - const alField = drawLeaguePlayoffField(alTeams); - const nlField = drawLeaguePlayoffField(nlTeams); + const alField = drawLeaguePlayoffField(alTeams, effectiveRDif); + const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif); if (!alField || !nlField) continue; // degenerate draw — skip effectiveN++; diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index a79e7a9..aa54784 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -60,7 +60,7 @@ */ import { database } from "~/database/context"; -import { eq } from "drizzle-orm"; +import { eq, and } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { logger } from "~/lib/logger"; @@ -254,6 +254,25 @@ export class NHLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); + // Check for a populated playoff bracket; if found, use bracket-aware simulation. + const bracketEvent = await db.query.scoringEvents.findFirst({ + where: and( + eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), + eq(schema.scoringEvents.eventType, "playoff_game") + ), + }); + + if (bracketEvent) { + const allMatches = await db.query.playoffMatches.findMany({ + where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id), + orderBy: (m, { asc }) => [asc(m.matchNumber)], + }); + const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id); + if (bracketPopulated) { + return this.simulateBracket(sportsSeasonId, allMatches); + } + } + // 1. Load participants and standings in parallel. const [participantRows, standings] = await Promise.all([ db @@ -539,4 +558,216 @@ export class NHLSimulator implements Simulator { return results; } + + // ── Mode: Bracket-Aware ─────────────────────────────────────────────────────── + + private async simulateBracket( + sportsSeasonId: string, + allMatches: Awaited["query"]["playoffMatches"]["findMany"]>> + ): Promise { + const db = database(); + + // Group matches by round, sorted by match count descending (R1 first → Final last). + 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()] + .toSorted((a, b) => b.length - a.length) + .map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber)); + + if (sortedRoundMatches.length < 4) { + throw new Error( + `NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` + + `Check the bracket template.` + ); + } + + const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card) + const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional) + const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals) + const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup) + + if (r1Matches.length !== 8) { + throw new Error( + `Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.` + ); + } + + // Load all participants so we can build the Elo map and return results for everyone. + const participantRows = await db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); + + const allParticipantIds = participantRows.map((r) => r.id); + const nameById = new Map(participantRows.map((r) => [r.id, r.name])); + + // Build Elo map: TEAMS_DATA lookup by name, fallback 1400. + const eloMap = new Map(); + for (const r of participantRows) { + eloMap.set(r.id, getTeamData(r.name)?.elo ?? 1400); + } + + // Load futures odds for blending. + const evRows = await db + .select({ + participantId: schema.participantExpectedValues.participantId, + sourceOdds: schema.participantExpectedValues.sourceOdds, + }) + .from(schema.participantExpectedValues) + .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)); + + const participantIdSet = new Set(allParticipantIds); + const oddsRows = evRows.filter( + (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) + ); + const hasOdds = oddsRows.length > 0; + + const normalizedOddsMap = new Map(); + if (hasOdds) { + const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0)); + const normalized = normalizeProbabilities(rawProbs); + oddsRows.forEach(({ participantId }, i) => { + normalizedOddsMap.set(participantId, normalized[i]); + }); + } + + // Per-round lookup maps for O(1) access. + const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m])); + const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m])); + const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m])); + const finalMatch = finalMatches[0]; + + // ── Helpers ────────────────────────────────────────────────────────────────── + + const gameWinProb = (aId: string, bId: string): number => { + const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400); + if (!hasOdds) return eloProb; + const o1 = normalizedOddsMap.get(aId) ?? 0; + const o2 = normalizedOddsMap.get(bId) ?? 0; + const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; + return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; + }; + + const simSeries = (aId: string, bId: string): { winner: string; loser: string } => { + const winProb = gameWinProb(aId, bId); + let wA = 0; + let wB = 0; + while (wA < 4 && wB < 4) { + if (Math.random() < winProb) wA++; else wB++; + } + return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId }; + }; + + const resolveSeries = ( + match: (typeof allMatches)[0] | undefined, + p1Fallback?: string, + p2Fallback?: string + ): { winner: string; loser: string } => { + if (match?.isComplete && match.winnerId && match.loserId) { + return { winner: match.winnerId, loser: match.loserId }; + } + const p1 = match?.participant1Id ?? p1Fallback; + const p2 = match?.participant2Id ?? p2Fallback; + if (!p1 || !p2) { + throw new Error( + `Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` + + `missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).` + ); + } + return simSeries(p1, p2); + }; + + // ── Placement count maps ────────────────────────────────────────────────────── + + const championCounts = new Map(allParticipantIds.map((id) => [id, 0])); + const finalistCounts = new Map(allParticipantIds.map((id) => [id, 0])); + const r3LoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); + const r2LoserCounts = new Map(allParticipantIds.map((id) => [id, 0])); + + // ── Monte Carlo simulation loop ─────────────────────────────────────────────── + + for (let s = 0; s < NUM_SIMULATIONS; s++) { + // Round 1: R1 losers score 0 points — not tracked. + const r1Winners: string[] = []; + for (let i = 1; i <= 8; i++) { + const { winner } = resolveSeries(r1ByNum.get(i)); + r1Winners.push(winner); + } + + // Round 2 (Quarterfinals / Divisional): losers → 5th–8th. + const r2Winners: string[] = []; + for (let i = 1; i <= 4; i++) { + const { winner, loser } = resolveSeries( + r2ByNum.get(i), + r1Winners[(i - 1) * 2], + r1Winners[(i - 1) * 2 + 1] + ); + r2Winners.push(winner); + r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1); + } + + // Round 3 (Semifinals / Conference Finals): losers → 3rd–4th. + const r3Winners: string[] = []; + for (let i = 1; i <= 2; i++) { + const { winner, loser } = resolveSeries( + r3ByNum.get(i), + r2Winners[(i - 1) * 2], + r2Winners[(i - 1) * 2 + 1] + ); + r3Winners.push(winner); + r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1); + } + + // Final (Stanley Cup): winner → 1st, loser → 2nd. + const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]); + championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1); + finalistCounts.set(loser, (finalistCounts.get(loser) ?? 0) + 1); + } + + // ── Convert counts to probability distributions ─────────────────────────────── + + const N = NUM_SIMULATIONS; + const results: SimulationResult[] = allParticipantIds.map((participantId) => { + const c = championCounts.get(participantId) ?? 0; + const f = finalistCounts.get(participantId) ?? 0; + const r3 = r3LoserCounts.get(participantId) ?? 0; + const r2 = r2LoserCounts.get(participantId) ?? 0; + return { + participantId, + probabilities: { + probFirst: c / N, + probSecond: f / N, + probThird: r3 / (2 * N), + probFourth: r3 / (2 * N), + probFifth: r2 / (4 * N), + probSixth: r2 / (4 * N), + probSeventh: r2 / (4 * N), + probEighth: r2 / (4 * N), + }, + source: "nhl_bracket_monte_carlo", + }; + }); + + // Per-position normalization. + 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; + } }