brackt/app/services/simulations/nba-simulator.ts
Chris Parsons 7093b22726
Simulate NBA remaining regular season games from standings (#201)
Replace static Basketball-Reference seed probability distributions
(p_1..p_10) with dynamic simulation of each team's remaining games
based on current standings from the DB.

- Load regularSeasonStandings in parallel with participants query
- Compute remainingGames = 82 - gamesPlayed per team
- Pre-compute per-game win probability (Elo vs average opponent 1500)
  on TeamEntry at construction time — not inside the hot loop
- Sort conference standings by projected wins to assign seeds, replacing
  the drawSeed() weighted-probability approach
- Conference resolved from standings table, falls back to TEAMS_DATA
- Strip all p_1..p_10 seed probability data from TEAMS_DATA
- Move simulateProjectedWins to module scope (no closure captures)
- Remove const N alias; use NUM_SIMULATIONS directly

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 23:37:20 -07:00

376 lines
18 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.

/**
* NBA Playoff Simulator
*
* Monte Carlo simulation of the NBA 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, gamesPlayed, conference)
* 3. Match participant names to hardcoded team data (Elo ratings)
* 4. For each simulation:
* a. For each team, simulate remaining regular season games (82 - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
* → projectedWins = currentWins + simulatedRemainingWins
* b. Sort each conference by projected wins (desc) + random tiebreaker → seeds 110
* → Top 6 lock in directly; seeds 710 enter the Play-In tournament
* c. Simulate Play-In (single game each):
* - Game 1: seed 7 vs seed 8 → winner becomes 7th playoff seed
* - Game 2: seed 9 vs seed 10 → winner advances
* - Game 3: Game 1 loser vs Game 2 winner → winner becomes 8th playoff seed
* d. Simulate NBA playoff bracket (best-of-7 series each round):
* Round 1: 1v8, 4v5, 2v7, 3v6 (per conference)
* Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6)
* Round 3: Conference Finals
* NBA Finals: East champion vs West champion
* 5. Track placement counts per scoring tier
* 6. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 400):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400))
*
* Regular season projection:
* Per-game win probability = eloWinProbability(teamElo, 1500) where 1500 = average opponent.
* If no standings exist in DB, defaults to 0 wins / 82 remaining games (seeding by Elo only).
* Conference is read from standings table; falls back to TEAMS_DATA if missing.
*
* Placement tiers → SimulationProbabilities mapping:
* probFirst = NBA champion (1 per sim)
* probSecond = NBA Finals loser (1 per sim)
* probThird/Fourth = Conference Finals losers (2 per sim — East + West)
* probFifthEighth = Conference Semis losers (4 per sim)
* Round 1 losers → all 0 (score 0 points)
* Missed playoffs → all 0
*
* Elo ratings are hardcoded below (March 2026 data).
* Source: Neil Paine Substack playoff Elo estimates (last 110 games, no regression,
* postseason games 3× weight). Update at the start of each season.
*/
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";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
/**
* Elo parity factor. NBA uses 400 (standard formula).
* A 400-point Elo difference → ~90.9% win probability per game.
*/
const PARITY_FACTOR = 400;
/** NBA regular season games per team. */
const NBA_REGULAR_SEASON_GAMES = 82;
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
//
// elo: Estimated Elo rating (higher = stronger).
// Source: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight).
// This is the appropriate signal for simulating both regular season win probability
// (vs. average opponent) and playoff matchups.
//
// conference: Used as a fallback when the standings table has no conference data.
interface NbaTeamData {
conference: "Eastern" | "Western";
elo: number;
}
const TEAMS_DATA: Record<string, NbaTeamData> = {
// ── Eastern Conference ──────────────────────────────────────────────────────
"Detroit Pistons": { conference: "Eastern", elo: 1558 },
"Boston Celtics": { conference: "Eastern", elo: 1699 },
"New York Knicks": { conference: "Eastern", elo: 1626 },
"Cleveland Cavaliers": { conference: "Eastern", elo: 1628 },
"Orlando Magic": { conference: "Eastern", elo: 1508 },
"Miami Heat": { conference: "Eastern", elo: 1530 },
"Toronto Raptors": { conference: "Eastern", elo: 1467 },
"Atlanta Hawks": { conference: "Eastern", elo: 1496 },
"Philadelphia 76ers": { conference: "Eastern", elo: 1471 },
"Charlotte Hornets": { conference: "Eastern", elo: 1496 },
"Milwaukee Bucks": { conference: "Eastern", elo: 1442 },
"Chicago Bulls": { conference: "Eastern", elo: 1381 },
"Brooklyn Nets": { conference: "Eastern", elo: 1334 },
"Indiana Pacers": { conference: "Eastern", elo: 1433 },
"Washington Wizards": { conference: "Eastern", elo: 1255 },
// ── Western Conference ──────────────────────────────────────────────────────
"Oklahoma City Thunder": { conference: "Western", elo: 1731 },
"San Antonio Spurs": { conference: "Western", elo: 1599 },
"Houston Rockets": { conference: "Western", elo: 1564 },
"Denver Nuggets": { conference: "Western", elo: 1618 },
"LA Lakers": { conference: "Western", elo: 1569 },
"Minnesota Timberwolves":{ conference: "Western", elo: 1603 },
"Phoenix Suns": { conference: "Western", elo: 1500 },
"LA Clippers": { conference: "Western", elo: 1573 },
"Golden State Warriors": { conference: "Western", elo: 1530 },
"Portland Trail Blazers":{ conference: "Western", elo: 1426 },
"Dallas Mavericks": { conference: "Western", elo: 1473 },
"Memphis Grizzlies": { conference: "Western", elo: 1417 },
"New Orleans Pelicans": { conference: "Western", elo: 1380 },
"Sacramento Kings": { conference: "Western", elo: 1352 },
"Utah Jazz": { conference: "Western", elo: 1334 },
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
export { normalizeTeamName };
/** Look up team data by participant name (case-insensitive). */
export function getTeamData(name: string): NbaTeamData | 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: NbaTeamData | undefined;
conference: "Eastern" | "Western";
/** Actual wins from the standings table (0 if no standings loaded). */
currentWins: number;
/** Remaining regular season games = 82 - gamesPlayed (0 if season is complete). */
remainingGames: number;
/** Elo win probability vs. average opponent (1500) — constant per team. */
winProb: number;
}
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/** Simulate remaining regular season games for a team.
* Uses the pre-computed per-team winProb (Elo vs. average opponent).
* Returns projected total wins for the season. */
function simulateProjectedWins(entry: TeamEntry): number {
let extra = 0;
for (let g = 0; g < entry.remainingGames; g++) {
if (Math.random() < entry.winProb) extra++;
}
return entry.currentWins + extra;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 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 NBA teams as participants before running simulation.`
);
}
// 2. Build standings lookup and construct team entries.
// Conference, currentWins, remainingGames, and per-game winProb are all
// resolved once here so nothing is recomputed inside the hot loop.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
const data = getTeamData(r.name);
const gamesPlayed = standing?.gamesPlayed ?? 0;
const conf = standing?.conference;
const conference: "Eastern" | "Western" =
conf === "Eastern" || conf === "Western"
? conf
: (data?.conference ?? "Eastern");
return {
id: r.id,
name: r.name,
data,
conference,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(data?.elo ?? 1400, 1500),
};
});
// 3. Separate by conference for simulation.
const easternTeams = teams.filter((t) => t.conference === "Eastern");
const westernTeams = teams.filter((t) => t.conference === "Western");
// Validate: each conference needs at least 10 teams to fill the bracket + play-in.
if (easternTeams.length < 10 || westernTeams.length < 10) {
throw new Error(
`Each conference needs at least 10 participants (got East: ${easternTeams.length}, ` +
`West: ${westernTeams.length}). Add all 30 NBA teams before running simulation.`
);
}
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
/** Simulate a single playoff game. Returns the winner. */
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
/** Simulate a best-of-7 series. Returns winner and loser. */
const simSeries = (a: TeamEntry, b: TeamEntry): { winner: TeamEntry; loser: TeamEntry } => {
const winProb = eloWinProbability(elo(a), elo(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 };
};
/** Simulate the Play-In tournament.
* @param candidates 4 teams sorted by seeding position [7th, 8th, 9th, 10th]
* @returns [7th playoff seed, 8th playoff seed] */
const simPlayIn = ([s7, s8, s9, s10]: [TeamEntry, TeamEntry, TeamEntry, TeamEntry]): [TeamEntry, TeamEntry] => {
// Game 1: 7 vs 8 — winner locks up the 7th seed
const game1Winner = simGame(s7, s8);
const game1Loser = game1Winner === s7 ? s8 : s7;
// Game 2: 9 vs 10 — winner advances to the final play-in game
const game2Winner = simGame(s9, s10);
// Game 3: loser of Game 1 vs winner of Game 2 — winner gets the 8th seed
return [game1Winner, simGame(game1Loser, game2Winner)];
};
/** Build an 8-team conference bracket [s1..s8] for one simulation iteration.
* Seeds are determined by simulated projected wins; positions 710 go through the Play-In. */
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
const projected = confTeams.map((t) => ({
team: t,
projectedWins: simulateProjectedWins(t),
tiebreaker: Math.random(),
}));
// Higher projected wins = better seed (sort descending; random tiebreaker for ties).
projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
const top6 = projected.slice(0, 6).map((x) => x.team);
const playIn = projected.slice(6, 10).map((x) => x.team) as
[TeamEntry, TeamEntry, TeamEntry, TeamEntry];
const [seed7, seed8] = simPlayIn(playIn);
return [...top6, seed7, seed8];
};
/** Round 1: 1v8, 4v5, 2v7, 3v6. Returns 4 winners. */
const simR1 = ([s1, s2, s3, s4, s5, s6, s7, s8]: TeamEntry[]): TeamEntry[] => [
simSeries(s1, s8).winner,
simSeries(s4, s5).winner,
simSeries(s2, s7).winner,
simSeries(s3, s6).winner,
];
/** Conference Semis: winner(1v8) vs winner(4v5), winner(2v7) vs winner(3v6). */
const simR2 = ([w0, w1, w2, w3]: TeamEntry[]): { winners: TeamEntry[]; losers: TeamEntry[] } => {
const m1 = simSeries(w0, w1);
const m2 = simSeries(w2, w3);
return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] };
};
// 4. 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 confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const confSemiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 5. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
// ── Build brackets ───────────────────────────────────────────────────────
const eastBracket = buildConferenceBracket(easternTeams);
const westBracket = buildConferenceBracket(westernTeams);
// ── Simulate rounds ──────────────────────────────────────────────────────
const { winners: eastR2Winners, losers: eastR2Losers } = simR2(simR1(eastBracket));
const { winner: eastChamp, loser: eastCFLoser } = simSeries(eastR2Winners[0], eastR2Winners[1]);
const { winners: westR2Winners, losers: westR2Losers } = simR2(simR1(westBracket));
const { winner: westChamp, loser: westCFLoser } = simSeries(westR2Winners[0], westR2Winners[1]);
const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
// ── Record counts (maps are pre-populated so .get() always returns a number) ───
championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
finalistCounts.set(finalist.id, (finalistCounts.get(finalist.id) ?? 0) + 1);
confFinalLoserCounts.set(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
confFinalLoserCounts.set(westCFLoser.id, (confFinalLoserCounts.get(westCFLoser.id) ?? 0) + 1);
for (const loser of [...eastR2Losers, ...westR2Losers]) {
confSemiLoserCounts.set(loser.id, (confSemiLoserCounts.get(loser.id) ?? 0) + 1);
}
// Round 1 losers are not counted (0 points per scoring rules).
}
// 6. Convert integer counts to probability distributions.
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → NUM_SIMULATIONS total (1 per sim)
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
// probFifthEighth → confSemiLoserCounts / (4*N) — 4 conf semi losers per sim
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 cs = confSemiLoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / NUM_SIMULATIONS,
probSecond: f / NUM_SIMULATIONS,
probThird: cf / (2 * NUM_SIMULATIONS),
probFourth: cf / (2 * NUM_SIMULATIONS),
probFifth: cs / (4 * NUM_SIMULATIONS),
probSixth: cs / (4 * NUM_SIMULATIONS),
probSeventh: cs / (4 * NUM_SIMULATIONS),
probEighth: cs / (4 * NUM_SIMULATIONS),
},
source: "nba_bracket_monte_carlo",
};
});
// 7. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 6.
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;
}
}