Fixes #227 - Replace hardcoded Elo ratings with 2026 FanGraphs Depth Charts projected run differential (RDif) for all 30 teams - Replace Elo formula with Bill James log5 using RDif → win rate conversion - Restore p_div/p_wc from FanGraphs playoff odds (divTitle/wcTitle) for seeding draws — previously these were incorrectly derived from raw win rates, which gave the Dodgers only ~24% division win probability instead of the correct ~90% - Add RDIF_DIVISOR constant to compress team strengths toward .500 for playoff parity; set to 8000 (Dodgers +137 → ~51.7% per-game win rate) - Fix minimum team check: < 5 → < 6 (need 3 div winners + 3 wild cards) - Fix Colorado Rockies p_div 0.000 → 0.001 for consistency - Fix stale comments throughout; update tests Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2e2d8db777
commit
2b48ef285d
2 changed files with 190 additions and 237 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, MlbTeamData> = {
|
||||
// ── 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<TeamEntry>();
|
||||
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."
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue