* 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:
parent
eca1508b3f
commit
bde1e6e5f0
13 changed files with 5206 additions and 1 deletions
|
|
@ -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`,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
149
app/services/simulations/__tests__/wnba-simulator.test.ts
Normal file
149
app/services/simulations/__tests__/wnba-simulator.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
317
app/services/simulations/wnba-simulator.ts
Normal file
317
app/services/simulations/wnba-simulator.ts
Normal 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 ±8–10. 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)
|
||||||
|
* probFifth–Eighth = 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 1–8 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
276
app/services/standings-sync/__tests__/wnba.test.ts
Normal file
276
app/services/standings-sync/__tests__/wnba.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
269
app/services/standings-sync/wnba.ts
Normal file
269
app/services/standings-sync/wnba.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
3
drizzle/0063_bored_ultimo.sql
Normal file
3
drizzle/0063_bored_ultimo.sql
Normal 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);
|
||||||
4164
drizzle/meta/0063_snapshot.json
Normal file
4164
drizzle/meta/0063_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue