From ba970cd5716d9288e3eda2b2680fc32475189cfd Mon Sep 17 00:00:00 2001
From: Chris Parsons
Date: Sun, 31 May 2026 23:42:35 -0700
Subject: [PATCH 1/2] Fix simulate route crash and surface actual error
messages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a simulation action threw (e.g. readiness check failure), React Router
tried to render the simulate route via GET to display the error — but the
route had no loader, so the real error was completely swallowed by React
Router's own 400 "no loader" message.
Four issues fixed:
- Move simulate into the intent-dispatch pattern on the season page (matching
delete/rescore/sync-standings/finalize-standings), so errors surface via
actionData instead of being lost
- Eliminate the ?simulationError= query param approach, which left stale error
messages in browser history and reflected user-controlled text in the UI
- Match the existing bg-destructive/15 error styling used elsewhere on the page
- Strip the now-unused action from simulate.tsx; keep a loader-only redirect
stub so any bookmarked URLs degrade gracefully to the season page
Co-Authored-By: Claude Sonnet 4.6
---
.../admin.sports-seasons.$id.simulate.tsx | 21 +++++--------------
app/routes/admin.sports-seasons.$id.tsx | 20 +++++++++++++++++-
2 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/app/routes/admin.sports-seasons.$id.simulate.tsx b/app/routes/admin.sports-seasons.$id.simulate.tsx
index 5dace5c..49dce26 100644
--- a/app/routes/admin.sports-seasons.$id.simulate.tsx
+++ b/app/routes/admin.sports-seasons.$id.simulate.tsx
@@ -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}`);
}
diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx
index fb84899..43aab95 100644
--- a/app/routes/admin.sports-seasons.$id.tsx
+++ b/app/routes/admin.sports-seasons.$id.tsx
@@ -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
)}
{simulatorInfo && (
-
+ {"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
+
+ {actionData.simulateError}
+
+ )}
--
2.45.3
From 0b53570723b1d8cea93386e49f7e7f0062aefea3 Mon Sep 17 00:00:00 2001
From: Chris Parsons
Date: Mon, 1 Jun 2026 11:55:24 -0700
Subject: [PATCH 2/2] Fix MLB simulator: use projected wins for playoff seeding
+ perf + cleanup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Replace hardcoded FanGraphs p_div/p_wc draws with Binomial regular-season
simulation so that admin-entered projected wins drive playoff qualification
odds, not just in-bracket game win probability
- Add getRegularSeasonStandings call so mid-season current wins feed into
projected final standings
- Use normal approximation (Box-Muller) in sampleBinomial for n≥30: drops
seeding phase from ~243M to ~3M Math.random() calls per 50K-sim run
- eloToRDif now calls rawWinRateFromElo instead of inlining the same formula
- Warn when recognized teams lack a standings row during mid-season simulation
(silent wrong seeding result with no diagnostic previously)
Co-Authored-By: Claude Sonnet 4.6
---
.../__tests__/mlb-simulator.test.ts | 152 ++++++--
app/services/simulations/mlb-simulator.ts | 348 +++++++++++-------
2 files changed, 336 insertions(+), 164 deletions(-)
diff --git a/app/services/simulations/__tests__/mlb-simulator.test.ts b/app/services/simulations/__tests__/mlb-simulator.test.ts
index 85e59ab..b994a9e 100644
--- a/app/services/simulations/__tests__/mlb-simulator.test.ts
+++ b/app/services/simulations/__tests__/mlb-simulator.test.ts
@@ -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;
diff --git a/app/services/simulations/mlb-simulator.ts b/app/services/simulations/mlb-simulator.ts
index f8f2234..48bb3bf 100644
--- a/app/services/simulations/mlb-simulator.ts
+++ b/app/services/simulations/mlb-simulator.ts
@@ -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 1–3 by RDif (best RDif = seed 1); WC teams 4–6 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 1–3 by final wins (best = seed 1);
+ * WC teams 4–6 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 = {
// ── 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,
- 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 1–3 by RDif descending (best RDif → seed 1).
- * 4. Rank WC teams 4–6 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 1–3 by final wins descending (best = seed 1).
+ * 5. Seed WC teams 4–6 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();
@@ -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();
+ 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();
+ const divWinnerSet = new Set();
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();
- 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 1–3 by RDif descending (best RDif = seed 1)
- const sortedDivWinners = divisionWinners.toSorted((a, b) => getRDif(b) - getRDif(a));
-
- // Rank WC teams 4–6 by RDif descending (best RDif = seed 4)
- const sortedWcTeams = wcTeams.toSorted((a, b) => getRDif(b) - getRDif(a));
+ // Seed: div winners 1–3 and WC teams 4–6, 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();
+ // Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding.
+ const rawWinRateMap = new Map();
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++;
--
2.45.3