Fix MLB simulator: projected wins drive seeding, perf fix, + surface simulate errors #65

Merged
chrisp merged 2 commits from fix/simulate-route-error-handling into main 2026-06-01 20:50:04 +00:00
4 changed files with 360 additions and 181 deletions

View file

@ -1,24 +1,13 @@
/**
* Admin: Run EV Simulation for a Sports Season
* Admin: Simulate action endpoint (stub)
*
* Action-only route. POSTing here:
* 1. Delegates validation + execution to the shared simulator runner
* 6. Redirects back to the sports season admin page
* Simulation is now handled by intent="simulate" on the season admin page.
* This stub redirects any direct GET navigation to that page.
*/
import { redirect } from "react-router";
import type { Route } from "./+types/admin.sports-seasons.$id.simulate";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
export async function action({ params }: Route.ActionArgs) {
const sportsSeasonId = params.id;
try {
await runSportsSeasonSimulation(sportsSeasonId);
} catch (error) {
throw new Response(error instanceof Error ? error.message : "Simulation failed", {
status: error instanceof Error && error.message.includes("already running") ? 409 : 400,
});
}
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
export async function loader({ params }: Route.LoaderArgs) {
return redirect(`/admin/sports-seasons/${params.id}`);
}

View file

@ -11,6 +11,7 @@ import { database } from "~/database/context";
import { participantEvSnapshots, seasonSports } from "~/database/schema";
import { eq, desc } from "drizzle-orm";
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
import { syncStandings } from "~/services/standings-sync/index";
import {
getPendingStandingsMappings,
@ -280,6 +281,17 @@ export async function action(args: Route.ActionArgs) {
}
}
if (intent === "simulate") {
try {
await runSportsSeasonSimulation(params.id);
return redirect(`/admin/sports-seasons/${params.id}/expected-values`);
} catch (error) {
return {
simulateError: error instanceof Error ? error.message : "Simulation failed",
};
}
}
// Update
const name = formData.get("name");
const year = formData.get("year");
@ -669,7 +681,8 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</Button>
)}
{simulatorInfo && (
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
<Form method="post">
<input type="hidden" name="intent" value="simulate" />
<Button
type="submit"
size="sm"
@ -702,6 +715,11 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
</>
: "Import futures odds to set probability distributions."}
</p>
{"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
<div className="mt-3 bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
{actionData.simulateError}
</div>
)}
</CardContent>
</Card>

View file

@ -3,8 +3,11 @@ import {
normalizeTeamName,
getTeamData,
winRateFromRDif,
rawWinRateFromRDif,
rawWinRateFromElo,
rdifWinProbability,
eloToRDif,
sampleBinomial,
simBo3,
simBo5,
simBo7,
@ -104,36 +107,6 @@ describe("getTeamData", () => {
});
});
// ─── Seeding probabilities sanity checks ─────────────────────────────────────
describe("team seeding probabilities", () => {
const DIVISIONS = {
"AL East": ["New York Yankees", "Baltimore Orioles", "Boston Red Sox", "Tampa Bay Rays", "Toronto Blue Jays"],
"AL Central": ["Kansas City Royals", "Cleveland Guardians", "Minnesota Twins", "Detroit Tigers", "Chicago White Sox"],
"AL West": ["Houston Astros", "Seattle Mariners", "Texas Rangers", "Los Angeles Angels", "Athletics"],
"NL East": ["Philadelphia Phillies", "Atlanta Braves", "New York Mets", "Washington Nationals", "Miami Marlins"],
"NL Central": ["Milwaukee Brewers", "Chicago Cubs", "St. Louis Cardinals", "Cincinnati Reds", "Pittsburgh Pirates"],
"NL West": ["Los Angeles Dodgers", "San Diego Padres", "Arizona Diamondbacks", "San Francisco Giants", "Colorado Rockies"],
};
it("p_div values sum to ~1.0 within each division", () => {
for (const [divName, teams] of Object.entries(DIVISIONS)) {
const sum = teams.reduce((s, name) => s + (getTeamData(name)?.p_div ?? 0), 0);
expect(sum, `${divName} p_div sum should be ~1`).toBeCloseTo(1.0, 1);
}
});
it("Dodgers have the highest p_div in NL West", () => {
const dodgers = getTeamData("Los Angeles Dodgers")?.p_div ?? 0;
expect(dodgers).toBeGreaterThan(0.8);
});
it("rebuilding teams have very low p_div", () => {
expect(getTeamData("Chicago White Sox")?.p_div ?? 1).toBeLessThan(0.02);
expect(getTeamData("Colorado Rockies")?.p_div ?? 1).toBeLessThan(0.01);
});
});
// ─── winRateFromRDif ──────────────────────────────────────────────────────────
describe("winRateFromRDif", () => {
@ -160,6 +133,59 @@ describe("winRateFromRDif", () => {
});
});
// ─── rawWinRateFromRDif ───────────────────────────────────────────────────────
describe("rawWinRateFromRDif", () => {
it("returns 0.5 for RDif = 0", () => {
expect(rawWinRateFromRDif(0)).toBeCloseTo(0.5, 6);
});
it("is higher than winRateFromRDif for positive RDif (less compression)", () => {
const rdif = 137;
expect(rawWinRateFromRDif(rdif)).toBeGreaterThan(winRateFromRDif(rdif));
});
it("Dodgers (RDif +137) project to ~.585 season win rate", () => {
// 137 runs / 1620 ≈ 0.585
expect(rawWinRateFromRDif(137)).toBeCloseTo(0.585, 2);
});
it("clamps to [0.01, 0.99]", () => {
expect(rawWinRateFromRDif(10000)).toBe(0.99);
expect(rawWinRateFromRDif(-10000)).toBe(0.01);
});
});
// ─── rawWinRateFromElo ────────────────────────────────────────────────────────
describe("rawWinRateFromElo", () => {
it("returns 0.5 for Elo 1500 (average)", () => {
expect(rawWinRateFromElo(1500)).toBeCloseTo(0.5, 6);
});
it("returns above 0.5 for Elo above 1500", () => {
expect(rawWinRateFromElo(1600)).toBeGreaterThan(0.5);
});
it("returns below 0.5 for Elo below 1500", () => {
expect(rawWinRateFromElo(1400)).toBeLessThan(0.5);
});
it("round-trips from projected wins: projectedWins → Elo → rawWinRate ≈ winRate", () => {
// 95 wins out of 162 → winRate ≈ 0.5864
const projectedWins = 95;
const totalGames = 162;
const winRate = projectedWins / totalGames;
// Build Elo from winRate the same way projectedWinsToElo does
const elo = 1500 - 400 * Math.log10((1 - winRate) / winRate);
expect(rawWinRateFromElo(elo)).toBeCloseTo(winRate, 4);
});
it("is symmetric around 1500", () => {
expect(rawWinRateFromElo(1600)).toBeCloseTo(1 - rawWinRateFromElo(1400), 6);
});
});
// ─── rdifWinProbability ───────────────────────────────────────────────────────
describe("rdifWinProbability (log5)", () => {
@ -191,10 +217,72 @@ describe("rdifWinProbability (log5)", () => {
});
});
// ─── sampleBinomial ───────────────────────────────────────────────────────────
describe("sampleBinomial", () => {
it("returns 0 when n = 0", () => {
expect(sampleBinomial(0, 0.5)).toBe(0);
});
it("returns 0 when p = 0 (exact path, n < 30)", () => {
expect(sampleBinomial(10, 0)).toBe(0);
});
it("returns n when p = 1 (exact path, n < 30)", () => {
expect(sampleBinomial(10, 1)).toBe(10);
});
it("returns 0 when p = 0 (normal-approx path, n >= 30)", () => {
// p <= 0 guard fires before normal approx
expect(sampleBinomial(162, 0)).toBe(0);
});
it("returns n when p = 1 (normal-approx path, n >= 30)", () => {
// p >= 1 guard fires before normal approx
expect(sampleBinomial(162, 1)).toBe(162);
});
it("result is always in [0, n] — exact path (n < 30)", () => {
for (let i = 0; i < 50; i++) {
const result = sampleBinomial(10, 0.5);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(10);
}
});
it("result is always in [0, n] — normal-approx path (n >= 30)", () => {
for (let i = 0; i < 50; i++) {
const result = sampleBinomial(162, 0.5);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(162);
}
});
it("mean is close to n*p over many samples — normal-approx path", () => {
const n = 162;
const p = 0.586;
const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p));
const mean = samples.reduce((s, x) => s + x, 0) / samples.length;
// Expected mean ≈ 94.9; allow ±3 (well within 3σ for 2000 samples)
expect(mean).toBeGreaterThan(n * p - 3);
expect(mean).toBeLessThan(n * p + 3);
});
it("mean is close to n*p over many samples — exact path", () => {
const n = 10;
const p = 0.6;
const samples = Array.from({ length: 2000 }, () => sampleBinomial(n, p));
const mean = samples.reduce((s, x) => s + x, 0) / samples.length;
// Expected mean = 6; allow ±0.5
expect(mean).toBeGreaterThan(n * p - 0.5);
expect(mean).toBeLessThan(n * p + 0.5);
});
});
// ─── Series simulators ────────────────────────────────────────────────────────
const teamA = { id: "a", name: "Team A", data: undefined };
const teamB = { id: "b", name: "Team B", data: undefined };
const teamA = { id: "a", name: "Team A", data: undefined, currentWins: 0, remainingGames: 0 };
const teamB = { id: "b", name: "Team B", data: undefined, currentWins: 0, remainingGames: 0 };
const alwaysA = () => 1.0; // team A always wins each game
const alwaysB = () => 0.0; // team B always wins each game
const coinFlip = () => 0.5;

View file

@ -6,35 +6,43 @@
*
* Algorithm:
* 1. Load all participants for the sports season from DB
* 2. Match participant names to hardcoded team data (RDif + league/division)
* 3. For each simulation:
* a. For each league (AL/NL), draw 1 division winner per division (3 draws),
* then draw 3 wildcard teams from the remaining pool all weighted by win rate.
* b. Seed division winners 13 by RDif (best RDif = seed 1); WC teams 46 by RDif.
* c. Run the playoff bracket per league:
* 2. Load current standings (wins, gamesPlayed) from regularSeasonStandings
* 3. Load sourceElo ratings from seasonParticipantExpectedValues
* 4. Match participant names to hardcoded team data (RDif + league/division)
* 5. For each simulation:
* a. For each league (AL/NL), simulate remaining regular season games for
* every team using Binomial sampling, giving final projected wins.
* b. Division winner = best record in each division (3 per league).
* Wild card = next 3 best records among non-division-winners per league.
* c. Seed division winners 13 by final wins (best = seed 1);
* WC teams 46 by final wins.
* d. Run the playoff bracket per league:
* - Wildcard Round (best-of-3): 3 vs 6, 4 vs 5 (seeds 1 & 2 get byes)
* - Division Series (best-of-5): 1 vs lowest WC survivor, 2 vs other
* - League Championship Series (best-of-7)
* d. World Series (best-of-7): AL champ vs NL champ
* 4. Track placement counts per scoring tier
* 5. Convert counts to probability distributions
* e. World Series (best-of-7): AL champ vs NL champ
* 6. Track placement counts per scoring tier
* 7. Convert counts to probability distributions
*
* Win probability (log5 formula):
* Step 1 convert projected RDif to win rate:
* Step 1 convert projected RDif to win rate for playoff matchups:
* winRate = clamp(0.5 + rdif / RDIF_DIVISOR, 0.01, 0.99)
* RDIF_DIVISOR compresses team strengths toward .500 for playoff parity.
* See the RDIF_DIVISOR constant below for tuning guidance.
* Step 2 Bill James log5 head-to-head probability:
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
*
* Regular season simulation (seeding):
* Each team's raw per-game win rate is derived from sourceElo (if set) or
* from the hardcoded RDif using SEEDING_RDIF_SCALE 10 runs/win × 162 games.
* Remaining games = TOTAL_SEASON_GAMES gamesPlayed are drawn from a
* Binomial distribution. This makes playoff seeding respond to both current
* standings and user-entered projected wins.
*
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
* the per-game win probability is blended:
* the per-game win probability for playoff series is blended:
* P(game) = RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb
* where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
* Normalized odds are vig-removed futures win probabilities.
* RDIF_WEIGHT = 0.7, ODDS_WEIGHT = 0.3.
* Falls back to RDif-only when no odds are stored.
*
* Placement tiers SimulationProbabilities mapping:
* probFirst = World Series champion (1 per sim)
@ -46,16 +54,12 @@
*
* Team data keys:
* rdif: FanGraphs Depth Charts projected run differential for 2026.
* Used for per-game win probability via log5.
* p_div: Probability of winning own division (FanGraphs Depth Charts divTitle).
* Must sum to ~1.0 within each 5-team division.
* p_wc: Probability of claiming one of 3 wildcard slots (FanGraphs Depth Charts wcTitle).
* Used as relative weights among non-division-winners in each league.
* Used as fallback for seeding win rate and for playoff series win probability.
* league/division: Static MLB structure does not change season-to-season.
*
* Sources:
* rdif: https://www.fangraphs.com/standings/projected-standings
* p_div/p_wc: https://www.fangraphs.com/standings/playoff-odds/dc/div
* Update at the start of each season.
* rdif: https://www.fangraphs.com/standings/projected-standings
* Update rdif at the start of each season.
*
* Divisions:
* AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays
@ -75,29 +79,30 @@ import {
convertAmericanOddsToProbability,
normalizeProbabilities,
} from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
// ─── Simulation parameters ────────────────────────────────────────────────────
const NUM_SIMULATIONS = 50_000;
const TOTAL_SEASON_GAMES = 162;
/**
* Controls how much projected run differential spreads teams away from .500.
*
* Baseline: 1620 ( 10 runs per win × 162 games). This gives the raw
* Pythagorean-derived win rate, which tends to overstate dominance in
* playoff matchups where you face elite pitching. Increasing this constant
* compresses all team strengths toward .500 reducing every strong team's
* win probability in each series and yielding more upset potential across
* the entire bracket. Decrease to amplify differences.
* Controls how much projected run differential spreads teams away from .500
* for single-game playoff matchups.
*
* Effect on Dodgers (RDif +137):
* 1620 win rate 0.585 (raw Pythagorean projection too dominant)
* 2000 win rate 0.568
* 4000 win rate 0.534
* 1620 win rate 0.585 (raw Pythagorean too dominant for playoff matchups)
* 8000 win rate 0.517 (current near coin-flip vs any playoff team)
*/
const RDIF_DIVISOR = 8000;
/**
* Scale for converting RDif to a raw per-game win rate for regular-season
* seeding simulation. Unlike RDIF_DIVISOR (which compresses for playoff
* parity), this uses ~10 runs/win × 162 games to give realistic season win%.
*/
const SEEDING_RDIF_SCALE = 1620;
/**
* Blend weights for RDif vs. Vegas futures odds when sourceOdds are available.
*/
@ -109,58 +114,58 @@ const ODDS_WEIGHT = 1 - RDIF_WEIGHT;
// rdif: Projected run differential from FanGraphs Depth Charts.
// Source: https://www.fangraphs.com/standings/projected-standings
//
// Update all three values at the start of each season from the FanGraphs pages above.
// league/division: Static MLB structure — update only if teams change leagues.
//
// Update rdif at the start of each season.
interface MlbTeamData {
league: "AL" | "NL";
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
rdif: number; // 2026 FanGraphs Depth Charts projected run differential
p_div: number; // 2026 FanGraphs Depth Charts divTitle — probability of winning own division
p_wc: number; // 2026 FanGraphs Depth Charts wcTitle — probability of claiming a wild card slot
}
const TEAMS_DATA: Record<string, MlbTeamData> = {
// ── American League East ───────────────────────────────────────────────────
"New York Yankees": { league: "AL", division: "AL East", rdif: 67, p_div: 0.374, p_wc: 0.358 },
"Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23, p_div: 0.128, p_wc: 0.310 },
"Boston Red Sox": { league: "AL", division: "AL East", rdif: 48, p_div: 0.247, p_wc: 0.373 },
"Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2, p_div: 0.066, p_wc: 0.229 },
"Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37, p_div: 0.184, p_wc: 0.349 },
"New York Yankees": { league: "AL", division: "AL East", rdif: 67 },
"Baltimore Orioles": { league: "AL", division: "AL East", rdif: 23 },
"Boston Red Sox": { league: "AL", division: "AL East", rdif: 48 },
"Tampa Bay Rays": { league: "AL", division: "AL East", rdif: 2 },
"Toronto Blue Jays": { league: "AL", division: "AL East", rdif: 37 },
// ── American League Central ────────────────────────────────────────────────
"Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8, p_div: 0.297, p_wc: 0.157 },
"Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38, p_div: 0.084, p_wc: 0.075 },
"Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15, p_div: 0.166, p_wc: 0.120 },
"Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26, p_div: 0.449, p_wc: 0.155 },
"Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112, p_div: 0.005, p_wc: 0.004 },
"Kansas City Royals": { league: "AL", division: "AL Central", rdif: 8 },
"Cleveland Guardians": { league: "AL", division: "AL Central", rdif: -38 },
"Minnesota Twins": { league: "AL", division: "AL Central", rdif: -15 },
"Detroit Tigers": { league: "AL", division: "AL Central", rdif: 26 },
"Chicago White Sox": { league: "AL", division: "AL Central", rdif: -112 },
// ── American League West ───────────────────────────────────────────────────
"Houston Astros": { league: "AL", division: "AL West", rdif: -1, p_div: 0.126, p_wc: 0.213 },
"Seattle Mariners": { league: "AL", division: "AL West", rdif: 66, p_div: 0.595, p_wc: 0.205 },
"Texas Rangers": { league: "AL", division: "AL West", rdif: 20, p_div: 0.201, p_wc: 0.270 },
"Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65, p_div: 0.011, p_wc: 0.035 },
"Athletics": { league: "AL", division: "AL West", rdif: -17, p_div: 0.068, p_wc: 0.146 },
"Houston Astros": { league: "AL", division: "AL West", rdif: -1 },
"Seattle Mariners": { league: "AL", division: "AL West", rdif: 66 },
"Texas Rangers": { league: "AL", division: "AL West", rdif: 20 },
"Los Angeles Angels": { league: "AL", division: "AL West", rdif: -65 },
"Athletics": { league: "AL", division: "AL West", rdif: -17 },
// ── National League East ───────────────────────────────────────────────────
"Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51, p_div: 0.243, p_wc: 0.444 },
"Atlanta Braves": { league: "NL", division: "NL East", rdif: 67, p_div: 0.357, p_wc: 0.425 },
"New York Mets": { league: "NL", division: "NL East", rdif: 71, p_div: 0.389, p_wc: 0.413 },
"Washington Nationals": { league: "NL", division: "NL East", rdif: -113, p_div: 0.001, p_wc: 0.006 },
"Miami Marlins": { league: "NL", division: "NL East", rdif: -48, p_div: 0.011, p_wc: 0.075 },
"Philadelphia Phillies": { league: "NL", division: "NL East", rdif: 51 },
"Atlanta Braves": { league: "NL", division: "NL East", rdif: 67 },
"New York Mets": { league: "NL", division: "NL East", rdif: 71 },
"Washington Nationals": { league: "NL", division: "NL East", rdif: -113 },
"Miami Marlins": { league: "NL", division: "NL East", rdif: -48 },
// ── National League Central ────────────────────────────────────────────────
"Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9, p_div: 0.243, p_wc: 0.170 },
"Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23, p_div: 0.347, p_wc: 0.183 },
"St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55, p_div: 0.038, p_wc: 0.049 },
"Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31, p_div: 0.086, p_wc: 0.095 },
"Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13, p_div: 0.285, p_wc: 0.178 },
"Milwaukee Brewers": { league: "NL", division: "NL Central", rdif: 9 },
"Chicago Cubs": { league: "NL", division: "NL Central", rdif: 23 },
"St. Louis Cardinals": { league: "NL", division: "NL Central", rdif: -55 },
"Cincinnati Reds": { league: "NL", division: "NL Central", rdif: -31 },
"Pittsburgh Pirates": { league: "NL", division: "NL Central", rdif: 13 },
// ── National League West ───────────────────────────────────────────────────
"Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137, p_div: 0.900, p_wc: 0.082 },
"San Diego Padres": { league: "NL", division: "NL West", rdif: -9, p_div: 0.023, p_wc: 0.240 },
"Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5, p_div: 0.039, p_wc: 0.322 },
"San Francisco Giants": { league: "NL", division: "NL West", rdif: 3, p_div: 0.038, p_wc: 0.317 },
"Colorado Rockies": { league: "NL", division: "NL West", rdif: -173, p_div: 0.001, p_wc: 0.001 },
"Los Angeles Dodgers": { league: "NL", division: "NL West", rdif: 137 },
"San Diego Padres": { league: "NL", division: "NL West", rdif: -9 },
"Arizona Diamondbacks": { league: "NL", division: "NL West", rdif: 5 },
"San Francisco Giants": { league: "NL", division: "NL West", rdif: 3 },
"Colorado Rockies": { league: "NL", division: "NL West", rdif: -173 },
};
// ─── Public helpers (exported for unit testing) ───────────────────────────────
@ -180,7 +185,7 @@ export function getTeamData(name: string): MlbTeamData | undefined {
}
/**
* Convert projected run differential to a compressed win rate for matchup probability.
* Convert projected run differential to a compressed win rate for playoff matchup probability.
* Uses RDIF_DIVISOR to control how much team strength spreads away from .500.
* Clamped to [0.01, 0.99] to avoid degenerate log5 values.
* Exported for unit testing.
@ -189,6 +194,26 @@ export function winRateFromRDif(rdif: number): number {
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
}
/**
* Convert projected run differential to a raw per-game win rate for regular-season
* seeding simulation. Uses SEEDING_RDIF_SCALE (~10 runs/win × 162 games) which
* gives a realistic season win percentage rather than the playoff-compressed value.
* Exported for unit testing.
*/
export function rawWinRateFromRDif(rdif: number): number {
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / SEEDING_RDIF_SCALE));
}
/**
* Derive a raw per-game win rate directly from an Elo rating.
* This is the inverse Elo formula, returning the same win probability
* that was originally used to compute the Elo from projected wins.
* Exported for unit testing.
*/
export function rawWinRateFromElo(elo: number): number {
return 1 / (1 + Math.pow(10, (1500 - elo) / 400));
}
/**
* Convert an Elo rating to an equivalent projected run differential.
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
@ -196,8 +221,7 @@ export function winRateFromRDif(rdif: number): number {
* Exported for unit testing.
*/
export function eloToRDif(elo: number): number {
const winRate = 1 / (1 + Math.pow(10, (1500 - elo) / 400));
return (winRate - 0.5) * RDIF_DIVISOR;
return (rawWinRateFromElo(elo) - 0.5) * RDIF_DIVISOR;
}
/**
@ -213,6 +237,39 @@ export function rdifWinProbability(rdifA: number, rdifB: number): number {
return (wA - wA * wB) / (wA + wB - 2 * wA * wB);
}
/**
* Sample the number of wins from n independent Bernoulli trials each with
* probability p. Used to project remaining regular-season wins per team.
*
* For n 30 (where CLT applies well: n·p 5 and n·(1-p) 5 for any
* realistic win rate), uses a Box-Muller normal approximation 2 Math.random()
* calls per team instead of n, cutting the seeding phase from ~243M to ~3M
* Math.random() calls per 50K-simulation run. For small n the exact Bernoulli
* loop is used. Both paths produce integer output clamped to [0, n].
* Exported for unit testing.
*/
export function sampleBinomial(n: number, p: number): number {
if (n <= 0) return 0;
if (p <= 0) return 0;
if (p >= 1) return n;
if (n >= 30) {
// Normal approximation via Box-Muller transform.
// Guard u1 > 0 to avoid log(0) = -Infinity.
const u1 = Math.max(Number.EPSILON, Math.random());
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
return Math.round(Math.min(n, Math.max(0, n * p + Math.sqrt(n * p * (1 - p)) * z)));
}
// Exact Bernoulli trials for small n.
let wins = 0;
for (let i = 0; i < n; i++) {
if (Math.random() < p) wins++;
}
return wins;
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
@ -220,6 +277,8 @@ interface TeamEntry {
name: string;
data: MlbTeamData | undefined;
originalSeed?: number;
currentWins: number; // from regularSeasonStandings (0 pre-season)
remainingGames: number; // TOTAL_SEASON_GAMES - gamesPlayed
}
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
@ -227,30 +286,6 @@ function getEntryRDif(entry: TeamEntry): number {
return entry.data?.rdif ?? 0;
}
/**
* Weighted pick without replacement using the given weight function.
* Returns undefined if no eligible team has positive weight.
*/
function weightedPickByKey(
pool: TeamEntry[],
excluded: Set<TeamEntry>,
getWeight: (t: TeamEntry) => number
): TeamEntry | undefined {
const eligible = pool.filter((t) => !excluded.has(t));
if (eligible.length === 0) return undefined;
const weights = eligible.map(getWeight);
const total = weights.reduce((s, w) => s + w, 0);
if (total === 0) return undefined;
let r = Math.random() * total;
for (let i = 0; i < eligible.length; i++) {
r -= weights[i];
if (r <= 0) return eligible[i];
}
return eligible[eligible.length - 1];
}
// ─── Series simulators ─────────────────────────────────────────────────────────
type SeriesResult = { winner: TeamEntry; loser: TeamEntry };
@ -301,21 +336,23 @@ export function simBo7(
// ─── League bracket builder ───────────────────────────────────────────────────
/**
* Draws the 6-team playoff field for one league (AL or NL).
* Draws the 6-team playoff field for one league (AL or NL) by simulating the
* remaining regular season for each team.
*
* Steps:
* 1. For each of the 3 divisions, draw 1 division winner weighted by p_div.
* 2. From the remaining non-division-winners, draw 3 WC teams weighted by p_wc.
* 3. Rank division winners 13 by RDif descending (best RDif seed 1).
* 4. Rank WC teams 46 by RDif descending (best RDif seed 4).
* 1. For each team, sample remaining wins from Binomial(remainingGames, seedingWinRate).
* A tiny uniform noise [0, 0.001) is added to break integer ties randomly.
* 2. Division winner = team with highest final wins in each division (3 per league).
* 3. Wild card = next 3 highest final wins among non-division-winners.
* 4. Seed division winners 13 by final wins descending (best = seed 1).
* 5. Seed WC teams 46 by final wins descending.
*
* Returns an array of 6 TeamEntry objects in seed order [1..6], each annotated
* with originalSeed, or undefined if any draw is degenerate (no eligible team
* with positive weight).
* with originalSeed, or undefined if the league has fewer than 3 eligible WC teams.
*/
function drawLeaguePlayoffField(
leagueTeams: TeamEntry[],
getRDif: (t: TeamEntry) => number = getEntryRDif
getSeedingWinRate: (t: TeamEntry) => number
): TeamEntry[] | undefined {
// Group by division
const divMap = new Map<string, TeamEntry[]>();
@ -325,33 +362,42 @@ function drawLeaguePlayoffField(
divMap.get(div)?.push(t);
}
// Simulate remaining games for each team; tiny noise breaks integer win ties
const finalWins = new Map<string, number>();
for (const t of leagueTeams) {
finalWins.set(
t.id,
t.currentWins + sampleBinomial(t.remainingGames, getSeedingWinRate(t)) + Math.random() * 0.001
);
}
// Division winners: best record per division
const divisionWinners: TeamEntry[] = [];
const allDivisionWinnerSet = new Set<TeamEntry>();
const divWinnerSet = new Set<TeamEntry>();
for (const divTeams of divMap.values()) {
const winner = weightedPickByKey(divTeams, new Set(), (t) => t.data?.p_div ?? 0);
if (!winner) return undefined;
const winner = divTeams.reduce((best, t) =>
(finalWins.get(t.id) ?? 0) > (finalWins.get(best.id) ?? 0) ? t : best
);
divisionWinners.push(winner);
allDivisionWinnerSet.add(winner);
divWinnerSet.add(winner);
}
// Draw 3 WC teams from non-division-winners, weighted by p_wc
const wcPool = leagueTeams.filter((t) => !allDivisionWinnerSet.has(t));
const wcTaken = new Set<TeamEntry>();
const wcTeams: TeamEntry[] = [];
// Wild card: top 3 non-division-winners by final wins
const wcTeams = leagueTeams
.filter((t) => !divWinnerSet.has(t))
.toSorted((a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0))
.slice(0, 3);
for (let i = 0; i < 3; i++) {
const wc = weightedPickByKey(wcPool, wcTaken, (t) => t.data?.p_wc ?? 0);
if (!wc) return undefined;
wcTeams.push(wc);
wcTaken.add(wc);
}
if (wcTeams.length < 3) return undefined;
// Rank division winners 13 by RDif descending (best RDif = seed 1)
const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a));
// Rank WC teams 46 by RDif descending (best RDif = seed 4)
const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a));
// Seed: div winners 13 and WC teams 46, both by final wins descending
const sortedDivWinners = divisionWinners.toSorted(
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
);
const sortedWcTeams = wcTeams.toSorted(
(a, b) => (finalWins.get(b.id) ?? 0) - (finalWins.get(a.id) ?? 0)
);
const seeds = [...sortedDivWinners, ...sortedWcTeams];
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
@ -421,12 +467,23 @@ export class MLBSimulator implements Simulator {
}
const participantIds = participantRows.map((r) => r.id);
const participantIdSet = new Set(participantIds);
const teams: TeamEntry[] = participantRows.map((r) => ({
id: r.id,
name: r.name,
data: getTeamData(r.name),
}));
// 2. Load current standings (wins + games played) to seed the regular-season simulation.
const standings = await getRegularSeasonStandings(sportsSeasonId);
const standingsByParticipantId = new Map(standings.map((s) => [s.participantId, s]));
const teams: TeamEntry[] = participantRows.map((r) => {
const standing = standingsByParticipantId.get(r.id);
const gamesPlayed = standing?.gamesPlayed ?? 0;
return {
id: r.id,
name: r.name,
data: getTeamData(r.name),
currentWins: standing?.wins ?? 0,
remainingGames: Math.max(0, TOTAL_SEASON_GAMES - gamesPlayed),
};
});
// Warn about participants that don't match any hardcoded team.
const unrecognized = teams.filter((t) => !t.data);
@ -437,6 +494,22 @@ export class MLBSimulator implements Simulator {
);
}
// Warn when standings exist for some teams but not all recognized ones — this
// usually means a partial sync. Missing teams fall back to 0 wins / 162
// remaining games (league-average strength), which distorts mid-season seeding.
if (standings.length > 0) {
const missingStandings = teams.filter(
(t) => t.data && !standingsByParticipantId.has(t.id)
);
if (missingStandings.length > 0) {
logger.warn(
`[MLBSimulator] ${missingStandings.length} recognized team(s) have no standings row — ` +
`seeding will use 0 wins / 162 remaining games for: ` +
missingStandings.map((t) => t.name).join(", ")
);
}
}
const alTeams = teams.filter((t) => t.data?.league === "AL");
const nlTeams = teams.filter((t) => t.data?.league === "NL");
@ -459,7 +532,6 @@ export class MLBSimulator implements Simulator {
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
const participantIdSet = new Set(participantIds);
const oddsRows = evRows.filter(
(r) => r.sourceOdds !== null && participantIdSet.has(r.participantId)
);
@ -476,22 +548,34 @@ export class MLBSimulator implements Simulator {
});
}
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif).
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif)
// for playoff series win probability.
const sourceEloRDifMap = new Map<string, number>();
// Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding.
const rawWinRateMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined && participantIdSet.has(r.participantId)) {
sourceEloRDifMap.set(r.participantId, eloToRDif(r.sourceElo));
rawWinRateMap.set(r.participantId, rawWinRateFromElo(r.sourceElo));
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Effective RDif: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */
/** Effective RDif for playoff series: prefer sourceElo-derived value over hardcoded rdif. */
const effectiveRDif = (entry: TeamEntry): number =>
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
/**
* Blended per-game win probability for team A over team B.
* Raw per-game win rate for regular-season seeding simulation.
* Uses sourceElo-derived rate if available; falls back to hardcoded rdif
* with SEEDING_RDIF_SCALE (Pythagorean approximation).
*/
const seedingWinRate = (entry: TeamEntry): number =>
rawWinRateMap.get(entry.id) ?? rawWinRateFromRDif(getEntryRDif(entry));
/**
* Blended per-game win probability for team A over team B in a playoff series.
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
@ -519,8 +603,8 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0;
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const alField = drawLeaguePlayoffField(alTeams, effectiveRDif);
const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif);
const alField = drawLeaguePlayoffField(alTeams, seedingWinRate);
const nlField = drawLeaguePlayoffField(nlTeams, seedingWinRate);
if (!alField || !nlField) continue; // degenerate draw — skip
effectiveN++;