brackt/app/services/simulations/nba-simulator.ts
Chris Parsons bcca8b76fa
Add regular season standings for NBA/NHL (fixes #89) (#192)
Adds live standings sync and display for bracket-based sports (NBA/NHL),
so league members can see W/L tables and which teams their opponents drafted
during the regular season — not just after the playoff bracket is set.

- New `regular_season_standings` table with upsert-on-conflict sync
- Standings sync service with NHL (api-web.nhle.com) and NBA (ESPN) adapters,
  externalId write-back for future syncs, and unmatched-team resolution UI
- `RegularSeasonStandings` component: flat (NBA) + division/wild-card (NHL) modes,
  playoff line, TeamOwnerBadge, projected Brackt points (EV), mobile horizontal scroll
- Admin "Sync Standings" card + "Resolve Unmatched" UI on sports season page
- Admin manual standings edit hatch at /admin/sports-seasons/:id/regular-standings
- Show standings above bracket until matches exist; below once bracket is set
- `normalize-team-name` utility extracted to shared lib

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

442 lines
19 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. Match participant names to hardcoded team data (Elo + seed probabilities)
* 3. For each simulation:
* a. Assign each team a seed based on its weighted probability distribution (p_1..p_10)
* Teams with no seed probabilities always miss the playoffs (seed = 11)
* b. Sort each conference by drawn seed + random tiebreaker
* → 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
* 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))
*
* 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 and seed probabilities are hardcoded below (March 2026 data).
* Source: Basketball-Reference Playoff Probabilities + Neil Paine Substack estimates.
* 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";
// ─── 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;
/** Seed probability keys — ordered p_1..p_10 for the drawSeed() inner loop. */
const SEED_KEYS = ["p_1", "p_2", "p_3", "p_4", "p_5", "p_6", "p_7", "p_8", "p_9", "p_10"] as const;
// ─── Team data (2025-26 season, as of March 6, 2026) ─────────────────────────
//
// elo: Estimated Elo rating (higher = stronger).
// p_1 through p_10: Probability of finishing at each conference seed.
// Sum of all p_X for a team = probability of making the playoffs.
// Teams with no p_X entries always miss the playoffs in simulation.
//
// Sources:
// - Elo: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight).
// This is the appropriate signal for simulating playoff matchups.
// - Seed probs: Basketball-Reference Playoff Probabilities Report (March 16, 2026).
//
// Seed probability methodology:
// BBRef reports conditional seed probabilities (summing to ~100% per team).
// We scale each team's values by their Playoffs% to get unconditional probabilities.
// The drawSeed() function then treats the remainder (1 - sum) as "miss playoffs".
// Formula: p_X = (BBRef_conditional_X / 100) × (Playoffs% / 100)
interface NbaTeamData {
conference: "Eastern" | "Western";
elo: number;
p_1?: number;
p_2?: number;
p_3?: number;
p_4?: number;
p_5?: number;
p_6?: number;
p_7?: number;
p_8?: number;
p_9?: number;
p_10?: number;
}
const TEAMS_DATA: Record<string, NbaTeamData> = {
// ── Eastern Conference ──────────────────────────────────────────────────────
// Elo = playoff rating (last 110 games, no regression, postseason games 3× weight)
// Seed probs = BBRef conditional × (Playoffs% / 100)
// Detroit (PO%=100%): locked as 1-seed
"Detroit Pistons": { conference: "Eastern", elo: 1558,
p_1: 0.982, p_2: 0.016, p_3: 0.001 },
// Boston (PO%=100%): clear 2/3 seed
"Boston Celtics": { conference: "Eastern", elo: 1699,
p_1: 0.014, p_2: 0.540, p_3: 0.389, p_4: 0.052, p_5: 0.004 },
// New York (PO%=100%)
"New York Knicks": { conference: "Eastern", elo: 1626,
p_1: 0.003, p_2: 0.423, p_3: 0.479, p_4: 0.086, p_5: 0.009, p_6: 0.001 },
// Cleveland (PO%=99.9%): mostly 4-seed, slight play-in risk
"Cleveland Cavaliers": { conference: "Eastern", elo: 1628,
p_2: 0.020, p_3: 0.117, p_4: 0.684, p_5: 0.137, p_6: 0.032, p_7: 0.009, p_8: 0.001 },
// Orlando (PO%=88.8%): 5/6-seed range, play-in risk
"Orlando Magic": { conference: "Eastern", elo: 1508,
p_3: 0.007, p_4: 0.075, p_5: 0.333, p_6: 0.182, p_7: 0.139, p_8: 0.091, p_9: 0.044, p_10: 0.018 },
// Miami (PO%=78.7%)
"Miami Heat": { conference: "Eastern", elo: 1530,
p_3: 0.004, p_4: 0.041, p_5: 0.212, p_6: 0.224, p_7: 0.255, p_8: 0.113, p_9: 0.037, p_10: 0.009 },
// Toronto (PO%=86.4%)
"Toronto Raptors": { conference: "Eastern", elo: 1467,
p_3: 0.001, p_4: 0.038, p_5: 0.165, p_6: 0.324, p_7: 0.192, p_8: 0.103, p_9: 0.041, p_10: 0.011 },
// Atlanta (PO%=46.7%): mostly play-in range
"Atlanta Hawks": { conference: "Eastern", elo: 1496,
p_4: 0.001, p_5: 0.011, p_6: 0.022, p_7: 0.058, p_8: 0.124, p_9: 0.134, p_10: 0.123 },
// Philadelphia (PO%=52.9%)
"Philadelphia 76ers": { conference: "Eastern", elo: 1471,
p_5: 0.011, p_6: 0.039, p_7: 0.079, p_8: 0.116, p_9: 0.134, p_10: 0.091 },
// Charlotte (PO%=47.1%): deep play-in territory
"Charlotte Hornets": { conference: "Eastern", elo: 1496,
p_5: 0.002, p_6: 0.004, p_7: 0.017, p_8: 0.058, p_9: 0.118, p_10: 0.201 },
// Milwaukee (PO%=0%)
"Milwaukee Bucks": { conference: "Eastern", elo: 1442 },
// Chicago (PO%=0%)
"Chicago Bulls": { conference: "Eastern", elo: 1381 },
// Brooklyn (PO%=0%)
"Brooklyn Nets": { conference: "Eastern", elo: 1334 },
// Indiana (PO%=0%)
"Indiana Pacers": { conference: "Eastern", elo: 1433 },
// Washington (PO%=0%)
"Washington Wizards": { conference: "Eastern", elo: 1255 },
// ── Western Conference ──────────────────────────────────────────────────────
// OKC (PO%=100%): dominant 1-seed
"Oklahoma City Thunder": { conference: "Western", elo: 1731,
p_1: 0.920, p_2: 0.081 },
// San Antonio (PO%=100%): locked as 2-seed
"San Antonio Spurs": { conference: "Western", elo: 1599,
p_1: 0.081, p_2: 0.919 },
// Houston (PO%=99.8%): 3/4 seed range
"Houston Rockets": { conference: "Western", elo: 1564,
p_3: 0.467, p_4: 0.258, p_5: 0.169, p_6: 0.092, p_7: 0.014, p_8: 0.001 },
// Denver (PO%=99.8%)
"Denver Nuggets": { conference: "Western", elo: 1618,
p_3: 0.222, p_4: 0.270, p_5: 0.277, p_6: 0.181, p_7: 0.043, p_8: 0.001 },
// LA Lakers (PO%=99.7%)
"Los Angeles Lakers": { conference: "Western", elo: 1569,
p_3: 0.219, p_4: 0.267, p_5: 0.242, p_6: 0.172, p_7: 0.079, p_8: 0.003 },
// Minnesota (PO%=96.2%)
"Minnesota Timberwolves": { conference: "Western", elo: 1603,
p_3: 0.072, p_4: 0.154, p_5: 0.222, p_6: 0.340, p_7: 0.173, p_8: 0.007 },
// Phoenix (PO%=83.4%): mostly 7-seed play-in entry
"Phoenix Suns": { conference: "Western", elo: 1500,
p_3: 0.011, p_4: 0.032, p_5: 0.065, p_6: 0.165, p_7: 0.517, p_8: 0.051, p_9: 0.005 },
// LA Clippers (PO%=71.1%): heavy play-in range
"LA Clippers": { conference: "Western", elo: 1573,
p_6: 0.042, p_7: 0.468, p_8: 0.112, p_9: 0.027 },
// Golden State (PO%=30.3%): longshot play-in
"Golden State Warriors": { conference: "Western", elo: 1530,
p_6: 0.003, p_7: 0.053, p_8: 0.160, p_9: 0.147 },
// Portland (PO%=19.5%): deep longshot
"Portland Trail Blazers": { conference: "Western", elo: 1426,
p_7: 0.013, p_8: 0.077, p_9: 0.111 },
// Dallas (PO%=0%)
"Dallas Mavericks": { conference: "Western", elo: 1473 },
// Memphis (PO%=0%)
"Memphis Grizzlies": { conference: "Western", elo: 1417 },
// New Orleans (PO%=0%)
"New Orleans Pelicans": { conference: "Western", elo: 1380 },
// Sacramento (PO%=0%)
"Sacramento Kings": { conference: "Western", elo: 1352 },
// Utah (PO%=0%)
"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;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class NBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season.
const participantRows = await db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add NBA teams as participants before running simulation.`
);
}
// 2. Match participant names to hardcoded team data.
// Teams with no match or no seed probabilities will always miss the playoffs.
const participantIds = participantRows.map((r) => r.id);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// Separate by conference for simulation (fall back to Eastern if no data found).
const easternTeams = teams.filter((t) => (t.data?.conference ?? "Eastern") === "Eastern");
const westernTeams = teams.filter((t) => t.data?.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) ─────────────────────────
/** Draw a conference seed (110) based on a team's probability distribution.
* Returns 11 if the draw falls outside all p_X values (team misses playoffs). */
const drawSeed = (entry: TeamEntry): number => {
const data = entry.data;
if (!data) return 11; // Unknown team — always misses playoffs
let r = Math.random();
for (let i = 0; i < SEED_KEYS.length; i++) {
const prob = data[SEED_KEYS[i]] ?? 0;
r -= prob;
if (r <= 0) return i + 1;
}
return 11; // Missed playoffs
};
/** Get Elo for a team entry.
* Fallback 1400 = conservative below-average estimate for unknown/unrecognized teams. */
const elo = (entry: TeamEntry): number => entry.data?.elo ?? 1400;
/** 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 drawn probabilistically; positions 710 go through the Play-In. */
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
const seeded = confTeams.map((t) => ({
team: t,
seed: drawSeed(t),
tiebreaker: Math.random(),
}));
seeded.sort((a, b) => a.seed - b.seed || a.tiebreaker - b.tiebreaker);
const top6 = seeded.slice(0, 6).map((x) => x.team);
const playIn = seeded.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] };
};
// 3. 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]));
// 4. 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() is always defined) ───
championCounts.set(champion.id, championCounts.get(champion.id)! + 1);
finalistCounts.set(finalist.id, finalistCounts.get(finalist.id)! + 1);
confFinalLoserCounts.set(eastCFLoser.id, confFinalLoserCounts.get(eastCFLoser.id)! + 1);
confFinalLoserCounts.set(westCFLoser.id, confFinalLoserCounts.get(westCFLoser.id)! + 1);
for (const loser of [...eastR2Losers, ...westR2Losers]) {
confSemiLoserCounts.set(loser.id, confSemiLoserCounts.get(loser.id)! + 1);
}
// Round 1 losers are not counted (0 points per scoring rules).
}
// 5. Convert integer counts to probability distributions.
// Exact denominators guarantee column sums of 1.0 by construction:
// probFirst/Second → N 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 N = NUM_SIMULATIONS;
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId)!;
const f = finalistCounts.get(participantId)!;
const cf = confFinalLoserCounts.get(participantId)!;
const cs = confSemiLoserCounts.get(participantId)!;
return {
participantId,
probabilities: {
probFirst: c / N,
probSecond: f / N,
probThird: cf / (2 * N),
probFourth: cf / (2 * N),
probFifth: cs / (4 * N),
probSixth: cs / (4 * N),
probSeventh: cs / (4 * N),
probEighth: cs / (4 * N),
},
source: "nba_bracket_monte_carlo",
};
});
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
// division residuals. Columns are already near-exactly 1.0 after step 5.
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;
}
}