* refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
801 lines
36 KiB
TypeScript
801 lines
36 KiB
TypeScript
/**
|
||
* 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)
|
||
* probFifth–probEighth = 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;
|
||
/** 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): 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, 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,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
})
|
||
.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.`
|
||
);
|
||
}
|
||
|
||
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, NHL_REGULAR_SEASON_GAMES - 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),
|
||
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.`
|
||
);
|
||
}
|
||
|
||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
||
|
||
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}.`
|
||
);
|
||
}
|
||
|
||
// 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,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||
]);
|
||
|
||
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);
|
||
}
|
||
|
||
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 → 5th–8th.
|
||
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 → 3rd–4th.
|
||
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;
|
||
}
|
||
}
|