Fix MLB simulator: use projected wins for playoff seeding + perf + cleanup
- Replace hardcoded FanGraphs p_div/p_wc draws with Binomial regular-season simulation so that admin-entered projected wins drive playoff qualification odds, not just in-bracket game win probability - Add getRegularSeasonStandings call so mid-season current wins feed into projected final standings - Use normal approximation (Box-Muller) in sampleBinomial for n≥30: drops seeding phase from ~243M to ~3M Math.random() calls per 50K-sim run - eloToRDif now calls rawWinRateFromElo instead of inlining the same formula - Warn when recognized teams lack a standings row during mid-season simulation (silent wrong seeding result with no diagnostic previously) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ba970cd571
commit
0b53570723
2 changed files with 336 additions and 164 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<string, MlbTeamData> = {
|
||||
// ── 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<TeamEntry>,
|
||||
getWeight: (t: TeamEntry) => number
|
||||
): TeamEntry | undefined {
|
||||
const eligible = pool.filter((t) => !excluded.has(t));
|
||||
if (eligible.length === 0) return undefined;
|
||||
|
||||
const weights = eligible.map(getWeight);
|
||||
const total = weights.reduce((s, w) => s + w, 0);
|
||||
if (total === 0) return undefined;
|
||||
|
||||
let r = Math.random() * total;
|
||||
for (let i = 0; i < eligible.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r <= 0) return eligible[i];
|
||||
}
|
||||
return eligible[eligible.length - 1];
|
||||
}
|
||||
|
||||
// ─── Series simulators ─────────────────────────────────────────────────────────
|
||||
|
||||
type SeriesResult = { winner: TeamEntry; loser: TeamEntry };
|
||||
|
|
@ -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<string, TeamEntry[]>();
|
||||
|
|
@ -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<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 allDivisionWinnerSet = new Set<TeamEntry>();
|
||||
const divWinnerSet = new Set<TeamEntry>();
|
||||
|
||||
for (const divTeams of divMap.values()) {
|
||||
const winner = weightedPickByKey(divTeams, new Set(), (t) => t.data?.p_div ?? 0);
|
||||
if (!winner) return undefined;
|
||||
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<TeamEntry>();
|
||||
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<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: 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++;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue