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 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-03-22 00:14:18 -07:00 committed by GitHub
parent 7093b22726
commit 46a2e9c199
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 211 additions and 8 deletions

View file

@ -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);
});
});

View file

@ -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<string, number>();
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) {