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>
This commit is contained in:
Chris Parsons 2026-03-26 00:34:51 -07:00 committed by GitHub
parent eca1508b3f
commit bde1e6e5f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 5206 additions and 1 deletions

View file

@ -22,6 +22,7 @@ export interface UpsertRegularSeasonStandingData {
homeRecord?: string | null; homeRecord?: string | null;
awayRecord?: string | null; awayRecord?: string | null;
externalTeamId?: string | null; externalTeamId?: string | null;
srs?: number | null;
syncedAt?: Date | null; syncedAt?: Date | null;
} }
@ -59,6 +60,7 @@ export async function upsertRegularSeasonStandings(
homeRecord: r.homeRecord ?? null, homeRecord: r.homeRecord ?? null,
awayRecord: r.awayRecord ?? null, awayRecord: r.awayRecord ?? null,
externalTeamId: r.externalTeamId ?? null, externalTeamId: r.externalTeamId ?? null,
srs: r.srs !== null && r.srs !== undefined ? r.srs.toString() : null,
syncedAt: r.syncedAt !== undefined ? r.syncedAt : now, syncedAt: r.syncedAt !== undefined ? r.syncedAt : now,
updatedAt: now, updatedAt: now,
})); }));
@ -90,6 +92,7 @@ export async function upsertRegularSeasonStandings(
homeRecord: sql`excluded.home_record`, homeRecord: sql`excluded.home_record`,
awayRecord: sql`excluded.away_record`, awayRecord: sql`excluded.away_record`,
externalTeamId: sql`excluded.external_team_id`, externalTeamId: sql`excluded.external_team_id`,
srs: sql`excluded.srs`,
syncedAt: sql`excluded.synced_at`, syncedAt: sql`excluded.synced_at`,
updatedAt: sql`excluded.updated_at`, updatedAt: sql`excluded.updated_at`,
}, },

View file

@ -0,0 +1,149 @@
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);
});
});

View file

@ -19,6 +19,7 @@ import { AFLSimulator } from "./afl-simulator";
import { SnookerSimulator } from "./snooker-simulator"; import { SnookerSimulator } from "./snooker-simulator";
import { TennisSimulator } from "./tennis-simulator"; import { TennisSimulator } from "./tennis-simulator";
import { MLBSimulator } from "./mlb-simulator"; import { MLBSimulator } from "./mlb-simulator";
import { WNBASimulator } from "./wnba-simulator";
export const SIMULATOR_TYPES = [ export const SIMULATOR_TYPES = [
"f1_standings", "f1_standings",
@ -34,6 +35,7 @@ export const SIMULATOR_TYPES = [
"snooker_bracket", "snooker_bracket",
"tennis_qualifying_points", "tennis_qualifying_points",
"mlb_bracket", "mlb_bracket",
"wnba_bracket",
] as const; ] as const;
export type SimulatorType = typeof SIMULATOR_TYPES[number]; export type SimulatorType = typeof SIMULATOR_TYPES[number];
@ -110,6 +112,10 @@ const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simul
info: { name: "MLB Playoff Monte Carlo", description: "Simulates MLB division races + full playoff bracket (WC best-of-3, DS best-of-5, LCS/WS best-of-7) using Elo ratings calibrated from FanGraphs projected wins" }, info: { name: "MLB Playoff Monte Carlo", description: "Simulates MLB division races + full playoff bracket (WC best-of-3, DS best-of-5, LCS/WS best-of-7) using Elo ratings calibrated from FanGraphs projected wins" },
create: () => new MLBSimulator(), create: () => new MLBSimulator(),
}, },
wnba_bracket: {
info: { name: "WNBA Playoff Monte Carlo", description: "Projects WNBA regular season seedings and simulates full playoff bracket (R1 best-of-3, Semis best-of-5, Finals best-of-7) using SRS-derived Elo ratings. SRS values sourced from basketball-reference.com." },
create: () => new WNBASimulator(),
},
}; };
export function getSimulator(simulatorType: SimulatorType): Simulator { export function getSimulator(simulatorType: SimulatorType): Simulator {

View file

@ -0,0 +1,317 @@
/**
* WNBA Playoff Simulator
*
* Monte Carlo simulation of the WNBA playoffs including regular season
* remainder projection and seeding.
*
* Algorithm:
* 1. Load participants, regular season standings, and futures odds in parallel.
* 2. Choose Elo source automatically based on season progress:
* Pre-season (avg gamesPlayed < SRS_GAMES_THRESHOLD):
* futures odds via convertFuturesToElo() from participantExpectedValues
* falls back to 1500 for teams without odds
* In-season (avg gamesPlayed >= SRS_GAMES_THRESHOLD):
* SRS from regularSeasonStandings: elo = 1500 + srs * SRS_ELO_SCALE
* falls back to futures Elo for teams without SRS, then 1500
* 3. For each simulation:
* a. For each team, simulate remaining regular season games (40 - gamesPlayed)
* using Elo win probability vs. an average opponent (Elo 1500)
* projectedWins = currentWins + simulatedRemainingWins
* b. Sort all teams by projected wins (desc) + random tiebreaker
* Top 8 qualify for playoffs
* c. Simulate WNBA playoff bracket (no byes, no play-in):
* Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
* Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
* Finals (best-of-7): Semifinal winners
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
*
* Win probability (Elo, PARITY_FACTOR = 400):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 400))
*
* SRS Elo conversion (in-season):
* elo = 1500 + srs * 20
* Calibration: WNBA SRS typically ranges ±810. A ±10 SRS gap ±200 Elo ~76% win
* probability for the stronger team, which is appropriate for single-game WNBA matchups.
* Source: basketball-reference.com/wnba/years/YYYY.html (SRS column in team standings)
*
* Futures Elo conversion (pre-season):
* Uses convertFuturesToElo() from probability-engine (same as BracketSimulator / UCLSimulator).
* Reads sourceOdds (American format) from participantExpectedValues.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = WNBA champion (1 per sim)
* probSecond = Finals loser (1 per sim)
* probThird/Fourth = Semifinal losers (2 per sim)
* probFifthEighth = Round 1 losers (4 per sim)
* Missed playoffs all 0
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { normalizeTeamName } from "~/lib/normalize-team-name";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { convertFuturesToElo, eloWinProbability } from "~/services/probability-engine";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
/** WNBA regular season games per team. */
const WNBA_REGULAR_SEASON_GAMES = 40;
/**
* Multiplier converting SRS to Elo offset from 1500.
* elo = 1500 + srs * SRS_ELO_SCALE
*
* At scale 20:
* SRS +10 Elo 1700, SRS -10 Elo 1300 P(+10 vs -10) 76%
* SRS +5 Elo 1600, SRS 0 Elo 1500 P(+5 vs 0) 64%
*/
const SRS_ELO_SCALE = 20;
/**
* Average games played threshold to switch from futures-derived Elo to SRS-derived Elo.
* Once most teams have played this many games, SRS is meaningful enough to use.
*/
const SRS_GAMES_THRESHOLD = 5;
/** Number of playoff teams. */
const PLAYOFF_TEAMS = 8;
// ─── Public helpers (exported for unit testing) ───────────────────────────────
export { normalizeTeamName };
/**
* Convert an SRS rating to an Elo rating.
* An SRS of 0 maps to 1500 (league average).
*/
export function srsToElo(srs: number): number {
return 1500 + srs * SRS_ELO_SCALE;
}
/**
* Resolve the Elo to use for a team given available signals and the current mode.
*
* In SRS mode (in-season): use SRS-derived Elo, fall back to futuresElo, then 1500.
* In futures mode (pre-season): use futuresElo, fall back to 1500.
*/
export function resolveElo(
srs: number | null,
futuresElo: number | null,
useSRS: boolean
): number {
if (useSRS) {
if (srs !== null) return srsToElo(srs);
return futuresElo ?? 1500;
}
return futuresElo ?? 1500;
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
elo: number;
currentWins: number;
remainingGames: number;
/** Elo win probability vs. average opponent (1500) — constant per team. */
winProb: number;
}
/** Simulate a best-of-N series.
* @param winTarget wins needed to win the series (2 for BoX3, 3 for BoX5, 4 for BoX7) */
export function simSeriesN(
a: TeamEntry,
b: TeamEntry,
winTarget: number
): { winner: TeamEntry; loser: TeamEntry } {
const winProb = eloWinProbability(a.elo, b.elo);
let winsA = 0;
let winsB = 0;
while (winsA < winTarget && winsB < winTarget) {
if (Math.random() < winProb) winsA++;
else winsB++;
}
return winsA === winTarget ? { winner: a, loser: b } : { winner: b, loser: a };
}
/** Simulate remaining regular season games for a team. Returns projected total wins. */
function simulateProjectedWins(team: TeamEntry): number {
let extra = 0;
for (let g = 0; g < team.remainingGames; g++) {
if (Math.random() < team.winProb) extra++;
}
return team.currentWins + extra;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class WNBASimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load participants, standings, and futures odds in parallel.
const [participantRows, standings, evRows] = await Promise.all([
db
.select({ id: schema.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId)),
]);
if (participantRows.length === 0) {
throw new Error(
`No participants found for sports season ${sportsSeasonId}. ` +
`Add WNBA teams as participants before running simulation.`
);
}
if (participantRows.length < PLAYOFF_TEAMS) {
throw new Error(
`WNBA simulation requires at least ${PLAYOFF_TEAMS} participants ` +
`(got ${participantRows.length}). Add all WNBA teams before running simulation.`
);
}
// 2. Build futures Elo map from championship odds.
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds ?? 0 }));
const futuresEloMap: Map<string, number> =
oddsInput.length > 0
? convertFuturesToElo(oddsInput, "american")
: new Map();
// 3. Build standings lookup and determine simulation mode.
const standingsMap = new Map(standings.map((s) => [s.participantId, s]));
const participantIds = participantRows.map((r) => r.id);
const totalGamesPlayed = participantRows.reduce((sum, r) => {
return sum + (standingsMap.get(r.id)?.gamesPlayed ?? 0);
}, 0);
const avgGamesPlayed = totalGamesPlayed / participantRows.length;
const useSRS = avgGamesPlayed >= SRS_GAMES_THRESHOLD;
// 4. Construct team entries with resolved Elo.
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsMap.get(r.id);
const srs =
standing?.srs !== null && standing?.srs !== undefined
? parseFloat(standing.srs)
: null;
const futuresElo = futuresEloMap.get(r.id) ?? null;
const elo = resolveElo(srs, futuresElo, useSRS);
const gamesPlayed = standing?.gamesPlayed ?? 0;
return {
id: r.id,
name: r.name,
elo,
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, WNBA_REGULAR_SEASON_GAMES - gamesPlayed),
winProb: eloWinProbability(elo, 1500),
};
});
const source = useSRS
? "wnba_bracket_monte_carlo_srs"
: "wnba_bracket_monte_carlo_futures";
/** Seed teams 18 by projected wins for this simulation. */
const buildSeededBracket = (): TeamEntry[] => {
const projected = teams.map((t) => ({
team: t,
projectedWins: simulateProjectedWins(t),
tiebreaker: Math.random(),
}));
projected.sort((a, b) => b.projectedWins - a.projectedWins || b.tiebreaker - a.tiebreaker);
return projected.slice(0, PLAYOFF_TEAMS).map((x) => x.team);
};
// 5. Integer placement count maps — initialized to 0 for all participants.
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const semiLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
const r1LoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
// 6. Monte Carlo simulation loop.
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const [s1, s2, s3, s4, s5, s6, s7, s8] = buildSeededBracket();
// Round 1 (best-of-3): 1v8, 4v5, 2v7, 3v6
const r1_a = simSeriesN(s1, s8, 2);
const r1_b = simSeriesN(s4, s5, 2);
const r1_c = simSeriesN(s2, s7, 2);
const r1_d = simSeriesN(s3, s6, 2);
r1LoserCounts.set(r1_a.loser.id, (r1LoserCounts.get(r1_a.loser.id) ?? 0) + 1);
r1LoserCounts.set(r1_b.loser.id, (r1LoserCounts.get(r1_b.loser.id) ?? 0) + 1);
r1LoserCounts.set(r1_c.loser.id, (r1LoserCounts.get(r1_c.loser.id) ?? 0) + 1);
r1LoserCounts.set(r1_d.loser.id, (r1LoserCounts.get(r1_d.loser.id) ?? 0) + 1);
// Semifinals (best-of-5): winner(1/8) vs winner(4/5), winner(2/7) vs winner(3/6)
const sf_a = simSeriesN(r1_a.winner, r1_b.winner, 3);
const sf_b = simSeriesN(r1_c.winner, r1_d.winner, 3);
semiLoserCounts.set(sf_a.loser.id, (semiLoserCounts.get(sf_a.loser.id) ?? 0) + 1);
semiLoserCounts.set(sf_b.loser.id, (semiLoserCounts.get(sf_b.loser.id) ?? 0) + 1);
// Finals (best-of-7)
const final = simSeriesN(sf_a.winner, sf_b.winner, 4);
championCounts.set(final.winner.id, (championCounts.get(final.winner.id) ?? 0) + 1);
finalistCounts.set(final.loser.id, (finalistCounts.get(final.loser.id) ?? 0) + 1);
}
// 7. Convert integer counts to probability distributions.
const results: SimulationResult[] = participantIds.map((participantId) => {
const c = championCounts.get(participantId) ?? 0;
const f = finalistCounts.get(participantId) ?? 0;
const sl = semiLoserCounts.get(participantId) ?? 0;
const r1 = r1LoserCounts.get(participantId) ?? 0;
return {
participantId,
probabilities: {
probFirst: c / NUM_SIMULATIONS,
probSecond: f / NUM_SIMULATIONS,
probThird: sl / (2 * NUM_SIMULATIONS),
probFourth: sl / (2 * NUM_SIMULATIONS),
probFifth: r1 / (4 * NUM_SIMULATIONS),
probSixth: r1 / (4 * NUM_SIMULATIONS),
probSeventh: r1 / (4 * NUM_SIMULATIONS),
probEighth: r1 / (4 * NUM_SIMULATIONS),
},
source,
};
});
// 8. Per-position normalization — belt-and-suspenders guard against floating-point residuals.
const positionKeys: Array<keyof (typeof results)[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}

View file

@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NbaStandingsAdapter } from "../nba"; import { NbaStandingsAdapter } from "../nba";
function makeStat(name: string, value: number, displayValue?: string) { function makeStat(name: string, value: number, displayValue?: string) {
@ -84,6 +84,10 @@ describe("NbaStandingsAdapter", () => {
vi.stubGlobal("fetch", vi.fn()); vi.stubGlobal("fetch", vi.fn());
}); });
afterEach(() => {
vi.restoreAllMocks();
});
it("maps ESPN API response to FetchedStandingsRecord[]", async () => { it("maps ESPN API response to FetchedStandingsRecord[]", async () => {
vi.mocked(fetch).mockResolvedValueOnce({ vi.mocked(fetch).mockResolvedValueOnce({
ok: true, ok: true,

View file

@ -0,0 +1,276 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { WnbaStandingsAdapter } from "../wnba";
function makeStat(name: string, value: number, displayValue?: string) {
return { name, value, displayValue: displayValue ?? String(value) };
}
// WNBA standings response (13 active teams — flat conference structure, no divisions).
const SAMPLE_STANDINGS_RESPONSE = {
children: [
{
name: "Eastern Conference",
standings: {
entries: [
{
team: { id: "5", displayName: "New York Liberty", abbreviation: "NY" },
stats: [
makeStat("wins", 28),
makeStat("losses", 12),
makeStat("winPercent", 0.7),
makeStat("gamesBehind", 0),
makeStat("playoffSeed", 1),
{ name: "streak", value: 3, displayValue: "W3" },
{ name: "Last Ten Games", value: 0, displayValue: "8-2" },
{ name: "Home", value: 0, displayValue: "15-5" },
{ name: "Road", value: 0, displayValue: "13-7" },
makeStat("avgPointsFor", 89.5),
makeStat("avgPointsAgainst", 82.1),
],
},
{
team: { id: "3", displayName: "Connecticut Sun", abbreviation: "CONN" },
stats: [
makeStat("wins", 20),
makeStat("losses", 20),
makeStat("winPercent", 0.5),
makeStat("gamesBehind", 8),
makeStat("playoffSeed", 4),
{ name: "streak", value: 1, displayValue: "L1" },
{ name: "Last Ten Games", value: 0, displayValue: "5-5" },
{ name: "Home", value: 0, displayValue: "11-9" },
{ name: "Road", value: 0, displayValue: "9-11" },
makeStat("avgPointsFor", 78.0),
makeStat("avgPointsAgainst", 78.0),
],
},
],
},
},
{
name: "Western Conference",
standings: {
entries: [
{
team: { id: "20", displayName: "Las Vegas Aces", abbreviation: "LV" },
stats: [
makeStat("wins", 26),
makeStat("losses", 14),
makeStat("winPercent", 0.65),
makeStat("gamesBehind", 2),
makeStat("playoffSeed", 1),
{ name: "streak", value: 2, displayValue: "W2" },
{ name: "Last Ten Games", value: 0, displayValue: "7-3" },
{ name: "Home", value: 0, displayValue: "14-6" },
{ name: "Road", value: 0, displayValue: "12-8" },
makeStat("avgPointsFor", 91.2),
makeStat("avgPointsAgainst", 85.0),
],
},
],
},
},
],
};
// Teams endpoint response — includes 2 expansion teams not yet in standings.
const SAMPLE_TEAMS_RESPONSE = {
sports: [
{
leagues: [
{
teams: [
{ team: { id: "5", displayName: "New York Liberty", isActive: true } },
{ team: { id: "3", displayName: "Connecticut Sun", isActive: true } },
{ team: { id: "20", displayName: "Las Vegas Aces", isActive: true } },
{ team: { id: "99001", displayName: "Portland Fire", isActive: true } },
{ team: { id: "99002", displayName: "Toronto Tempo", isActive: true } },
],
},
],
},
],
};
// Teams response with no expansion teams beyond what's in standings.
const TEAMS_RESPONSE_NO_EXTRAS = {
sports: [{ leagues: [{ teams: [
{ team: { id: "5", displayName: "New York Liberty", isActive: true } },
{ team: { id: "3", displayName: "Connecticut Sun", isActive: true } },
{ team: { id: "20", displayName: "Las Vegas Aces", isActive: true } },
] }] }],
};
function mockFetch(standingsBody: unknown, teamsBody: unknown) {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => standingsBody } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => teamsBody } as Response);
}
describe("WnbaStandingsAdapter", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns standings teams + zero-record expansion teams", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
// 3 from standings + 2 expansion
expect(records).toHaveLength(5);
});
it("expansion teams have wins=0, losses=0, gamesPlayed=0, srs=null", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const portland = records.find((r) => r.teamName === "Portland Fire");
if (!portland) throw new Error("Portland Fire not found");
expect(portland.wins).toBe(0);
expect(portland.losses).toBe(0);
expect(portland.gamesPlayed).toBe(0);
expect(portland.srs).toBeNull();
const toronto = records.find((r) => r.teamName === "Toronto Tempo");
if (!toronto) throw new Error("Toronto Tempo not found");
expect(toronto.wins).toBe(0);
});
it("expansion teams rank below all active teams", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const maxActiveRank = Math.max(
...records
.filter((r) => r.wins > 0 || r.gamesPlayed > 0)
.map((r) => r.leagueRank)
);
const portland = records.find((r) => r.teamName === "Portland Fire");
const toronto = records.find((r) => r.teamName === "Toronto Tempo");
if (!portland) throw new Error("Portland Fire not found");
if (!toronto) throw new Error("Toronto Tempo not found");
expect(portland.leagueRank).toBeGreaterThan(maxActiveRank);
expect(toronto.leagueRank).toBeGreaterThan(maxActiveRank);
});
it("parses wins, losses, and gamesPlayed correctly", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const ny = records.find((r) => r.teamName === "New York Liberty");
if (!ny) throw new Error("New York Liberty not found");
expect(ny.wins).toBe(28);
expect(ny.losses).toBe(12);
expect(ny.gamesPlayed).toBe(40);
});
it("assigns leagueRank 1 to the team with most wins", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const rank1 = records.find((r) => r.leagueRank === 1);
expect(rank1?.teamName).toBe("New York Liberty");
});
it("assigns conference from the standings response", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const ny = records.find((r) => r.teamName === "New York Liberty");
expect(ny?.conference).toBe("Eastern Conference");
const lv = records.find((r) => r.teamName === "Las Vegas Aces");
expect(lv?.conference).toBe("Western Conference");
});
it("expansion teams have no division (WNBA has no divisions)", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
for (const record of records) {
expect(record.division).toBeUndefined();
}
});
it("computes srs as net rating (avgPointsFor - avgPointsAgainst)", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const ny = records.find((r) => r.teamName === "New York Liberty");
expect(ny?.srs).toBeCloseTo(7.4, 1); // 89.5 - 82.1
const conn = records.find((r) => r.teamName === "Connecticut Sun");
expect(conn?.srs).toBeCloseTo(0, 1); // 78.0 - 78.0
});
it("does not duplicate teams that appear in both standings and teams list", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, TEAMS_RESPONSE_NO_EXTRAS);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
expect(records).toHaveLength(3);
const names = records.map((r) => r.teamName);
expect(new Set(names).size).toBe(3);
});
it("extracts streak and lastTen from standings", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const ny = records.find((r) => r.teamName === "New York Liberty");
expect(ny?.streak).toBe("W3");
expect(ny?.lastTen).toBe("8-2");
});
it("extracts home and away records", async () => {
mockFetch(SAMPLE_STANDINGS_RESPONSE, SAMPLE_TEAMS_RESPONSE);
const adapter = new WnbaStandingsAdapter();
const records = await adapter.fetchStandings();
const ny = records.find((r) => r.teamName === "New York Liberty");
expect(ny?.homeRecord).toBe("15-5");
expect(ny?.awayRecord).toBe("13-7");
});
it("throws on non-ok standings response", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: false, status: 503, statusText: "Service Unavailable" } as Response)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_TEAMS_RESPONSE } as Response);
const adapter = new WnbaStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("503");
});
it("throws on non-ok teams response", async () => {
vi.mocked(fetch)
.mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_STANDINGS_RESPONSE } as Response)
.mockResolvedValueOnce({ ok: false, status: 429, statusText: "Too Many Requests" } as Response);
const adapter = new WnbaStandingsAdapter();
await expect(adapter.fetchStandings()).rejects.toThrow("429");
});
});

View file

@ -9,6 +9,7 @@ import { NhlStandingsAdapter } from "./nhl";
import { NbaStandingsAdapter } from "./nba"; import { NbaStandingsAdapter } from "./nba";
import { AflStandingsAdapter } from "./afl"; import { AflStandingsAdapter } from "./afl";
import { MlbStandingsAdapter } from "./mlb"; import { MlbStandingsAdapter } from "./mlb";
import { WnbaStandingsAdapter } from "./wnba";
import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types"; import type { StandingsSyncAdapter, SyncResult, UnmatchedTeam } from "./types";
/** /**
@ -26,6 +27,8 @@ function getAdapter(simulatorType: string): StandingsSyncAdapter {
return new AflStandingsAdapter(); return new AflStandingsAdapter();
case "mlb_bracket": case "mlb_bracket":
return new MlbStandingsAdapter(); return new MlbStandingsAdapter();
case "wnba_bracket":
return new WnbaStandingsAdapter();
case "f1_standings": case "f1_standings":
throw new Error( throw new Error(
"F1 standings sync is not yet implemented. Use the manual standings page." "F1 standings sync is not yet implemented. Use the manual standings page."
@ -123,6 +126,7 @@ export async function syncStandings(sportsSeasonId: string): Promise<SyncResult>
homeRecord: record.homeRecord ?? null, homeRecord: record.homeRecord ?? null,
awayRecord: record.awayRecord ?? null, awayRecord: record.awayRecord ?? null,
externalTeamId: record.externalTeamId, externalTeamId: record.externalTeamId,
srs: record.srs ?? null,
syncedAt: new Date(), syncedAt: new Date(),
}); });
} }

View file

@ -17,6 +17,7 @@ export interface FetchedStandingsRecord {
lastTen?: string; lastTen?: string;
homeRecord?: string; homeRecord?: string;
awayRecord?: string; awayRecord?: string;
srs?: number | null; // Net rating or SRS proxy (pointsFor - pointsAgainst per game)
} }
export interface StandingsSyncAdapter { export interface StandingsSyncAdapter {

View file

@ -0,0 +1,269 @@
import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
const WNBA_STANDINGS_URL =
"https://site.api.espn.com/apis/v2/sports/basketball/wnba/standings";
/**
* Returns all registered WNBA teams (including pre-season expansion teams not yet
* in standings). Used to supplement the standings response so every team gets a record.
*/
const WNBA_TEAMS_URL =
"https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/teams";
interface EspnStat {
name: string;
displayName?: string;
shortDisplayName?: string;
description?: string;
abbreviation?: string;
type?: string;
value?: number;
displayValue?: string;
}
interface EspnTeam {
id: string;
displayName: string;
shortDisplayName?: string;
abbreviation?: string;
location?: string;
name?: string;
}
interface EspnStandingsEntry {
team: EspnTeam;
stats: EspnStat[];
}
interface EspnStandingsGroup {
name?: string;
standings?: { entries?: EspnStandingsEntry[] };
children?: EspnStandingsGroup[];
}
interface EspnStandingsResponse {
children?: EspnStandingsGroup[];
}
interface EspnTeamItem {
team: {
id: string;
displayName: string;
isActive?: boolean;
};
}
interface EspnTeamsResponse {
sports?: Array<{
leagues?: Array<{
teams?: EspnTeamItem[];
}>;
}>;
}
function statsMap(stats: EspnStat[]): Map<string, EspnStat> {
const map = new Map<string, EspnStat>();
for (const stat of stats) {
map.set(stat.name, stat);
}
return map;
}
/**
* Flatten the ESPN standings response.
* WNBA has conferences (East/West) but no divisions entries are either directly
* under the conference group or nested one level deeper (ESPN sometimes wraps
* conferences in a single top-level group). Handles both layouts.
*/
function flattenEspnStandings(
response: EspnStandingsResponse
): Array<{ entry: EspnStandingsEntry; conference: string }> {
const results: Array<{ entry: EspnStandingsEntry; conference: string }> = [];
for (const topGroup of response.children ?? []) {
if (topGroup.children && topGroup.children.length > 0) {
// Top-level group has sub-groups — each sub-group is a conference (no divisions).
for (const confGroup of topGroup.children) {
const conferenceName = confGroup.name ?? topGroup.name ?? "";
for (const entry of confGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName });
}
}
} else {
// Top-level group is the conference itself.
const conferenceName = topGroup.name ?? "";
for (const entry of topGroup.standings?.entries ?? []) {
results.push({ entry, conference: conferenceName });
}
}
}
return results;
}
/** Parse a standings entry into a FetchedStandingsRecord (rank assigned by caller). */
function parseEntry(
entry: EspnStandingsEntry,
conference: string,
leagueRank: number
): FetchedStandingsRecord {
const sm = statsMap(entry.stats);
const wins = sm.get("wins")?.value ?? 0;
const losses = sm.get("losses")?.value ?? 0;
const winPercent = sm.get("winPercent")?.value ?? sm.get("winPct")?.value ?? 0;
const gamesBehind = sm.get("gamesBehind")?.value;
const streak =
sm.get("streak")?.displayValue ??
sm.get("streakSummary")?.displayValue;
const lastTen =
sm.get("Last Ten Games")?.displayValue ??
sm.get("L10")?.displayValue ??
undefined;
const homeRecord = sm.get("Home")?.displayValue ?? undefined;
const awayRecord = sm.get("Road")?.displayValue ?? undefined;
const playoffSeedStat = sm.get("playoffSeed");
const conferenceRank =
playoffSeedStat?.value !== undefined && playoffSeedStat.value !== null
? Math.round(playoffSeedStat.value)
: playoffSeedStat?.displayValue
? parseInt(playoffSeedStat.displayValue, 10) || undefined
: undefined;
// SRS proxy: average point differential per game.
// ESPN exposes "avgPointsFor" and "avgPointsAgainst" (or "pointsFor"/"pointsAgainst").
// If unavailable, fall back to null so the simulator uses the league-average Elo (1500).
const ptFor =
sm.get("avgPointsFor")?.value ??
sm.get("pointsFor")?.value ??
null;
const ptAgainst =
sm.get("avgPointsAgainst")?.value ??
sm.get("pointsAgainst")?.value ??
null;
const srs =
ptFor !== null && ptFor !== undefined &&
ptAgainst !== null && ptAgainst !== undefined
? Math.round((ptFor - ptAgainst) * 100) / 100
: null;
return {
teamName: entry.team.displayName,
externalTeamId: entry.team.id,
wins: Math.round(wins),
losses: Math.round(losses),
winPct: winPercent,
gamesPlayed: Math.round(wins) + Math.round(losses),
gamesBack: gamesBehind,
conference: conference || undefined,
conferenceRank,
leagueRank,
streak,
lastTen,
homeRecord,
awayRecord,
srs,
};
}
export class WnbaStandingsAdapter implements StandingsSyncAdapter {
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
// Fetch both endpoints in parallel.
const [standingsResponse, teamsResponse] = await Promise.all([
fetch(WNBA_STANDINGS_URL),
fetch(WNBA_TEAMS_URL),
]);
if (!standingsResponse.ok) {
throw new Error(
`WNBA standings API returned ${standingsResponse.status}: ${standingsResponse.statusText}`
);
}
if (!teamsResponse.ok) {
throw new Error(
`WNBA teams API returned ${teamsResponse.status}: ${teamsResponse.statusText}`
);
}
const [standingsJson, teamsJson] = await Promise.all([
standingsResponse.json() as Promise<EspnStandingsResponse>,
teamsResponse.json() as Promise<EspnTeamsResponse>,
]);
// Build standings records from the standings endpoint, sorted by wins desc.
// Pre-parse each entry once so statsMap isn't rebuilt multiple times per entry.
const flattened = flattenEspnStandings(standingsJson).map(({ entry, conference }) => ({
entry,
conference,
sm: statsMap(entry.stats),
}));
const sortedEntries = [...flattened].toSorted((a, b) => {
const winsA = a.sm.get("wins")?.value ?? 0;
const winsB = b.sm.get("wins")?.value ?? 0;
if (winsB !== winsA) return winsB - winsA;
const lossA = a.sm.get("losses")?.value ?? 0;
const lossB = b.sm.get("losses")?.value ?? 0;
return lossA - lossB;
});
// Map externalTeamId → parsed record (so we can detect which teams are missing).
const recordsByTeamId = new Map<string, FetchedStandingsRecord>();
sortedEntries.forEach(({ entry, conference }, idx) => {
recordsByTeamId.set(
entry.team.id,
parseEntry(entry, conference, idx + 1)
);
});
// Extract the full team list from the teams endpoint.
const allTeams: Array<{ id: string; displayName: string }> = (
teamsJson.sports?.[0]?.leagues?.[0]?.teams ?? []
)
.filter((t) => t.team.isActive !== false)
.map((t) => ({ id: t.team.id, displayName: t.team.displayName }));
if (allTeams.length === 0 && recordsByTeamId.size === 0) {
throw new Error(
"WNBA standings API returned no entries — response shape may have changed"
);
}
// Merge: use standing records where available; fill in zero-records for
// expansion teams not yet in the standings (pre-season or mid-season addition).
const results: FetchedStandingsRecord[] = [];
const seenIds = new Set<string>();
// First, add all teams that have standings data (preserving their rank).
for (const record of recordsByTeamId.values()) {
results.push(record);
seenIds.add(record.externalTeamId);
}
// Then, append zero-records for any team in the teams list but not in standings.
let expansionRank = results.length + 1;
for (const team of allTeams) {
if (!seenIds.has(team.id)) {
results.push({
teamName: team.displayName,
externalTeamId: team.id,
wins: 0,
losses: 0,
winPct: 0,
gamesPlayed: 0,
leagueRank: expansionRank++,
srs: null,
});
}
}
// If the teams endpoint returned nothing (API change), fall back to standings only.
if (results.length === 0) {
throw new Error(
"WNBA standings API returned no entries — response shape may have changed"
);
}
return results;
}
}

View file

@ -96,6 +96,7 @@ export const simulatorTypeEnum = pgEnum("simulator_type", [
"snooker_bracket", "snooker_bracket",
"tennis_qualifying_points", "tennis_qualifying_points",
"mlb_bracket", "mlb_bracket",
"wnba_bracket",
]); ]);
export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [ export const playoffMatchGameStatusEnum = pgEnum("playoff_match_game_status", [
@ -1031,6 +1032,7 @@ export const regularSeasonStandings = pgTable("regular_season_standings", {
homeRecord: varchar("home_record", { length: 15 }), homeRecord: varchar("home_record", { length: 15 }),
awayRecord: varchar("away_record", { length: 15 }), awayRecord: varchar("away_record", { length: 15 }),
externalTeamId: varchar("external_team_id", { length: 255 }), externalTeamId: varchar("external_team_id", { length: 255 }),
srs: decimal("srs", { precision: 6, scale: 2 }), // Simple Rating System (point diff adjusted for SOS)
syncedAt: timestamp("synced_at"), // null = manually entered syncedAt: timestamp("synced_at"), // null = manually entered
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),

View file

@ -0,0 +1,3 @@
ALTER TYPE "public"."simulator_type" ADD VALUE IF NOT EXISTS 'mlb_bracket';--> statement-breakpoint
ALTER TYPE "public"."simulator_type" ADD VALUE 'wnba_bracket';--> statement-breakpoint
ALTER TABLE "regular_season_standings" ADD COLUMN "srs" numeric(6, 2);

File diff suppressed because it is too large Load diff

View file

@ -442,6 +442,13 @@
"when": 1774500000000, "when": 1774500000000,
"tag": "0062_add_mlb_bracket_simulator_type", "tag": "0062_add_mlb_bracket_simulator_type",
"breakpoints": true "breakpoints": true
},
{
"idx": 63,
"version": "7",
"when": 1774504559479,
"tag": "0063_bored_ultimo",
"breakpoints": true
} }
] ]
} }