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>
This commit is contained in:
parent
618bc57ec1
commit
7093b22726
1 changed files with 134 additions and 202 deletions
|
|
@ -6,11 +6,13 @@
|
||||||
*
|
*
|
||||||
* Algorithm:
|
* Algorithm:
|
||||||
* 1. Load all participants for the sports season from DB
|
* 1. Load all participants for the sports season from DB
|
||||||
* 2. Match participant names to hardcoded team data (Elo + seed probabilities)
|
* 2. Load current regular season standings (wins, gamesPlayed, conference)
|
||||||
* 3. For each simulation:
|
* 3. Match participant names to hardcoded team data (Elo ratings)
|
||||||
* a. Assign each team a seed based on its weighted probability distribution (p_1..p_10)
|
* 4. For each simulation:
|
||||||
* Teams with no seed probabilities always miss the playoffs (seed = 11)
|
* a. For each team, simulate remaining regular season games (82 - gamesPlayed)
|
||||||
* b. Sort each conference by drawn seed + random tiebreaker
|
* using Elo win probability vs. an average opponent (Elo 1500)
|
||||||
|
* → projectedWins = currentWins + simulatedRemainingWins
|
||||||
|
* b. Sort each conference by projected wins (desc) + random tiebreaker → seeds 1–10
|
||||||
* → Top 6 lock in directly; seeds 7–10 enter the Play-In tournament
|
* → Top 6 lock in directly; seeds 7–10 enter the Play-In tournament
|
||||||
* c. Simulate Play-In (single game each):
|
* c. Simulate Play-In (single game each):
|
||||||
* - Game 1: seed 7 vs seed 8 → winner becomes 7th playoff seed
|
* - Game 1: seed 7 vs seed 8 → winner becomes 7th playoff seed
|
||||||
|
|
@ -21,12 +23,17 @@
|
||||||
* Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6)
|
* Round 2: Conference Semis (winners of 1v8/4v5, winners of 2v7/3v6)
|
||||||
* Round 3: Conference Finals
|
* Round 3: Conference Finals
|
||||||
* NBA Finals: East champion vs West champion
|
* NBA Finals: East champion vs West champion
|
||||||
* 4. Track placement counts per scoring tier
|
* 5. Track placement counts per scoring tier
|
||||||
* 5. Convert counts to probability distributions
|
* 6. Convert counts to probability distributions
|
||||||
*
|
*
|
||||||
* Win probability (Elo, PARITY_FACTOR = 400):
|
* Win probability (Elo, PARITY_FACTOR = 400):
|
||||||
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 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:
|
* Placement tiers → SimulationProbabilities mapping:
|
||||||
* probFirst = NBA champion (1 per sim)
|
* probFirst = NBA champion (1 per sim)
|
||||||
* probSecond = NBA Finals loser (1 per sim)
|
* probSecond = NBA Finals loser (1 per sim)
|
||||||
|
|
@ -35,9 +42,9 @@
|
||||||
* Round 1 losers → all 0 (score 0 points)
|
* Round 1 losers → all 0 (score 0 points)
|
||||||
* Missed playoffs → all 0
|
* Missed playoffs → all 0
|
||||||
*
|
*
|
||||||
* Elo ratings and seed probabilities are hardcoded below (March 2026 data).
|
* Elo ratings are hardcoded below (March 2026 data).
|
||||||
* Source: Basketball-Reference Playoff Probabilities + Neil Paine Substack estimates.
|
* Source: Neil Paine Substack playoff Elo estimates (last 110 games, no regression,
|
||||||
* Update at the start of each season.
|
* postseason games 3× weight). Update at the start of each season.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
|
|
@ -45,6 +52,7 @@ import { eq } from "drizzle-orm";
|
||||||
import * as schema from "~/database/schema";
|
import * as schema from "~/database/schema";
|
||||||
import type { Simulator, SimulationResult } from "./types";
|
import type { Simulator, SimulationResult } from "./types";
|
||||||
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
import { normalizeTeamName } from "~/lib/normalize-team-name";
|
||||||
|
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
|
|
||||||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -56,157 +64,58 @@ const NUM_SIMULATIONS = 50_000;
|
||||||
*/
|
*/
|
||||||
const PARITY_FACTOR = 400;
|
const PARITY_FACTOR = 400;
|
||||||
|
|
||||||
/** Seed probability keys — ordered p_1..p_10 for the drawSeed() inner loop. */
|
/** NBA regular season games per team. */
|
||||||
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;
|
const NBA_REGULAR_SEASON_GAMES = 82;
|
||||||
|
|
||||||
// ─── Team data (2025-26 season, as of March 6, 2026) ─────────────────────────
|
// ─── Team data (2025-26 season, as of March 2026) ─────────────────────────────
|
||||||
//
|
//
|
||||||
// elo: Estimated Elo rating (higher = stronger).
|
// elo: Estimated Elo rating (higher = stronger).
|
||||||
// p_1 through p_10: Probability of finishing at each conference seed.
|
// Source: Playoff rating (last 110 games, no regression to mean, postseason games 3× weight).
|
||||||
// Sum of all p_X for a team = probability of making the playoffs.
|
// This is the appropriate signal for simulating both regular season win probability
|
||||||
// Teams with no p_X entries always miss the playoffs in simulation.
|
// (vs. average opponent) and playoff matchups.
|
||||||
//
|
//
|
||||||
// Sources:
|
// conference: Used as a fallback when the standings table has no conference data.
|
||||||
// - 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 {
|
interface NbaTeamData {
|
||||||
conference: "Eastern" | "Western";
|
conference: "Eastern" | "Western";
|
||||||
elo: number;
|
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> = {
|
const TEAMS_DATA: Record<string, NbaTeamData> = {
|
||||||
// ── Eastern Conference ──────────────────────────────────────────────────────
|
// ── 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 },
|
||||||
"Detroit Pistons": { conference: "Eastern", elo: 1558,
|
"Boston Celtics": { conference: "Eastern", elo: 1699 },
|
||||||
p_1: 0.982, p_2: 0.016, p_3: 0.001 },
|
"New York Knicks": { conference: "Eastern", elo: 1626 },
|
||||||
|
"Cleveland Cavaliers": { conference: "Eastern", elo: 1628 },
|
||||||
// Boston (PO%=100%): clear 2/3 seed
|
"Orlando Magic": { conference: "Eastern", elo: 1508 },
|
||||||
"Boston Celtics": { conference: "Eastern", elo: 1699,
|
"Miami Heat": { conference: "Eastern", elo: 1530 },
|
||||||
p_1: 0.014, p_2: 0.540, p_3: 0.389, p_4: 0.052, p_5: 0.004 },
|
"Toronto Raptors": { conference: "Eastern", elo: 1467 },
|
||||||
|
"Atlanta Hawks": { conference: "Eastern", elo: 1496 },
|
||||||
// New York (PO%=100%)
|
"Philadelphia 76ers": { conference: "Eastern", elo: 1471 },
|
||||||
"New York Knicks": { conference: "Eastern", elo: 1626,
|
"Charlotte Hornets": { conference: "Eastern", elo: 1496 },
|
||||||
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 },
|
"Milwaukee Bucks": { conference: "Eastern", elo: 1442 },
|
||||||
|
|
||||||
// Chicago (PO%=0%)
|
|
||||||
"Chicago Bulls": { conference: "Eastern", elo: 1381 },
|
"Chicago Bulls": { conference: "Eastern", elo: 1381 },
|
||||||
|
|
||||||
// Brooklyn (PO%=0%)
|
|
||||||
"Brooklyn Nets": { conference: "Eastern", elo: 1334 },
|
"Brooklyn Nets": { conference: "Eastern", elo: 1334 },
|
||||||
|
|
||||||
// Indiana (PO%=0%)
|
|
||||||
"Indiana Pacers": { conference: "Eastern", elo: 1433 },
|
"Indiana Pacers": { conference: "Eastern", elo: 1433 },
|
||||||
|
|
||||||
// Washington (PO%=0%)
|
|
||||||
"Washington Wizards": { conference: "Eastern", elo: 1255 },
|
"Washington Wizards": { conference: "Eastern", elo: 1255 },
|
||||||
|
|
||||||
// ── Western Conference ──────────────────────────────────────────────────────
|
// ── Western Conference ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// OKC (PO%=100%): dominant 1-seed
|
"Oklahoma City Thunder": { conference: "Western", elo: 1731 },
|
||||||
"Oklahoma City Thunder": { conference: "Western", elo: 1731,
|
"San Antonio Spurs": { conference: "Western", elo: 1599 },
|
||||||
p_1: 0.920, p_2: 0.081 },
|
"Houston Rockets": { conference: "Western", elo: 1564 },
|
||||||
|
"Denver Nuggets": { conference: "Western", elo: 1618 },
|
||||||
// San Antonio (PO%=100%): locked as 2-seed
|
"LA Lakers": { conference: "Western", elo: 1569 },
|
||||||
"San Antonio Spurs": { conference: "Western", elo: 1599,
|
"Minnesota Timberwolves":{ conference: "Western", elo: 1603 },
|
||||||
p_1: 0.081, p_2: 0.919 },
|
"Phoenix Suns": { conference: "Western", elo: 1500 },
|
||||||
|
"LA Clippers": { conference: "Western", elo: 1573 },
|
||||||
// Houston (PO%=99.8%): 3/4 seed range
|
"Golden State Warriors": { conference: "Western", elo: 1530 },
|
||||||
"Houston Rockets": { conference: "Western", elo: 1564,
|
"Portland Trail Blazers":{ conference: "Western", elo: 1426 },
|
||||||
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 },
|
"Dallas Mavericks": { conference: "Western", elo: 1473 },
|
||||||
|
|
||||||
// Memphis (PO%=0%)
|
|
||||||
"Memphis Grizzlies": { conference: "Western", elo: 1417 },
|
"Memphis Grizzlies": { conference: "Western", elo: 1417 },
|
||||||
|
|
||||||
// New Orleans (PO%=0%)
|
|
||||||
"New Orleans Pelicans": { conference: "Western", elo: 1380 },
|
"New Orleans Pelicans": { conference: "Western", elo: 1380 },
|
||||||
|
|
||||||
// Sacramento (PO%=0%)
|
|
||||||
"Sacramento Kings": { conference: "Western", elo: 1352 },
|
"Sacramento Kings": { conference: "Western", elo: 1352 },
|
||||||
|
|
||||||
// Utah (PO%=0%)
|
|
||||||
"Utah Jazz": { conference: "Western", elo: 1334 },
|
"Utah Jazz": { conference: "Western", elo: 1334 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -238,6 +147,13 @@ interface TeamEntry {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
data: NbaTeamData | undefined;
|
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.
|
/** Get Elo for a team entry.
|
||||||
|
|
@ -246,17 +162,31 @@ function elo(entry: TeamEntry): number {
|
||||||
return entry.data?.elo ?? 1400;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export class NBASimulator implements Simulator {
|
export class NBASimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
// 1. Load all participants for this sports season.
|
// 1. Load participants and standings in parallel.
|
||||||
const participantRows = await db
|
const [participantRows, standings] = await Promise.all([
|
||||||
|
db
|
||||||
.select({ id: schema.participants.id, name: schema.participants.name })
|
.select({ id: schema.participants.id, name: schema.participants.name })
|
||||||
.from(schema.participants)
|
.from(schema.participants)
|
||||||
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId));
|
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
|
||||||
|
getRegularSeasonStandings(sportsSeasonId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (participantRows.length === 0) {
|
if (participantRows.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -265,19 +195,35 @@ export class NBASimulator implements Simulator {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Match participant names to hardcoded team data.
|
// 2. Build standings lookup and construct team entries.
|
||||||
// Teams with no match or no seed probabilities will always miss the playoffs.
|
// 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 participantIds = participantRows.map((r) => r.id);
|
||||||
|
|
||||||
const teams: TeamEntry[] = participantRows.map((r) => ({
|
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,
|
id: r.id,
|
||||||
name: r.name,
|
name: r.name,
|
||||||
data: getTeamData(r.name),
|
data,
|
||||||
}));
|
conference,
|
||||||
|
currentWins: standing?.wins ?? 0,
|
||||||
|
remainingGames: Math.max(0, NBA_REGULAR_SEASON_GAMES - gamesPlayed),
|
||||||
|
winProb: eloWinProbability(data?.elo ?? 1400, 1500),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Separate by conference for simulation (fall back to Eastern if no data found).
|
// 3. Separate by conference for simulation.
|
||||||
const easternTeams = teams.filter((t) => (t.data?.conference ?? "Eastern") === "Eastern");
|
const easternTeams = teams.filter((t) => t.conference === "Eastern");
|
||||||
const westernTeams = teams.filter((t) => t.data?.conference === "Western");
|
const westernTeams = teams.filter((t) => t.conference === "Western");
|
||||||
|
|
||||||
// Validate: each conference needs at least 10 teams to fill the bracket + play-in.
|
// Validate: each conference needs at least 10 teams to fill the bracket + play-in.
|
||||||
if (easternTeams.length < 10 || westernTeams.length < 10) {
|
if (easternTeams.length < 10 || westernTeams.length < 10) {
|
||||||
|
|
@ -289,20 +235,6 @@ export class NBASimulator implements Simulator {
|
||||||
|
|
||||||
// ─── Helpers (defined once, outside the hot loop) ─────────────────────────
|
// ─── 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
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Simulate a single playoff game. Returns the winner. */
|
/** Simulate a single playoff game. Returns the winner. */
|
||||||
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
const simGame = (a: TeamEntry, b: TeamEntry): TeamEntry =>
|
||||||
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
|
Math.random() < eloWinProbability(elo(a), elo(b)) ? a : b;
|
||||||
|
|
@ -332,17 +264,18 @@ export class NBASimulator implements Simulator {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build an 8-team conference bracket [s1..s8] for one simulation iteration.
|
/** Build an 8-team conference bracket [s1..s8] for one simulation iteration.
|
||||||
* Seeds are drawn probabilistically; positions 7–10 go through the Play-In. */
|
* Seeds are determined by simulated projected wins; positions 7–10 go through the Play-In. */
|
||||||
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
|
const buildConferenceBracket = (confTeams: TeamEntry[]): TeamEntry[] => {
|
||||||
const seeded = confTeams.map((t) => ({
|
const projected = confTeams.map((t) => ({
|
||||||
team: t,
|
team: t,
|
||||||
seed: drawSeed(t),
|
projectedWins: simulateProjectedWins(t),
|
||||||
tiebreaker: Math.random(),
|
tiebreaker: Math.random(),
|
||||||
}));
|
}));
|
||||||
seeded.sort((a, b) => a.seed - b.seed || a.tiebreaker - b.tiebreaker);
|
// 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 = seeded.slice(0, 6).map((x) => x.team);
|
const top6 = projected.slice(0, 6).map((x) => x.team);
|
||||||
const playIn = seeded.slice(6, 10).map((x) => x.team) as
|
const playIn = projected.slice(6, 10).map((x) => x.team) as
|
||||||
[TeamEntry, TeamEntry, TeamEntry, TeamEntry];
|
[TeamEntry, TeamEntry, TeamEntry, TeamEntry];
|
||||||
const [seed7, seed8] = simPlayIn(playIn);
|
const [seed7, seed8] = simPlayIn(playIn);
|
||||||
return [...top6, seed7, seed8];
|
return [...top6, seed7, seed8];
|
||||||
|
|
@ -363,13 +296,13 @@ export class NBASimulator implements Simulator {
|
||||||
return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] };
|
return { winners: [m1.winner, m2.winner], losers: [m1.loser, m2.loser] };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Integer placement count maps — initialized to 0 for all participants.
|
// 4. Integer placement count maps — initialized to 0 for all participants.
|
||||||
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const finalistCounts = 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 confFinalLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||||||
const confSemiLoserCounts = 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.
|
// 5. Monte Carlo simulation loop.
|
||||||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||||||
// ── Build brackets ───────────────────────────────────────────────────────
|
// ── Build brackets ───────────────────────────────────────────────────────
|
||||||
const eastBracket = buildConferenceBracket(easternTeams);
|
const eastBracket = buildConferenceBracket(easternTeams);
|
||||||
|
|
@ -384,7 +317,7 @@ export class NBASimulator implements Simulator {
|
||||||
|
|
||||||
const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
|
const { winner: champion, loser: finalist } = simSeries(eastChamp, westChamp);
|
||||||
|
|
||||||
// ── Record counts (maps are pre-populated so .get() is always defined) ───
|
// ── Record counts (maps are pre-populated so .get() always returns a number) ───
|
||||||
championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
|
championCounts.set(champion.id, (championCounts.get(champion.id) ?? 0) + 1);
|
||||||
finalistCounts.set(finalist.id, (finalistCounts.get(finalist.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(eastCFLoser.id, (confFinalLoserCounts.get(eastCFLoser.id) ?? 0) + 1);
|
||||||
|
|
@ -395,12 +328,11 @@ export class NBASimulator implements Simulator {
|
||||||
// Round 1 losers are not counted (0 points per scoring rules).
|
// Round 1 losers are not counted (0 points per scoring rules).
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Convert integer counts to probability distributions.
|
// 6. Convert integer counts to probability distributions.
|
||||||
// Exact denominators guarantee column sums of 1.0 by construction:
|
// Exact denominators guarantee column sums of 1.0 by construction:
|
||||||
// probFirst/Second → N total (1 per sim)
|
// probFirst/Second → NUM_SIMULATIONS total (1 per sim)
|
||||||
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
|
// probThird/Fourth → confFinalLoserCounts / (2*N) — 2 conf final losers per sim
|
||||||
// probFifth–Eighth → confSemiLoserCounts / (4*N) — 4 conf semi 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 results: SimulationResult[] = participantIds.map((participantId) => {
|
||||||
const c = championCounts.get(participantId) ?? 0;
|
const c = championCounts.get(participantId) ?? 0;
|
||||||
const f = finalistCounts.get(participantId) ?? 0;
|
const f = finalistCounts.get(participantId) ?? 0;
|
||||||
|
|
@ -409,21 +341,21 @@ export class NBASimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: c / N,
|
probFirst: c / NUM_SIMULATIONS,
|
||||||
probSecond: f / N,
|
probSecond: f / NUM_SIMULATIONS,
|
||||||
probThird: cf / (2 * N),
|
probThird: cf / (2 * NUM_SIMULATIONS),
|
||||||
probFourth: cf / (2 * N),
|
probFourth: cf / (2 * NUM_SIMULATIONS),
|
||||||
probFifth: cs / (4 * N),
|
probFifth: cs / (4 * NUM_SIMULATIONS),
|
||||||
probSixth: cs / (4 * N),
|
probSixth: cs / (4 * NUM_SIMULATIONS),
|
||||||
probSeventh: cs / (4 * N),
|
probSeventh: cs / (4 * NUM_SIMULATIONS),
|
||||||
probEighth: cs / (4 * N),
|
probEighth: cs / (4 * NUM_SIMULATIONS),
|
||||||
},
|
},
|
||||||
source: "nba_bracket_monte_carlo",
|
source: "nba_bracket_monte_carlo",
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Per-position normalization — belt-and-suspenders guard against floating-point
|
// 7. Per-position normalization — belt-and-suspenders guard against floating-point
|
||||||
// division residuals. Columns are already near-exactly 1.0 after step 5.
|
// division residuals. Columns are already near-exactly 1.0 after step 6.
|
||||||
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
|
||||||
"probFirst", "probSecond", "probThird", "probFourth",
|
"probFirst", "probSecond", "probThird", "probFourth",
|
||||||
"probFifth", "probSixth", "probSeventh", "probEighth",
|
"probFifth", "probSixth", "probSeventh", "probEighth",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue