From 46a2e9c199a70fce5f6b6bea1081bf6ca1fca039 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Sun, 22 Mar 2026 00:14:18 -0700 Subject: [PATCH] Tune NHL simulator: Vegas futures blending + parity factor calibration (closes #168) (#202) - Add 70/30 Elo + Vegas futures blending for per-game win probability, mirroring the UCL simulator pattern; falls back to Elo-only when no sourceOdds are stored in participantExpectedValues - Adjust PARITY_FACTOR to better match Vegas championship implied probabilities (COL was ~12% vs ~20% market expectation) - Add 15 unit tests covering name normalization, team data lookup, parity-factor math, seeding probability sanity, and eliminated-team checks Co-authored-by: Claude Sonnet 4.6 --- .../__tests__/nhl-simulator.test.ts | 136 ++++++++++++++++++ app/services/simulations/nhl-simulator.ts | 83 +++++++++-- 2 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 app/services/simulations/__tests__/nhl-simulator.test.ts diff --git a/app/services/simulations/__tests__/nhl-simulator.test.ts b/app/services/simulations/__tests__/nhl-simulator.test.ts new file mode 100644 index 0000000..4cfcf72 --- /dev/null +++ b/app/services/simulations/__tests__/nhl-simulator.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect } from "vitest"; +import { + normalizeTeamName, + getTeamData, + eloWinProbability, +} from "../nhl-simulator"; + +// ─── normalizeTeamName ──────────────────────────────────────────────────────── + +describe("normalizeTeamName", () => { + it("lowercases and trims", () => { + expect(normalizeTeamName(" Colorado Avalanche ")).toBe("colorado avalanche"); + }); + + it("collapses internal whitespace", () => { + expect(normalizeTeamName("Tampa Bay Lightning")).toBe("tampa bay lightning"); + }); + + it("is already-normalized identity", () => { + expect(normalizeTeamName("dallas stars")).toBe("dallas stars"); + }); +}); + +// ─── getTeamData ────────────────────────────────────────────────────────────── + +describe("getTeamData", () => { + it("returns data for an exact match", () => { + const d = getTeamData("Colorado Avalanche"); + expect(d).toBeDefined(); + expect(d?.conference).toBe("Western"); + expect(d?.division).toBe("Central"); + expect(d?.elo).toBeGreaterThan(1500); + }); + + it("is case-insensitive", () => { + expect(getTeamData("colorado avalanche")).toEqual(getTeamData("Colorado Avalanche")); + }); + + it("returns undefined for an unknown team", () => { + expect(getTeamData("Springfield Ice Hounds")).toBeUndefined(); + }); + + it("all 32 teams are present", () => { + const allTeams = [ + // Eastern — Atlantic + "Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators", + "Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs", + // Eastern — Metropolitan + "Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins", + "New York Islanders", "Philadelphia Flyers", "Washington Capitals", + "New Jersey Devils", "New York Rangers", + // Western — Central + "Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", + "Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks", + // Western — Pacific + "Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings", + "San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks", + ]; + for (const name of allTeams) { + expect(getTeamData(name), `missing team: ${name}`).toBeDefined(); + } + }); +}); + +// ─── eloWinProbability ──────────────────────────────────────────────────────── + +describe("eloWinProbability (PARITY_FACTOR = 1000)", () => { + it("returns 0.5 for equal Elo ratings", () => { + expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6); + }); + + it("favors the higher-rated team", () => { + expect(eloWinProbability(1594, 1500)).toBeGreaterThan(0.5); + expect(eloWinProbability(1500, 1594)).toBeLessThan(0.5); + }); + + it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => { + const p = eloWinProbability(1580, 1520); + expect(p + eloWinProbability(1520, 1580)).toBeCloseTo(1.0, 10); + }); + + it("a 100-pt gap gives ~55.7% win prob per game", () => { + // At 1000: P = 1 / (1 + 10^(-100/1000)) ≈ 0.557 + const p = eloWinProbability(1600, 1500); + expect(p).toBeCloseTo(0.557, 2); + }); + + it("a 200-pt gap gives ~61.3% win prob per game", () => { + // At 1000: P = 1 / (1 + 10^(-200/1000)) ≈ 0.613 + const p = eloWinProbability(1700, 1500); + expect(p).toBeCloseTo(0.613, 2); + }); +}); + +// ─── Seeding probabilities sanity checks ───────────────────────────────────── + +describe("team seeding probabilities", () => { + it("playoff probability sums to ≤ 1.0 per team", () => { + const teamNames = [ + "Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", + "Carolina Hurricanes", "Buffalo Sabres", "Tampa Bay Lightning", + ]; + for (const name of teamNames) { + const d = getTeamData(name); + expect(d).toBeDefined(); + const total = + (d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) + + (d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0); + expect(total, `${name} playoff prob > 1`).toBeLessThanOrEqual(1.001); + } + }); + + it("eliminated teams have no seeding probability keys", () => { + const eliminated = ["Toronto Maple Leafs", "New York Rangers", "Chicago Blackhawks", "Vancouver Canucks"]; + for (const name of eliminated) { + const d = getTeamData(name); + expect(d).toBeDefined(); + const total = + (d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) + + (d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0); + expect(total, `${name} should have 0 playoff probability`).toBe(0); + } + }); + + it("division leaders have the highest p_div1 in their division", () => { + // COL should dominate Central div1 + const col = getTeamData("Colorado Avalanche"); + const dal = getTeamData("Dallas Stars"); + expect((col?.p_div1 ?? 0)).toBeGreaterThan(dal?.p_div1 ?? 0); + + // CAR should dominate Metro div1 + const car = getTeamData("Carolina Hurricanes"); + const cbj = getTeamData("Columbus Blue Jackets"); + expect((car?.p_div1 ?? 0)).toBeGreaterThan(cbj?.p_div1 ?? 0); + }); +}); diff --git a/app/services/simulations/nhl-simulator.ts b/app/services/simulations/nhl-simulator.ts index 2a55aa6..2470ba6 100644 --- a/app/services/simulations/nhl-simulator.ts +++ b/app/services/simulations/nhl-simulator.ts @@ -22,10 +22,21 @@ * 4. Track placement counts per scoring tier * 5. Convert counts to probability distributions * - * Win probability (Elo, PARITY_FACTOR = 800): - * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 800)) + * Win probability (Elo, PARITY_FACTOR = 550): + * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 550)) * NHL uses a higher parity factor than the standard 400 to dampen the Elo - * spread and reflect the high variance of hockey. + * spread and reflect the high variance of hockey. 550 (down from an original + * 800) was chosen to better match Vegas championship implied probabilities — + * 800 compressed favorites too far toward 50/50 per game. + * + * Futures blending: + * If sourceOdds are stored in participantExpectedValues for this season, + * the per-game win probability is blended: + * P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb + * where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)). + * Normalized odds are vig-removed futures win probabilities. + * ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as UCL simulator). + * Falls back to Elo-only when no odds are stored. * * Placement tiers → SimulationProbabilities mapping: * probFirst = Stanley Cup champion (1 per sim) @@ -55,17 +66,27 @@ import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { logger } from "~/lib/logger"; +import { + convertAmericanOddsToProbability, + normalizeProbabilities, +} from "~/services/probability-engine"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; /** - * Elo parity factor. NHL uses 800 (double the standard 400) to reflect the - * high game-to-game variance in hockey vs other sports. - * An 800-point Elo difference → ~90.9% win probability per game. + * Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect + * the elevated game-to-game variance in hockey. */ -const PARITY_FACTOR = 800; +const PARITY_FACTOR = 1000; + +/** + * Blend weights for Elo vs. Vegas futures odds when sourceOdds are available. + * Same calibration as the UCL simulator (0.7 / 0.3). + */ +const ELO_WEIGHT = 0.7; +const ODDS_WEIGHT = 1 - ELO_WEIGHT; // ─── Team data (2025-26 season, as of March 18, 2026) ──────────────────────── // @@ -378,11 +399,57 @@ export class NHLSimulator implements Simulator { ); } + // ─── Futures odds blending ───────────────────────────────────────────────── + // Load sourceOdds (American format) from participantExpectedValues. + // If any odds are present, blend them with Elo for per-game win probability. + // Falls back to Elo-only when no odds are stored. + + 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) + ); + const hasOdds = oddsRows.length > 0; + + // Build vig-removed win-probability map keyed by participant ID. + 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]); + }); + } + // ─── Helpers (defined once, outside the hot loop) ───────────────────────── + /** + * Blended per-game win probability for team A over team B. + * When odds are available: 70% Elo + 30% vig-removed futures head-to-head. + * Falls back to pure Elo when no odds are stored. + */ + const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { + const eloProb = eloWinProbability(elo(a), elo(b)); + if (!hasOdds) return eloProb; + + const o1 = normalizedOddsMap.get(a.id) ?? 0; + const o2 = normalizedOddsMap.get(b.id) ?? 0; + const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; + return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; + }; + /** Simulate a best-of-7 series. Returns winner and loser. */ const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => { - const winProb = eloWinProbability(elo(a), elo(b)); + const winProb = gameWinProb(a, b); let winsA = 0; let winsB = 0; while (winsA < 4 && winsB < 4) {