/** * 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 = { // ── 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, 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(); 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(); 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(); 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 { 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(); 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(participantIds.map((id) => [id, 0])); const finalistCounts = new Map(participantIds.map((id) => [id, 0])); const lcsLoserCounts = new Map(participantIds.map((id) => [id, 0])); const dsLoserCounts = new Map(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 = [ "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; } }