334 lines
14 KiB
TypeScript
334 lines
14 KiB
TypeScript
/**
|
||
* 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, eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||
import { positiveConfigNumber } from "./config-access";
|
||
|
||
// ─── Simulation parameters (defaults; overridable via season config) ───────────
|
||
|
||
const DEFAULT_NUM_SIMULATIONS = 50_000;
|
||
|
||
/** Elo parity factor. WNBA defaults to 400 (standard formula). */
|
||
const DEFAULT_PARITY_FACTOR = 400;
|
||
|
||
/** WNBA regular season games per team. */
|
||
const DEFAULT_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,
|
||
parityFactor = DEFAULT_PARITY_FACTOR
|
||
): { winner: TeamEntry; loser: TeamEntry } {
|
||
const winProb = eloWinProbabilityWithParity(a.elo, b.elo, parityFactor);
|
||
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, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
const parityFactor = positiveConfigNumber(config, "parityFactor", DEFAULT_PARITY_FACTOR);
|
||
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
||
const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_REGULAR_SEASON_GAMES));
|
||
|
||
// 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<string, number> =
|
||
oddsInput.length > 0
|
||
? convertFuturesToElo(oddsInput, "american")
|
||
: new Map();
|
||
|
||
const sourceEloMap = new Map<string, number>();
|
||
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, seasonGames - gamesPlayed),
|
||
winProb: eloWinProbabilityWithParity(elo, 1500, parityFactor),
|
||
};
|
||
});
|
||
|
||
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<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 < numSimulations; 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, parityFactor);
|
||
const r1_b = simSeriesN(s4, s5, 2, parityFactor);
|
||
const r1_c = simSeriesN(s2, s7, 2, parityFactor);
|
||
const r1_d = simSeriesN(s3, s6, 2, parityFactor);
|
||
|
||
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, parityFactor);
|
||
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3, parityFactor);
|
||
|
||
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, parityFactor);
|
||
|
||
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 / numSimulations,
|
||
probSecond: f / numSimulations,
|
||
probThird: sl / (2 * numSimulations),
|
||
probFourth: sl / (2 * numSimulations),
|
||
probFifth: r1 / (4 * numSimulations),
|
||
probSixth: r1 / (4 * numSimulations),
|
||
probSeventh: r1 / (4 * numSimulations),
|
||
probEighth: r1 / (4 * numSimulations),
|
||
},
|
||
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;
|
||
}
|
||
}
|