brackt/app/services/standings-sync/mlb.ts
Chris Parsons 2e2d8db777
Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225)
* Add MLB playoff simulator with AL/NL division standings, fixes #121

- New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners
  (weighted by p_div) and 3 WC teams per league, then simulating
  WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series.
  Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available.
- New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API,
  mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits.
- New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division
  winner per division section + Wild Card section with 3-spot playoff line,
  headings read "AL/NL League" instead of "Conference".
- Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections
  (divisionSpots + wcSpots params); conferenceLabel now flows through group
  objects rather than being derived from displayMode in the render.
- DB migration 0062 adds `mlb_bracket` to simulator_type enum.
- 37 new tests across simulator, adapter, and component.

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

* Fix flaky tennis simulator test: increase trials and lower threshold

500 trials with a ~3–5% expected win rate had enough variance to
occasionally land below the 0.03 threshold (~2% failure rate).
Bumping to 2000 trials reduces the std dev by 2x; lowering the
threshold to 0.02 keeps the assertion meaningful (still well above
random 0.78%) while eliminating the flakiness.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00

160 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import type { FetchedStandingsRecord, StandingsSyncAdapter } from "./types";
// MLB Stats API — free, no key required.
// leagueId 103 = American League, 104 = National League
const MLB_STANDINGS_URL =
"https://statsapi.mlb.com/api/v1/standings?leagueId=103,104&standingsTypes=regularSeason&hydrate=team(division,league)";
interface MlbSplitRecord {
type: string; // "home", "away", "lastTen", etc.
wins: number;
losses: number;
}
interface MlbTeamRecord {
team: {
id: number;
name: string;
league?: { name?: string };
division?: { name?: string };
};
wins: number;
losses: number;
gamesPlayed: number;
winningPercentage: string; // e.g. ".617"
divisionRank?: string; // e.g. "1""5" — rank within the 5-team division (all teams)
wildCardRank?: string; // e.g. "1" — only set for WC-ranked teams
leagueRank?: string; // rank within the 15-team AL or NL
gamesBack: string; // "-" for leader, "2.0" otherwise
wildCardGamesBack?: string;
streak?: { streakCode: string; streakNumber: number };
records?: { splitRecords?: MlbSplitRecord[] };
}
interface MlbDivisionRecord {
teamRecords: MlbTeamRecord[];
}
interface MlbStandingsResponse {
records: MlbDivisionRecord[];
}
function sortByRecord(a: MlbTeamRecord, b: MlbTeamRecord): number {
return b.wins !== a.wins ? b.wins - a.wins : a.losses - b.losses;
}
export class MlbStandingsAdapter implements StandingsSyncAdapter {
constructor(private season: number = new Date().getFullYear()) {}
async fetchStandings(): Promise<FetchedStandingsRecord[]> {
const url = `${MLB_STANDINGS_URL}&season=${this.season}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`MLB standings API returned ${response.status}: ${response.statusText}`);
}
const json = (await response.json()) as MlbStandingsResponse;
if (!json.records || !Array.isArray(json.records)) {
throw new Error("Unexpected MLB standings API response shape");
}
// Collect all team records from all division groups
const allTeamRecords: MlbTeamRecord[] = json.records.flatMap(
(divRecord) => divRecord.teamRecords ?? []
);
if (allTeamRecords.length === 0) {
throw new Error("MLB standings API returned no team records");
}
// Compute leagueRank by sorting all teams by wins desc, losses asc within each league.
// We'll assign ranks per league (AL rank 115, NL rank 115) separately since
// the API's leagueRank field may reflect conference-level rank.
const alTeams = allTeamRecords.filter(
(t) => t.team.league?.name?.includes("American")
);
const nlTeams = allTeamRecords.filter(
(t) => t.team.league?.name?.includes("National")
);
const alSorted = [...alTeams].toSorted(sortByRecord);
const nlSorted = [...nlTeams].toSorted(sortByRecord);
const conferenceRankMap = new Map<number, number>();
for (const [i, t] of alSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
for (const [i, t] of nlSorted.entries()) conferenceRankMap.set(t.team.id, i + 1);
// Compute overall league rank across all 30 teams for leagueRank field
const allSorted = [...allTeamRecords].toSorted(sortByRecord);
const overallRankMap = new Map<number, number>();
for (const [i, t] of allSorted.entries()) overallRankMap.set(t.team.id, i + 1);
return allTeamRecords.map((team): FetchedStandingsRecord => {
// Conference: "American League" → "AL", "National League" → "NL"
const leagueName = team.team.league?.name ?? "";
const conference = leagueName.startsWith("American")
? "AL"
: leagueName.startsWith("National")
? "NL"
: leagueName;
// Division: "AL East", "AL Central", etc.
const division = team.team.division?.name ?? null;
// Win percentage
const winPct = parseFloat(team.winningPercentage) || 0;
// Games back — "-" means leader; use null. Use isNaN guard so GB=0 (tied for first) is kept.
const gbParsed = parseFloat(team.gamesBack);
const gbStr = (team.gamesBack === "-" || isNaN(gbParsed)) ? null : gbParsed;
// Division rank and conference rank
const divisionRank = team.divisionRank ? parseInt(team.divisionRank, 10) : null;
// Conference rank: use the per-league rank computed above
const conferenceRank = conferenceRankMap.get(team.team.id) ?? null;
// Streak: e.g. "W3" or "L2"
const streak = team.streak
? `${team.streak.streakCode}${team.streak.streakNumber}`
: undefined;
// Split records
const splits = team.records?.splitRecords ?? [];
const homeSplit = splits.find((s) => s.type === "home");
const awaySplit = splits.find((s) => s.type === "away");
const lastTenSplit = splits.find((s) => s.type === "lastTen");
const homeRecord = homeSplit
? `${homeSplit.wins}-${homeSplit.losses}`
: undefined;
const awayRecord = awaySplit
? `${awaySplit.wins}-${awaySplit.losses}`
: undefined;
const lastTen = lastTenSplit
? `${lastTenSplit.wins}-${lastTenSplit.losses}`
: undefined;
return {
teamName: team.team.name,
externalTeamId: String(team.team.id),
wins: team.wins,
losses: team.losses,
gamesPlayed: team.gamesPlayed,
winPct,
// MLB has no OT losses
conference,
division: division ?? undefined,
divisionRank: divisionRank ?? undefined,
conferenceRank: conferenceRank ?? undefined,
leagueRank: overallRankMap.get(team.team.id) ?? 1,
gamesBack: gbStr ?? undefined,
streak,
lastTen,
homeRecord,
awayRecord,
};
});
}
}