- 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:
parent
7093b22726
commit
46a2e9c199
2 changed files with 211 additions and 8 deletions
136
app/services/simulations/__tests__/nhl-simulator.test.ts
Normal file
136
app/services/simulations/__tests__/nhl-simulator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue