diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts index 85e59ab..b994a9e 100644 --- a/app/services/simulations/__tests__/mlb-simulator.test.ts +++ b/app/services/simulations/__tests__/mlb-simulator.test.ts @@ -3,8 +3,11 @@ import { normalizeTeamName, getTeamData, winRateFromRDif, + rawWinRateFromRDif, + rawWinRateFromElo, rdifWinProbability, eloToRDif, + sampleBinomial, simBo3, simBo5, simBo7, @@ -104,36 +107,6 @@ describe("getTeamData", () => { }); }); -// ─── Seeding probabilities sanity checks ───────────────────────────────────── - -describe("team seeding probabilities", () => { - const DIVISIONS = { - "AL East": ["New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays"], - "AL Central": ["Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox"], - "AL West": ["Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics"], - "NL East": ["Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins"], - "NL Central": ["Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates"], - "NL West": ["Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies"], - }; - - it("p_div values sum to ~1.0 within each division", () => { - for (const [divName, teams] of Object.entries(DIVISIONS)) { - const sum = teams.reduce((s, name) => s + (getTeamData(name)?.p_div ?? 0), 0); - expect(sum, `${divName} p_div sum should be ~1`).toBeCloseTo(1.0, 1); - } - }); - - it("Dodgers have the highest p_div in NL West", () => { - const dodgers = getTeamData("Los Angeles Dodgers")?.p_div ?? 0; - expect(dodgers).toBeGreaterThan(0.8); - }); - - 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", () => { @@ -160,6 +133,59 @@ describe("winRateFromRDif", () => { }); }); +// ─── rawWinRateFromRDif ─────────────────────────────────────────────────────── + +describe("rawWinRateFromRDif", () => { + it("returns 0.5 for RDif = 0", () => { + expect(rawWinRateFromRDif(0)).toBeCloseTo(0.5, 6); + }); + + it("is higher than winRateFromRDif for positive RDif (less compression)", () => { + const rdif = 137; + expect(rawWinRateFromRDif(rdif)).toBeGreaterThan(winRateFromRDif(rdif)); + }); + + it("Dodgers (RDif +137) project to ~.585 season win rate", () => { + // 137 runs / 1620 ≈ 0.585 + expect(rawWinRateFromRDif(137)).toBeCloseTo(0.585, 2); + }); + + it("clamps to [0.01, 0.99]", () => { + expect(rawWinRateFromRDif(10000)).toBe(0.99); + expect(rawWinRateFromRDif(-10000)).toBe(0.01); + }); +}); + +// ─── rawWinRateFromElo ──────────────────────────────────────────────────────── + +describe("rawWinRateFromElo", () => { + it("returns 0.5 for Elo 1500 (average)", () => { + expect(rawWinRateFromElo(1500)).toBeCloseTo(0.5, 6); + }); + + it("returns above 0.5 for Elo above 1500", () => { + expect(rawWinRateFromElo(1600)).toBeGreaterThan(0.5); + }); + + it("returns below 0.5 for Elo below 1500", () => { + expect(rawWinRateFromElo(1400)).toBeLessThan(0.5); + }); + + it("round-trips from projected wins: projectedWins → Elo → rawWinRate ≈ winRate", () => { + // 95 wins out of 162 → winRate ≈ 0.5864 + const projectedWins = 95; + const totalGames = 162; + const winRate = projectedWins / totalGames; + // Build Elo from winRate the same way projectedWinsToElo does + const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate); + expect(rawWinRateFromElo(elo)).toBeCloseTo(winRate, 4); + }); + + it("is symmetric around 1500", () => { + expect(rawWinRateFromElo(1600)).toBeCloseTo(1 - rawWinRateFromElo(1400), 6); + }); +}); + // ─── rdifWinProbability ─────────────────────────────────────────────────────── describe("rdifWinProbability (log5)", () => { @@ -191,10 +217,72 @@ describe("rdifWinProbability (log5)", () => { }); }); +// ─── sampleBinomial ─────────────────────────────────────────────────────────── + +describe("sampleBinomial", () => { + it("returns 0 when n = 0", () => { + expect(sampleBinomial(0, 0.5)).toBe(0); + }); + + it("returns 0 when p = 0 (exact path, n < 30)", () => { + expect(sampleBinomial(10, 0)).toBe(0); + }); + + it("returns n when p = 1 (exact path, n < 30)", () => { + expect(sampleBinomial(10, 1)).toBe(10); + }); + + it("returns 0 when p = 0 (normal-approx path, n >= 30)", () => { + // p <= 0 guard fires before normal approx + expect(sampleBinomial(162, 0)).toBe(0); + }); + + it("returns n when p = 1 (normal-approx path, n >= 30)", () => { + // p >= 1 guard fires before normal approx + expect(sampleBinomial(162, 1)).toBe(162); + }); + + it("result is always in [0, n] — exact path (n < 30)", () => { + for (let i = 0; i < 50; i++) { + const result = sampleBinomial(10, 0.5); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(10); + } + }); + + it("result is always in [0, n] — normal-approx path (n >= 30)", () => { + for (let i = 0; i < 50; i++) { + const result = sampleBinomial(162, 0.5); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(162); + } + }); + + it("mean is close to n*p over many samples — normal-approx path", () => { + const n = 162; + const p = 0.586; + const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p)); + const mean = samples.reduce((s, x) => s + x, 0) / samples.length; + // Expected mean ≈ 94.9; allow ±3 (well within 3σ for 2000 samples) + expect(mean).toBeGreaterThan(n * p - 3); + expect(mean).toBeLessThan(n * p + 3); + }); + + it("mean is close to n*p over many samples — exact path", () => { + const n = 10; + const p = 0.6; + const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p)); + const mean = samples.reduce((s, x) => s + x, 0) / samples.length; + // Expected mean = 6; allow ±0.5 + expect(mean).toBeGreaterThan(n * p - 0.5); + expect(mean).toBeLessThan(n * p + 0.5); + }); +}); + // ─── Series simulators ──────────────────────────────────────────────────────── -const teamA = { id: "a", name: "Team A", data: undefined }; -const teamB = { id: "b", name: "Team B", data: undefined }; +const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 }; +const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 }; const alwaysA = () => 1.0; // team A always wins each game const alwaysB = () => 0.0; // team B always wins each game const coinFlip = () => 0.5; diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts index f8f2234..48bb3bf 100644 --- a/app/services/simulations/mlb-simulator.ts +++ b/app/services/simulations/mlb-simulator.ts @@ -6,35 +6,43 @@ * * Algorithm: * 1. Load all participants for the sports season from DB - * 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 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: + * 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) - * d. World Series (best-of-7): AL champ vs NL champ - * 4. Track placement counts per scoring tier - * 5. Convert counts to probability distributions + * 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: + * 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. - * 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) * + * 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 is blended: + * the per-game win probability for playoff series is blended: * P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb - * where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)). - * Normalized odds are vig-removed futures win probabilities. * 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) @@ -46,16 +54,12 @@ * * Team data keys: * 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. + * 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 - * p_div/p_wc: https://www.fangraphs.com/standings/playoff-odds/dc/div - * Update at the start of each season. + * 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 @@ -75,29 +79,30 @@ import { convertAmericanOddsToProbability, normalizeProbabilities, } from "~/services/probability-engine"; +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. - * - * 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. + * 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 projection — too dominant) - * 2000 → win rate 0.568 - * 4000 → win rate 0.534 + * 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; + /** * Blend weights for RDif vs. Vegas futures odds when sourceOdds are available. */ @@ -109,58 +114,58 @@ const ODDS_WEIGHT = 1 - RDIF_WEIGHT; // rdif: Projected run differential from FanGraphs Depth Charts. // Source: https://www.fangraphs.com/standings/projected-standings // -// Update all three values at the start of each season from the FanGraphs pages above. +// 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 - 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", 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 }, + "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, 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 }, + "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, 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 }, + "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, 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 }, + "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, 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 }, + "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, 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 }, + "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) ─────────────────────────────── @@ -180,7 +185,7 @@ export function getTeamData(name: string): MlbTeamData | undefined { } /** - * Convert projected run differential to a compressed win rate for matchup probability. + * 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. @@ -189,6 +194,26 @@ 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), @@ -196,8 +221,7 @@ export function winRateFromRDif(rdif: number): number { * Exported for unit testing. */ export function eloToRDif(elo: number): number { - const winRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400)); - return (winRate - 0.5) * RDIF_DIVISOR; + return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR; } /** @@ -213,6 +237,39 @@ export function rdifWinProbability(rdifA: number, rdifB: number): number { 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 { @@ -220,6 +277,8 @@ interface TeamEntry { 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. */ @@ -227,30 +286,6 @@ function getEntryRDif(entry: TeamEntry): number { return entry.data?.rdif ?? 0; } -/** - * Weighted pick without replacement using the given weight function. - * 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 }; @@ -301,21 +336,23 @@ export function simBo7( // ─── League bracket builder ─────────────────────────────────────────────────── /** - * Draws the 6-team playoff field for one league (AL or NL). + * 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 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 RDif descending (best RDif → seed 1). - * 4. Rank WC teams 4–6 by RDif descending (best RDif → seed 4). + * 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 any draw is degenerate (no eligible team - * with positive weight). + * with originalSeed, or undefined if the league has fewer than 3 eligible WC teams. */ function drawLeaguePlayoffField( leagueTeams: TeamEntry[], - getRDif: (t: TeamEntry) => number = getEntryRDif + getSeedingWinRate: (t: TeamEntry) => number ): TeamEntry[] | undefined { // Group by division const divMap = new Map(); @@ -325,33 +362,42 @@ function drawLeaguePlayoffField( divMap.get(div)?.push(t); } + // Simulate remaining games for each team; tiny noise breaks integer win ties + const finalWins = new Map(); + 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 allDivisionWinnerSet = new Set(); + const divWinnerSet = new Set(); for (const divTeams of divMap.values()) { - const winner = weightedPickByKey(divTeams, new Set(), (t) => t.data?.p_div ?? 0); - if (!winner) return undefined; + const winner = divTeams.reduce((best, t) => + (finalWins.get(t.id) ?? 0) > (finalWins.get(best.id) ?? 0) ? t : best + ); divisionWinners.push(winner); - allDivisionWinnerSet.add(winner); + divWinnerSet.add(winner); } - // 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[] = []; + // 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); - 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); - } + if (wcTeams.length < 3) return undefined; - // Rank division winners 1–3 by RDif descending (best RDif = seed 1) - const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a)); - - // Rank WC teams 4–6 by RDif descending (best RDif = seed 4) - const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a)); + // 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 })); @@ -421,12 +467,23 @@ export class MLBSimulator implements Simulator { } const participantIds = participantRows.map((r) => r.id); + const participantIdSet = new Set(participantIds); - const teams: TeamEntry[] = participantRows.map((r) => ({ - id: r.id, - name: r.name, - data: getTeamData(r.name), - })); + // 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); @@ -437,6 +494,22 @@ export class MLBSimulator implements Simulator { ); } + // 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"); @@ -459,7 +532,6 @@ export class MLBSimulator implements Simulator { .from(schema.seasonParticipantExpectedValues) .where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); - const participantIdSet = new Set(participantIds); const oddsRows = evRows.filter( (r) => r.sourceOdds !== null && participantIdSet.has(r.participantId) ); @@ -476,22 +548,34 @@ export class MLBSimulator implements Simulator { }); } - // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif). + // Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif) + // for playoff series win probability. const sourceEloRDifMap = new Map(); + // Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding. + const rawWinRateMap = new Map(); 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: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */ + /** Effective RDif for playoff series: prefer sourceElo-derived value over hardcoded rdif. */ const effectiveRDif = (entry: TeamEntry): number => sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry); /** - * Blended per-game win probability for team A over team B. + * 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)); + + /** + * Blended per-game win probability for team A over team B in a playoff series. * When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head. */ const gameWinProb = (a: TeamEntry, b: TeamEntry): number => { @@ -519,8 +603,8 @@ export class MLBSimulator implements Simulator { let effectiveN = 0; for (let s = 0; s < NUM_SIMULATIONS; s++) { - const alField = drawLeaguePlayoffField(alTeams, effectiveRDif); - const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif); + const alField = drawLeaguePlayoffField(alTeams, seedingWinRate); + const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate); if (!alField || !nlField) continue; // degenerate draw — skip effectiveN++;