brackt/app/services/simulations/__tests__/wnba-simulator.test.ts
Chris Parsons bde1e6e5f0
Add WNBA playoff simulator with SRS-based Elo ratings, fixes #125 (#231)
* Add WNBA playoff simulator with SRS-based Elo ratings, fixes #125

- New WNBASimulator: Monte Carlo (50k sims) projecting remaining regular
  season games → seeding → R1 Bo3 / Semis Bo5 / Finals Bo7 bracket
- Hybrid Elo sourcing: pre-season uses futures odds (ICM); once avg
  gamesPlayed ≥ 5, switches automatically to SRS-derived Elo
  (elo = 1500 + srs × 20)
- New WnbaStandingsAdapter: fetches ESPN standings + teams endpoints in
  parallel; includes zero-records for 2026 expansion teams (Portland
  Fire, Toronto Tempo) not yet in standings
- Added srs column to regular_season_standings (migration 0063);
  stored as net rating proxy (avgPointsFor − avgPointsAgainst)
- Added wnba_bracket to simulatorTypeEnum

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix missing afterEach import in wnba standings test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 00:34:51 -07:00

149 lines
5.3 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { srsToElo, simSeriesN, resolveElo } from "../wnba-simulator";
import { eloWinProbability } from "~/services/probability-engine";
// ─── srsToElo ─────────────────────────────────────────────────────────────────
describe("srsToElo", () => {
it("maps SRS 0 to league-average Elo 1500", () => {
expect(srsToElo(0)).toBe(1500);
});
it("maps positive SRS above 1500", () => {
expect(srsToElo(5)).toBe(1600);
expect(srsToElo(10)).toBe(1700);
});
it("maps negative SRS below 1500", () => {
expect(srsToElo(-5)).toBe(1400);
expect(srsToElo(-10)).toBe(1300);
});
it("is linear with scale 20", () => {
expect(srsToElo(3)).toBe(srsToElo(1) + 40);
});
});
// ─── eloWinProbability ────────────────────────────────────────────────────────
describe("eloWinProbability", () => {
it("returns 0.5 for equal Elo ratings", () => {
expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
});
it("favors the higher-rated team", () => {
expect(eloWinProbability(1600, 1500)).toBeGreaterThan(0.5);
expect(eloWinProbability(1500, 1600)).toBeLessThan(0.5);
});
it("is anti-symmetric: P(A>B) + P(B>A) = 1", () => {
const p = eloWinProbability(1640, 1430);
expect(p + eloWinProbability(1430, 1640)).toBeCloseTo(1.0, 10);
});
it("SRS +10 vs SRS -10 gives ~76% win probability", () => {
// srsToElo(+10) = 1700, srsToElo(-10) = 1300 → gap 400 → P = 1/(1+10^(-1)) ≈ 0.909
// Actually gap of 400 → P ≈ 0.909. Let's verify the ±10 SRS case:
// srsToElo(+10) = 1700, srsToElo(-10) = 1300 → gap = 400 → P ≈ 0.909
const p = eloWinProbability(srsToElo(10), srsToElo(-10));
expect(p).toBeGreaterThan(0.9);
expect(p).toBeLessThan(0.92);
});
it("SRS +5 vs SRS 0 gives ~64% win probability", () => {
// srsToElo(+5) = 1600, srsToElo(0) = 1500 → gap = 100 → P = 1/(1+10^(-0.25)) ≈ 0.640
const p = eloWinProbability(srsToElo(5), srsToElo(0));
expect(p).toBeGreaterThan(0.63);
expect(p).toBeLessThan(0.66);
});
});
// ─── resolveElo ───────────────────────────────────────────────────────────────
describe("resolveElo", () => {
it("SRS mode with SRS present: uses SRS-derived Elo", () => {
expect(resolveElo(5, 1600, true)).toBe(srsToElo(5)); // 1600
});
it("SRS mode with no SRS: falls back to futures Elo", () => {
expect(resolveElo(null, 1620, true)).toBe(1620);
});
it("SRS mode with no SRS and no futures: falls back to 1500", () => {
expect(resolveElo(null, null, true)).toBe(1500);
});
it("futures mode: uses futures Elo regardless of SRS", () => {
expect(resolveElo(8, 1650, false)).toBe(1650);
});
it("futures mode with no futures: falls back to 1500", () => {
expect(resolveElo(8, null, false)).toBe(1500);
});
});
// ─── simSeriesN ───────────────────────────────────────────────────────────────
const makeTeam = (id: string, elo: number) => ({
id,
name: id,
elo,
currentWins: 0,
remainingGames: 0,
winProb: 0.5,
});
describe("simSeriesN", () => {
it("best-of-3: winner reaches exactly 2 wins", () => {
const a = makeTeam("A", 1600);
const b = makeTeam("B", 1400);
for (let i = 0; i < 100; i++) {
const { winner, loser } = simSeriesN(a, b, 2);
expect(winner === a || winner === b).toBe(true);
expect(loser === a || loser === b).toBe(true);
expect(winner).not.toBe(loser);
}
});
it("best-of-5: winner and loser are always different teams", () => {
const a = makeTeam("A", 1500);
const b = makeTeam("B", 1500);
for (let i = 0; i < 100; i++) {
const { winner, loser } = simSeriesN(a, b, 3);
expect(winner).not.toBe(loser);
}
});
it("best-of-7: returns the input team objects (referential identity)", () => {
const a = makeTeam("TeamA", 1550);
const b = makeTeam("TeamB", 1450);
const { winner, loser } = simSeriesN(a, b, 4);
expect(winner === a || winner === b).toBe(true);
expect(loser === a || loser === b).toBe(true);
});
it("heavily favored team wins most best-of-3 series", () => {
const strong = makeTeam("Strong", 1800);
const weak = makeTeam("Weak", 1200);
let strongWins = 0;
const N = 1000;
for (let i = 0; i < N; i++) {
if (simSeriesN(strong, weak, 2).winner === strong) strongWins++;
}
// P(game) ≈ 0.994, P(series) should be > 0.99
expect(strongWins / N).toBeGreaterThan(0.97);
});
it("even matchup produces roughly 50% win rate in a large sample", () => {
const a = makeTeam("A", 1500);
const b = makeTeam("B", 1500);
let aWins = 0;
const N = 2000;
for (let i = 0; i < N; i++) {
if (simSeriesN(a, b, 3).winner === a) aWins++;
}
// Should be close to 50% — allow ±5%
expect(aWins / N).toBeGreaterThan(0.45);
expect(aWins / N).toBeLessThan(0.55);
});
});