brackt/app/services/simulations/nhl-simulator.ts
Claude 6f5920eb2b
Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.

MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.

https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
2026-04-30 05:22:11 +00:00

773 lines
34 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 {
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.
*/
const PARITY_FACTOR = 1000;
/**
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
* Same calibration as the UCL simulator (0.7 / 0.3).
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2025-26 season) ───────────────────────────────────────────────
//
// 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) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── 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;
}
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/**
* 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): 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 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 ────────────────────────────────────────────────────────────────
export class NHLSimulator implements Simulator {
async simulate(sportsSeasonId: string): 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);
}
}
// 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(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add NHL teams as participants before running simulation.`
);
}
const participantIds = participantRows.map((r) => r.id);
// 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.
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.`
);
}
// ─── Futures odds blending ─────────────────────────────────────────────────
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) =>
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
);
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/**
* Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const eloProb = eloWinProbability(elo(a), elo(b));
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(a.id) ?? 0;
const o2 = normalizedOddsMap.get(b.id) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = 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(projectTeam));
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 < NUM_SIMULATIONS; 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"]>>
): 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}.`
);
}
// Load all participants so we can build the Elo map and return results for everyone.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
const allParticipantIds = participantRows.map((r) => r.id);
const nameById = new Map(participantRows.map((r) => [r.id, r.name]));
// Build Elo map: TEAMS_DATA lookup by name, fallback 1400.
const eloMap = new Map<string, number>();
for (const r of participantRows) {
eloMap.set(r.id, getTeamData(r.name)?.elo ?? 1400);
}
// Load futures odds for blending.
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(allParticipantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
const hasOdds = oddsRows.length > 0;
const normalizedOddsMap = new Map<string, number>();
if (hasOdds) {
const rawProbs = oddsRows.map((r) => convertAmericanOddsToProbability(r.sourceOdds ?? 0));
const normalized = normalizeProbabilities(rawProbs);
oddsRows.forEach(({ participantId }, i) => {
normalizedOddsMap.set(participantId, normalized[i]);
});
}
// 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 ──────────────────────────────────────────────────────────────────
const gameWinProb = (aId: string, bId: string): number => {
const eloProb = eloWinProbability(eloMap.get(aId) ?? 1400, eloMap.get(bId) ?? 1400);
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(aId) ?? 0;
const o2 = normalizedOddsMap.get(bId) ?? 0;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
};
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 < NUM_SIMULATIONS; 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 = NUM_SIMULATIONS;
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;
}
}