Add NBA playoff bracket Monte Carlo simulator (#154)
Adds a new `nba_bracket` simulator that projects both regular season seedings and the full NBA playoff bracket, unlike other simulators that operate on a pre-set bracket stored in the DB. Algorithm: - Per iteration: each team draws a conference seed (1–10) weighted by BBRef seed probabilities, then seeds 7–10 play the Play-In tournament (single elimination), then the resulting 8-team bracket is simulated as best-of-7 series using Elo playoff ratings - Elo source: playoff rating (last 110 games, no regression to mean, postseason games weighted 3×) — appropriate for playoff matchup quality - Seed probs source: Basketball-Reference (March 16, 2026), scaled from conditional to unconditional by multiplying by each team's Playoffs% Placement tiers map to SimulationProbabilities: probFirst/Second → NBA champion / Finals loser probThird/Fourth → Conference Finals losers (2 per sim) probFifth–Eighth → Conference Semis losers (4 per sim) Files: - app/services/simulations/nba-simulator.ts (new) - drizzle/0046_add_nba_bracket_simulator_type.sql (new) - database/schema.ts: adds `nba_bracket` to simulatorTypeEnum - app/services/simulations/registry.ts: registers NBASimulator Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b1535b2976
commit
cbaab920f9
4 changed files with 460 additions and 0 deletions
444
app/services/simulations/nba-simulator.ts
Normal file
444
app/services/simulations/nba-simulator.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
/**
|
||||
* 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 7–10 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)
|
||||
* probFifth–Eighth = 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";
|
||||
|
||||
// ─── 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) ───────────────────────────────
|
||||
|
||||
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
|
||||
export function normalizeTeamName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** 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 (1–10) 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 7–10 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
|
||||
// probFifth–Eighth → 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import { GolfSimulator } from "./golf-simulator";
|
|||
import { UCLSimulator } from "./ucl-simulator";
|
||||
import { NCAAMSimulator } from "./ncaam-simulator";
|
||||
import { NCAAWSimulator } from "./ncaaw-simulator";
|
||||
import { NBASimulator } from "./nba-simulator";
|
||||
|
||||
export const SIMULATOR_TYPES = [
|
||||
"f1_standings",
|
||||
|
|
@ -22,6 +23,7 @@ export const SIMULATOR_TYPES = [
|
|||
"ucl_bracket",
|
||||
"ncaam_bracket",
|
||||
"ncaaw_bracket",
|
||||
"nba_bracket",
|
||||
] as const;
|
||||
|
||||
export type SimulatorType = typeof SIMULATOR_TYPES[number];
|
||||
|
|
@ -74,6 +76,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
|
|||
info: { name: "NCAAW Bracket Monte Carlo", description: "Simulates the NCAA Women's Basketball Tournament 64-team bracket using Barttorvik Barthag ratings" },
|
||||
create: () => new NCAAWSimulator(),
|
||||
},
|
||||
nba_bracket: {
|
||||
info: { name: "NBA Playoff Monte Carlo", description: "Simulates NBA playoff seedings (via seed probabilities) and full bracket (best-of-7 series) using Elo ratings" },
|
||||
create: () => new NBASimulator(),
|
||||
},
|
||||
};
|
||||
|
||||
export function getSimulator(simulatorType: SimulatorType): Simulator {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
|
|||
"ucl_bracket",
|
||||
"ncaam_bracket",
|
||||
"ncaaw_bracket",
|
||||
"nba_bracket",
|
||||
]);
|
||||
|
||||
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
|
||||
|
|
|
|||
9
drizzle/0046_add_nba_bracket_simulator_type.sql
Normal file
9
drizzle/0046_add_nba_bracket_simulator_type.sql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = 'nba_bracket'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'simulator_type')
|
||||
) THEN
|
||||
ALTER TYPE "public"."simulator_type" ADD VALUE 'nba_bracket';
|
||||
END IF;
|
||||
END $$;
|
||||
Loading…
Add table
Reference in a new issue