brackt/app/services/simulations/nhl-simulator.ts
chrisp 3e50619629
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m17s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 11s
claude/probability-config-review-1tdolw (#119)
Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #119
2026-06-30 23:24:48 +00:00

761 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* NHL Playoff Simulator
*
* Monte Carlo simulation of the NHL playoffs including seeding projection
* for the current season (2025-26).
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 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
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
*
* 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,
* 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)
* probSecond = Stanley Cup Final loser (1 per sim)
* probThird / probFourth = Conference Final losers (2 per sim — East + West)
* probFifthprobEighth = Second Round losers (4 per sim)
* First Round losers → all 0 (score 0 points)
* Missed playoffs → all 0
*
* 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";
import { eq, and } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { logger } from "~/lib/logger";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { positiveConfigNumber, configNumber } from "./config-access";
// ─── Simulation parameters (defaults; overridable via season config) ───────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
/** NHL regular season games per team. */
const DEFAULT_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 DEFAULT_OT_RATE = 0.23;
/**
* Elo parity factor. NHL defaults to 1000 (higher than the standard 400) to
* reflect the elevated game-to-game variance in hockey. Raising it flattens the
* finish-position distribution; the season config's `parityFactor` overrides it.
*
* Futures odds influence the simulation only through each team's resolved Elo
* (odds → Elo happens once, in the central input-policy resolver). The simulator
* does not blend raw odds into per-game probabilities — doing so double-counted
* the same signal and over-sharpened favorites.
*/
const DEFAULT_PARITY_FACTOR = 1000;
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
//
// 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.
//
// 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
// Eastern — Metropolitan: CAR, CBJ, NJD, NYI, NYR, PHI, PIT, WSH
// Western — Central: CHI, COL, DAL, MIN, NSH, STL, UTA, WPG
// Western — Pacific: ANA, CGY, EDM, LAK, SJS, SEA, VAN, VGK
interface NhlTeamData {
conference: "Eastern" | "Western";
division: "Atlantic" | "Metropolitan" | "Central" | "Pacific";
elo: number;
}
const TEAMS_DATA: Record<string, NhlTeamData> = {
// ── Eastern Conference — Atlantic ──────────────────────────────────────────
"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 ─────────────────────────────────────
"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 ───────────────────────────────────────────
"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 ───────────────────────────────────────────
"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) ───────────────────────────────
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
export function normalizeTeamName(name: string): string {
return name.toLowerCase().trim().replace(/\s+/g, " ");
}
/** Look up team data by participant name (case-insensitive). */
export function getTeamData(name: string): NhlTeamData | undefined {
const normalized = normalizeTeamName(name);
for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
if (normalizeTeamName(teamName) === normalized) return data;
}
return undefined;
}
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / parityFactor))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
}
// ─── Internal types ───────────────────────────────────────────────────────────
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;
/** Resolved Elo: sourceElo from DB > hardcoded TEAMS_DATA > fallback 1400. */
resolvedElo: number;
}
/** Get Elo for a team entry. */
function elo(entry: TeamEntry): number {
return entry.resolvedElo;
}
/**
* 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.
*/
export function simulateProjectedPoints(entry: TeamEntry, otRate = DEFAULT_OT_RATE): number {
let extra = 0;
const { winProb, remainingGames } = entry;
const otlProb = (1 - winProb) * otRate;
for (let g = 0; g < remainingGames; g++) {
const r = Math.random();
if (r < winProb) {
extra += 2;
} else if (r < winProb + otlProb) {
extra += 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, otRate: number): ProjectedTeam {
return { team: t, pts: simulateProjectedPoints(t, otRate), 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 ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
const db = database();
// Check for a populated playoff bracket; if found, use bracket-aware simulation.
const bracketEvent = await db.query.scoringEvents.findFirst({
where: and(
eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
eq(schema.scoringEvents.eventType, "playoff_game")
),
});
if (bracketEvent) {
const allMatches = await db.query.playoffMatches.findMany({
where: eq(schema.playoffMatches.scoringEventId, bracketEvent.id),
orderBy: (m, { asc }) => [asc(m.matchNumber)],
});
const bracketPopulated = allMatches.some((m) => m.participant1Id && m.participant2Id);
if (bracketPopulated) {
return this.simulateBracket(sportsSeasonId, allMatches, config);
}
}
// 1. Load participants, standings, and sourceElo values in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add NHL teams as participants before running simulation.`
);
}
// Engine knobs (admin-overridable via the season config; default to NHL values).
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
const otRate = configNumber(config, "overtimeRate", DEFAULT_OT_RATE);
const participantIds = participantRows.map((r) => r.id);
// 2. Build standings lookup, sourceElo map, and construct team entries.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
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, seasonGames - gamesPlayed);
const resolvedElo = dbEloMap.get(r.id) ?? data?.elo ?? 1400;
return {
id: r.id,
name: r.name,
data,
conference,
division,
currentPoints,
remainingGames,
winProb: eloWinProbability(resolvedElo, 1500, parityFactor),
resolvedElo,
};
});
// Warn about participants that don't match any hardcoded team.
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 use fallback Elo 1400: ` +
unrecognized.map((t) => t.name).join(", ")
);
}
// Separate recognized teams by conference + division.
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");
if (
easternAtlantic.length < 3 ||
easternMetro.length < 3 ||
westernCentral.length < 3 ||
westernPacific.length < 3
) {
throw new Error(
`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.`
);
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/**
* Per-game win probability for team A over team B, from resolved Elo.
* Futures odds already influenced `elo()` via the central odds→Elo resolver,
* so they are not blended in again here.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
eloWinProbability(elo(a), elo(b), parityFactor);
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = gameWinProb(a, b);
let winsA = 0;
let winsB = 0;
while (winsA < 4 && winsB < 4) {
if (Math.random() < winProb) winsA++; else winsB++;
}
return winsA === 4 ? { winner: a, loser: b } : { winner: b, loser: a };
};
/**
* Build the 8-team playoff bracket for one conference.
*
* 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).
*
* 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[]
): [[TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry], [TeamEntry, TeamEntry]] | undefined => {
if (divATeams.length < 3 || divBTeams.length < 3) return undefined;
// 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((t) => projectTeam(t, otRate)));
const divAProjected = allProjected.filter((p) => divASet.has(p.team.id));
const divBProjected = allProjected.filter((p) => !divASet.has(p.team.id));
const divASeeds = divAProjected.slice(0, 3);
const divBSeeds = divBProjected.slice(0, 3);
// 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));
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] =
divASeeds[0].pts >= divBSeeds[0].pts
? [divASeeds, divBSeeds]
: [divBSeeds, divASeeds];
return [
[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
];
};
// ─── Placement count maps ──────────────────────────────────────────────────
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const r2LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
let effectiveN = 0;
for (let s = 0; s < numSimulations; s++) {
const eastBracket = buildConferenceBracket(easternAtlantic, easternMetro);
const westBracket = buildConferenceBracket(westernCentral, westernPacific);
if (!eastBracket || !westBracket) continue;
effectiveN++;
// ── Eastern Conference ────────────────────────────────────────────────────
const eastR1Winners = eastBracket.map(([a, b]) => simSeries(a, b).winner);
const eastR2m1 = simSeries(eastR1Winners[0], eastR1Winners[2]);
const eastR2m2 = simSeries(eastR1Winners[1], eastR1Winners[3]);
const eastCF = simSeries(eastR2m1.winner, eastR2m2.winner);
const eastChamp = eastCF.winner;
confFinalLoserCounts.set(eastCF.loser.id, (confFinalLoserCounts.get(eastCF.loser.id) ?? 0) + 1);
// ── Western Conference ────────────────────────────────────────────────────
const westR1Winners = westBracket.map(([a, b]) => simSeries(a, b).winner);
const westR2m1 = simSeries(westR1Winners[0], westR1Winners[2]);
const westR2m2 = simSeries(westR1Winners[1], westR1Winners[3]);
const westCF = simSeries(westR2m1.winner, westR2m2.winner);
const westChamp = westCF.winner;
confFinalLoserCounts.set(westCF.loser.id, (confFinalLoserCounts.get(westCF.loser.id) ?? 0) + 1);
// ── Stanley Cup Final ─────────────────────────────────────────────────────
const final = simSeries(eastChamp, westChamp);
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
for (const loser of [eastR2m1.loser, eastR2m2.loser, westR2m1.loser, westR2m2.loser]) {
r2LoserCounts.set(loser.id, (r2LoserCounts.get(loser.id) ?? 0) + 1);
}
}
if (effectiveN === 0) {
throw new Error(
"All simulations produced degenerate brackets. " +
"Check that each division has at least 3 participants with standings data."
);
}
// ─── Convert counts to probability distributions ───────────────────────────
const N = effectiveN;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const cf = confFinalLoserCounts.get(participantId) ?? 0;
const r2 = r2LoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: cf / (2 * N),
probFourth: cf / (2 * N),
probFifth: r2 / (4 * N),
probSixth: r2 / (4 * N),
probSeventh: r2 / (4 * N),
probEighth: r2 / (4 * N),
},
source: "nhl_bracket_monte_carlo",
};
});
// ─── Per-position normalization ────────────────────────────────────────────
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
// ── Mode: Bracket-Aware ───────────────────────────────────────────────────────
private async simulateBracket(
sportsSeasonId: string,
allMatches: Awaited<ReturnType<ReturnType<typeof database>["query"]["playoffMatches"]["findMany"]>>,
config: Record<string, unknown> = {}
): Promise<SimulationResult[]> {
const db = database();
// Group matches by round, sorted by match count descending (R1 first → Final last).
const byRound = new Map<string, typeof allMatches>();
for (const m of allMatches) {
if (!byRound.has(m.round)) byRound.set(m.round, []);
byRound.get(m.round)?.push(m);
}
const sortedRoundMatches = [...byRound.values()]
.toSorted((a, b) => b.length - a.length)
.map((matches) => matches.toSorted((a, b) => a.matchNumber - b.matchNumber));
if (sortedRoundMatches.length < 4) {
throw new Error(
`NHL bracket has unexpected structure: expected 4 rounds, found ${sortedRoundMatches.length}. ` +
`Check the bracket template.`
);
}
const r1Matches = sortedRoundMatches[0]; // 8 matches (Round of 16 / Wild Card)
const r2Matches = sortedRoundMatches[1]; // 4 matches (Quarterfinals / Divisional)
const r3Matches = sortedRoundMatches[2]; // 2 matches (Semifinals / Conf Finals)
const finalMatches = sortedRoundMatches[3]; // 1 match (Finals / Stanley Cup)
if (r1Matches.length !== 8) {
throw new Error(
`Expected 8 first-round matches for NHL bracket, found ${r1Matches.length}.`
);
}
// Validate all R1 matches have participants before entering the hot loop.
for (const m of r1Matches) {
if (!m.participant1Id || !m.participant2Id) {
throw new Error(
`Round 1 match ${m.matchNumber} is missing participants. ` +
`Seed all 16 teams into the bracket before running simulation.`
);
}
}
// Load all participants and EV data in parallel.
const [participantRows, evRows] = await Promise.all([
db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId)),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const allParticipantIds = participantRows.map((r) => r.id);
const dbEloMap = new Map<string, number>();
for (const row of evRows) {
if (row.sourceElo !== null && row.sourceElo !== undefined) {
dbEloMap.set(row.participantId, row.sourceElo);
}
}
// Build Elo map: sourceElo > TEAMS_DATA > fallback 1400.
const eloMap = new Map<string, number>();
for (const r of participantRows) {
eloMap.set(r.id, dbEloMap.get(r.id) ?? getTeamData(r.name)?.elo ?? 1400);
}
// Per-round lookup maps for O(1) access.
const r1ByNum = new Map(r1Matches.map((m) => [m.matchNumber, m]));
const r2ByNum = new Map(r2Matches.map((m) => [m.matchNumber, m]));
const r3ByNum = new Map(r3Matches.map((m) => [m.matchNumber, m]));
const finalMatch = finalMatches[0];
// ── Helpers ──────────────────────────────────────────────────────────────────
// Futures odds already shaped each team's resolved Elo via the central
// odds→Elo resolver, so per-game probability is pure Elo (no odds re-blend).
const gameWinProb = (aId: string, bId: string): number =>
eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400, parityFactor);
const simSeries = (aId: string, bId: string): { winner: string; loser: string } => {
const winProb = gameWinProb(aId, bId);
let wA = 0;
let wB = 0;
while (wA < 4 && wB < 4) {
if (Math.random() < winProb) wA++; else wB++;
}
return wA === 4 ? { winner: aId, loser: bId } : { winner: bId, loser: aId };
};
const resolveSeries = (
match: (typeof allMatches)[0] | undefined,
p1Fallback?: string,
p2Fallback?: string
): { winner: string; loser: string } => {
if (match?.isComplete && match.winnerId && match.loserId) {
return { winner: match.winnerId, loser: match.loserId };
}
const p1 = match?.participant1Id ?? p1Fallback;
const p2 = match?.participant2Id ?? p2Fallback;
if (!p1 || !p2) {
throw new Error(
`Cannot resolve NHL bracket match ${match?.id ?? "(undefined)"}: ` +
`missing participants (p1=${p1 ?? "null"}, p2=${p2 ?? "null"}).`
);
}
return simSeries(p1, p2);
};
// ── Placement count maps ──────────────────────────────────────────────────────
const championCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const r3LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
const r2LoserCounts = new Map<string, number>(allParticipantIds.map((id) => [id, 0]));
// ── Monte Carlo simulation loop ───────────────────────────────────────────────
for (let s = 0; s < numSimulations; s++) {
// Round 1: R1 losers score 0 points — not tracked.
const r1Winners: string[] = [];
for (let i = 1; i <= 8; i++) {
const { winner } = resolveSeries(r1ByNum.get(i));
r1Winners.push(winner);
}
// Round 2 (Quarterfinals / Divisional): losers → 5th8th.
const r2Winners: string[] = [];
for (let i = 1; i <= 4; i++) {
const { winner, loser } = resolveSeries(
r2ByNum.get(i),
r1Winners[(i - 1) * 2],
r1Winners[(i - 1) * 2 + 1]
);
r2Winners.push(winner);
r2LoserCounts.set(loser, (r2LoserCounts.get(loser) ?? 0) + 1);
}
// Round 3 (Semifinals / Conference Finals): losers → 3rd4th.
const r3Winners: string[] = [];
for (let i = 1; i <= 2; i++) {
const { winner, loser } = resolveSeries(
r3ByNum.get(i),
r2Winners[(i - 1) * 2],
r2Winners[(i - 1) * 2 + 1]
);
r3Winners.push(winner);
r3LoserCounts.set(loser, (r3LoserCounts.get(loser) ?? 0) + 1);
}
// Final (Stanley Cup): winner → 1st, loser → 2nd.
const { winner, loser } = resolveSeries(finalMatch, r3Winners[0], r3Winners[1]);
championCounts.set(winner, (championCounts.get(winner) ?? 0) + 1);
finalistCounts.set(loser, (finalistCounts.get(loser) ?? 0) + 1);
}
// ── Convert counts to probability distributions ───────────────────────────────
const N = numSimulations;
const results: SimulationResult[] = allParticipantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const r3 = r3LoserCounts.get(participantId) ?? 0;
const r2 = r2LoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: r3 / (2 * N),
probFourth: r3 / (2 * N),
probFifth: r2 / (4 * N),
probSixth: r2 / (4 * N),
probSeventh: r2 / (4 * N),
probEighth: r2 / (4 * N),
},
source: "nhl_bracket_monte_carlo",
};
});
// Per-position normalization.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}