brackt/app/services/simulations/__tests__/mlb-simulator.test.ts
chrisp a528aecc86
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m34s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m19s
🚀 Deploy / 🐳 Build (push) Successful in 1m9s
🚀 Deploy / 🚀 Deploy (push) Successful in 13s
Fix MLB simulator: projected wins drive seeding, perf fix, + surface simulate errors (#65)
## Summary

- **MLB simulator seeding now responds to projected wins**: replaced hardcoded FanGraphs `p_div`/`p_wc` probability draws with Binomial regular-season simulation. Admin-entered projected wins (via Elo) now drive both playoff _qualification_ odds and in-bracket game win probability, not just the latter.
- **Reads current standings mid-season**: `getRegularSeasonStandings` is called so synced `wins`/`gamesPlayed` feed into each simulation iteration's projected final standings.
- **27× seeding performance improvement**: `sampleBinomial` now uses a Box-Muller normal approximation for `n ≥ 30`, dropping the seeding phase from ~243M to ~3M `Math.random()` calls per 50K-sim run (pre-season, 162 remaining games × 30 teams).
- **Missing-standings warning**: when standings exist for some teams but not all recognized ones (partial sync), a `logger.warn` now surfaces which teams are falling back to 0 wins / 162 remaining instead of silently distorting seeding.
- **Cleanup**: `eloToRDif` calls `rawWinRateFromElo` instead of inlining the same formula.
- **Simulate errors surface inline**: moved simulation action from dedicated route to parent page `intent="simulate"` so errors display inline rather than crashing to an error page.

## Test plan

- [ ] `npm run test:run -- mlb-simulator` — 49 tests pass
- [ ] `npm run typecheck` — clean
- [ ] Trigger a simulation from the admin panel for an MLB season with projected wins entered; confirm teams with higher projected wins show meaningfully higher EVs
- [ ] Confirm that running simulate with no projected wins entered (pre-season defaults) still produces a valid bracket
- [ ] Check logs after simulating a season where some teams lack standings rows — confirm warning appears

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #65
2026-06-01 20:50:03 +00:00

354 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from "vitest";
import {
normalizeTeamName,
getTeamData,
winRateFromRDif,
rawWinRateFromRDif,
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
sampleBinomial,
simBo3,
simBo5,
simBo7,
} from "../mlb-simulator";
// ─── normalizeTeamName ────────────────────────────────────────────────────────
describe("normalizeTeamName", () => {
it("lowercases and trims", () => {
expect(normalizeTeamName(" Los Angeles Dodgers ")).toBe("los angeles dodgers");
});
it("collapses internal whitespace", () => {
expect(normalizeTeamName("New York Yankees")).toBe("new york yankees");
});
it("is identity for already-normalized strings", () => {
expect(normalizeTeamName("houston astros")).toBe("houston astros");
});
});
// ─── getTeamData ──────────────────────────────────────────────────────────────
describe("getTeamData", () => {
it("returns data for an exact match", () => {
const d = getTeamData("Los Angeles Dodgers");
expect(d).toBeDefined();
expect(d?.league).toBe("NL");
expect(d?.division).toBe("NL West");
expect(d?.rdif).toBeGreaterThan(0);
});
it("is case-insensitive", () => {
expect(getTeamData("los angeles dodgers")).toEqual(getTeamData("Los Angeles Dodgers"));
});
it("returns undefined for an unknown team", () => {
expect(getTeamData("Springfield Isotopes")).toBeUndefined();
});
it("all 30 MLB teams are present", () => {
const allTeams = [
// 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",
];
for (const name of allTeams) {
expect(getTeamData(name), `missing team: ${name}`).toBeDefined();
}
});
it("all teams have the correct league", () => {
const alTeams = [
"New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays",
"Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox",
"Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics",
];
const nlTeams = [
"Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins",
"Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates",
"Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies",
];
for (const name of alTeams) {
expect(getTeamData(name)?.league, `${name} should be AL`).toBe("AL");
}
for (const name of nlTeams) {
expect(getTeamData(name)?.league, `${name} should be NL`).toBe("NL");
}
});
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("rebuilding teams have negative RDif", () => {
expect(getTeamData("Chicago White Sox")?.rdif ?? 0).toBeLessThan(0);
expect(getTeamData("Colorado Rockies")?.rdif ?? 0).toBeLessThan(0);
});
});
// ─── 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);
});
});
// ─── 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)", () => {
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);
});
});
// ─── 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, 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;
describe("simBo3 (Wildcard Round — first to 2 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo3(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo3(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo3(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect([teamA, teamB]).toContain(result.loser);
expect(result.winner).not.toBe(result.loser);
});
});
describe("simBo5 (Division Series — first to 3 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo5(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo5(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo5(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect(result.winner).not.toBe(result.loser);
});
});
describe("simBo7 (LCS / World Series — first to 4 wins)", () => {
it("correct team wins when one side is dominant", () => {
expect(simBo7(teamA, teamB, alwaysA).winner).toBe(teamA);
expect(simBo7(teamA, teamB, alwaysB).winner).toBe(teamB);
});
it("returns a winner and loser", () => {
const result = simBo7(teamA, teamB, coinFlip);
expect([teamA, teamB]).toContain(result.winner);
expect(result.winner).not.toBe(result.loser);
});
});
// ─── eloToRDif ────────────────────────────────────────────────────────────────
describe("eloToRDif", () => {
it("maps Elo 1500 (average) to RDif 0", () => {
expect(eloToRDif(1500)).toBeCloseTo(0, 5);
});
it("maps Elo above 1500 to a positive RDif", () => {
expect(eloToRDif(1600)).toBeGreaterThan(0);
});
it("maps Elo below 1500 to a negative RDif", () => {
expect(eloToRDif(1400)).toBeLessThan(0);
});
it("is symmetric: eloToRDif(1500 + d) = -eloToRDif(1500 - d)", () => {
expect(eloToRDif(1600)).toBeCloseTo(-eloToRDif(1400), 5);
});
it("round-trips through winRateFromRDif: winRate(eloToRDif(elo)) ≈ eloWinProb(elo, 1500)", () => {
const elo = 1620;
const expectedWinRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
expect(winRateFromRDif(eloToRDif(elo))).toBeCloseTo(expectedWinRate, 4);
});
});