brackt/app/services/simulations/wnba-simulator.ts

318 lines
13 KiB
TypeScript
Raw Normal View History

/**
* 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 ±810. 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)
* probFifthEighth = 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.
*
* In SRS mode (in-season): use SRS-derived Elo, fall back to futuresElo, then 1500.
* In futures mode (pre-season): use futuresElo, fall back to 1500.
*/
export function resolveElo(
srs: number | null,
futuresElo: number | null,
useSRS: boolean
): number {
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<SimulationResult[]> {
const db = database();
// 1. Load participants, standings, and futures odds in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.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.
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
const futuresEloMap: Map<string, number> =
oddsInput.length > 0
? convertFuturesToElo(oddsInput, "american")
: new Map();
// 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);
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 18 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<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const semiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const r1LoserCounts = new Map<string, number>(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<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;
}
}