653 lines
25 KiB
TypeScript
653 lines
25 KiB
TypeScript
|
|
/**
|
|||
|
|
* MLB Playoff Simulator
|
|||
|
|
*
|
|||
|
|
* Monte Carlo simulation of the MLB season and playoffs including seeding
|
|||
|
|
* projection for the current season (2026).
|
|||
|
|
*
|
|||
|
|
* Algorithm:
|
|||
|
|
* 1. Load all participants for the sports season from DB
|
|||
|
|
* 2. Match participant names to hardcoded team data (Elo + seeding probabilities)
|
|||
|
|
* 3. For each simulation:
|
|||
|
|
* a. For each league (AL/NL), draw 1 division winner per division (3 draws),
|
|||
|
|
* then draw 3 wildcard teams from the remaining pool — all weighted draws.
|
|||
|
|
* b. Seed division winners 1–3 by Elo (best Elo = seed 1); WC teams 4–6 by Elo.
|
|||
|
|
* c. Run the playoff bracket per league:
|
|||
|
|
* - Wildcard Round (best-of-3): 3 vs 6, 4 vs 5 (seeds 1 & 2 get byes)
|
|||
|
|
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
|
|||
|
|
* - League Championship Series (best-of-7)
|
|||
|
|
* d. World Series (best-of-7): AL champ vs NL champ
|
|||
|
|
* 4. Track placement counts per scoring tier
|
|||
|
|
* 5. Convert counts to probability distributions
|
|||
|
|
*
|
|||
|
|
* Win probability (Elo, PARITY_FACTOR = 350):
|
|||
|
|
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 350))
|
|||
|
|
* MLB uses a lower parity factor than the NBA (400) or NHL (1000) to reflect
|
|||
|
|
* that better teams win series at a somewhat higher rate in baseball. The short
|
|||
|
|
* best-of-3 wildcard round introduces significant upset potential.
|
|||
|
|
*
|
|||
|
|
* Futures blending:
|
|||
|
|
* If sourceOdds are stored in participantExpectedValues for this season,
|
|||
|
|
* the per-game win probability is blended:
|
|||
|
|
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
|
|||
|
|
* where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
|
|||
|
|
* Normalized odds are vig-removed futures win probabilities.
|
|||
|
|
* ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as NHL simulator).
|
|||
|
|
* Falls back to Elo-only when no odds are stored.
|
|||
|
|
*
|
|||
|
|
* Placement tiers → SimulationProbabilities mapping:
|
|||
|
|
* probFirst = World Series champion (1 per sim)
|
|||
|
|
* probSecond = World Series loser (1 per sim)
|
|||
|
|
* probThird / probFourth = LCS losers (2 per sim — AL + NL, split evenly)
|
|||
|
|
* probFifth–probEighth = Division Series losers (4 per sim, split evenly)
|
|||
|
|
* Wildcard Round losers → all 0 (score 0 points, same as non-playoff teams)
|
|||
|
|
* Missed playoffs → all 0
|
|||
|
|
*
|
|||
|
|
* Team data keys:
|
|||
|
|
* elo: Elo rating derived from FanGraphs projected wins (ExpW) via:
|
|||
|
|
* elo = 1500 + 350 * log10(winRate / (1 - winRate))
|
|||
|
|
* where winRate = ExpW / 162
|
|||
|
|
* p_div: Probability of winning own division (FanGraphs divTitle).
|
|||
|
|
* Must sum to ~1.0 within each 5-team division.
|
|||
|
|
* p_wc: Probability of claiming one of 3 wildcard slots (FanGraphs wcTitle).
|
|||
|
|
* Used as relative weights among non-division-winners in each league.
|
|||
|
|
*
|
|||
|
|
* Elo ratings and seeding probabilities are hardcoded below (2026 pre-season).
|
|||
|
|
* Source: https://www.fangraphs.com/standings/playoff-odds/fg/div
|
|||
|
|
* Update at the start of each season using the formula above.
|
|||
|
|
*
|
|||
|
|
* Divisions:
|
|||
|
|
* AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays
|
|||
|
|
* AL Central: Royals, Guardians, Twins, Tigers, White Sox
|
|||
|
|
* AL West: Astros, Mariners, Rangers, Angels, Athletics
|
|||
|
|
* NL East: Phillies, Braves, Mets, Nationals, Marlins
|
|||
|
|
* NL Central: Brewers, Cubs, Cardinals, Reds, Pirates
|
|||
|
|
* NL West: Dodgers, Padres, Diamondbacks, Giants, Rockies
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { database } from "~/database/context";
|
|||
|
|
import { eq } from "drizzle-orm";
|
|||
|
|
import * as schema from "~/database/schema";
|
|||
|
|
import type { Simulator, SimulationResult } from "./types";
|
|||
|
|
import { logger } from "~/lib/logger";
|
|||
|
|
import {
|
|||
|
|
convertAmericanOddsToProbability,
|
|||
|
|
normalizeProbabilities,
|
|||
|
|
} from "~/services/probability-engine";
|
|||
|
|
|
|||
|
|
// ─── Simulation parameters ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const NUM_SIMULATIONS = 50_000;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Elo parity factor. MLB uses 350 (below NHL's 1000 and NBA's 400) to reflect
|
|||
|
|
* that teams of different quality win series at a higher rate in baseball
|
|||
|
|
* compared to hockey, where single-game variance is very high.
|
|||
|
|
*/
|
|||
|
|
const PARITY_FACTOR = 350;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
|
|||
|
|
* Same calibration as the NHL simulator (0.7 / 0.3).
|
|||
|
|
*/
|
|||
|
|
const ELO_WEIGHT = 0.7;
|
|||
|
|
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
|
|||
|
|
|
|||
|
|
// ─── Team data (2026 pre-season estimates) ────────────────────────────────────
|
|||
|
|
//
|
|||
|
|
// elo: Derived from FanGraphs projected wins (ExpW) via:
|
|||
|
|
// elo = 1500 + 350 * log10(winRate / (1 - winRate))
|
|||
|
|
// where winRate = ExpW / 162
|
|||
|
|
// p_div: FanGraphs divTitle — probability of winning own division.
|
|||
|
|
// Sum within each 5-team division should equal ~1.0.
|
|||
|
|
// p_wc: FanGraphs wcTitle — probability of claiming a wildcard slot.
|
|||
|
|
// Used as relative weights among non-division-winners per league.
|
|||
|
|
// Does not need to sum to any specific value.
|
|||
|
|
//
|
|||
|
|
// Update these at the start of each season using the FanGraphs playoff odds page:
|
|||
|
|
// https://www.fangraphs.com/standings/playoff-odds/fg/div
|
|||
|
|
//
|
|||
|
|
// Quick update formula (JavaScript):
|
|||
|
|
// const winRate = ExpW / 162;
|
|||
|
|
// const elo = Math.round(1500 + 350 * Math.log10(winRate / (1 - winRate)));
|
|||
|
|
|
|||
|
|
interface MlbTeamData {
|
|||
|
|
league: "AL" | "NL";
|
|||
|
|
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
|
|||
|
|
elo: number;
|
|||
|
|
p_div: number;
|
|||
|
|
p_wc: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const TEAMS_DATA: Record<string, MlbTeamData> = {
|
|||
|
|
// ── American League East ───────────────────────────────────────────────────
|
|||
|
|
"New York Yankees": {
|
|||
|
|
league: "AL", division: "AL East", elo: 1545,
|
|||
|
|
p_div: 0.30, p_wc: 0.42,
|
|||
|
|
},
|
|||
|
|
"Baltimore Orioles": {
|
|||
|
|
league: "AL", division: "AL East", elo: 1535,
|
|||
|
|
p_div: 0.28, p_wc: 0.41,
|
|||
|
|
},
|
|||
|
|
"Boston Red Sox": {
|
|||
|
|
league: "AL", division: "AL East", elo: 1520,
|
|||
|
|
p_div: 0.22, p_wc: 0.35,
|
|||
|
|
},
|
|||
|
|
"Tampa Bay Rays": {
|
|||
|
|
league: "AL", division: "AL East", elo: 1513,
|
|||
|
|
p_div: 0.12, p_wc: 0.25,
|
|||
|
|
},
|
|||
|
|
"Toronto Blue Jays": {
|
|||
|
|
league: "AL", division: "AL East", elo: 1506,
|
|||
|
|
p_div: 0.08, p_wc: 0.18,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── American League Central ────────────────────────────────────────────────
|
|||
|
|
"Kansas City Royals": {
|
|||
|
|
league: "AL", division: "AL Central", elo: 1519,
|
|||
|
|
p_div: 0.28, p_wc: 0.30,
|
|||
|
|
},
|
|||
|
|
"Cleveland Guardians": {
|
|||
|
|
league: "AL", division: "AL Central", elo: 1516,
|
|||
|
|
p_div: 0.27, p_wc: 0.29,
|
|||
|
|
},
|
|||
|
|
"Minnesota Twins": {
|
|||
|
|
league: "AL", division: "AL Central", elo: 1514,
|
|||
|
|
p_div: 0.23, p_wc: 0.25,
|
|||
|
|
},
|
|||
|
|
"Detroit Tigers": {
|
|||
|
|
league: "AL", division: "AL Central", elo: 1510,
|
|||
|
|
p_div: 0.15, p_wc: 0.18,
|
|||
|
|
},
|
|||
|
|
"Chicago White Sox": {
|
|||
|
|
league: "AL", division: "AL Central", elo: 1440,
|
|||
|
|
p_div: 0.07, p_wc: 0.03,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── American League West ───────────────────────────────────────────────────
|
|||
|
|
"Houston Astros": {
|
|||
|
|
league: "AL", division: "AL West", elo: 1537,
|
|||
|
|
p_div: 0.35, p_wc: 0.40,
|
|||
|
|
},
|
|||
|
|
"Seattle Mariners": {
|
|||
|
|
league: "AL", division: "AL West", elo: 1528,
|
|||
|
|
p_div: 0.28, p_wc: 0.35,
|
|||
|
|
},
|
|||
|
|
"Texas Rangers": {
|
|||
|
|
league: "AL", division: "AL West", elo: 1522,
|
|||
|
|
p_div: 0.22, p_wc: 0.28,
|
|||
|
|
},
|
|||
|
|
"Los Angeles Angels": {
|
|||
|
|
league: "AL", division: "AL West", elo: 1475,
|
|||
|
|
p_div: 0.09, p_wc: 0.08,
|
|||
|
|
},
|
|||
|
|
"Athletics": {
|
|||
|
|
league: "AL", division: "AL West", elo: 1455,
|
|||
|
|
p_div: 0.06, p_wc: 0.04,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── National League East ───────────────────────────────────────────────────
|
|||
|
|
"Philadelphia Phillies": {
|
|||
|
|
league: "NL", division: "NL East", elo: 1553,
|
|||
|
|
p_div: 0.35, p_wc: 0.42,
|
|||
|
|
},
|
|||
|
|
"Atlanta Braves": {
|
|||
|
|
league: "NL", division: "NL East", elo: 1550,
|
|||
|
|
p_div: 0.32, p_wc: 0.40,
|
|||
|
|
},
|
|||
|
|
"New York Mets": {
|
|||
|
|
league: "NL", division: "NL East", elo: 1534,
|
|||
|
|
p_div: 0.22, p_wc: 0.33,
|
|||
|
|
},
|
|||
|
|
"Washington Nationals": {
|
|||
|
|
league: "NL", division: "NL East", elo: 1483,
|
|||
|
|
p_div: 0.07, p_wc: 0.07,
|
|||
|
|
},
|
|||
|
|
"Miami Marlins": {
|
|||
|
|
league: "NL", division: "NL East", elo: 1468,
|
|||
|
|
p_div: 0.04, p_wc: 0.03,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── National League Central ────────────────────────────────────────────────
|
|||
|
|
"Milwaukee Brewers": {
|
|||
|
|
league: "NL", division: "NL Central", elo: 1529,
|
|||
|
|
p_div: 0.35, p_wc: 0.38,
|
|||
|
|
},
|
|||
|
|
"Chicago Cubs": {
|
|||
|
|
league: "NL", division: "NL Central", elo: 1515,
|
|||
|
|
p_div: 0.27, p_wc: 0.28,
|
|||
|
|
},
|
|||
|
|
"St. Louis Cardinals": {
|
|||
|
|
league: "NL", division: "NL Central", elo: 1507,
|
|||
|
|
p_div: 0.20, p_wc: 0.20,
|
|||
|
|
},
|
|||
|
|
"Cincinnati Reds": {
|
|||
|
|
league: "NL", division: "NL Central", elo: 1503,
|
|||
|
|
p_div: 0.12, p_wc: 0.13,
|
|||
|
|
},
|
|||
|
|
"Pittsburgh Pirates": {
|
|||
|
|
league: "NL", division: "NL Central", elo: 1498,
|
|||
|
|
p_div: 0.06, p_wc: 0.07,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// ── National League West ───────────────────────────────────────────────────
|
|||
|
|
"Los Angeles Dodgers": {
|
|||
|
|
league: "NL", division: "NL West", elo: 1570,
|
|||
|
|
p_div: 0.65, p_wc: 0.28,
|
|||
|
|
},
|
|||
|
|
"San Diego Padres": {
|
|||
|
|
league: "NL", division: "NL West", elo: 1525,
|
|||
|
|
p_div: 0.15, p_wc: 0.30,
|
|||
|
|
},
|
|||
|
|
"Arizona Diamondbacks": {
|
|||
|
|
league: "NL", division: "NL West", elo: 1520,
|
|||
|
|
p_div: 0.12, p_wc: 0.28,
|
|||
|
|
},
|
|||
|
|
"San Francisco Giants": {
|
|||
|
|
league: "NL", division: "NL West", elo: 1510,
|
|||
|
|
p_div: 0.06, p_wc: 0.20,
|
|||
|
|
},
|
|||
|
|
"Colorado Rockies": {
|
|||
|
|
league: "NL", division: "NL West", elo: 1442,
|
|||
|
|
p_div: 0.02, p_wc: 0.02,
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── 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): MlbTeamData | 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: MlbTeamData | undefined;
|
|||
|
|
originalSeed?: number;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
|
|||
|
|
function elo(entry: TeamEntry): number {
|
|||
|
|
return entry.data?.elo ?? 1400;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Weighted pick without replacement using the given weight key.
|
|||
|
|
* Returns undefined if no eligible team has positive weight.
|
|||
|
|
*/
|
|||
|
|
function weightedPickByKey(
|
|||
|
|
pool: TeamEntry[],
|
|||
|
|
excluded: Set<TeamEntry>,
|
|||
|
|
getWeight: (t: TeamEntry) => number
|
|||
|
|
): TeamEntry | undefined {
|
|||
|
|
const eligible = pool.filter((t) => !excluded.has(t));
|
|||
|
|
if (eligible.length === 0) return undefined;
|
|||
|
|
|
|||
|
|
const weights = eligible.map(getWeight);
|
|||
|
|
const total = weights.reduce((s, w) => s + w, 0);
|
|||
|
|
if (total === 0) return undefined;
|
|||
|
|
|
|||
|
|
let r = Math.random() * total;
|
|||
|
|
for (let i = 0; i < eligible.length; i++) {
|
|||
|
|
r -= weights[i];
|
|||
|
|
if (r <= 0) return eligible[i];
|
|||
|
|
}
|
|||
|
|
return eligible[eligible.length - 1];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Series simulators ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
type SeriesResult = { winner: TeamEntry; loser: TeamEntry };
|
|||
|
|
|
|||
|
|
/** Simulate a series where the winner must reach `winsNeeded` wins. */
|
|||
|
|
function simSeries(
|
|||
|
|
a: TeamEntry,
|
|||
|
|
b: TeamEntry,
|
|||
|
|
winsNeeded: number,
|
|||
|
|
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
|||
|
|
): SeriesResult {
|
|||
|
|
const prob = gameWinProb(a, b);
|
|||
|
|
let winsA = 0;
|
|||
|
|
let winsB = 0;
|
|||
|
|
while (winsA < winsNeeded && winsB < winsNeeded) {
|
|||
|
|
if (Math.random() < prob) winsA++; else winsB++;
|
|||
|
|
}
|
|||
|
|
return winsA === winsNeeded ? { winner: a, loser: b } : { winner: b, loser: a };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Wildcard Round: best-of-3 (first to 2 wins). */
|
|||
|
|
export function simBo3(
|
|||
|
|
a: TeamEntry,
|
|||
|
|
b: TeamEntry,
|
|||
|
|
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
|||
|
|
): SeriesResult {
|
|||
|
|
return simSeries(a, b, 2, gameWinProb);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** Division Series: best-of-5 (first to 3 wins). */
|
|||
|
|
export function simBo5(
|
|||
|
|
a: TeamEntry,
|
|||
|
|
b: TeamEntry,
|
|||
|
|
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
|||
|
|
): SeriesResult {
|
|||
|
|
return simSeries(a, b, 3, gameWinProb);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** League Championship + World Series: best-of-7 (first to 4 wins). */
|
|||
|
|
export function simBo7(
|
|||
|
|
a: TeamEntry,
|
|||
|
|
b: TeamEntry,
|
|||
|
|
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
|||
|
|
): SeriesResult {
|
|||
|
|
return simSeries(a, b, 4, gameWinProb);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── League bracket builder ───────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Draws the 6-team playoff field for one league (AL or NL).
|
|||
|
|
*
|
|||
|
|
* Steps:
|
|||
|
|
* 1. For each of the 3 divisions, draw 1 division winner weighted by p_div.
|
|||
|
|
* 2. From the remaining non-division-winners, draw 3 WC teams weighted by p_wc.
|
|||
|
|
* 3. Rank division winners 1–3 by Elo (best Elo → seed 1).
|
|||
|
|
* 4. Rank WC teams 4–6 by Elo (best Elo → seed 4).
|
|||
|
|
*
|
|||
|
|
* Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated
|
|||
|
|
* with originalSeed, or undefined if any draw is degenerate (no eligible team
|
|||
|
|
* with positive weight).
|
|||
|
|
*/
|
|||
|
|
function drawLeaguePlayoffField(
|
|||
|
|
leagueTeams: TeamEntry[]
|
|||
|
|
): TeamEntry[] | undefined {
|
|||
|
|
// Group by division
|
|||
|
|
const divMap = new Map<string, TeamEntry[]>();
|
|||
|
|
for (const t of leagueTeams) {
|
|||
|
|
const div = t.data?.division ?? "Unknown";
|
|||
|
|
if (!divMap.has(div)) divMap.set(div, []);
|
|||
|
|
divMap.get(div)?.push(t);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const divisionWinners: TeamEntry[] = [];
|
|||
|
|
const allDivisionWinnerSet = new Set<TeamEntry>();
|
|||
|
|
|
|||
|
|
for (const divTeams of divMap.values()) {
|
|||
|
|
const winner = weightedPickByKey(divTeams, new Set(), (t) => t.data?.p_div ?? 0);
|
|||
|
|
if (!winner) return undefined;
|
|||
|
|
divisionWinners.push(winner);
|
|||
|
|
allDivisionWinnerSet.add(winner);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Draw 3 WC teams from non-division-winners
|
|||
|
|
const wcPool = leagueTeams.filter((t) => !allDivisionWinnerSet.has(t));
|
|||
|
|
const wcTaken = new Set<TeamEntry>();
|
|||
|
|
const wcTeams: TeamEntry[] = [];
|
|||
|
|
|
|||
|
|
for (let i = 0; i < 3; i++) {
|
|||
|
|
const wc = weightedPickByKey(wcPool, wcTaken, (t) => t.data?.p_wc ?? 0);
|
|||
|
|
if (!wc) return undefined;
|
|||
|
|
wcTeams.push(wc);
|
|||
|
|
wcTaken.add(wc);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Rank division winners 1–3 by Elo descending (best Elo = seed 1)
|
|||
|
|
const sortedDivWinners = divisionWinners.toSorted((a, b) => elo(b) - elo(a));
|
|||
|
|
|
|||
|
|
// Rank WC teams 4–6 by Elo descending (best Elo = seed 4)
|
|||
|
|
const sortedWcTeams = wcTeams.toSorted((a, b) => elo(b) - elo(a));
|
|||
|
|
|
|||
|
|
const seeds = [...sortedDivWinners, ...sortedWcTeams];
|
|||
|
|
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Simulate the full playoff bracket for one league.
|
|||
|
|
*
|
|||
|
|
* Bracket structure:
|
|||
|
|
* Wildcard Round (best-of-3): seeds 3v6, 4v5 — seeds 1 & 2 get byes
|
|||
|
|
* Division Series (best-of-5): 1 vs lowest-seeded WC survivor; 2 vs other
|
|||
|
|
* League Championship Series (best-of-7)
|
|||
|
|
*
|
|||
|
|
* Returns { lcWinner, lcLoser, dsLosers[2], wcLosers[2] }
|
|||
|
|
*/
|
|||
|
|
function simLeagueBracket(
|
|||
|
|
seeds: TeamEntry[],
|
|||
|
|
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
|||
|
|
): {
|
|||
|
|
lcWinner: TeamEntry;
|
|||
|
|
lcLoser: TeamEntry;
|
|||
|
|
dsLosers: [TeamEntry, TeamEntry];
|
|||
|
|
wcLosers: [TeamEntry, TeamEntry];
|
|||
|
|
} {
|
|||
|
|
const [s1, s2, s3, s4, s5, s6] = seeds;
|
|||
|
|
|
|||
|
|
// Wildcard Round (best-of-3)
|
|||
|
|
const wc1 = simBo3(s3, s6, gameWinProb);
|
|||
|
|
const wc2 = simBo3(s4, s5, gameWinProb);
|
|||
|
|
|
|||
|
|
// Division Series: re-seed — seed 1 plays the worse WC survivor, seed 2 plays the better one.
|
|||
|
|
// Sort by originalSeed ascending: [0] = lower seed number = better team, [1] = worse team.
|
|||
|
|
const survivors = [wc1.winner, wc2.winner].toSorted(
|
|||
|
|
(a, b) => (a.originalSeed ?? 0) - (b.originalSeed ?? 0)
|
|||
|
|
);
|
|||
|
|
const ds1 = simBo5(s1, survivors[1], gameWinProb); // 1 vs worse remaining (higher seed number)
|
|||
|
|
const ds2 = simBo5(s2, survivors[0], gameWinProb); // 2 vs better remaining (lower seed number)
|
|||
|
|
|
|||
|
|
// League Championship Series (best-of-7)
|
|||
|
|
const lcs = simBo7(ds1.winner, ds2.winner, gameWinProb);
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
lcWinner: lcs.winner,
|
|||
|
|
lcLoser: lcs.loser,
|
|||
|
|
dsLosers: [ds1.loser, ds2.loser],
|
|||
|
|
wcLosers: [wc1.loser, wc2.loser],
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Simulator ────────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
export class MLBSimulator 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 MLB teams as participants before running simulation.`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const participantIds = participantRows.map((r) => r.id);
|
|||
|
|
|
|||
|
|
const teams: TeamEntry[] = participantRows.map((r) => ({
|
|||
|
|
id: r.id,
|
|||
|
|
name: r.name,
|
|||
|
|
data: getTeamData(r.name),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// Warn about participants that don't match any hardcoded team.
|
|||
|
|
const unrecognized = teams.filter((t) => !t.data);
|
|||
|
|
if (unrecognized.length > 0) {
|
|||
|
|
logger.warn(
|
|||
|
|
`[MLBSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
|
|||
|
|
unrecognized.map((t) => t.name).join(", ")
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const alTeams = teams.filter((t) => t.data?.league === "AL");
|
|||
|
|
const nlTeams = teams.filter((t) => t.data?.league === "NL");
|
|||
|
|
|
|||
|
|
if (alTeams.length < 5 || nlTeams.length < 5) {
|
|||
|
|
throw new Error(
|
|||
|
|
`Each league needs at least 5 recognized participants ` +
|
|||
|
|
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
|
|||
|
|
`Add MLB teams before running simulation.`
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Futures odds blending ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const evRows = await db
|
|||
|
|
.select({
|
|||
|
|
participantId: schema.participantExpectedValues.participantId,
|
|||
|
|
sourceOdds: schema.participantExpectedValues.sourceOdds,
|
|||
|
|
})
|
|||
|
|
.from(schema.participantExpectedValues)
|
|||
|
|
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
|||
|
|
|
|||
|
|
const participantIdSet = new Set(participantIds);
|
|||
|
|
const oddsRows = evRows.filter(
|
|||
|
|
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
|
|||
|
|
);
|
|||
|
|
const hasOdds = oddsRows.length > 0;
|
|||
|
|
|
|||
|
|
const normalizedOddsMap = new Map<string, number>();
|
|||
|
|
if (hasOdds) {
|
|||
|
|
const rawProbs = oddsRows.map((r) =>
|
|||
|
|
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
|
|||
|
|
);
|
|||
|
|
const normalized = normalizeProbabilities(rawProbs);
|
|||
|
|
oddsRows.forEach(({ participantId }, i) => {
|
|||
|
|
normalizedOddsMap.set(participantId, normalized[i]);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Blended per-game win probability for team A over team B.
|
|||
|
|
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
|
|||
|
|
*/
|
|||
|
|
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
|||
|
|
const eloProb = eloWinProbability(elo(a), elo(b));
|
|||
|
|
if (!hasOdds) return eloProb;
|
|||
|
|
|
|||
|
|
const o1 = normalizedOddsMap.get(a.id);
|
|||
|
|
const o2 = normalizedOddsMap.get(b.id);
|
|||
|
|
// Fall back to Elo if either team lacks odds — avoids inflating one side to 100%.
|
|||
|
|
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return eloProb;
|
|||
|
|
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
|||
|
|
return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─── Placement count maps ──────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|||
|
|
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|||
|
|
const lcsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|||
|
|
const dsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
|||
|
|
// WC losers are not tracked — they score 0 points
|
|||
|
|
|
|||
|
|
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
|
|||
|
|
|
|||
|
|
let effectiveN = 0;
|
|||
|
|
|
|||
|
|
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
|||
|
|
const alField = drawLeaguePlayoffField(alTeams);
|
|||
|
|
const nlField = drawLeaguePlayoffField(nlTeams);
|
|||
|
|
if (!alField || !nlField) continue; // degenerate draw — skip
|
|||
|
|
|
|||
|
|
effectiveN++;
|
|||
|
|
|
|||
|
|
// Simulate both league brackets
|
|||
|
|
const alResult = simLeagueBracket(alField, gameWinProb);
|
|||
|
|
const nlResult = simLeagueBracket(nlField, gameWinProb);
|
|||
|
|
|
|||
|
|
// LCS losers (3rd/4th tier)
|
|||
|
|
lcsLoserCounts.set(alResult.lcLoser.id, (lcsLoserCounts.get(alResult.lcLoser.id) ?? 0) + 1);
|
|||
|
|
lcsLoserCounts.set(nlResult.lcLoser.id, (lcsLoserCounts.get(nlResult.lcLoser.id) ?? 0) + 1);
|
|||
|
|
|
|||
|
|
// DS losers (5th–8th tier)
|
|||
|
|
for (const loser of [...alResult.dsLosers, ...nlResult.dsLosers]) {
|
|||
|
|
dsLoserCounts.set(loser.id, (dsLoserCounts.get(loser.id) ?? 0) + 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// World Series (best-of-7)
|
|||
|
|
const ws = simBo7(alResult.lcWinner, nlResult.lcWinner, gameWinProb);
|
|||
|
|
championCounts.set(ws.winner.id, (championCounts.get(ws.winner.id) ?? 0) + 1);
|
|||
|
|
finalistCounts.set(ws.loser.id, (finalistCounts.get(ws.loser.id) ?? 0) + 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (effectiveN === 0) {
|
|||
|
|
throw new Error(
|
|||
|
|
"All simulations produced degenerate brackets. " +
|
|||
|
|
"Check that each division has teams with positive p_div values."
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── Convert counts to probability distributions ───────────────────────────
|
|||
|
|
//
|
|||
|
|
// probFirst/Second → count / N (1 team per sim)
|
|||
|
|
// probThird/Fourth → count / (2 * N) (2 LCS losers per sim: AL + NL)
|
|||
|
|
// probFifth–Eighth → count / (4 * N) (4 DS losers per sim: 2 per league)
|
|||
|
|
// WC losers → 0 (all probs zero)
|
|||
|
|
|
|||
|
|
const N = effectiveN;
|
|||
|
|
const results: SimulationResult[] = participantIds.map((participantId) => {
|
|||
|
|
const c = championCounts.get(participantId) ?? 0;
|
|||
|
|
const f = finalistCounts.get(participantId) ?? 0;
|
|||
|
|
const lcs = lcsLoserCounts.get(participantId) ?? 0;
|
|||
|
|
const ds = dsLoserCounts.get(participantId) ?? 0;
|
|||
|
|
return {
|
|||
|
|
participantId,
|
|||
|
|
probabilities: {
|
|||
|
|
probFirst: c / N,
|
|||
|
|
probSecond: f / N,
|
|||
|
|
probThird: lcs / (2 * N),
|
|||
|
|
probFourth: lcs / (2 * N),
|
|||
|
|
probFifth: ds / (4 * N),
|
|||
|
|
probSixth: ds / (4 * N),
|
|||
|
|
probSeventh: ds / (4 * N),
|
|||
|
|
probEighth: ds / (4 * N),
|
|||
|
|
},
|
|||
|
|
source: "mlb_bracket_monte_carlo",
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ─── Per-position normalization ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|