diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts index 2741d62..68f782b 100644 --- a/app/services/simulations/__tests__/mlb-simulator.test.ts +++ b/app/services/simulations/__tests__/mlb-simulator.test.ts @@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest"; import { normalizeTeamName, getTeamData, - eloWinProbability, + winRateFromRDif, + rdifWinProbability, simBo3, simBo5, simBo7, @@ -32,7 +33,7 @@ describe("getTeamData", () => { expect(d).toBeDefined(); expect(d?.league).toBe("NL"); expect(d?.division).toBe("NL West"); - expect(d?.elo).toBeGreaterThan(1500); + expect(d?.rdif).toBeGreaterThan(0); }); it("is case-insensitive", () => { @@ -87,34 +88,18 @@ describe("getTeamData", () => { expect(getTeamData(name)?.league, `${name} should be NL`).toBe("NL"); } }); -}); -// ─── eloWinProbability ──────────────────────────────────────────────────────── - -describe("eloWinProbability (PARITY_FACTOR = 350)", () => { - it("returns 0.5 for equal Elo ratings", () => { - expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6); + it("Dodgers have the highest RDif in NL West", () => { + const dodgers = getTeamData("Los Angeles Dodgers")?.rdif ?? -999; + const padres = getTeamData("San Diego Padres")?.rdif ?? -999; + const dbacks = getTeamData("Arizona Diamondbacks")?.rdif ?? -999; + expect(dodgers).toBeGreaterThan(padres); + expect(dodgers).toBeGreaterThan(dbacks); }); - it("favors the higher-rated team", () => { - expect(eloWinProbability(1570, 1500)).toBeGreaterThan(0.5); - expect(eloWinProbability(1500, 1570)).toBeLessThan(0.5); - }); - - it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => { - const p = eloWinProbability(1560, 1480); - expect(p + eloWinProbability(1480, 1560)).toBeCloseTo(1.0, 10); - }); - - it("a 100-pt gap gives ~65.9% win prob per game", () => { - // At 350: P = 1 / (1 + 10^(-100/350)) = 1 / (1 + 0.518) ≈ 0.659 - const p = eloWinProbability(1600, 1500); - expect(p).toBeCloseTo(0.659, 2); - }); - - it("returns a value strictly between 0 and 1", () => { - expect(eloWinProbability(1200, 1800)).toBeGreaterThan(0); - expect(eloWinProbability(1200, 1800)).toBeLessThan(1); + it("rebuilding teams have negative RDif", () => { + expect(getTeamData("Chicago White Sox")?.rdif ?? 0).toBeLessThan(0); + expect(getTeamData("Colorado Rockies")?.rdif ?? 0).toBeLessThan(0); }); }); @@ -137,28 +122,71 @@ describe("team seeding probabilities", () => { } }); - it("p_div + p_wc does not exceed 1.0 per team", () => { - for (const teams of Object.values(DIVISIONS)) { - for (const name of teams) { - const d = getTeamData(name); - expect((d?.p_div ?? 0) + (d?.p_wc ?? 0), `${name} p_div + p_wc > 1`).toBeLessThanOrEqual(1.001); - } - } - }); - it("Dodgers have the highest p_div in NL West", () => { - const dodgers = getTeamData("Los Angeles Dodgers"); - const padres = getTeamData("San Diego Padres"); - const dbacks = getTeamData("Arizona Diamondbacks"); - expect(dodgers?.p_div ?? 0).toBeGreaterThan(padres?.p_div ?? 0); - expect(dodgers?.p_div ?? 0).toBeGreaterThan(dbacks?.p_div ?? 0); + const dodgers = getTeamData("Los Angeles Dodgers")?.p_div ?? 0; + expect(dodgers).toBeGreaterThan(0.8); }); - it("rebuilding teams have low p_div", () => { - const whiteSox = getTeamData("Chicago White Sox"); - const rockies = getTeamData("Colorado Rockies"); - expect(whiteSox?.p_div ?? 0).toBeLessThan(0.15); - expect(rockies?.p_div ?? 0).toBeLessThan(0.10); + it("rebuilding teams have very low p_div", () => { + expect(getTeamData("Chicago White Sox")?.p_div ?? 1).toBeLessThan(0.02); + expect(getTeamData("Colorado Rockies")?.p_div ?? 1).toBeLessThan(0.01); + }); +}); + +// ─── winRateFromRDif ────────────────────────────────────────────────────────── + +describe("winRateFromRDif", () => { + it("returns 0.5 for RDif = 0", () => { + expect(winRateFromRDif(0)).toBeCloseTo(0.5, 6); + }); + + it("returns a value above 0.5 for positive RDif", () => { + expect(winRateFromRDif(100)).toBeGreaterThan(0.5); + }); + + it("returns a value below 0.5 for negative RDif", () => { + expect(winRateFromRDif(-100)).toBeLessThan(0.5); + }); + + it("clamps to [0.01, 0.99]", () => { + expect(winRateFromRDif(10000)).toBe(0.99); + expect(winRateFromRDif(-10000)).toBe(0.01); + }); + + it("Dodgers (RDif +137) project to a win rate above .500", () => { + expect(winRateFromRDif(137)).toBeGreaterThan(0.5); + expect(winRateFromRDif(137)).toBeLessThan(0.65); + }); +}); + +// ─── rdifWinProbability ─────────────────────────────────────────────────────── + +describe("rdifWinProbability (log5)", () => { + it("returns 0.5 for equal RDif", () => { + expect(rdifWinProbability(50, 50)).toBeCloseTo(0.5, 6); + }); + + it("favors the team with better RDif", () => { + expect(rdifWinProbability(100, 0)).toBeGreaterThan(0.5); + expect(rdifWinProbability(0, 100)).toBeLessThan(0.5); + }); + + it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => { + const p = rdifWinProbability(80, -20); + expect(p + rdifWinProbability(-20, 80)).toBeCloseTo(1.0, 10); + }); + + it("returns a value strictly between 0 and 1", () => { + expect(rdifWinProbability(200, -200)).toBeGreaterThan(0); + expect(rdifWinProbability(200, -200)).toBeLessThan(1); + }); + + it("Dodgers vs Rockies favors the Dodgers", () => { + // Even the largest RDif gap in the league should still favour the better team, + // but compression toward .500 via RDIF_DIVISOR keeps it well below 1.0. + const p = rdifWinProbability(137, -173); + expect(p).toBeGreaterThan(0.5); + expect(p).toBeLessThan(1.0); }); }); diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts index 49a17ff..77b7577 100644 --- a/app/services/simulations/mlb-simulator.ts +++ b/app/services/simulations/mlb-simulator.ts @@ -6,11 +6,11 @@ * * Algorithm: * 1. Load all participants for the sports season from DB - * 2. Match participant names to hardcoded team data (Elo + seeding probabilities) + * 2. Match participant names to hardcoded team data (RDif + league/division) * 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. + * then draw 3 wildcard teams from the remaining pool — all weighted by win rate. + * b. Seed division winners 1–3 by RDif (best RDif = seed 1); WC teams 4–6 by RDif. * 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 @@ -19,20 +19,22 @@ * 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. + * Win probability (log5 formula): + * Step 1 — convert projected RDif to win rate: + * winRate = clamp(0.5 + rdif / RDIF_DIVISOR, 0.01, 0.99) + * RDIF_DIVISOR compresses team strengths toward .500 for playoff parity. + * See the RDIF_DIVISOR constant below for tuning guidance. + * Step 2 — Bill James log5 head-to-head probability: + * P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB) * * 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 + * P(game) = RDIF_WEIGHT * rdifProb + 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. + * RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3. + * Falls back to RDif-only when no odds are stored. * * Placement tiers → SimulationProbabilities mapping: * probFirst = World Series champion (1 per sim) @@ -43,17 +45,17 @@ * 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. + * rdif: FanGraphs Depth Charts projected run differential for 2026. + * Used for per-game win probability via log5. + * p_div: Probability of winning own division (FanGraphs Depth Charts divTitle). + * Must sum to ~1.0 within each 5-team division. + * p_wc: Probability of claiming one of 3 wildcard slots (FanGraphs Depth Charts 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. + * Sources: + * rdif: https://www.fangraphs.com/standings/projected-standings + * p_div/p_wc: https://www.fangraphs.com/standings/playoff-odds/dc/div + * Update at the start of each season. * * Divisions: * AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays @@ -79,177 +81,86 @@ import { 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. + * Controls how much projected run differential spreads teams away from .500. + * + * Baseline: 1620 (≈ 10 runs per win × 162 games). This gives the raw + * Pythagorean-derived win rate, which tends to overstate dominance in + * playoff matchups where you face elite pitching. Increasing this constant + * compresses all team strengths toward .500 — reducing every strong team's + * win probability in each series and yielding more upset potential across + * the entire bracket. Decrease to amplify differences. + * + * Effect on Dodgers (RDif +137): + * 1620 → win rate 0.585 (raw Pythagorean projection — too dominant) + * 2000 → win rate 0.568 + * 4000 → win rate 0.534 + * 8000 → win rate 0.517 (current — near coin-flip vs any playoff team) */ -const PARITY_FACTOR = 350; +const RDIF_DIVISOR = 8000; /** - * Blend weights for Elo vs. Vegas futures odds when sourceOdds are available. - * Same calibration as the NHL simulator (0.7 / 0.3). + * Blend weights for RDif vs. Vegas futures odds when sourceOdds are available. */ -const ELO_WEIGHT = 0.7; -const ODDS_WEIGHT = 1 - ELO_WEIGHT; +const RDIF_WEIGHT = 0.7; +const ODDS_WEIGHT = 1 - RDIF_WEIGHT; -// ─── Team data (2026 pre-season estimates) ──────────────────────────────────── +// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ──────────────────── // -// 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. +// rdif: Projected run differential from FanGraphs Depth Charts. +// Source: https://www.fangraphs.com/standings/projected-standings // -// 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))); +// Update all three values at the start of each season from the FanGraphs pages above. 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; + rdif: number; // 2026 FanGraphs Depth Charts projected run differential + p_div: number; // 2026 FanGraphs Depth Charts divTitle — probability of winning own division + p_wc: number; // 2026 FanGraphs Depth Charts wcTitle — probability of claiming a wild card slot } 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, - }, + "New York Yankees": { league: "AL", division: "AL East", rdif: 67, p_div: 0.374, p_wc: 0.358 }, + "Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23, p_div: 0.128, p_wc: 0.310 }, + "Boston Red Sox": { league: "AL", division: "AL East", rdif: 48, p_div: 0.247, p_wc: 0.373 }, + "Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2, p_div: 0.066, p_wc: 0.229 }, + "Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37, p_div: 0.184, p_wc: 0.349 }, // ── 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, - }, + "Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8, p_div: 0.297, p_wc: 0.157 }, + "Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38, p_div: 0.084, p_wc: 0.075 }, + "Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15, p_div: 0.166, p_wc: 0.120 }, + "Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26, p_div: 0.449, p_wc: 0.155 }, + "Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112, p_div: 0.005, p_wc: 0.004 }, // ── 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, - }, + "Houston Astros": { league: "AL", division: "AL West", rdif: -1, p_div: 0.126, p_wc: 0.213 }, + "Seattle Mariners": { league: "AL", division: "AL West", rdif: 66, p_div: 0.595, p_wc: 0.205 }, + "Texas Rangers": { league: "AL", division: "AL West", rdif: 20, p_div: 0.201, p_wc: 0.270 }, + "Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65, p_div: 0.011, p_wc: 0.035 }, + "Athletics": { league: "AL", division: "AL West", rdif: -17, p_div: 0.068, p_wc: 0.146 }, // ── 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, - }, + "Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51, p_div: 0.243, p_wc: 0.444 }, + "Atlanta Braves": { league: "NL", division: "NL East", rdif: 67, p_div: 0.357, p_wc: 0.425 }, + "New York Mets": { league: "NL", division: "NL East", rdif: 71, p_div: 0.389, p_wc: 0.413 }, + "Washington Nationals": { league: "NL", division: "NL East", rdif: -113, p_div: 0.001, p_wc: 0.006 }, + "Miami Marlins": { league: "NL", division: "NL East", rdif: -48, p_div: 0.011, p_wc: 0.075 }, // ── 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, - }, + "Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9, p_div: 0.243, p_wc: 0.170 }, + "Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23, p_div: 0.347, p_wc: 0.183 }, + "St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55, p_div: 0.038, p_wc: 0.049 }, + "Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31, p_div: 0.086, p_wc: 0.095 }, + "Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13, p_div: 0.285, p_wc: 0.178 }, // ── 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, - }, + "Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137, p_div: 0.900, p_wc: 0.082 }, + "San Diego Padres": { league: "NL", division: "NL West", rdif: -9, p_div: 0.023, p_wc: 0.240 }, + "Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5, p_div: 0.039, p_wc: 0.322 }, + "San Francisco Giants": { league: "NL", division: "NL West", rdif: 3, p_div: 0.038, p_wc: 0.317 }, + "Colorado Rockies": { league: "NL", division: "NL West", rdif: -173, p_div: 0.001, p_wc: 0.001 }, }; // ─── Public helpers (exported for unit testing) ─────────────────────────────── @@ -269,12 +180,26 @@ export function getTeamData(name: string): MlbTeamData | undefined { } /** - * Elo win probability for team A over team B. - * P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR)) + * Convert projected run differential to a compressed win rate for 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 eloWinProbability(eloA: number, eloB: number): number { - return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR)); +export function winRateFromRDif(rdif: number): number { + return Math.min(0.99, Math.max(0.01, 0.5 + rdif / 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); } // ─── Internal types ─────────────────────────────────────────────────────────── @@ -286,13 +211,13 @@ interface TeamEntry { originalSeed?: number; } -/** Get Elo for a team entry. Fallback 1400 for unknown teams. */ -function elo(entry: TeamEntry): number { - return entry.data?.elo ?? 1400; +/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */ +function getEntryRDif(entry: TeamEntry): number { + return entry.data?.rdif ?? 0; } /** - * Weighted pick without replacement using the given weight key. + * Weighted pick without replacement using the given weight function. * Returns undefined if no eligible team has positive weight. */ function weightedPickByKey( @@ -370,8 +295,8 @@ export function simBo7( * 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). + * 3. Rank division winners 1–3 by RDif descending (best RDif → seed 1). + * 4. Rank WC teams 4–6 by RDif descending (best RDif → 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 @@ -398,7 +323,7 @@ function drawLeaguePlayoffField( allDivisionWinnerSet.add(winner); } - // Draw 3 WC teams from non-division-winners + // Draw 3 WC teams from non-division-winners, weighted by p_wc const wcPool = leagueTeams.filter((t) => !allDivisionWinnerSet.has(t)); const wcTaken = new Set(); const wcTeams: TeamEntry[] = []; @@ -410,11 +335,11 @@ function drawLeaguePlayoffField( 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 division winners 1–3 by RDif descending (best RDif = seed 1) + const sortedDivWinners = divisionWinners.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a)); - // Rank WC teams 4–6 by Elo descending (best Elo = seed 4) - const sortedWcTeams = wcTeams.toSorted((a, b) => elo(b) - elo(a)); + // Rank WC teams 4–6 by RDif descending (best RDif = seed 4) + const sortedWcTeams = wcTeams.toSorted((a, b) => getEntryRDif(b) - getEntryRDif(a)); const seeds = [...sortedDivWinners, ...sortedWcTeams]; return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 })); @@ -503,9 +428,9 @@ export class MLBSimulator implements Simulator { 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) { + if (alTeams.length < 6 || nlTeams.length < 6) { throw new Error( - `Each league needs at least 5 recognized participants ` + + `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.` ); @@ -542,18 +467,18 @@ export class MLBSimulator implements Simulator { /** * Blended per-game win probability for team A over team B. - * When odds are available: 70% Elo + 30% vig-removed futures head-to-head. + * When odds are available: 70% RDif log5 + 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 rdifProb = rdifWinProbability(getEntryRDif(a), getEntryRDif(b)); + if (!hasOdds) return rdifProb; 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; + // Fall back to RDif if either team lacks odds — avoids inflating one side to 100%. + if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb; const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5; - return ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb; + return RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb; }; // ─── Placement count maps ────────────────────────────────────────────────── @@ -597,7 +522,7 @@ export class MLBSimulator implements Simulator { if (effectiveN === 0) { throw new Error( "All simulations produced degenerate brackets. " + - "Check that each division has teams with positive p_div values." + "Check that each division has teams with non-degenerate RDif values." ); }