From ef0ddeff39cd891d273c41513e2535e7be51209b Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:33:57 -0700 Subject: [PATCH] Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix NHL bracket-aware simulation and MLB sourceElo support 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 * Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators All three simulators had entries in simulator-config.ts (enabling the projected wins form in the admin UI) but silently ignored the sourceElo that was saved. NBA: Both bracket-aware and season-projection modes now load sourceElo and prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry so eloOfEntry() uses the correct value throughout. NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single parallel query (removing the duplicate evRows query). Bracket-aware mode also loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400. WNBA: resolveElo() now accepts sourceElo as an optional highest-priority argument. The evRows query fetches sourceElo alongside sourceOdds and passes it through when building team entries. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests - NHLSimulator.simulateBracket: remove unused nameById variable - NHLSimulator.simulateBracket: validate all R1 participants before entering Monte Carlo loop; throws with a clear message if any slot is missing - wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter on resolveElo() (overrides SRS, overrides futures, overrides fallback, null/undefined fall through to normal priority chain) - nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests) covering fallback to season-projection, structure validation errors, probability distributions, fully-decided champion, and source tag https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts TeamEntry requires resolvedElo after the simulator refactor. https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn * Fix lint: replace non-null assertions with null-safe alternatives in NHL test https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn --------- Co-authored-by: Claude --- .../__tests__/mlb-simulator.test.ts | 27 ++ .../__tests__/nhl-simulator.test.ts | 280 ++++++++++++++++- .../__tests__/wnba-simulator.test.ts | 20 ++ app/services/simulations/mlb-simulator.ts | 37 ++- app/services/simulations/nba-simulator.ts | 55 +++- app/services/simulations/nhl-simulator.ts | 289 +++++++++++++++++- app/services/simulations/wnba-simulator.ts | 19 +- 7 files changed, 689 insertions(+), 38 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/__tests__/nhl-simulator.test.ts b/app/services/simulations/__tests__/nhl-simulator.test.ts index 80e2029..9e819cf 100644 --- a/app/services/simulations/__tests__/nhl-simulator.test.ts +++ b/app/services/simulations/__tests__/nhl-simulator.test.ts @@ -1,9 +1,10 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest"; import { normalizeTeamName, getTeamData, eloWinProbability, simulateProjectedPoints, + NHLSimulator, } from "../nhl-simulator"; // ─── normalizeTeamName ──────────────────────────────────────────────────────── @@ -135,6 +136,7 @@ const makeEntry = (overrides: Partial<{ currentPoints: overrides.currentPoints ?? 80, remainingGames: overrides.remainingGames ?? 10, winProb: overrides.winProb ?? 0.55, + resolvedElo: 1500, }); describe("simulateProjectedPoints", () => { @@ -171,3 +173,279 @@ describe("simulateProjectedPoints", () => { expect(avg(0.7)).toBeGreaterThan(avg(0.4)); }); }); + +// ─── NHLSimulator — bracket-aware mode ─────────────────────────────────────── + +const TEAM_IDS = Array.from({ length: 16 }, (_, i) => `team-${i + 1}`); + +function makeMatch( + round: string, + matchNumber: number, + opts: { + p1?: string | null; + p2?: string | null; + winnerId?: string | null; + loserId?: string | null; + isComplete?: boolean; + } = {} +) { + return { + id: `${round.replace(/\s/g, "-").toLowerCase()}-m${matchNumber}`, + scoringEventId: "event-1", + round, + matchNumber, + participant1Id: opts.p1 ?? null, + participant2Id: opts.p2 ?? null, + winnerId: opts.winnerId ?? null, + loserId: opts.loserId ?? null, + isComplete: opts.isComplete ?? false, + isScoring: false, + templateRound: round, + seedInfo: null, + participant1Score: null, + participant2Score: null, + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +/** + * Builds a full 16-team NHL bracket fixture: + * R1: 8 matches (Wild Card, matchNumbers 1–8) + * R2: 4 matches (Divisional, matchNumbers 1–4) — participants null (filled from R1 winners) + * R3: 2 matches (Conference Finals, matchNumbers 1–2) + * Final: 1 match (Stanley Cup, matchNumber 1) + */ +function buildFullNHLBracket() { + const t = TEAM_IDS; + return [ + makeMatch("Wild Card", 1, { p1: t[0], p2: t[15] }), + makeMatch("Wild Card", 2, { p1: t[1], p2: t[14] }), + makeMatch("Wild Card", 3, { p1: t[2], p2: t[13] }), + makeMatch("Wild Card", 4, { p1: t[3], p2: t[12] }), + makeMatch("Wild Card", 5, { p1: t[4], p2: t[11] }), + makeMatch("Wild Card", 6, { p1: t[5], p2: t[10] }), + makeMatch("Wild Card", 7, { p1: t[6], p2: t[9] }), + makeMatch("Wild Card", 8, { p1: t[7], p2: t[8] }), + makeMatch("Divisional", 1), + makeMatch("Divisional", 2), + makeMatch("Divisional", 3), + makeMatch("Divisional", 4), + makeMatch("Conference Finals", 1), + makeMatch("Conference Finals", 2), + makeMatch("Stanley Cup", 1), + ]; +} + +vi.mock("~/database/context", () => ({ + database: vi.fn(), +})); + +vi.mock("~/models/regular-season-standings", () => ({ + getRegularSeasonStandings: vi.fn().mockResolvedValue([]), +})); + +describe("NHLSimulator — bracket-aware mode", () => { + 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: vi.fn().mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue( + TEAM_IDS.map((id) => ({ id, name: id })) + ), + }), + }), + }; + + (database as unknown as MockInstance).mockReturnValue(mockDb); + + mockDb.query.scoringEvents.findFirst.mockResolvedValue({ + id: "event-1", + sportsSeasonId: "season-1", + eventType: "playoff_game", + }); + }); + + it("falls back to season-projection when no bracket event exists", async () => { + mockDb.query.scoringEvents.findFirst.mockResolvedValue(null); + + const sim = new NHLSimulator(); + // Season-projection fails: unknown team IDs → no division data → division check throws + await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/); + }); + + it("falls back to season-projection when bracket matches have no participants", async () => { + const emptyMatches = buildFullNHLBracket().map((m) => ({ + ...m, + participant1Id: null, + participant2Id: null, + })); + mockDb.query.playoffMatches.findMany.mockResolvedValue(emptyMatches); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/Each division needs at least 3/); + }); + + it("throws if bracket has fewer than 4 rounds", async () => { + const oneRound = Array.from({ length: 8 }, (_, i) => + makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 8] ?? TEAM_IDS[0] }) + ); + mockDb.query.playoffMatches.findMany.mockResolvedValue(oneRound); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/unexpected structure/); + }); + + it("throws if R1 has wrong match count", async () => { + const shortR1 = [ + ...Array.from({ length: 4 }, (_, i) => + makeMatch("Wild Card", i + 1, { p1: TEAM_IDS[i], p2: TEAM_IDS[i + 4] }) + ), + makeMatch("Divisional", 1), + makeMatch("Divisional", 2), + makeMatch("Conference Finals", 1), + makeMatch("Stanley Cup", 1), + ]; + mockDb.query.playoffMatches.findMany.mockResolvedValue(shortR1); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/Expected 8 first-round matches/); + }); + + it("throws if an R1 participant slot is missing", async () => { + const matches = buildFullNHLBracket(); + const r1m3 = matches.find((m) => m.round === "Wild Card" && m.matchNumber === 3); + if (!r1m3) throw new Error("R1 M3 not found in test fixture"); + r1m3.participant2Id = null; + + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + + const sim = new NHLSimulator(); + await expect(sim.simulate("season-1")).rejects.toThrow(/missing participants/); + }); + + it("returns results for all 16 participants", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + expect(results).toHaveLength(16); + }); + + it("each probability column sums to 1.0 across all 16 participants", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + 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, `column ${key} should sum to 1`).toBeCloseTo(1.0, 1); + } + }); + + it("all probabilities are non-negative", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + for (const r of results) { + for (const v of Object.values(r.probabilities)) { + expect(v).toBeGreaterThanOrEqual(0); + } + } + }); + + it("champion has probFirst=1 when bracket is fully decided", async () => { + const t = TEAM_IDS; + const matches = buildFullNHLBracket(); + + // R1: p1 always wins + const r1 = matches.filter((m) => m.round === "Wild Card"); + for (const m of r1) { + m.winnerId = m.participant1Id; + m.loserId = m.participant2Id; + m.isComplete = true; + } + const r1Winners = r1.map((m) => m.winnerId ?? ""); + + // R2: fill from R1 winners, p1 always wins + const r2 = matches.filter((m) => m.round === "Divisional"); + for (let i = 0; i < 4; i++) { + r2[i].participant1Id = r1Winners[i * 2]; + r2[i].participant2Id = r1Winners[i * 2 + 1]; + r2[i].winnerId = r2[i].participant1Id; + r2[i].loserId = r2[i].participant2Id; + r2[i].isComplete = true; + } + const r2Winners = r2.map((m) => m.winnerId ?? ""); + + // R3: fill from R2 winners, p1 always wins + const r3 = matches.filter((m) => m.round === "Conference Finals"); + for (let i = 0; i < 2; i++) { + r3[i].participant1Id = r2Winners[i * 2]; + r3[i].participant2Id = r2Winners[i * 2 + 1]; + r3[i].winnerId = r3[i].participant1Id; + r3[i].loserId = r3[i].participant2Id; + r3[i].isComplete = true; + } + const r3Winners = r3.map((m) => m.winnerId ?? ""); + + // Final: t[0] vs t[1], t[0] wins + const champion = t[0]; + const final = matches.find((m) => m.round === "Stanley Cup"); + if (!final) throw new Error("Stanley Cup match not found in test fixture"); + final.participant1Id = r3Winners[0]; + final.participant2Id = r3Winners[1]; + final.winnerId = champion; + final.loserId = r3Winners[1]; + final.isComplete = true; + + mockDb.query.playoffMatches.findMany.mockResolvedValue(matches); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + const champResult = results.find((r) => r.participantId === champion); + expect(champResult, "champion result should exist").toBeDefined(); + expect(champResult?.probabilities.probFirst).toBeCloseTo(1.0, 3); + + for (const r of results) { + if (r.participantId !== champion) { + expect(r.probabilities.probFirst).toBeCloseTo(0, 3); + } + } + }); + + it("uses source 'nhl_bracket_monte_carlo' on all results", async () => { + mockDb.query.playoffMatches.findMany.mockResolvedValue(buildFullNHLBracket()); + + const sim = new NHLSimulator(); + const results = await sim.simulate("season-1"); + + for (const r of results) { + expect(r.source).toBe("nhl_bracket_monte_carlo"); + } + }); +}); diff --git a/app/services/simulations/__tests__/wnba-simulator.test.ts b/app/services/simulations/__tests__/wnba-simulator.test.ts index 1d6a20a..36d6fac 100644 --- a/app/services/simulations/__tests__/wnba-simulator.test.ts +++ b/app/services/simulations/__tests__/wnba-simulator.test.ts @@ -80,6 +80,26 @@ describe("resolveElo", () => { it("futures mode with no futures: falls back to 1500", () => { expect(resolveElo(8, null, false)).toBe(1500); }); + + it("sourceElo overrides SRS when in SRS mode", () => { + expect(resolveElo(5, 1600, true, 1750)).toBe(1750); + }); + + it("sourceElo overrides futures when not in SRS mode", () => { + expect(resolveElo(null, 1600, false, 1750)).toBe(1750); + }); + + it("sourceElo overrides 1500 fallback when all other signals are absent", () => { + expect(resolveElo(null, null, false, 1700)).toBe(1700); + }); + + it("null sourceElo falls through to normal priority chain", () => { + expect(resolveElo(5, 1600, true, null)).toBe(srsToElo(5)); + }); + + it("undefined sourceElo falls through to normal priority chain", () => { + expect(resolveElo(null, 1620, false, undefined)).toBe(1620); + }); }); // ─── simSeriesN ─────────────────────────────────────────────────────────────── 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/nba-simulator.ts b/app/services/simulations/nba-simulator.ts index 1e2884f..cb4dacd 100644 --- a/app/services/simulations/nba-simulator.ts +++ b/app/services/simulations/nba-simulator.ts @@ -309,19 +309,35 @@ export class NBASimulator implements Simulator { } const participantIds = [...participantIdSet]; - // Load participant names to map IDs → Elo via TEAMS_DATA. - const participantRows = await db - .select({ id: schema.participants.id, name: schema.participants.name }) - .from(schema.participants) - .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); + // Load participant names and sourceElo values in parallel. + const [participantRows, evRows] = await Promise.all([ + db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), + db + .select({ + participantId: schema.participantExpectedValues.participantId, + sourceElo: schema.participantExpectedValues.sourceElo, + }) + .from(schema.participantExpectedValues) + .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), + ]); const nameById = new Map(participantRows.map((r) => [r.id, r.name])); - // Build Elo map: TEAMS_DATA lookup by name, fallback 1400. + const dbEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + dbEloMap.set(row.participantId, row.sourceElo); + } + } + + // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400. const eloMap = new Map(); for (const id of participantIds) { const name = nameById.get(id) ?? ""; - eloMap.set(id, getTeamData(name)?.elo ?? 1400); + eloMap.set(id, dbEloMap.get(id) ?? getTeamData(name)?.elo ?? 1400); } // Build per-round lookup maps for O(1) access in the hot loop. @@ -454,12 +470,19 @@ export class NBASimulator implements Simulator { private async simulateSeasonProjection(sportsSeasonId: string): Promise { const db = database(); - const [participantRows, standings] = await Promise.all([ + const [participantRows, standings, evRows] = await Promise.all([ db .select({ id: schema.participants.id, name: schema.participants.name }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), getRegularSeasonStandings(sportsSeasonId), + db + .select({ + participantId: schema.participantExpectedValues.participantId, + sourceElo: schema.participantExpectedValues.sourceElo, + }) + .from(schema.participantExpectedValues) + .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), ]); if (participantRows.length === 0) { @@ -472,6 +495,13 @@ export class NBASimulator implements Simulator { const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const participantIds = participantRows.map((r) => r.id); + const dbEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + dbEloMap.set(row.participantId, row.sourceElo); + } + } + interface TeamEntry { id: string; name: string; @@ -480,6 +510,7 @@ export class NBASimulator implements Simulator { currentWins: number; remainingGames: number; winProb: number; + resolvedElo: number; } const teams: TeamEntry[] = participantRows.map((r) => { @@ -491,6 +522,7 @@ export class NBASimulator implements Simulator { conf === "Eastern" || conf === "Western" ? conf : (data?.conference ?? "Eastern"); + const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; return { id: r.id, name: r.name, @@ -498,7 +530,8 @@ export class NBASimulator implements Simulator { conference, currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed), - winProb: eloWinProbability(data?.elo ?? 1400, 1500), + winProb: eloWinProbability(resolvedElo, 1500), + resolvedElo, }; }); @@ -618,11 +651,11 @@ export class NBASimulator implements Simulator { // simulateSeasonProjection). eloOfEntry is module-level so the linter doesn't flag it for // "consistent-function-scoping" (it doesn't close over any local variables). interface TeamEntryBase { - data: { elo: number } | undefined; + resolvedElo: number; } function eloOfEntry(entry: TeamEntryBase): number { - return entry.data?.elo ?? 1400; + return entry.resolvedElo; } interface TeamForProjection { diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index a79e7a9..5891634 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"; @@ -200,11 +200,13 @@ interface TeamEntry { remainingGames: number; /** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */ winProb: number; + /** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */ + resolvedElo: number; } -/** Get Elo for a team entry. Fallback 1400 for unknown teams. */ +/** Get Elo for a team entry. */ function elo(entry: TeamEntry): number { - return entry.data?.elo ?? 1400; + return entry.resolvedElo; } /** @@ -254,13 +256,40 @@ export class NHLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); - // 1. Load participants and standings in parallel. - const [participantRows, standings] = await Promise.all([ + // 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, standings, and sourceElo values in parallel. + const [participantRows, standings, evRows] = await Promise.all([ db .select({ id: schema.participants.id, name: schema.participants.name }) .from(schema.participants) .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), getRegularSeasonStandings(sportsSeasonId), + db + .select({ + participantId: schema.participantExpectedValues.participantId, + sourceElo: schema.participantExpectedValues.sourceElo, + sourceOdds: schema.participantExpectedValues.sourceOdds, + }) + .from(schema.participantExpectedValues) + .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), ]); if (participantRows.length === 0) { @@ -272,9 +301,16 @@ export class NHLSimulator implements Simulator { const participantIds = participantRows.map((r) => r.id); - // 2. Build standings lookup and construct team entries. + // 2. Build standings lookup, sourceElo map, and construct team entries. const standingsMap = new Map(standings.map((s) => [s.participantId, s])); + const dbEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + dbEloMap.set(row.participantId, row.sourceElo); + } + } + if (standings.length === 0) { logger.warn( `[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` + @@ -301,6 +337,7 @@ export class NHLSimulator implements Simulator { const otLosses = standing?.otLosses ?? 0; const currentPoints = wins * 2 + otLosses; const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed); + const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400; return { id: r.id, @@ -310,7 +347,8 @@ export class NHLSimulator implements Simulator { division, currentPoints, remainingGames, - winProb: eloWinProbability(data?.elo ?? 1400, 1500), + winProb: eloWinProbability(resolvedElo, 1500), + resolvedElo, }; }); @@ -345,14 +383,6 @@ export class NHLSimulator implements Simulator { // ─── Futures odds 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(participantIds); const oddsRows = evRows.filter( (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) @@ -539,4 +569,233 @@ 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}.` + ); + } + + // Validate all R1 matches have participants before entering the hot loop. + for (const m of r1Matches) { + if (!m.participant1Id || !m.participant2Id) { + throw new Error( + `Round 1 match ${m.matchNumber} is missing participants. ` + + `Seed all 16 teams into the bracket before running simulation.` + ); + } + } + + // Load all participants and EV data in parallel. + const [participantRows, evRows] = await Promise.all([ + db + .select({ id: schema.participants.id, name: schema.participants.name }) + .from(schema.participants) + .where(eq(schema.participants.sportsSeasonId, sportsSeasonId)), + db + .select({ + participantId: schema.participantExpectedValues.participantId, + sourceOdds: schema.participantExpectedValues.sourceOdds, + sourceElo: schema.participantExpectedValues.sourceElo, + }) + .from(schema.participantExpectedValues) + .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), + ]); + + const allParticipantIds = participantRows.map((r) => r.id); + + const dbEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + dbEloMap.set(row.participantId, row.sourceElo); + } + } + + // Build Elo map: sourceElo > TEAMS_DATA > fallback 1400. + const eloMap = new Map(); + for (const r of participantRows) { + eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400); + } + + 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; + } } diff --git a/app/services/simulations/wnba-simulator.ts b/app/services/simulations/wnba-simulator.ts index d8b5c0b..a820202 100644 --- a/app/services/simulations/wnba-simulator.ts +++ b/app/services/simulations/wnba-simulator.ts @@ -96,14 +96,15 @@ export function srsToElo(srs: number): number { /** * Resolve the Elo to use for a team given available signals and the current mode. * - * In SRS mode (in-season): use SRS-derived Elo, fall back to futuresElo, then 1500. - * In futures mode (pre-season): use futuresElo, fall back to 1500. + * Priority: sourceElo (admin-entered projected wins) > SRS (in-season) > futures odds > 1500. */ export function resolveElo( srs: number | null, futuresElo: number | null, - useSRS: boolean + useSRS: boolean, + sourceElo?: number | null ): number { + if (sourceElo !== null && sourceElo !== undefined) return sourceElo; if (useSRS) { if (srs !== null) return srsToElo(srs); return futuresElo ?? 1500; @@ -166,6 +167,7 @@ export class WNBASimulator implements Simulator { .select({ participantId: schema.participantExpectedValues.participantId, sourceOdds: schema.participantExpectedValues.sourceOdds, + sourceElo: schema.participantExpectedValues.sourceElo, }) .from(schema.participantExpectedValues) .where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)), @@ -185,7 +187,7 @@ export class WNBASimulator implements Simulator { ); } - // 2. Build futures Elo map from championship odds. + // 2. Build futures Elo map from championship odds and sourceElo map from projected wins. const oddsInput = evRows .filter((r) => r.sourceOdds !== null) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); @@ -194,6 +196,13 @@ export class WNBASimulator implements Simulator { ? convertFuturesToElo(oddsInput, "american") : new Map(); + const sourceEloMap = new Map(); + for (const row of evRows) { + if (row.sourceElo !== null && row.sourceElo !== undefined) { + sourceEloMap.set(row.participantId, row.sourceElo); + } + } + // 3. Build standings lookup and determine simulation mode. const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const participantIds = participantRows.map((r) => r.id); @@ -212,7 +221,7 @@ export class WNBASimulator implements Simulator { ? parseFloat(standing.srs) : null; const futuresElo = futuresEloMap.get(r.id) ?? null; - const elo = resolveElo(srs, futuresElo, useSRS); + const elo = resolveElo(srs, futuresElo, useSRS, sourceEloMap.get(r.id) ?? null); const gamesPlayed = standing?.gamesPlayed ?? 0; return { id: r.id,