brackt/app/services/standings-sync/mlb.ts

161 lines
5.7 KiB
TypeScript
Raw Normal View History

Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225) * Add MLB playoff simulator with AL/NL division standings, fixes #121 - New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners (weighted by p_div) and 3 WC teams per league, then simulating WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series. Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available. - New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API, mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits. - New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division winner per division section + Wild Card section with 3-spot playoff line, headings read "AL/NL League" instead of "Conference". - Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections (divisionSpots + wcSpots params); conferenceLabel now flows through group objects rather than being derived from displayMode in the render. - DB migration 0062 adds `mlb_bracket` to simulator_type enum. - 37 new tests across simulator, adapter, and component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix flaky tennis simulator test: increase trials and lower threshold 500 trials with a ~3–5% expected win rate had enough variance to occasionally land below the 0.03 threshold (~2% failure rate). Bumping to 2000 trials reduces the std dev by 2x; lowering the threshold to 0.02 keeps the assertion meaningful (still well above random 0.78%) while eliminating the flakiness. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
import 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,
};
});
}
}