653 lines
28 KiB
TypeScript
653 lines
28 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. Load current standings (wins, gamesPlayed) from regularSeasonStandings
|
||
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
|
||
* 4. Match participant names to hardcoded team data (RDif + league/division)
|
||
* 5. For each simulation:
|
||
* a. For each league (AL/NL), simulate remaining regular season games for
|
||
* every team using Binomial sampling, giving final projected wins.
|
||
* b. Division winner = best record in each division (3 per league).
|
||
* Wild card = next 3 best records among non-division-winners per league.
|
||
* c. Seed division winners 1–3 by final wins (best = seed 1);
|
||
* WC teams 4–6 by final wins.
|
||
* d. 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)
|
||
* e. World Series (best-of-7): AL champ vs NL champ
|
||
* 6. Track placement counts per scoring tier
|
||
* 7. Convert counts to probability distributions
|
||
*
|
||
* Win probability (log5 formula):
|
||
* Step 1 — convert projected RDif to win rate for playoff matchups:
|
||
* winRate = clamp(0.5 + rdif / RDIF_DIVISOR, 0.01, 0.99)
|
||
* RDIF_DIVISOR compresses team strengths toward .500 for playoff parity.
|
||
* Step 2 — Bill James log5 head-to-head probability:
|
||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||
*
|
||
* Regular season simulation (seeding):
|
||
* Each team's raw per-game win rate is derived from sourceElo (if set) or
|
||
* from the hardcoded RDif using SEEDING_RDIF_SCALE ≈ 10 runs/win × 162 games.
|
||
* Remaining games = TOTAL_SEASON_GAMES − gamesPlayed are drawn from a
|
||
* Binomial distribution. This makes playoff seeding respond to both current
|
||
* standings and user-entered projected wins.
|
||
*
|
||
* Futures blending:
|
||
* If sourceOdds are stored in participantExpectedValues for this season,
|
||
* the per-game win probability for playoff series is blended:
|
||
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
|
||
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
|
||
*
|
||
* 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:
|
||
* rdif: FanGraphs Depth Charts projected run differential for 2026.
|
||
* Used as fallback for seeding win rate and for playoff series win probability.
|
||
* league/division: Static MLB structure — does not change season-to-season.
|
||
*
|
||
* Sources:
|
||
* rdif: https://www.fangraphs.com/standings/projected-standings
|
||
* Update rdif at the start of each season.
|
||
*
|
||
* 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 { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50_000;
|
||
const TOTAL_SEASON_GAMES = 162;
|
||
|
||
/**
|
||
* Controls how much projected run differential spreads teams away from .500
|
||
* for single-game playoff matchups.
|
||
*
|
||
* Effect on Dodgers (RDif +137):
|
||
* 1620 → win rate 0.585 (raw Pythagorean — too dominant for playoff matchups)
|
||
* 8000 → win rate 0.517 (current — near coin-flip vs any playoff team)
|
||
*/
|
||
const RDIF_DIVISOR = 8000;
|
||
|
||
/**
|
||
* Scale for converting RDif to a raw per-game win rate for regular-season
|
||
* seeding simulation. Unlike RDIF_DIVISOR (which compresses for playoff
|
||
* parity), this uses ~10 runs/win × 162 games to give realistic season win%.
|
||
*/
|
||
const SEEDING_RDIF_SCALE = 1620;
|
||
|
||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||
//
|
||
// rdif: Projected run differential from FanGraphs Depth Charts.
|
||
// Source: https://www.fangraphs.com/standings/projected-standings
|
||
//
|
||
// league/division: Static MLB structure — update only if teams change leagues.
|
||
//
|
||
// Update rdif at the start of each season.
|
||
|
||
interface MlbTeamData {
|
||
league: "AL" | "NL";
|
||
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
|
||
rdif: number; // 2026 FanGraphs Depth Charts projected run differential
|
||
}
|
||
|
||
const TEAMS_DATA: Record<string, MlbTeamData> = {
|
||
// ── American League East ───────────────────────────────────────────────────
|
||
"New York Yankees": { league: "AL", division: "AL East", rdif: 67 },
|
||
"Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23 },
|
||
"Boston Red Sox": { league: "AL", division: "AL East", rdif: 48 },
|
||
"Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2 },
|
||
"Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37 },
|
||
|
||
// ── American League Central ────────────────────────────────────────────────
|
||
"Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8 },
|
||
"Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38 },
|
||
"Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15 },
|
||
"Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26 },
|
||
"Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112 },
|
||
|
||
// ── American League West ───────────────────────────────────────────────────
|
||
"Houston Astros": { league: "AL", division: "AL West", rdif: -1 },
|
||
"Seattle Mariners": { league: "AL", division: "AL West", rdif: 66 },
|
||
"Texas Rangers": { league: "AL", division: "AL West", rdif: 20 },
|
||
"Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65 },
|
||
"Athletics": { league: "AL", division: "AL West", rdif: -17 },
|
||
|
||
// ── National League East ───────────────────────────────────────────────────
|
||
"Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51 },
|
||
"Atlanta Braves": { league: "NL", division: "NL East", rdif: 67 },
|
||
"New York Mets": { league: "NL", division: "NL East", rdif: 71 },
|
||
"Washington Nationals": { league: "NL", division: "NL East", rdif: -113 },
|
||
"Miami Marlins": { league: "NL", division: "NL East", rdif: -48 },
|
||
|
||
// ── National League Central ────────────────────────────────────────────────
|
||
"Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9 },
|
||
"Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23 },
|
||
"St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55 },
|
||
"Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31 },
|
||
"Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13 },
|
||
|
||
// ── National League West ───────────────────────────────────────────────────
|
||
"Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137 },
|
||
"San Diego Padres": { league: "NL", division: "NL West", rdif: -9 },
|
||
"Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5 },
|
||
"San Francisco Giants": { league: "NL", division: "NL West", rdif: 3 },
|
||
"Colorado Rockies": { league: "NL", division: "NL West", rdif: -173 },
|
||
};
|
||
|
||
// ─── 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;
|
||
}
|
||
|
||
/**
|
||
* Convert projected run differential to a compressed win rate for playoff matchup probability.
|
||
* Uses RDIF_DIVISOR to control how much team strength spreads away from .500.
|
||
* Clamped to [0.01, 0.99] to avoid degenerate log5 values.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function winRateFromRDif(rdif: number): number {
|
||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
|
||
}
|
||
|
||
/**
|
||
* Convert projected run differential to a raw per-game win rate for regular-season
|
||
* seeding simulation. Uses SEEDING_RDIF_SCALE (~10 runs/win × 162 games) which
|
||
* gives a realistic season win percentage rather than the playoff-compressed value.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rawWinRateFromRDif(rdif: number): number {
|
||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / SEEDING_RDIF_SCALE));
|
||
}
|
||
|
||
/**
|
||
* Derive a raw per-game win rate directly from an Elo rating.
|
||
* This is the inverse Elo formula, returning the same win probability
|
||
* that was originally used to compute the Elo from projected wins.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rawWinRateFromElo(elo: number): number {
|
||
return 1 / (1 + Math.pow(10, (1500 - elo) / 400));
|
||
}
|
||
|
||
/**
|
||
* Convert an Elo rating to an equivalent projected run differential.
|
||
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
||
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
||
* Exported for unit testing.
|
||
*/
|
||
export function eloToRDif(elo: number): number {
|
||
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
|
||
}
|
||
|
||
/**
|
||
* Bill James log5 head-to-head win probability for team A over team B,
|
||
* given their projected run differentials.
|
||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rdifWinProbability(rdifA: number, rdifB: number): number {
|
||
const wA = winRateFromRDif(rdifA);
|
||
const wB = winRateFromRDif(rdifB);
|
||
// Denominator is always > 0 when wA and wB are in (0,1).
|
||
return (wA - wA * wB) / (wA + wB - 2 * wA * wB);
|
||
}
|
||
|
||
/**
|
||
* Sample the number of wins from n independent Bernoulli trials each with
|
||
* probability p. Used to project remaining regular-season wins per team.
|
||
*
|
||
* For n ≥ 30 (where CLT applies well: n·p ≥ 5 and n·(1-p) ≥ 5 for any
|
||
* realistic win rate), uses a Box-Muller normal approximation — 2 Math.random()
|
||
* calls per team instead of n, cutting the seeding phase from ~243M to ~3M
|
||
* Math.random() calls per 50K-simulation run. For small n the exact Bernoulli
|
||
* loop is used. Both paths produce integer output clamped to [0, n].
|
||
* Exported for unit testing.
|
||
*/
|
||
export function sampleBinomial(n: number, p: number): number {
|
||
if (n <= 0) return 0;
|
||
if (p <= 0) return 0;
|
||
if (p >= 1) return n;
|
||
|
||
if (n >= 30) {
|
||
// Normal approximation via Box-Muller transform.
|
||
// Guard u1 > 0 to avoid log(0) = -Infinity.
|
||
const u1 = Math.max(Number.EPSILON, Math.random());
|
||
const u2 = Math.random();
|
||
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
|
||
return Math.round(Math.min(n, Math.max(0, n * p + Math.sqrt(n * p * (1 - p)) * z)));
|
||
}
|
||
|
||
// Exact Bernoulli trials for small n.
|
||
let wins = 0;
|
||
for (let i = 0; i < n; i++) {
|
||
if (Math.random() < p) wins++;
|
||
}
|
||
return wins;
|
||
}
|
||
|
||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||
|
||
interface TeamEntry {
|
||
id: string;
|
||
name: string;
|
||
data: MlbTeamData | undefined;
|
||
originalSeed?: number;
|
||
currentWins: number; // from regularSeasonStandings (0 pre-season)
|
||
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
|
||
}
|
||
|
||
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
|
||
function getEntryRDif(entry: TeamEntry): number {
|
||
return entry.data?.rdif ?? 0;
|
||
}
|
||
|
||
// ─── 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) by simulating the
|
||
* remaining regular season for each team.
|
||
*
|
||
* Steps:
|
||
* 1. For each team, sample remaining wins from Binomial(remainingGames, seedingWinRate).
|
||
* A tiny uniform noise [0, 0.001) is added to break integer ties randomly.
|
||
* 2. Division winner = team with highest final wins in each division (3 per league).
|
||
* 3. Wild card = next 3 highest final wins among non-division-winners.
|
||
* 4. Seed division winners 1–3 by final wins descending (best = seed 1).
|
||
* 5. Seed WC teams 4–6 by final wins descending.
|
||
*
|
||
* Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated
|
||
* with originalSeed, or undefined if the league has fewer than 3 eligible WC teams.
|
||
*/
|
||
function drawLeaguePlayoffField(
|
||
leagueTeams: TeamEntry[],
|
||
getSeedingWinRate: (t: TeamEntry) => number
|
||
): 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);
|
||
}
|
||
|
||
// Simulate remaining games for each team; tiny noise breaks integer win ties
|
||
const finalWins = new Map<string, number>();
|
||
for (const t of leagueTeams) {
|
||
finalWins.set(
|
||
t.id,
|
||
t.currentWins + sampleBinomial(t.remainingGames, getSeedingWinRate(t)) + Math.random() * 0.001
|
||
);
|
||
}
|
||
|
||
// Division winners: best record per division
|
||
const divisionWinners: TeamEntry[] = [];
|
||
const divWinnerSet = new Set<TeamEntry>();
|
||
|
||
for (const divTeams of divMap.values()) {
|
||
const winner = divTeams.reduce((best, t) =>
|
||
(finalWins.get(t.id) ?? 0) > (finalWins.get(best.id) ?? 0) ? t : best
|
||
);
|
||
divisionWinners.push(winner);
|
||
divWinnerSet.add(winner);
|
||
}
|
||
|
||
// Wild card: top 3 non-division-winners by final wins
|
||
const wcTeams = leagueTeams
|
||
.filter((t) => !divWinnerSet.has(t))
|
||
.toSorted((a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0))
|
||
.slice(0, 3);
|
||
|
||
if (wcTeams.length < 3) return undefined;
|
||
|
||
// Seed: div winners 1–3 and WC teams 4–6, both by final wins descending
|
||
const sortedDivWinners = divisionWinners.toSorted(
|
||
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
|
||
);
|
||
const sortedWcTeams = wcTeams.toSorted(
|
||
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
|
||
);
|
||
|
||
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.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.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 participantIdSet = new Set(participantIds);
|
||
|
||
// 2. Load current standings (wins + games played) to seed the regular-season simulation.
|
||
const standings = await getRegularSeasonStandings(sportsSeasonId);
|
||
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
|
||
|
||
const teams: TeamEntry[] = participantRows.map((r) => {
|
||
const standing = standingsByParticipantId.get(r.id);
|
||
const gamesPlayed = standing?.gamesPlayed ?? 0;
|
||
return {
|
||
id: r.id,
|
||
name: r.name,
|
||
data: getTeamData(r.name),
|
||
currentWins: standing?.wins ?? 0,
|
||
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
|
||
};
|
||
});
|
||
|
||
// 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(", ")
|
||
);
|
||
}
|
||
|
||
// Warn when standings exist for some teams but not all recognized ones — this
|
||
// usually means a partial sync. Missing teams fall back to 0 wins / 162
|
||
// remaining games (league-average strength), which distorts mid-season seeding.
|
||
if (standings.length > 0) {
|
||
const missingStandings = teams.filter(
|
||
(t) => t.data && !standingsByParticipantId.has(t.id)
|
||
);
|
||
if (missingStandings.length > 0) {
|
||
logger.warn(
|
||
`[MLBSimulator] ${missingStandings.length} recognized team(s) have no standings row — ` +
|
||
`seeding will use 0 wins / 162 remaining games for: ` +
|
||
missingStandings.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 < 6 || nlTeams.length < 6) {
|
||
throw new Error(
|
||
`Each league needs at least 6 recognized participants (3 division winners + 3 wild cards) ` +
|
||
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
|
||
`Add MLB teams before running simulation.`
|
||
);
|
||
}
|
||
|
||
// ─── Strength from the resolved Elo ────────────────────────────────────────
|
||
// sourceElo is the single Elo produced by the input policy — already a blend
|
||
// of any raw Elo / projections / futures odds — so the simulator just reads it
|
||
// and no longer blends odds itself.
|
||
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.from(schema.seasonParticipantExpectedValues)
|
||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
|
||
|
||
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
|
||
// for playoff series win probability.
|
||
const sourceEloRDifMap = new Map<string, number>();
|
||
// Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding.
|
||
const rawWinRateMap = new Map<string, number>();
|
||
for (const r of evRows) {
|
||
if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) {
|
||
sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo));
|
||
rawWinRateMap.set(r.participantId, rawWinRateFromElo(r.sourceElo));
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/** Effective RDif for playoff series: prefer sourceElo-derived value over hardcoded rdif. */
|
||
const effectiveRDif = (entry: TeamEntry): number =>
|
||
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
|
||
|
||
/**
|
||
* Raw per-game win rate for regular-season seeding simulation.
|
||
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
|
||
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
|
||
*/
|
||
const seedingWinRate = (entry: TeamEntry): number =>
|
||
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
|
||
|
||
/**
|
||
* Per-game win probability for team A over team B in a playoff series, from
|
||
* the (resolved-Elo-derived) run differential.
|
||
*/
|
||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number =>
|
||
rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
||
|
||
// ─── 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, seedingWinRate);
|
||
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
|
||
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 non-degenerate RDif 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;
|
||
}
|
||
}
|