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,
getTeamData,
eloWinProbability,
simulateProjectedPoints,
} from "../nhl-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
@ -60,6 +61,35 @@ describe("getTeamData", () => {
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 ────────────────────────────────────────────────────────
@ -80,57 +110,64 @@ describe("eloWinProbability (PARITY_FACTOR = 1000)", () => {
});
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 ─────────────────────────────────────
// ─── simulateProjectedPoints ──────────────────────────────────────────────────
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);
const makeEntry = (overrides: Partial<{
currentPoints: number;
remainingGames: number;
winProb: number;
}> = {}) => ({
id: "test",
name: "Test Team",
data: undefined,
conference: "Western" as const,
division: "Central",
currentPoints: overrides.currentPoints ?? 80,
remainingGames: overrides.remainingGames ?? 10,
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", () => {
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("returns at most currentPoints + remainingGames * 2 (can't exceed max)", () => {
const entry = makeEntry({ currentPoints: 60, remainingGames: 15, winProb: 0.9 });
for (let i = 0; i < 100; i++) {
expect(simulateProjectedPoints(entry)).toBeLessThanOrEqual(60 + 15 * 2);
}
});
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);
it("higher winProb produces higher projected points on average", () => {
const base = makeEntry({ currentPoints: 0, remainingGames: 82 });
const runs = 2000;
const avg = (winProb: number) => {
let total = 0;
for (let i = 0; i < runs; i++) {
total += simulateProjectedPoints({ ...base, winProb });
}
return total / runs;
};
expect(avg(0.7)).toBeGreaterThan(avg(0.4));
});
});

View file

@ -6,28 +6,34 @@
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities)
* 3. For each simulation:
* a. For each conference, draw division placements (1st/2nd/3rd per division)
* and two wildcard teams using weighted probability distributions
* b. Build the NHL bracket:
* - Higher-Elo division winner faces WC2; other division winner faces WC1
* - Each division's 2nd vs 3rd seeds play each other
* c. Simulate 4 rounds of best-of-7 series:
* Round 1 (Wild Card): m0=topDiv1st/WC2, m1=otherDiv1st/WC1,
* 2. Load current regular season standings (wins, otLosses, gamesPlayed, conference, division)
* 3. Match participant names to hardcoded team data (Elo ratings + conference/division fallback)
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (82 - gamesPlayed):
* - Win (2 pts) with eloWinProb vs. a league-average opponent (Elo 1500)
* - OT/SO loss (1 pt) at NHL_OT_RATE × (1 winProb)
* - Regulation loss (0 pts) otherwise
* projectedPoints = currentPoints + simulated extra points
* 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
* Round 2 (Div Finals): m0w vs m2w, m1w vs m3w
* Round 3 (Conf Finals): two division-final winners per conference
* Stanley Cup Final: East champ vs West champ
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
*
* 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. 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.
* Win probability (Elo, PARITY_FACTOR = 1000):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 1000))
* NHL uses a higher parity factor than the standard 400 to reflect the high
* variance of hockey.
*
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
@ -46,19 +52,11 @@
* First Round losers all 0 (score 0 points)
* Missed playoffs all 0
*
* Seeding probability keys per team:
* p_div1 = prob of winning own division
* p_div2 = prob of finishing 2nd in own division
* p_div3 = prob of finishing 3rd in own division
* p_wc1 = prob of being conference wildcard 1 (higher-ranked WC)
* 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.
* Elo ratings are hardcoded (2025-26 season data).
* Source: https://elo.harvitronix.com/nhl/2025-2026
* Conference and division are read from the standings table (synced from the
* NHL API); TEAMS_DATA values serve as fallbacks if standings are missing.
* Update Elo values at the start of each season.
*/
import { database } from "~/database/context";
@ -70,11 +68,22 @@ import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ────────────────────────────────────────────────────
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
* the elevated game-to-game variance in hockey.
@ -88,17 +97,15 @@ const PARITY_FACTOR = 1000;
const ELO_WEIGHT = 0.7;
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.
// p_div1: Probability of finishing 1st in own division.
// p_div2: Probability of finishing 2nd in own division.
// 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).
// elo: Elo rating from elo.harvitronix.com/nhl/2025-2026.
// Used for both regular-season game simulation (vs. avg opponent Elo 1500)
// and for all playoff matchup win probabilities.
//
// These are weights used in sequential weighted sampling (not strict distributions).
// Sum of all p_* per team ≈ their overall playoff% from the Harvitronix site.
// conference / division: Used as fallbacks when the standings table has no
// conference or division data for a participant. In normal operation these
// are read from regularSeasonStandings (synced from the NHL API).
//
// Divisions:
// Eastern — Atlantic: BOS, BUF, DET, FLA, MTL, OTT, TBL, TOR
@ -110,168 +117,48 @@ interface NhlTeamData {
conference: "Eastern" | "Western";
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
elo: number;
p_div1?: number;
p_div2?: number;
p_div3?: number;
p_wc1?: number;
p_wc2?: number;
}
const TEAMS_DATA: Record<string, NhlTeamData> = {
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
// BUF 108 pts, TBL 107 pts — neck-and-neck for 1st; both near-certain to make playoffs.
// MTL 101 pts (86.7% PO) — almost certainly 3rd in Atlantic or WC.
// OTT 96 pts (50.2%), DET 98 pts (63%), BOS 96 pts (51.1%) — WC bubble.
// FLA 85 pts (0.5%), TOR 83 pts (0%) — eliminated.
"Tampa Bay Lightning": {
conference: "Eastern", division: "Atlantic", elo: 1587,
p_div1: 0.50, p_div2: 0.40, p_div3: 0.06,
},
"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
},
"Tampa Bay Lightning": { conference: "Eastern", division: "Atlantic", elo: 1587 },
"Buffalo Sabres": { conference: "Eastern", division: "Atlantic", elo: 1572 },
"Montreal Canadiens": { conference: "Eastern", division: "Atlantic", elo: 1527 },
"Ottawa Senators": { conference: "Eastern", division: "Atlantic", elo: 1542 },
"Detroit Red Wings": { conference: "Eastern", division: "Atlantic", elo: 1506 },
"Boston Bruins": { conference: "Eastern", division: "Atlantic", elo: 1501 },
"Florida Panthers": { conference: "Eastern", division: "Atlantic", elo: 1505 },
"Toronto Maple Leafs": { conference: "Eastern", division: "Atlantic", elo: 1479 },
// ── Eastern Conference — Metropolitan ─────────────────────────────────────
// CAR 109 pts (99.9%) — clear 1st seed.
// CBJ/PIT/NYI all tied at 99 pts — volatile 2nd/3rd/WC race.
// WSH 89 pts (4.7%), PHI 90 pts (7.9%) — long shots.
// NJD 86 pts (0.5%), NYR 80 pts (0%) — eliminated.
"Carolina Hurricanes": {
conference: "Eastern", division: "Metropolitan", elo: 1574,
p_div1: 0.98, p_div2: 0.01,
},
"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
},
"Carolina Hurricanes": { conference: "Eastern", division: "Metropolitan", elo: 1574 },
"Columbus Blue Jackets":{ conference: "Eastern", division: "Metropolitan", elo: 1530 },
"Pittsburgh Penguins": { conference: "Eastern", division: "Metropolitan", elo: 1518 },
"New York Islanders": { conference: "Eastern", division: "Metropolitan", elo: 1513 },
"Philadelphia Flyers": { conference: "Eastern", division: "Metropolitan", elo: 1473 },
"Washington Capitals": { conference: "Eastern", division: "Metropolitan", elo: 1506 },
"New Jersey Devils": { conference: "Eastern", division: "Metropolitan", elo: 1488 },
"New York Rangers": { conference: "Eastern", division: "Metropolitan", elo: 1480 },
// ── Western Conference — Central ───────────────────────────────────────────
// COL 119 pts, DAL 113 pts, MIN 106 pts — all locked in as top 3.
// UTA 93 pts (92.8%) — leading WC candidate from Central.
// NSH 85 pts (21.9%), WPG 82 pts (6.6%), STL 80 pts (2.2%) — long shots.
// CHI 75 pts (0%) — eliminated.
"Colorado Avalanche": {
conference: "Western", division: "Central", elo: 1594,
p_div1: 0.90, p_div2: 0.09, p_div3: 0.01,
},
"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
},
"Colorado Avalanche": { conference: "Western", division: "Central", elo: 1594 },
"Dallas Stars": { conference: "Western", division: "Central", elo: 1581 },
"Minnesota Wild": { conference: "Western", division: "Central", elo: 1548 },
"Utah Mammoth": { conference: "Western", division: "Central", elo: 1529 },
"Nashville Predators": { conference: "Western", division: "Central", elo: 1471 },
"Winnipeg Jets": { conference: "Western", division: "Central", elo: 1483 },
"St. Louis Blues": { conference: "Western", division: "Central", elo: 1473 },
"Chicago Blackhawks": { conference: "Western", division: "Central", elo: 1411 },
// ── Western Conference — Pacific ───────────────────────────────────────────
// ANA 94 pts, EDM 93 pts, VGK 93 pts — tight 3-way race for all 3 Pacific seeds.
// LAK 87 pts (39.1%), SJS 87 pts (38.9%), SEA 86 pts (26.6%) — WC bubble.
// CGY 73 pts (0%), VAN 63 pts (0%) — eliminated.
"Anaheim Ducks": {
conference: "Western", division: "Pacific", elo: 1490,
p_div1: 0.40, p_div2: 0.33, p_div3: 0.17, p_wc1: 0.02, p_wc2: 0.01,
},
"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
},
"Anaheim Ducks": { conference: "Western", division: "Pacific", elo: 1490 },
"Edmonton Oilers": { conference: "Western", division: "Pacific", elo: 1531 },
"Vegas Golden Knights": { conference: "Western", division: "Pacific", elo: 1519 },
"Los Angeles Kings": { conference: "Western", division: "Pacific", elo: 1482 },
"San Jose Sharks": { conference: "Western", division: "Pacific", elo: 1455 },
"Seattle Kraken": { conference: "Western", division: "Pacific", elo: 1469 },
"Calgary Flames": { conference: "Western", division: "Pacific", elo: 1436 },
"Vancouver Canucks": { conference: "Western", division: "Pacific", elo: 1403 },
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
@ -305,43 +192,60 @@ interface TeamEntry {
id: string;
name: string;
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. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/**
* Weighted pick without replacement.
* Draws one team from `pool` using the given seeding weight key, excluding `excluded`.
* Returns undefined if no eligible team has positive weight the caller should
* treat that as a degenerate draw and skip the iteration.
* Simulate remaining regular season games for one team.
* Each game:
* - Win (2 pts) with probability winProb
* - 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(
pool: TeamEntry[],
weightKey: "p_div1" | "p_div2" | "p_div3" | "p_wc1" | "p_wc2",
excluded: Set<TeamEntry>
): TeamEntry | undefined {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map((t) => t.data?.[weightKey] ?? 0);
const total = weights.reduce((s, w) => s + w, 0);
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];
export function simulateProjectedPoints(entry: TeamEntry): number {
let extra = 0;
const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * NHL_OT_RATE;
for (let g = 0; g < remainingGames; g++) {
const r = Math.random();
if (r < winProb) {
extra += 2;
} else if (r < winProb + otlProb) {
extra += 1;
}
}
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 ────────────────────────────────────────────────────────────────
@ -350,11 +254,14 @@ export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
// 1. Load participants and standings in parallel.
const [participantRows, standings] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
]);
if (participantRows.length === 0) {
throw new Error(
@ -365,44 +272,78 @@ export class NHLSimulator implements Simulator {
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// 2. Build standings lookup and construct team entries.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
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.
// These are excluded entirely rather than silently landing in the wildcard pool.
const unrecognized = teams.filter((t) => !t.data);
if (unrecognized.length > 0) {
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(", ")
);
}
// Separate recognized teams by conference + division.
const easternAtlantic = teams.filter((t) => t.data?.division === "Atlantic");
const easternMetro = teams.filter((t) => t.data?.division === "Metropolitan");
const westernCentral = teams.filter((t) => t.data?.division === "Central");
const westernPacific = teams.filter((t) => t.data?.division === "Pacific");
const easternAtlantic = teams.filter((t) => t.conference === "Eastern" && t.division === "Atlantic");
const easternMetro = teams.filter((t) => t.conference === "Eastern" && t.division === "Metropolitan");
const westernCentral = teams.filter((t) => t.conference === "Western" && t.division === "Central");
const westernPacific = teams.filter((t) => t.conference === "Western" && t.division === "Pacific");
// Only recognized teams enter the conference wildcard pools.
const easternTeams = teams.filter((t) => t.data?.conference === "Eastern");
const westernTeams = teams.filter((t) => t.data?.conference === "Western");
if (easternTeams.length < 8 || westernTeams.length < 8) {
if (
easternAtlantic.length < 3 ||
easternMetro.length < 3 ||
westernCentral.length < 3 ||
westernPacific.length < 3
) {
throw new Error(
`Each conference needs at least 8 recognized participants ` +
`(got East: ${easternTeams.length}, West: ${westernTeams.length}). ` +
`Each division needs at least 3 participants ` +
`(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.`
);
}
// ─── 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({
@ -418,7 +359,6 @@ export class NHLSimulator implements Simulator {
);
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) =>
@ -435,7 +375,6 @@ export class NHLSimulator implements Simulator {
/**
* 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));
@ -458,73 +397,52 @@ export class NHLSimulator implements Simulator {
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.
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed].
*
* Bracket:
* m0: topDiv_1st vs WC2 (top division winner plays weaker wildcard)
* m1: otherDiv_1st vs WC1 (other division winner plays stronger wildcard)
* m2: topDiv_2nd vs topDiv_3rd
* m3: otherDiv_2nd vs otherDiv_3rd
* For each sim iteration:
* 1. Simulate projected points for all teams in both divisions.
* 2. Top 3 per division (by projected pts + random tiebreaker) division seeds.
* 3. Top 2 remaining conference teams WC1 (more pts) and WC2 (fewer pts).
* 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
* uses regular season points, but Elo is a reasonable per-simulation proxy since
* we don't simulate the regular season it approximates the same ordering.
* Returns [m0, m1, m2, m3] where each element is [higher-seed, lower-seed],
* or undefined if the conference doesn't have enough teams for a valid bracket.
*/
const buildConferenceBracket = (
divATeams: TeamEntry[],
divBTeams: TeamEntry[],
allConfTeams: TeamEntry[]
divBTeams: TeamEntry[]
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
const divA = drawDivisionSeedings(divATeams);
const divB = drawDivisionSeedings(divBTeams);
if (!divA || !divB) return undefined;
if (divATeams.length < 3 || divBTeams.length < 3) return undefined;
const divisionWinners = new Set([divA.first, divA.second, divA.third, divB.first, divB.second, divB.third]);
const wcPool = allConfTeams.filter((t) => !divisionWinners.has(t));
const wcTaken = new Set<TeamEntry>();
// Project all conference teams exactly once (single simulateProjectedPoints call per team).
const divASet = new Set(divATeams.map((t) => t.id));
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);
if (!wc1) return undefined;
wcTaken.add(wc1);
const divASeeds = divAProjected.slice(0, 3);
const divBSeeds = divBProjected.slice(0, 3);
const wc2 = weightedPick(wcPool, "p_wc2", wcTaken);
if (!wc2) return undefined;
// Wildcard pool: non-division-seed teams, already sorted by projected pts.
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] =
elo(divA.first) >= elo(divB.first)
? [divA, divB]
: [divB, divA];
divASeeds[0].pts >= divBSeeds[0].pts
? [divASeeds, divBSeeds]
: [divBSeeds, divASeeds];
return [
[topDiv.first, wc2], // m0
[otherDiv.first, wc1], // m1
[topDiv.second, topDiv.third], // m2
[otherDiv.second, otherDiv.third], // m3
[topDiv[0].team, wc2.team], // m0: 1st seed vs WC2
[otherDiv[0].team, wc1.team], // m1: 2nd seed vs WC1
[topDiv[1].team, topDiv[2].team], // m2: top div 2nd vs 3rd
[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 ───────────────────────────────────────────
// Track effective iterations separately: degenerate bracket draws are skipped
// and must not count toward the probability denominators.
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// Build conference brackets.
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro, easternTeams);
const westBracket = buildConferenceBracket(westernCentral, westernPacific, westernTeams);
if (!eastBracket || !westBracket) continue; // degenerate draw — do not count
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue;
effectiveN++;
// ── Eastern Conference ────────────────────────────────────────────────────
// Round 1: 4 series (Wild Card round).
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 eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
// Conference Final.
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.winner;
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);
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]) {
r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
}
@ -585,16 +492,12 @@ export class NHLSimulator implements Simulator {
if (effectiveN === 0) {
throw new Error(
"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 ───────────────────────────
//
// 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 results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
@ -618,10 +521,7 @@ export class NHLSimulator implements Simulator {
});
// ─── 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"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",