Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
normalizeTeamName,
|
|
|
|
|
|
getTeamData,
|
2026-03-25 19:05:12 -07:00
|
|
|
|
winRateFromRDif,
|
2026-06-01 20:50:03 +00:00
|
|
|
|
rawWinRateFromRDif,
|
|
|
|
|
|
rawWinRateFromElo,
|
2026-03-25 19:05:12 -07:00
|
|
|
|
rdifWinProbability,
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
on resolveElo() (overrides SRS, overrides futures, overrides fallback,
null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
covering fallback to season-projection, structure validation errors, probability
distributions, fully-decided champion, and source tag
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
eloToRDif,
|
2026-06-01 20:50:03 +00:00
|
|
|
|
sampleBinomial,
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
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");
|
2026-03-25 19:05:12 -07:00
|
|
|
|
expect(d?.rdif).toBeGreaterThan(0);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
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);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
it("rebuilding teams have negative RDif", () => {
|
|
|
|
|
|
expect(getTeamData("Chicago White Sox")?.rdif ?? 0).toBeLessThan(0);
|
|
|
|
|
|
expect(getTeamData("Colorado Rockies")?.rdif ?? 0).toBeLessThan(0);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-01 20:50:03 +00:00
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-25 19:05:12 -07:00
|
|
|
|
// ─── 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);
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-01 20:50:03 +00:00
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
// ─── Series simulators ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-01 20:50:03 +00:00
|
|
|
|
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 };
|
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121
- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
(weighted by p_div) and 3 WC teams per league, then simulating
WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
winner per division section + Wild Card section with 3-spot playoff line,
headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
(divisionSpots + wcSpots params); conferenceLabel now flows through group
objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix flaky tennis simulator test: increase trials and lower threshold
500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
Fix simulator brackets: NHL bracket-aware mode, MLB/NBA/WNBA sourceElo support (#358)
* Fix NHL bracket-aware simulation and MLB sourceElo support
NHL: NHLSimulator now checks for a populated playoff_game bracket before
falling back to season-projection mode. When a bracket exists (e.g. via
simple_16 template), it simulates the actual bracket structure, respecting
completed matches and using ELO+odds blending for the rest — matching the
pattern used by Snooker, NBA, and UCL simulators.
MLB: MLBSimulator now reads sourceElo from participantExpectedValues and
converts it to an effective RDif (via eloToRDif), overriding the hardcoded
TEAMS_DATA.rdif when present. This wires up the expected-wins admin form
(which saves sourceElo) to the simulation engine.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Wire sourceElo from projected wins form into NBA, NHL, and WNBA simulators
All three simulators had entries in simulator-config.ts (enabling the projected
wins form in the admin UI) but silently ignored the sourceElo that was saved.
NBA: Both bracket-aware and season-projection modes now load sourceElo and
prefer it over hardcoded TEAMS_DATA elo. Added resolvedElo field to TeamEntry
so eloOfEntry() uses the correct value throughout.
NHL: Season-projection mode now fetches sourceElo + sourceOdds in a single
parallel query (removing the duplicate evRows query). Bracket-aware mode also
loads sourceElo alongside sourceOdds. Both modes use sourceElo > TEAMS_DATA > 1400.
WNBA: resolveElo() now accepts sourceElo as an optional highest-priority
argument. The evRows query fetches sourceElo alongside sourceOdds and passes
it through when building team entries.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Add review fixes: NHL R1 validation, WNBA sourceElo tests, NHL bracket unit tests
- NHLSimulator.simulateBracket: remove unused nameById variable
- NHLSimulator.simulateBracket: validate all R1 participants before entering
Monte Carlo loop; throws with a clear message if any slot is missing
- wnba-simulator.test.ts: add 5 tests covering the sourceElo override parameter
on resolveElo() (overrides SRS, overrides futures, overrides fallback,
null/undefined fall through to normal priority chain)
- nhl-simulator.test.ts: add full bracket-aware integration suite (10 tests)
covering fallback to season-projection, structure validation errors, probability
distributions, fully-decided champion, and source tag
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix TS error: add resolvedElo to makeEntry in nhl-simulator.test.ts
TeamEntry requires resolvedElo after the simulator refactor.
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
* Fix lint: replace non-null assertions with null-safe alternatives in NHL test
https://claude.ai/code/session_01139GJVg2gqgDjcUzqhnBNn
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-04-30 08:33:57 -07:00
|
|
|
|
|
|
|
|
|
|
// ─── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|