/** * WNBA Playoff Simulator * * Monte Carlo simulation of the WNBA playoffs including regular season * remainder projection and seeding. * * Algorithm: * 1. Load participants, regular season standings, and futures odds in parallel. * 2. Choose Elo source automatically based on season progress: * Pre-season (avg gamesPlayed < SRS_GAMES_THRESHOLD): * → futures odds via convertFuturesToElo() from participantExpectedValues * → falls back to 1500 for teams without odds * In-season (avg gamesPlayed >= SRS_GAMES_THRESHOLD): * → SRS from regularSeasonStandings: elo = 1500 + srs * SRS_ELO_SCALE * → falls back to futures Elo for teams without SRS, then 1500 * 3. For each simulation: * a. For each team, simulate remaining regular season games (40 - gamesPlayed) * using Elo win probability vs. an average opponent (Elo 1500) * → projectedWins = currentWins + simulatedRemainingWins * b. Sort all teams by projected wins (desc) + random tiebreaker * → Top 8 qualify for playoffs * c. Simulate WNBA playoff bracket (no byes, no play-in): * Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6 * Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6) * Finals (best-of-7): Semifinal winners * 4. Track placement counts per scoring tier * 5. Convert counts to probability distributions * * Win probability (Elo, PARITY_FACTOR = 400): * P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400)) * * SRS → Elo conversion (in-season): * elo = 1500 + srs * 20 * Calibration: WNBA SRS typically ranges ±8–10. A ±10 SRS gap → ±200 Elo → ~76% win * probability for the stronger team, which is appropriate for single-game WNBA matchups. * Source: basketball-reference.com/wnba/years/YYYY.html (SRS column in team standings) * * Futures → Elo conversion (pre-season): * Uses convertFuturesToElo() from probability-engine (same as BracketSimulator / UCLSimulator). * Reads sourceOdds (American format) from participantExpectedValues. * * Placement tiers → SimulationProbabilities mapping: * probFirst = WNBA champion (1 per sim) * probSecond = Finals loser (1 per sim) * probThird/Fourth = Semifinal losers (2 per sim) * probFifth–Eighth = Round 1 losers (4 per sim) * Missed playoffs → all 0 */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { normalizeTeamName } from "~/lib/normalize-team-name"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine"; // ─── Simulation parameters ──────────────────────────────────────────────────── const NUM_SIMULATIONS = 50_000; /** WNBA regular season games per team. */ const WNBA_REGULAR_SEASON_GAMES = 40; /** * Multiplier converting SRS to Elo offset from 1500. * elo = 1500 + srs * SRS_ELO_SCALE * * At scale 20: * SRS +10 → Elo 1700, SRS -10 → Elo 1300 → P(+10 vs -10) ≈ 76% * SRS +5 → Elo 1600, SRS 0 → Elo 1500 → P(+5 vs 0) ≈ 64% */ const SRS_ELO_SCALE = 20; /** * Average games played threshold to switch from futures-derived Elo to SRS-derived Elo. * Once most teams have played this many games, SRS is meaningful enough to use. */ const SRS_GAMES_THRESHOLD = 5; /** Number of playoff teams. */ const PLAYOFF_TEAMS = 8; // ─── Public helpers (exported for unit testing) ─────────────────────────────── export { normalizeTeamName }; /** * Convert an SRS rating to an Elo rating. * An SRS of 0 maps to 1500 (league average). */ export function srsToElo(srs: number): number { return 1500 + srs * SRS_ELO_SCALE; } /** * Resolve the Elo to use for a team given available signals and the current mode. * * Priority: sourceElo (admin-entered projected wins) > SRS (in-season) > futures odds > 1500. */ export function resolveElo( srs: number | null, futuresElo: number | null, useSRS: boolean, sourceElo?: number | null ): number { if (sourceElo !== null && sourceElo !== undefined) return sourceElo; if (useSRS) { if (srs !== null) return srsToElo(srs); return futuresElo ?? 1500; } return futuresElo ?? 1500; } // ─── Internal types ─────────────────────────────────────────────────────────── interface TeamEntry { id: string; name: string; elo: number; currentWins: number; remainingGames: number; /** Elo win probability vs. average opponent (1500) — constant per team. */ winProb: number; } /** Simulate a best-of-N series. * @param winTarget wins needed to win the series (2 for BoX3, 3 for BoX5, 4 for BoX7) */ export function simSeriesN( a: TeamEntry, b: TeamEntry, winTarget: number ): { winner: TeamEntry; loser: TeamEntry } { const winProb = eloWinProbability(a.elo, b.elo); let winsA = 0; let winsB = 0; while (winsA < winTarget && winsB < winTarget) { if (Math.random() < winProb) winsA++; else winsB++; } return winsA === winTarget ? { winner: a, loser: b } : { winner: b, loser: a }; } /** Simulate remaining regular season games for a team. Returns projected total wins. */ function simulateProjectedWins(team: TeamEntry): number { let extra = 0; for (let g = 0; g < team.remainingGames; g++) { if (Math.random() < team.winProb) extra++; } return team.currentWins + extra; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class WNBASimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load participants, standings, and futures odds 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, sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds, 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 WNBA teams as participants before running simulation.` ); } if (participantRows.length < PLAYOFF_TEAMS) { throw new Error( `WNBA simulation requires at least ${PLAYOFF_TEAMS} participants ` + `(got ${participantRows.length}). Add all WNBA teams before running simulation.` ); } // 2. Build futures Elo map from championship odds and sourceElo map from projected wins. const oddsInput = evRows .filter((r) => r.sourceOdds !== null) .map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 })); const futuresEloMap: Map = oddsInput.length > 0 ? convertFuturesToElo(oddsInput, "american") : new Map(); const sourceEloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) { sourceEloMap.set(row.participantId, row.sourceElo); } } // 3. Build standings lookup and determine simulation mode. const standingsMap = new Map(standings.map((s) => [s.participantId, s])); const participantIds = participantRows.map((r) => r.id); const totalGamesPlayed = participantRows.reduce((sum, r) => { return sum + (standingsMap.get(r.id)?.gamesPlayed ?? 0); }, 0); const avgGamesPlayed = totalGamesPlayed / participantRows.length; const useSRS = avgGamesPlayed >= SRS_GAMES_THRESHOLD; // 4. Construct team entries with resolved Elo. const teams: TeamEntry[] = participantRows.map((r) => { const standing = standingsMap.get(r.id); const srs = standing?.srs !== null && standing?.srs !== undefined ? parseFloat(standing.srs) : null; const futuresElo = futuresEloMap.get(r.id) ?? null; const elo = resolveElo(srs, futuresElo, useSRS, sourceEloMap.get(r.id) ?? null); const gamesPlayed = standing?.gamesPlayed ?? 0; return { id: r.id, name: r.name, elo, currentWins: standing?.wins ?? 0, remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed), winProb: eloWinProbability(elo, 1500), }; }); const source = useSRS ? "wnba_bracket_monte_carlo_srs" : "wnba_bracket_monte_carlo_futures"; /** Seed teams 1–8 by projected wins for this simulation. */ const buildSeededBracket = (): TeamEntry[] => { const projected = teams.map((t) => ({ team: t, projectedWins: simulateProjectedWins(t), tiebreaker: Math.random(), })); projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker); return projected.slice(0, PLAYOFF_TEAMS).map((x) => x.team); }; // 5. Integer placement count maps — initialized to 0 for all participants. const championCounts = new Map(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const semiLoserCounts = new Map(participantIds.map((id) => [id, 0])); const r1LoserCounts = new Map(participantIds.map((id) => [id, 0])); // 6. Monte Carlo simulation loop. for (let s = 0; s < NUM_SIMULATIONS; s++) { const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket(); // Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6 const r1_a = simSeriesN(s1, s8, 2); const r1_b = simSeriesN(s4, s5, 2); const r1_c = simSeriesN(s2, s7, 2); const r1_d = simSeriesN(s3, s6, 2); r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_c.loser.id, (r1LoserCounts.get(r1_c.loser.id) ?? 0) + 1); r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1); // Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6) const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3); const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3); semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1); semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1); // Finals (best-of-7) const final = simSeriesN(sf_a.winner, sf_b.winner, 4); championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1); finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1); } // 7. Convert integer counts to probability distributions. const results: SimulationResult[] = participantIds.map((participantId) => { const c = championCounts.get(participantId) ?? 0; const f = finalistCounts.get(participantId) ?? 0; const sl = semiLoserCounts.get(participantId) ?? 0; const r1 = r1LoserCounts.get(participantId) ?? 0; return { participantId, probabilities: { probFirst: c / NUM_SIMULATIONS, probSecond: f / NUM_SIMULATIONS, probThird: sl / (2 * NUM_SIMULATIONS), probFourth: sl / (2 * NUM_SIMULATIONS), probFifth: r1 / (4 * NUM_SIMULATIONS), probSixth: r1 / (4 * NUM_SIMULATIONS), probSeventh: r1 / (4 * NUM_SIMULATIONS), probEighth: r1 / (4 * NUM_SIMULATIONS), }, source, }; }); // 8. Per-position normalization — belt-and-suspenders guard against floating-point residuals. const positionKeys: Array = [ "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; } }