Fix NHL simulator seeding bugs and code quality issues (#281)

* Fix NHL simulator dynamic seeding and code quality issues

- Replace hardcoded seeding probabilities with standings-based Monte Carlo
  projection: simulates remaining regular season games per team using Elo
  win probability vs. a league-average opponent, so mathematically eliminated
  teams naturally fall out of playoff contention
- Fix double-simulation bug in wildcard pool: all 16 conference teams are now
  projected exactly once via sortProjected([...divA, ...divB].map(projectTeam)),
  then filtered — wildcard pool no longer re-runs simulateProjectedPoints with
  fresh randomness independent of the division seeding
- Move ProjectedTeam interface, projectTeam(), and sortProjected() to module
  scope (defined once, not recreated on every buildConferenceBracket call)
- Replace conference-level participant count check (east < 8 || west < 8) with
  per-division checks (each division < 3) for a more actionable error message
- Add logger.warn when standings.length === 0 so admins know the sim is running
  without synced data
- Remove unused easternTeams/westernTeams variables
- Simplify test: replace Object.fromEntries pattern with a plain for..of loop

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix oxlint errors in NHL simulator and tests

- Replace non-null assertions (data!) with optional chaining (data?.x ?? "")
- Move makeEntry helper to module scope to avoid recreating on every call

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-04-08 21:33:33 -04:00 committed by GitHub
parent 0da80bd2c0
commit 9e5d1cda5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 294 additions and 357 deletions

View file

@ -3,6 +3,7 @@ import {
normalizeTeamName, normalizeTeamName,
getTeamData, getTeamData,
eloWinProbability, eloWinProbability,
simulateProjectedPoints,
} from "../nhl-simulator"; } from "../nhl-simulator";
// ─── normalizeTeamName ──────────────────────────────────────────────────────── // ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -60,6 +61,35 @@ describe("getTeamData", () => {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined(); expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
} }
}); });
it("all 32 teams have a valid conference and division", () => {
const validConferences = new Set(["Eastern", "Western"]);
const validDivisions = new Set(["Atlantic", "Metropolitan", "Central", "Pacific"]);
const allTeams = [
"Tampa Bay Lightning", "Buffalo Sabres", "Montreal Canadiens", "Ottawa Senators",
"Detroit Red Wings", "Boston Bruins", "Florida Panthers", "Toronto Maple Leafs",
"Carolina Hurricanes", "Columbus Blue Jackets", "Pittsburgh Penguins",
"New York Islanders", "Philadelphia Flyers", "Washington Capitals",
"New Jersey Devils", "New York Rangers",
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth",
"Nashville Predators", "Winnipeg Jets", "St. Louis Blues", "Chicago Blackhawks",
"Anaheim Ducks", "Edmonton Oilers", "Vegas Golden Knights", "Los Angeles Kings",
"San Jose Sharks", "Seattle Kraken", "Calgary Flames", "Vancouver Canucks",
];
for (const name of allTeams) {
const data = getTeamData(name);
expect(validConferences.has(data?.conference ?? ""), `${name}: invalid conference`).toBe(true);
expect(validDivisions.has(data?.division ?? ""), `${name}: invalid division`).toBe(true);
}
});
it("conference/division assignments are correct for spot-checked teams", () => {
expect(getTeamData("Carolina Hurricanes")?.division).toBe("Metropolitan");
expect(getTeamData("Colorado Avalanche")?.division).toBe("Central");
expect(getTeamData("Vegas Golden Knights")?.division).toBe("Pacific");
expect(getTeamData("Buffalo Sabres")?.conference).toBe("Eastern");
expect(getTeamData("Edmonton Oilers")?.conference).toBe("Western");
});
}); });
// ─── eloWinProbability ──────────────────────────────────────────────────────── // ─── eloWinProbability ────────────────────────────────────────────────────────
@ -80,57 +110,64 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
}); });
it("a 100-pt gap gives ~55.7% win prob per game", () => { 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); const p = eloWinProbability(1600, 1500);
expect(p).toBeCloseTo(0.557, 2); expect(p).toBeCloseTo(0.557, 2);
}); });
it("a 200-pt gap gives ~61.3% win prob per game", () => { 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); const p = eloWinProbability(1700, 1500);
expect(p).toBeCloseTo(0.613, 2); expect(p).toBeCloseTo(0.613, 2);
}); });
}); });
// ─── Seeding probabilities sanity checks ───────────────────────────────────── // ─── simulateProjectedPoints ──────────────────────────────────────────────────
describe("team seeding probabilities", () => { const makeEntry = (overrides: Partial<{
it("playoff probability sums to ≤ 1.0 per team", () => { currentPoints: number;
const teamNames = [ remainingGames: number;
"Colorado Avalanche", "Dallas Stars", "Minnesota Wild", "Utah Mammoth", winProb: number;
"Carolina Hurricanes", "Buffalo Sabres", "Tampa Bay Lightning", }> = {}) => ({
]; id: "test",
for (const name of teamNames) { name: "Test Team",
const d = getTeamData(name); data: undefined,
expect(d).toBeDefined(); conference: "Western" as const,
const total = division: "Central",
(d?.p_div1 ?? 0) + (d?.p_div2 ?? 0) + (d?.p_div3 ?? 0) + currentPoints: overrides.currentPoints ?? 80,
(d?.p_wc1 ?? 0) + (d?.p_wc2 ?? 0); remainingGames: overrides.remainingGames ?? 10,
expect(total, `${name} playoff prob > 1`).toBeLessThanOrEqual(1.001); winProb: overrides.winProb ?? 0.55,
});
describe("simulateProjectedPoints", () => {
it("returns exactly currentPoints when no games remain", () => {
const entry = makeEntry({ currentPoints: 95, remainingGames: 0 });
expect(simulateProjectedPoints(entry)).toBe(95);
});
it("returns at least currentPoints (points never decrease)", () => {
const entry = makeEntry({ currentPoints: 70, remainingGames: 20, winProb: 0.5 });
for (let i = 0; i < 100; i++) {
expect(simulateProjectedPoints(entry)).toBeGreaterThanOrEqual(70);
} }
}); });
it("eliminated teams have no seeding probability keys", () => { it("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => {
const eliminated = ["Toronto Maple Leafs", "New York Rangers", "Chicago Blackhawks", "Vancouver Canucks"]; const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 });
for (const name of eliminated) { for (let i = 0; i < 100; i++) {
const d = getTeamData(name); expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2);
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", () => { it("higher winProb produces higher projected points on average", () => {
// COL should dominate Central div1 const base = makeEntry({ currentPoints: 0, remainingGames: 82 });
const col = getTeamData("Colorado Avalanche"); const runs = 2000;
const dal = getTeamData("Dallas Stars"); const avg = (winProb: number) => {
expect((col?.p_div1 ?? 0)).toBeGreaterThan(dal?.p_div1 ?? 0); let total = 0;
for (let i = 0; i < runs; i++) {
// CAR should dominate Metro div1 total += simulateProjectedPoints({ ...base, winProb });
const car = getTeamData("Carolina Hurricanes"); }
const cbj = getTeamData("Columbus Blue Jackets"); return total / runs;
expect((car?.p_div1 ?? 0)).toBeGreaterThan(cbj?.p_div1 ?? 0); };
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
}); });
}); });

View file

@ -6,28 +6,34 @@
* *
* Algorithm: * Algorithm:
* 1. Load all participants for the sports season from DB * 1. Load all participants for the sports season from DB
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities) * 2. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division)
* 3. For each simulation: * 3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback)
* a. For each conference, draw division placements (1st/2nd/3rd per division) * 4. For each simulation:
* and two wildcard teams using weighted probability distributions * a. For each team, simulate remaining regular season games (82 - gamesPlayed):
* b. Build the NHL bracket: * - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500)
* - Higher-Elo division winner faces WC2; other division winner faces WC1 * - OT/SO loss (1 pt) at NHL_OT_RATE × (1 winProb)
* - Each division's 2nd vs 3rd seeds play each other * - Regulation loss (0 pts) otherwise
* c. Simulate 4 rounds of best-of-7 series: * projectedPoints = currentPoints + simulated extra points
* Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1, * b. Per division: rank by projected points (random tiebreaker) top 3 are division seeds
* c. Per conference: top 2 non-division-seed teams by projected points WC1 and WC2
* (WC1 has more points than WC2)
* d. Build the 8-team bracket per conference:
* - Division winner with more points = 1st seed, faces WC2
* - Other division winner = 2nd seed, faces WC1
* - Each division's 2nd and 3rd seeds face each other
* e. Simulate 4 rounds of best-of-7 series:
* Round 1 (Wild Card): m0=1stSeed/WC2, m1=2ndSeed/WC1,
* m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd * m2=topDiv2nd/3rd, m3=otherDiv2nd/3rd
* Round 2 (Div Finals): m0w vs m2w, m1w vs m3w * Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
* Round 3 (Conf Finals): two division-final winners per conference * Round 3 (Conf Finals): two division-final winners per conference
* Stanley Cup Final: East champ vs West champ * Stanley Cup Final: East champ vs West champ
* 4. Track placement counts per scoring tier * 5. Track placement counts per scoring tier
* 5. Convert counts to probability distributions * 6. Convert counts to probability distributions
* *
* Win probability (Elo, PARITY_FACTOR = 550): * Win probability (Elo, PARITY_FACTOR = 1000):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 550)) * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000))
* NHL uses a higher parity factor than the standard 400 to dampen the Elo * NHL uses a higher parity factor than the standard 400 to reflect the high
* spread and reflect the high variance of hockey. 550 (down from an original * variance of hockey.
* 800) was chosen to better match Vegas championship implied probabilities
* 800 compressed favorites too far toward 50/50 per game.
* *
* Futures blending: * Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season, * If sourceOdds are stored in participantExpectedValues for this season,
@ -46,19 +52,11 @@
* First Round losers all 0 (score 0 points) * First Round losers all 0 (score 0 points)
* Missed playoffs all 0 * Missed playoffs all 0
* *
* Seeding probability keys per team: * Elo ratings are hardcoded (2025-26 season data).
* p_div1 = prob of winning own division * Source: https://elo.harvitronix.com/nhl/2025-2026
* p_div2 = prob of finishing 2nd in own division * Conference and division are read from the standings table (synced from the
* p_div3 = prob of finishing 3rd in own division * NHL API); TEAMS_DATA values serve as fallbacks if standings are missing.
* p_wc1 = prob of being conference wildcard 1 (higher-ranked WC) * Update Elo values at the start of each season.
* p_wc2 = prob of being conference wildcard 2 (lower-ranked WC)
* Sum of all p_* team's overall playoff probability.
* Teams with no p_* entries always miss the playoffs.
*
* Elo ratings and seeding probabilities are hardcoded below (March 2026 data).
* Source: https://elo.harvitronix.com/nhl/2025-2026 (Elo ratings + playoff% calibration).
* Seeding probabilities are hand-derived from current standings as of March 18, 2026.
* Update at the start of each season.
*/ */
import { database } from "~/database/context"; import { database } from "~/database/context";
@ -70,11 +68,22 @@ import {
convertAmericanOddsToProbability, convertAmericanOddsToProbability,
normalizeProbabilities, normalizeProbabilities,
} from "~/services/probability-engine"; } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ──────────────────────────────────────────────────── // ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000; const NUM_SIMULATIONS = 50_000;
/** NHL regular season games per team. */
const NHL_REGULAR_SEASON_GAMES = 82;
/**
* Fraction of NHL games that go to overtime / shootout.
* The losing team earns 1 point (instead of 0) in these games.
* Historical average: ~23% of games.
*/
const NHL_OT_RATE = 0.23;
/** /**
* Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect * Elo parity factor. NHL uses 1000 (higher than the standard 400) to reflect
* the elevated game-to-game variance in hockey. * the elevated game-to-game variance in hockey.
@ -88,17 +97,15 @@ const PARITY_FACTOR = 1000;
const ELO_WEIGHT = 0.7; const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT; const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2025-26 season, as of March 18, 2026) ──────────────────────── // ─── Team data (2025-26 season) ───────────────────────────────────────────────
// //
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026. // elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
// p_div1: Probability of finishing 1st in own division. // Used for both regular-season game simulation (vs. avg opponent Elo 1500)
// p_div2: Probability of finishing 2nd in own division. // and for all playoff matchup win probabilities.
// p_div3: Probability of finishing 3rd in own division.
// p_wc1: Probability of being conference wildcard 1 (higher seed).
// p_wc2: Probability of being conference wildcard 2 (lower seed).
// //
// These are weights used in sequential weighted sampling (not strict distributions). // conference / division: Used as fallbacks when the standings table has no
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site. // conference or division data for a participant. In normal operation these
// are read from regularSeasonStandings (synced from the NHL API).
// //
// Divisions: // Divisions:
// Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR // Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
@ -110,168 +117,48 @@ interface NhlTeamData {
conference: "Eastern" | "Western"; conference: "Eastern" | "Western";
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific"; division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
elo: number; elo: number;
p_div1?: number;
p_div2?: number;
p_div3?: number;
p_wc1?: number;
p_wc2?: number;
} }
const TEAMS_DATA: Record<string, NhlTeamData> = { const TEAMS_DATA: Record<string, NhlTeamData> = {
// ── Eastern Conference — Atlantic ────────────────────────────────────────── // ── Eastern Conference — Atlantic ──────────────────────────────────────────
// BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs. "Tampa Bay Lightning": { conference: "Eastern", division: "Atlantic", elo: 1587 },
// MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC. "Buffalo Sabres": { conference: "Eastern", division: "Atlantic", elo: 1572 },
// OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble. "Montreal Canadiens": { conference: "Eastern", division: "Atlantic", elo: 1527 },
// FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated. "Ottawa Senators": { conference: "Eastern", division: "Atlantic", elo: 1542 },
"Detroit Red Wings": { conference: "Eastern", division: "Atlantic", elo: 1506 },
"Tampa Bay Lightning": { "Boston Bruins": { conference: "Eastern", division: "Atlantic", elo: 1501 },
conference: "Eastern", division: "Atlantic", elo: 1587, "Florida Panthers": { conference: "Eastern", division: "Atlantic", elo: 1505 },
p_div1: 0.50, p_div2: 0.40, p_div3: 0.06, "Toronto Maple Leafs": { conference: "Eastern", division: "Atlantic", elo: 1479 },
},
"Buffalo Sabres": {
conference: "Eastern", division: "Atlantic", elo: 1572,
p_div1: 0.45, p_div2: 0.42, p_div3: 0.07,
},
"Montreal Canadiens": {
conference: "Eastern", division: "Atlantic", elo: 1527,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.60, p_wc1: 0.12, p_wc2: 0.06,
},
"Ottawa Senators": {
conference: "Eastern", division: "Atlantic", elo: 1542,
p_div1: 0.01, p_div2: 0.05, p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Detroit Red Wings": {
conference: "Eastern", division: "Atlantic", elo: 1506,
p_div3: 0.09, p_wc1: 0.27, p_wc2: 0.22,
},
"Boston Bruins": {
conference: "Eastern", division: "Atlantic", elo: 1501,
p_div3: 0.08, p_wc1: 0.22, p_wc2: 0.19,
},
"Florida Panthers": {
conference: "Eastern", division: "Atlantic", elo: 1505,
p_wc2: 0.005,
},
"Toronto Maple Leafs": {
conference: "Eastern", division: "Atlantic", elo: 1479,
// 0% playoff probability — always miss
},
// ── Eastern Conference — Metropolitan ───────────────────────────────────── // ── Eastern Conference — Metropolitan ─────────────────────────────────────
// CAR 109 pts (99.9%) — clear 1st seed. "Carolina Hurricanes": { conference: "Eastern", division: "Metropolitan", elo: 1574 },
// CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race. "Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan", elo: 1530 },
// WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots. "Pittsburgh Penguins": { conference: "Eastern", division: "Metropolitan", elo: 1518 },
// NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated. "New York Islanders": { conference: "Eastern", division: "Metropolitan", elo: 1513 },
"Philadelphia Flyers": { conference: "Eastern", division: "Metropolitan", elo: 1473 },
"Carolina Hurricanes": { "Washington Capitals": { conference: "Eastern", division: "Metropolitan", elo: 1506 },
conference: "Eastern", division: "Metropolitan", elo: 1574, "New Jersey Devils": { conference: "Eastern", division: "Metropolitan", elo: 1488 },
p_div1: 0.98, p_div2: 0.01, "New York Rangers": { conference: "Eastern", division: "Metropolitan", elo: 1480 },
},
"Columbus Blue Jackets": {
conference: "Eastern", division: "Metropolitan", elo: 1530,
p_div2: 0.33, p_div3: 0.33, p_wc1: 0.05, p_wc2: 0.04,
},
"Pittsburgh Penguins": {
conference: "Eastern", division: "Metropolitan", elo: 1518,
p_div2: 0.35, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"New York Islanders": {
conference: "Eastern", division: "Metropolitan", elo: 1513,
p_div2: 0.28, p_div3: 0.35, p_wc1: 0.07, p_wc2: 0.07,
},
"Philadelphia Flyers": {
conference: "Eastern", division: "Metropolitan", elo: 1473,
p_wc1: 0.02, p_wc2: 0.05,
},
"Washington Capitals": {
conference: "Eastern", division: "Metropolitan", elo: 1506,
p_wc1: 0.01, p_wc2: 0.03,
},
"New Jersey Devils": {
conference: "Eastern", division: "Metropolitan", elo: 1488,
p_wc2: 0.005,
},
"New York Rangers": {
conference: "Eastern", division: "Metropolitan", elo: 1480,
// 0% playoff probability — always miss
},
// ── Western Conference — Central ─────────────────────────────────────────── // ── Western Conference — Central ───────────────────────────────────────────
// COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3. "Colorado Avalanche": { conference: "Western", division: "Central", elo: 1594 },
// UTA 93 pts (92.8%) — leading WC candidate from Central. "Dallas Stars": { conference: "Western", division: "Central", elo: 1581 },
// NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots. "Minnesota Wild": { conference: "Western", division: "Central", elo: 1548 },
// CHI 75 pts (0%) — eliminated. "Utah Mammoth": { conference: "Western", division: "Central", elo: 1529 },
"Nashville Predators": { conference: "Western", division: "Central", elo: 1471 },
"Colorado Avalanche": { "Winnipeg Jets": { conference: "Western", division: "Central", elo: 1483 },
conference: "Western", division: "Central", elo: 1594, "St. Louis Blues": { conference: "Western", division: "Central", elo: 1473 },
p_div1: 0.90, p_div2: 0.09, p_div3: 0.01, "Chicago Blackhawks": { conference: "Western", division: "Central", elo: 1411 },
},
"Dallas Stars": {
conference: "Western", division: "Central", elo: 1581,
p_div1: 0.08, p_div2: 0.80, p_div3: 0.11,
},
"Minnesota Wild": {
conference: "Western", division: "Central", elo: 1548,
p_div1: 0.02, p_div2: 0.10, p_div3: 0.86, p_wc1: 0.01,
},
"Utah Mammoth": {
conference: "Western", division: "Central", elo: 1529,
p_div3: 0.02, p_wc1: 0.55, p_wc2: 0.33,
},
"Nashville Predators": {
conference: "Western", division: "Central", elo: 1471,
p_wc1: 0.04, p_wc2: 0.17,
},
"Winnipeg Jets": {
conference: "Western", division: "Central", elo: 1483,
p_wc1: 0.01, p_wc2: 0.05,
},
"St. Louis Blues": {
conference: "Western", division: "Central", elo: 1473,
p_wc2: 0.02,
},
"Chicago Blackhawks": {
conference: "Western", division: "Central", elo: 1411,
// 0% playoff probability — always miss
},
// ── Western Conference — Pacific ─────────────────────────────────────────── // ── Western Conference — Pacific ───────────────────────────────────────────
// ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds. "Anaheim Ducks": { conference: "Western", division: "Pacific", elo: 1490 },
// LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble. "Edmonton Oilers": { conference: "Western", division: "Pacific", elo: 1531 },
// CGY 73 pts (0%), VAN 63 pts (0%) — eliminated. "Vegas Golden Knights": { conference: "Western", division: "Pacific", elo: 1519 },
"Los Angeles Kings": { conference: "Western", division: "Pacific", elo: 1482 },
"Anaheim Ducks": { "San Jose Sharks": { conference: "Western", division: "Pacific", elo: 1455 },
conference: "Western", division: "Pacific", elo: 1490, "Seattle Kraken": { conference: "Western", division: "Pacific", elo: 1469 },
p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01, "Calgary Flames": { conference: "Western", division: "Pacific", elo: 1436 },
}, "Vancouver Canucks": { conference: "Western", division: "Pacific", elo: 1403 },
"Edmonton Oilers": {
conference: "Western", division: "Pacific", elo: 1531,
p_div1: 0.30, p_div2: 0.32, p_div3: 0.20, p_wc1: 0.04, p_wc2: 0.04,
},
"Vegas Golden Knights": {
conference: "Western", division: "Pacific", elo: 1519,
p_div1: 0.25, p_div2: 0.28, p_div3: 0.20, p_wc1: 0.06, p_wc2: 0.06,
},
"Los Angeles Kings": {
conference: "Western", division: "Pacific", elo: 1482,
p_div2: 0.02, p_div3: 0.09, p_wc1: 0.14, p_wc2: 0.12,
},
"San Jose Sharks": {
conference: "Western", division: "Pacific", elo: 1455,
p_div2: 0.01, p_div3: 0.08, p_wc1: 0.14, p_wc2: 0.12,
},
"Seattle Kraken": {
conference: "Western", division: "Pacific", elo: 1469,
p_div3: 0.07, p_wc1: 0.10, p_wc2: 0.10,
},
"Calgary Flames": {
conference: "Western", division: "Pacific", elo: 1436,
// 0% playoff probability — always miss
},
"Vancouver Canucks": {
conference: "Western", division: "Pacific", elo: 1403,
// 0% playoff probability — always miss
},
}; };
// ─── Public helpers (exported for unit testing) ─────────────────────────────── // ─── Public helpers (exported for unit testing) ───────────────────────────────
@ -305,43 +192,60 @@ interface TeamEntry {
id: string; id: string;
name: string; name: string;
data: NhlTeamData | undefined; data: NhlTeamData | undefined;
conference: "Eastern" | "Western";
division: string;
/** Current regular season points: wins × 2 + OT losses × 1. */
currentPoints: number;
/** Remaining regular season games = 82 gamesPlayed. */
remainingGames: number;
/** Win probability vs. a league-average opponent (Elo 1500). Pre-computed. */
winProb: number;
} }
type DivisionSeedings = {
first: TeamEntry;
second: TeamEntry;
third: TeamEntry;
};
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */ /** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number { function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400; return entry.data?.elo ?? 1400;
} }
/** /**
* Weighted pick without replacement. * Simulate remaining regular season games for one team.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`. * Each game:
* Returns undefined if no eligible team has positive weight the caller should * - Win (2 pts) with probability winProb
* treat that as a degenerate draw and skip the iteration. * - OT/SO loss (1 pt) with probability (1 winProb) × NHL_OT_RATE
* - Regulation loss (0 pts) otherwise
* Returns projected total points for the season.
*/ */
function weightedPick( export function simulateProjectedPoints(entry: TeamEntry): number {
pool: TeamEntry[], let extra = 0;
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2", const { winProb, remainingGames } = entry;
excluded: Set<TeamEntry> const otlProb = (1 - winProb) * NHL_OT_RATE;
): TeamEntry | undefined { for (let g = 0; g < remainingGames; g++) {
const eligible = pool.filter((t) => !excluded.has(t)); const r = Math.random();
if (eligible.length === 0) return undefined; if (r < winProb) {
extra += 2;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0); } else if (r < winProb + otlProb) {
const total = weights.reduce((s, w) => s + w, 0); extra += 1;
if (total === 0) return undefined; }
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
} }
return eligible[eligible.length - 1]; return entry.currentPoints + extra;
}
// ─── Projected-points helpers (module-level for hot-loop efficiency) ──────────
interface ProjectedTeam {
team: TeamEntry;
pts: number;
tb: number;
}
/** Project one team's end-of-season point total with a random tiebreaker. */
function projectTeam(t: TeamEntry): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t), tb: Math.random() };
}
/** Sort projected teams descending by pts, then by random tiebreaker. Mutates arr. */
function sortProjected(arr: ProjectedTeam[]): ProjectedTeam[] {
return arr.toSorted((a, b) => b.pts - a.pts || b.tb - a.tb);
} }
// ─── Simulator ──────────────────────────────────────────────────────────────── // ─── Simulator ────────────────────────────────────────────────────────────────
@ -350,11 +254,14 @@ export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> { async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database(); const db = database();
// 1. Load all participants for this sports season. // 1. Load participants and standings in parallel.
const participantRows = await db const [participantRows, standings] = await Promise.all([
.select({ id: schema.participants.id, name: schema.participants.name }) db
.from(schema.participants) .select({ id: schema.participants.id, name: schema.participants.name })
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)); .from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);
if (participantRows.length === 0) { if (participantRows.length === 0) {
throw new Error( throw new Error(
@ -365,44 +272,78 @@ export class NHLSimulator implements Simulator {
const participantIds = participantRows.map((r) => r.id); const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({ // 2. Build standings lookup and construct team entries.
id: r.id, const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
name: r.name,
data: getTeamData(r.name), if (standings.length === 0) {
})); logger.warn(
`[NHLSimulator] No standings data found for sports season ${sportsSeasonId}. ` +
`Simulation will use 0 current points and 82 remaining games for all teams. ` +
`Sync standings before running for accurate results.`
);
}
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
const data = getTeamData(r.name);
// Conference and division: prefer standings table, fall back to TEAMS_DATA.
const rawConf = standing?.conference;
const conference: "Eastern" | "Western" =
rawConf === "Eastern" || rawConf === "Western"
? rawConf
: (data?.conference ?? "Eastern");
const division: string = standing?.division ?? data?.division ?? "";
const gamesPlayed = standing?.gamesPlayed ?? 0;
const wins = standing?.wins ?? 0;
const otLosses = standing?.otLosses ?? 0;
const currentPoints = wins * 2 + otLosses;
const remainingGames = Math.max(0, NHL_REGULAR_SEASON_GAMES - gamesPlayed);
return {
id: r.id,
name: r.name,
data,
conference,
division,
currentPoints,
remainingGames,
winProb: eloWinProbability(data?.elo ?? 1400, 1500),
};
});
// Warn about participants that don't match any hardcoded team. // Warn about participants that don't match any hardcoded team.
// These are excluded entirely rather than silently landing in the wildcard pool.
const unrecognized = teams.filter((t) => !t.data); const unrecognized = teams.filter((t) => !t.data);
if (unrecognized.length > 0) { if (unrecognized.length > 0) {
logger.warn( logger.warn(
`[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` + `[NHLSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will use fallback Elo 1400: ` +
unrecognized.map((t) => t.name).join(", ") unrecognized.map((t) => t.name).join(", ")
); );
} }
// Separate recognized teams by conference + division. // Separate recognized teams by conference + division.
const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic"); const easternAtlantic = teams.filter((t) => t.conference === "Eastern" && t.division === "Atlantic");
const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan"); const easternMetro = teams.filter((t) => t.conference === "Eastern" && t.division === "Metropolitan");
const westernCentral = teams.filter((t) => t.data?.division === "Central"); const westernCentral = teams.filter((t) => t.conference === "Western" && t.division === "Central");
const westernPacific = teams.filter((t) => t.data?.division === "Pacific"); const westernPacific = teams.filter((t) => t.conference === "Western" && t.division === "Pacific");
// Only recognized teams enter the conference wildcard pools. if (
const easternTeams = teams.filter((t) => t.data?.conference === "Eastern"); easternAtlantic.length < 3 ||
const westernTeams = teams.filter((t) => t.data?.conference === "Western"); easternMetro.length < 3 ||
westernCentral.length < 3 ||
if (easternTeams.length < 8 || westernTeams.length < 8) { westernPacific.length < 3
) {
throw new Error( throw new Error(
`Each conference needs at least 8 recognized participants ` + `Each division needs at least 3 participants ` +
`(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` + `(got Eastern/Atlantic: ${easternAtlantic.length}, Eastern/Metro: ${easternMetro.length}, ` +
`Western/Central: ${westernCentral.length}, Western/Pacific: ${westernPacific.length}). ` +
`Add all 32 NHL teams before running simulation.` `Add all 32 NHL teams before running simulation.`
); );
} }
// ─── Futures odds blending ───────────────────────────────────────────────── // ─── 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 const evRows = await db
.select({ .select({
@ -418,7 +359,6 @@ export class NHLSimulator implements Simulator {
); );
const hasOdds = oddsRows.length > 0; const hasOdds = oddsRows.length > 0;
// Build vig-removed win-probability map keyed by participant ID.
const normalizedOddsMap = new Map<string, number>(); const normalizedOddsMap = new Map<string, number>();
if (hasOdds) { if (hasOdds) {
const rawProbs = oddsRows.map((r) => const rawProbs = oddsRows.map((r) =>
@ -435,7 +375,6 @@ export class NHLSimulator implements Simulator {
/** /**
* Blended per-game win probability for team A over team B. * Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head. * 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 gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const eloProb = eloWinProbability(elo(a), elo(b)); const eloProb = eloWinProbability(elo(a), elo(b));
@ -458,73 +397,52 @@ export class NHLSimulator implements Simulator {
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a }; return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
}; };
/**
* Draw the top-3 playoff seeds for a single division.
* Returns undefined for each position if no team has weight for that slot
* (shouldn't happen with correct data, but handled gracefully).
*/
const drawDivisionSeedings = (divTeams: TeamEntry[]): DivisionSeedings | undefined => {
const taken = new Set<TeamEntry>();
const first = weightedPick(divTeams, "p_div1", taken);
if (!first) return undefined;
taken.add(first);
const second = weightedPick(divTeams, "p_div2", taken);
if (!second) return undefined;
taken.add(second);
const third = weightedPick(divTeams, "p_div3", taken);
if (!third) return undefined;
return { first, second, third };
};
/** /**
* Build the 8-team playoff bracket for one conference. * Build the 8-team playoff bracket for one conference.
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
* *
* Bracket: * For each sim iteration:
* m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard) * 1. Simulate projected points for all teams in both divisions.
* m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard) * 2. Top 3 per division (by projected pts + random tiebreaker) division seeds.
* m2: topDiv_2nd vs topDiv_3rd * 3. Top 2 remaining conference teams WC1 (more pts) and WC2 (fewer pts).
* m3: otherDiv_2nd vs otherDiv_3rd * 4. Division winner with more pts = 1st seed (faces WC2);
* other division winner = 2nd seed (faces WC1).
* *
* "Top division" = whichever division winner has higher Elo. The actual NHL rule * Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed],
* uses regular season points, but Elo is a reasonable per-simulation proxy since * or undefined if the conference doesn't have enough teams for a valid bracket.
* we don't simulate the regular season it approximates the same ordering.
*/ */
const buildConferenceBracket = ( const buildConferenceBracket = (
divATeams: TeamEntry[], divATeams: TeamEntry[],
divBTeams: TeamEntry[], divBTeams: TeamEntry[]
allConfTeams: TeamEntry[]
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => { ): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
const divA = drawDivisionSeedings(divATeams); if (divATeams.length < 3 || divBTeams.length < 3) return undefined;
const divB = drawDivisionSeedings(divBTeams);
if (!divA || !divB) return undefined;
const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]); // Project all conference teams exactly once (single simulateProjectedPoints call per team).
const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t)); const divASet = new Set(divATeams.map((t) => t.id));
const wcTaken = new Set<TeamEntry>(); const allProjected = sortProjected([...divATeams, ...divBTeams].map(projectTeam));
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
const wc1 = weightedPick(wcPool, "p_wc1", wcTaken); const divASeeds = divAProjected.slice(0, 3);
if (!wc1) return undefined; const divBSeeds = divBProjected.slice(0, 3);
wcTaken.add(wc1);
const wc2 = weightedPick(wcPool, "p_wc2", wcTaken); // Wildcard pool: non-division-seed teams, already sorted by projected pts.
if (!wc2) return undefined; const divisionSeedIds = new Set([...divASeeds, ...divBSeeds].map((p) => p.team.id));
const wcPool = allProjected.filter((p) => !divisionSeedIds.has(p.team.id));
// Higher-Elo division winner faces WC2 (the weaker wildcard). if (wcPool.length < 2) return undefined;
const [wc1, wc2] = wcPool; // wc1 has more pts → stronger wildcard
// Division winner with more projected points is the top seed (plays WC2).
const [topDiv, otherDiv] = const [topDiv, otherDiv] =
elo(divA.first) >= elo(divB.first) divASeeds[0].pts >= divBSeeds[0].pts
? [divA, divB] ? [divASeeds, divBSeeds]
: [divB, divA]; : [divBSeeds, divASeeds];
return [ return [
[topDiv.first, wc2], // m0 [topDiv[0].team, wc2.team], // m0: 1st seed vs WC2
[otherDiv.first, wc1], // m1 [otherDiv[0].team, wc1.team], // m1: 2nd seed vs WC1
[topDiv.second, topDiv.third], // m2 [topDiv[1].team, topDiv[2].team], // m2: top div 2nd vs 3rd
[otherDiv.second, otherDiv.third], // m3 [otherDiv[1].team, otherDiv[2].team], // m3: other div 2nd vs 3rd
]; ];
}; };
@ -537,28 +455,18 @@ export class NHLSimulator implements Simulator {
// ─── Monte Carlo simulation loop ─────────────────────────────────────────── // ─── Monte Carlo simulation loop ───────────────────────────────────────────
// Track effective iterations separately: degenerate bracket draws are skipped
// and must not count toward the probability denominators.
let effectiveN = 0; let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) { for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Build conference brackets. const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams); const westBracket = buildConferenceBracket(westernCentral, westernPacific);
const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams); if (!eastBracket || !westBracket) continue;
if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
effectiveN++; effectiveN++;
// ── Eastern Conference ──────────────────────────────────────────────────── // ── Eastern Conference ────────────────────────────────────────────────────
// Round 1: 4 series (Wild Card round).
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner); const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
// Round 2 (Division Finals):
// m0w (topDiv1st/WC2 winner) vs m2w (topDiv 2nd/3rd winner)
// m1w (otherDiv1st/WC1 winner) vs m3w (otherDiv 2nd/3rd winner)
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]); const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]); const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
// Conference Final.
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner); const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.winner; const eastChamp = eastCF.winner;
confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1); confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1);
@ -576,7 +484,6 @@ export class NHLSimulator implements Simulator {
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
// Round 2 losers (4 per sim). Round 1 losers are not counted (0 points).
for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) { for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1); r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
} }
@ -585,16 +492,12 @@ export class NHLSimulator implements Simulator {
if (effectiveN === 0) { if (effectiveN === 0) {
throw new Error( throw new Error(
"All simulations produced degenerate brackets. " + "All simulations produced degenerate brackets. " +
"Check that each division has teams with positive seeding probabilities." "Check that each division has at least 3 participants with standings data."
); );
} }
// ─── Convert counts to probability distributions ─────────────────────────── // ─── Convert counts to probability distributions ───────────────────────────
//
// probFirst/Second → count / N (1 team per sim)
// probThird/Fourth → count / (2 * N) (2 conf final losers per sim)
// probFifthEighth → count / (4 * N) (4 R2 losers per sim)
//
const N = effectiveN; const N = effectiveN;
const results: SimulationResult[] = participantIds.map((participantId) => { const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0; const c = championCounts.get(participantId) ?? 0;
@ -618,10 +521,7 @@ export class NHLSimulator implements Simulator {
}); });
// ─── Per-position normalization ──────────────────────────────────────────── // ─── Per-position normalization ────────────────────────────────────────────
//
// Belt-and-suspenders guard against floating-point residuals.
// Columns are already near-exactly 1.0 by construction above.
//
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [ const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth", "probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth", "probFifth", "probSixth", "probSeventh", "probEighth",