* refactor(schema): rename per-window tables to season_* prefix Renames participants, participant_expected_values, participant_qualifying_totals, participant_results, participant_surface_elos to season_* prefixed names. Renames event_results.participant_id to season_participant_id. Phase 1a of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor: rename participant.ts model file to season-participant.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(models): update model layer to use renamed schema exports Updated all model files to use the renamed schema exports from Task 1: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantQualifyingTotals → seasonParticipantQualifyingTotals - participantResults → seasonParticipantResults - participantSurfaceElos → seasonParticipantSurfaceElos - eventResults.participantId → eventResults.seasonParticipantId - db.query relation accessors updated - Relation field .participant → .seasonParticipant where applicable - Import paths updated: ./participant → ./season-participant Files updated (14 model files + 3 test files): - draft-pick.ts - draft-utils.ts - event-result.ts - group-stage-match.ts - participant-result.ts - qualifying-points.ts - scoring-calculator.ts - scoring-event.ts - sports-season.ts - surface-elo.ts - team-score-events.ts - cs2-major-stage.ts - golf-skills.ts - participant-expected-value.ts - __tests__/sports-season.clone.test.ts - __tests__/auto-pick.test.ts - __tests__/executeAutoPick.timer.test.ts Typecheck errors decreased: 779 → 499 (280 fewer) All model file errors related to renamed schemas resolved. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route layer to use renamed schema exports - Update model import from ~/models/participant to ~/models/season-participant - Rename schema.participants to schema.seasonParticipants - Rename schema.participantResults to schema.seasonParticipantResults - Rename db.query.participants to db.query.seasonParticipants - Update 9 route files and 1 test file Affected files: - admin.sports-seasons.$id.events.$eventId.bracket.server.ts - admin.sports-seasons.$id.participants.tsx - api/draft.force-manual-pick.ts - api/draft.make-pick.ts - api/draft.replace-pick.ts - api/seasons.$seasonId.draft.ts - leagues/$leagueId.draft-board.$seasonId.tsx - leagues/$leagueId.sports-seasons.$sportsSeasonId.server.ts - admin/__tests__/sports-seasons-participants.test.ts Error count reduced from 499 to 453 (46 errors fixed). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(routes): update route files for schema rename Update route imports from ~/models/participant to ~/models/season-participant and fix references to .participant/.participantId on event results to use .seasonParticipant/.seasonParticipantId after schema rename. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * refactor(services): update simulators and services for renamed schema Update all simulators, services, and server files to use renamed schema tables: - participants → seasonParticipants - participantExpectedValues → seasonParticipantExpectedValues - participantResults → seasonParticipantResults - eventResults.participantId → eventResults.seasonParticipantId Files updated: - 20 sport simulators (NBA, NHL, NFL, MLB, etc.) - probability-updater.ts - standings-sync/index.ts - sports-data-sync.server.ts - server/socket.ts Typecheck errors reduced from 365 to 0. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: rename per-window tables to season_* prefix * fix(tests): update mock query keys after participants table rename Change mock db.query.participants to db.query.seasonParticipants in test files to match the schema rename from commit66145a9. This fixes "Cannot read properties of undefined (reading 'findFirst'/'findMany')" errors that occurred when production code queries db.query.seasonParticipants but test mocks only defined the old participants key. Files updated: - app/services/simulations/__tests__/world-cup-simulator.test.ts - app/routes/api/__tests__/draft.force-manual-pick.test.ts - app/routes/api/__tests__/draft.force-manual-pick.timer-mode.test.ts - app/routes/api/__tests__/draft.make-pick.timer-mode.test.ts - server/__tests__/timer-autodraft.test.ts - app/models/__tests__/team-score-events.test.ts Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tests): update remaining mock paths and keys after schema rename * fix(tests): final two mock stragglers after schema rename - draft-pick.test.ts: assertion on db.query.participantQualifyingTotals - process-match-result.test.ts: mock key participants → seasonParticipants Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: add post-phase1a baseline capture (temp, for diff verification) * chore: capture pre-migration baselines * chore: remove post-phase1a capture helper after verification * schema: add canonical tournament & participant tables Adds tournaments, participants (canonical), tournament_results, and participant_surface_elos (canonical). Adds nullable tournament_id to scoring_events and nullable participant_id to season_participants. Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(models): add canonical tournament, participant, result, surface-elo models Adds CRUD modules for the canonical tables created in commit775b905. Each module mirrors existing app/models conventions (database() from ~/database/context, schema from ~/database/schema, mock-based tests). Key implementation notes: - participant.ts exports use "Canonical" prefix (CanonicalParticipant, createCanonicalParticipant, etc.) to avoid collision with existing season-participant.ts exports - All four models include comprehensive unit tests following the audit-log.test.ts pattern - Tests use mocked db responses (no real database access) - Upsert functions use onConflictDoUpdate for appropriate unique constraints Part of Phase 1b of canonical tournament layer migration. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * migration: create canonical tables, add nullable FKs * scripts: add extractTournamentIdentity helper for backfill Pure function that derives canonical (name, year) identity from a scoring_events row, stripping trailing 4-digit years from the name or falling back to eventDate. Used by the Phase 2 backfill to group per-window events into canonical tournaments. * scripts: add backfill orchestrator for canonical layer Populates canonical tournaments, participants, tournament_results, and participant_surface_elos from per-window data for qualifying-points sports. Skips already-linked rows, is idempotent, and supports dry-run mode. Critical invariants enforced by the implementation: - qualifying_points_awarded is never copied to tournament_results - season_participant_qualifying_totals is never touched - conflicting surface-Elo values between windows raise a loud error (recorded in report.errors) rather than overwriting * scripts: add backfill CLI with dry-run default Wires backfill-canonical-layer.ts to a CLI entry point exposed as `npm run backfill:canonical`. Defaults to --dry-run; requires --apply to actually write. Supports --sport=<uuid> to limit to a single sport. Exits 2 if the backfill reports errors (e.g., surface-Elo conflicts). * fix(backfill-cli): wrap runBackfill in DatabaseContext.run The orchestrator uses database() from ~/database/context, which requires AsyncLocalStorage to be populated. Wrap the CLI invocation with DatabaseContext.run(db, ...) using server/db's cached connection pool. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(backfill-cli): exit 0 on success so pg pool doesn't block The cached postgres connection pool keeps the Node event loop open after main() returns. Explicit process.exit(0) on success mirrors the pattern in scripts/capture-baseline.ts. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Chris Parsons <chrisp@extrahop.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
602 lines
27 KiB
TypeScript
602 lines
27 KiB
TypeScript
/**
|
||
* MLB Playoff Simulator
|
||
*
|
||
* Monte Carlo simulation of the MLB season and playoffs including seeding
|
||
* projection for the current season (2026).
|
||
*
|
||
* 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:
|
||
* - 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
|
||
*
|
||
* Win probability (log5 formula):
|
||
* Step 1 — convert projected RDif to win rate:
|
||
* 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)
|
||
*
|
||
* Futures blending:
|
||
* If sourceOdds are stored in participantExpectedValues for this season,
|
||
* the per-game win probability 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)
|
||
* probSecond = World Series loser (1 per sim)
|
||
* probThird / probFourth = LCS losers (2 per sim — AL + NL, split evenly)
|
||
* probFifth–probEighth = Division Series losers (4 per sim, split evenly)
|
||
* Wildcard Round losers → all 0 (score 0 points, same as non-playoff teams)
|
||
* Missed playoffs → all 0
|
||
*
|
||
* 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.
|
||
*
|
||
* 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.
|
||
*
|
||
* Divisions:
|
||
* AL East: Yankees, Orioles, Red Sox, Rays, Blue Jays
|
||
* AL Central: Royals, Guardians, Twins, Tigers, White Sox
|
||
* AL West: Astros, Mariners, Rangers, Angels, Athletics
|
||
* NL East: Phillies, Braves, Mets, Nationals, Marlins
|
||
* NL Central: Brewers, Cubs, Cardinals, Reds, Pirates
|
||
* NL West: Dodgers, Padres, Diamondbacks, Giants, Rockies
|
||
*/
|
||
|
||
import { database } from "~/database/context";
|
||
import { eq } from "drizzle-orm";
|
||
import * as schema from "~/database/schema";
|
||
import type { Simulator, SimulationResult } from "./types";
|
||
import { logger } from "~/lib/logger";
|
||
import {
|
||
convertAmericanOddsToProbability,
|
||
normalizeProbabilities,
|
||
} from "~/services/probability-engine";
|
||
|
||
// ─── Simulation parameters ────────────────────────────────────────────────────
|
||
|
||
const NUM_SIMULATIONS = 50_000;
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* 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
|
||
* 8000 → win rate 0.517 (current — near coin-flip vs any playoff team)
|
||
*/
|
||
const RDIF_DIVISOR = 8000;
|
||
|
||
/**
|
||
* Blend weights for RDif vs. Vegas futures odds when sourceOdds are available.
|
||
*/
|
||
const RDIF_WEIGHT = 0.7;
|
||
const ODDS_WEIGHT = 1 - RDIF_WEIGHT;
|
||
|
||
// ─── Team data (2026 pre-season — FanGraphs Depth Charts) ────────────────────
|
||
//
|
||
// 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.
|
||
|
||
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 },
|
||
|
||
// ── 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 },
|
||
|
||
// ── 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 },
|
||
|
||
// ── 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 },
|
||
|
||
// ── 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 },
|
||
|
||
// ── 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 },
|
||
};
|
||
|
||
// ─── Public helpers (exported for unit testing) ───────────────────────────────
|
||
|
||
/** Normalize a team name for lookup (lowercase, trimmed, collapsed whitespace). */
|
||
export function normalizeTeamName(name: string): string {
|
||
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
||
}
|
||
|
||
/** Look up team data by participant name (case-insensitive). */
|
||
export function getTeamData(name: string): MlbTeamData | undefined {
|
||
const normalized = normalizeTeamName(name);
|
||
for (const [teamName, data] of Object.entries(TEAMS_DATA)) {
|
||
if (normalizeTeamName(teamName) === normalized) return data;
|
||
}
|
||
return undefined;
|
||
}
|
||
|
||
/**
|
||
* Convert projected run differential to a compressed win rate for 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.
|
||
*/
|
||
export function winRateFromRDif(rdif: number): number {
|
||
return Math.min(0.99, Math.max(0.01, 0.5 + rdif / RDIF_DIVISOR));
|
||
}
|
||
|
||
/**
|
||
* Convert an Elo rating to an equivalent projected run differential.
|
||
* Uses the standard Elo win probability formula (parity factor 400, average Elo 1500),
|
||
* then inverts the winRateFromRDif formula: rdif = (winRate − 0.5) × RDIF_DIVISOR.
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* Bill James log5 head-to-head win probability for team A over team B,
|
||
* given their projected run differentials.
|
||
* P(A beats B) = (wA - wA·wB) / (wA + wB - 2·wA·wB)
|
||
* Exported for unit testing.
|
||
*/
|
||
export function rdifWinProbability(rdifA: number, rdifB: number): number {
|
||
const wA = winRateFromRDif(rdifA);
|
||
const wB = winRateFromRDif(rdifB);
|
||
// Denominator is always > 0 when wA and wB are in (0,1).
|
||
return (wA - wA * wB) / (wA + wB - 2 * wA * wB);
|
||
}
|
||
|
||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||
|
||
interface TeamEntry {
|
||
id: string;
|
||
name: string;
|
||
data: MlbTeamData | undefined;
|
||
originalSeed?: number;
|
||
}
|
||
|
||
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
|
||
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 };
|
||
|
||
/** Simulate a series where the winner must reach `winsNeeded` wins. */
|
||
function simSeries(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
winsNeeded: number,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
const prob = gameWinProb(a, b);
|
||
let winsA = 0;
|
||
let winsB = 0;
|
||
while (winsA < winsNeeded && winsB < winsNeeded) {
|
||
if (Math.random() < prob) winsA++; else winsB++;
|
||
}
|
||
return winsA === winsNeeded ? { winner: a, loser: b } : { winner: b, loser: a };
|
||
}
|
||
|
||
/** Wildcard Round: best-of-3 (first to 2 wins). */
|
||
export function simBo3(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 2, gameWinProb);
|
||
}
|
||
|
||
/** Division Series: best-of-5 (first to 3 wins). */
|
||
export function simBo5(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 3, gameWinProb);
|
||
}
|
||
|
||
/** League Championship + World Series: best-of-7 (first to 4 wins). */
|
||
export function simBo7(
|
||
a: TeamEntry,
|
||
b: TeamEntry,
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): SeriesResult {
|
||
return simSeries(a, b, 4, gameWinProb);
|
||
}
|
||
|
||
// ─── League bracket builder ───────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Draws the 6-team playoff field for one league (AL or NL).
|
||
*
|
||
* 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).
|
||
*
|
||
* 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).
|
||
*/
|
||
function drawLeaguePlayoffField(
|
||
leagueTeams: TeamEntry[],
|
||
getRDif: (t: TeamEntry) => number = getEntryRDif
|
||
): TeamEntry[] | undefined {
|
||
// Group by division
|
||
const divMap = new Map<string, TeamEntry[]>();
|
||
for (const t of leagueTeams) {
|
||
const div = t.data?.division ?? "Unknown";
|
||
if (!divMap.has(div)) divMap.set(div, []);
|
||
divMap.get(div)?.push(t);
|
||
}
|
||
|
||
const divisionWinners: TeamEntry[] = [];
|
||
const allDivisionWinnerSet = 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;
|
||
divisionWinners.push(winner);
|
||
allDivisionWinnerSet.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[] = [];
|
||
|
||
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);
|
||
}
|
||
|
||
// 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));
|
||
|
||
const seeds = [...sortedDivWinners, ...sortedWcTeams];
|
||
return seeds.map((t, i) => ({ ...t, originalSeed: i + 1 }));
|
||
}
|
||
|
||
/**
|
||
* Simulate the full playoff bracket for one league.
|
||
*
|
||
* Bracket structure:
|
||
* Wildcard Round (best-of-3): seeds 3v6, 4v5 — seeds 1 & 2 get byes
|
||
* Division Series (best-of-5): 1 vs lowest-seeded WC survivor; 2 vs other
|
||
* League Championship Series (best-of-7)
|
||
*
|
||
* Returns { lcWinner, lcLoser, dsLosers[2], wcLosers[2] }
|
||
*/
|
||
function simLeagueBracket(
|
||
seeds: TeamEntry[],
|
||
gameWinProb: (a: TeamEntry, b: TeamEntry) => number
|
||
): {
|
||
lcWinner: TeamEntry;
|
||
lcLoser: TeamEntry;
|
||
dsLosers: [TeamEntry, TeamEntry];
|
||
wcLosers: [TeamEntry, TeamEntry];
|
||
} {
|
||
const [s1, s2, s3, s4, s5, s6] = seeds;
|
||
|
||
// Wildcard Round (best-of-3)
|
||
const wc1 = simBo3(s3, s6, gameWinProb);
|
||
const wc2 = simBo3(s4, s5, gameWinProb);
|
||
|
||
// Division Series: re-seed — seed 1 plays the worse WC survivor, seed 2 plays the better one.
|
||
// Sort by originalSeed ascending: [0] = lower seed number = better team, [1] = worse team.
|
||
const survivors = [wc1.winner, wc2.winner].toSorted(
|
||
(a, b) => (a.originalSeed ?? 0) - (b.originalSeed ?? 0)
|
||
);
|
||
const ds1 = simBo5(s1, survivors[1], gameWinProb); // 1 vs worse remaining (higher seed number)
|
||
const ds2 = simBo5(s2, survivors[0], gameWinProb); // 2 vs better remaining (lower seed number)
|
||
|
||
// League Championship Series (best-of-7)
|
||
const lcs = simBo7(ds1.winner, ds2.winner, gameWinProb);
|
||
|
||
return {
|
||
lcWinner: lcs.winner,
|
||
lcLoser: lcs.loser,
|
||
dsLosers: [ds1.loser, ds2.loser],
|
||
wcLosers: [wc1.loser, wc2.loser],
|
||
};
|
||
}
|
||
|
||
// ─── Simulator ────────────────────────────────────────────────────────────────
|
||
|
||
export class MLBSimulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
const db = database();
|
||
|
||
// 1. Load all participants for this sports season.
|
||
const participantRows = await db
|
||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||
.from(schema.seasonParticipants)
|
||
.where(eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId));
|
||
|
||
if (participantRows.length === 0) {
|
||
throw new Error(
|
||
`No participants found for sports season ${sportsSeasonId}. ` +
|
||
`Add MLB teams as participants before running simulation.`
|
||
);
|
||
}
|
||
|
||
const participantIds = participantRows.map((r) => r.id);
|
||
|
||
const teams: TeamEntry[] = participantRows.map((r) => ({
|
||
id: r.id,
|
||
name: r.name,
|
||
data: getTeamData(r.name),
|
||
}));
|
||
|
||
// Warn about participants that don't match any hardcoded team.
|
||
const unrecognized = teams.filter((t) => !t.data);
|
||
if (unrecognized.length > 0) {
|
||
logger.warn(
|
||
`[MLBSimulator] ${unrecognized.length} participant(s) not found in TEAMS_DATA and will be excluded: ` +
|
||
unrecognized.map((t) => t.name).join(", ")
|
||
);
|
||
}
|
||
|
||
const alTeams = teams.filter((t) => t.data?.league === "AL");
|
||
const nlTeams = teams.filter((t) => t.data?.league === "NL");
|
||
|
||
if (alTeams.length < 6 || nlTeams.length < 6) {
|
||
throw new Error(
|
||
`Each league needs at least 6 recognized participants (3 division winners + 3 wild cards) ` +
|
||
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
|
||
`Add MLB teams before running simulation.`
|
||
);
|
||
}
|
||
|
||
// ─── Futures odds blending ─────────────────────────────────────────────────
|
||
|
||
const evRows = await db
|
||
.select({
|
||
participantId: schema.seasonParticipantExpectedValues.participantId,
|
||
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
|
||
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
|
||
})
|
||
.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)
|
||
);
|
||
const hasOdds = oddsRows.length > 0;
|
||
|
||
const normalizedOddsMap = new Map<string, number>();
|
||
if (hasOdds) {
|
||
const rawProbs = oddsRows.map((r) =>
|
||
convertAmericanOddsToProbability(r.sourceOdds ?? 0)
|
||
);
|
||
const normalized = normalizeProbabilities(rawProbs);
|
||
oddsRows.forEach(({ participantId }, i) => {
|
||
normalizedOddsMap.set(participantId, normalized[i]);
|
||
});
|
||
}
|
||
|
||
// Build a map of sourceElo-derived RDif values (overrides hardcoded TEAMS_DATA.rdif).
|
||
const sourceEloRDifMap = 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));
|
||
}
|
||
}
|
||
|
||
// ─── Helpers ──────────────────────────────────────────────────────────────
|
||
|
||
/** Effective RDif: prefer sourceElo-derived value over hardcoded TEAMS_DATA.rdif. */
|
||
const effectiveRDif = (entry: TeamEntry): number =>
|
||
sourceEloRDifMap.get(entry.id) ?? getEntryRDif(entry);
|
||
|
||
/**
|
||
* Blended per-game win probability for team A over team B.
|
||
* When odds are available: 70% RDif log5 + 30% vig-removed futures head-to-head.
|
||
*/
|
||
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
|
||
const rdifProb = rdifWinProbability(effectiveRDif(a), effectiveRDif(b));
|
||
if (!hasOdds) return rdifProb;
|
||
|
||
const o1 = normalizedOddsMap.get(a.id);
|
||
const o2 = normalizedOddsMap.get(b.id);
|
||
// Fall back to RDif if either team lacks odds — avoids inflating one side to 100%.
|
||
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return rdifProb;
|
||
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
|
||
return RDIF_WEIGHT * rdifProb + ODDS_WEIGHT * oddsProb;
|
||
};
|
||
|
||
// ─── Placement count maps ──────────────────────────────────────────────────
|
||
|
||
const championCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const finalistCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const lcsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
const dsLoserCounts = new Map<string, number>(participantIds.map((id) => [id, 0]));
|
||
// WC losers are not tracked — they score 0 points
|
||
|
||
// ─── Monte Carlo simulation loop ───────────────────────────────────────────
|
||
|
||
let effectiveN = 0;
|
||
|
||
for (let s = 0; s < NUM_SIMULATIONS; s++) {
|
||
const alField = drawLeaguePlayoffField(alTeams, effectiveRDif);
|
||
const nlField = drawLeaguePlayoffField(nlTeams, effectiveRDif);
|
||
if (!alField || !nlField) continue; // degenerate draw — skip
|
||
|
||
effectiveN++;
|
||
|
||
// Simulate both league brackets
|
||
const alResult = simLeagueBracket(alField, gameWinProb);
|
||
const nlResult = simLeagueBracket(nlField, gameWinProb);
|
||
|
||
// LCS losers (3rd/4th tier)
|
||
lcsLoserCounts.set(alResult.lcLoser.id, (lcsLoserCounts.get(alResult.lcLoser.id) ?? 0) + 1);
|
||
lcsLoserCounts.set(nlResult.lcLoser.id, (lcsLoserCounts.get(nlResult.lcLoser.id) ?? 0) + 1);
|
||
|
||
// DS losers (5th–8th tier)
|
||
for (const loser of [...alResult.dsLosers, ...nlResult.dsLosers]) {
|
||
dsLoserCounts.set(loser.id, (dsLoserCounts.get(loser.id) ?? 0) + 1);
|
||
}
|
||
|
||
// World Series (best-of-7)
|
||
const ws = simBo7(alResult.lcWinner, nlResult.lcWinner, gameWinProb);
|
||
championCounts.set(ws.winner.id, (championCounts.get(ws.winner.id) ?? 0) + 1);
|
||
finalistCounts.set(ws.loser.id, (finalistCounts.get(ws.loser.id) ?? 0) + 1);
|
||
}
|
||
|
||
if (effectiveN === 0) {
|
||
throw new Error(
|
||
"All simulations produced degenerate brackets. " +
|
||
"Check that each division has teams with non-degenerate RDif values."
|
||
);
|
||
}
|
||
|
||
// ─── Convert counts to probability distributions ───────────────────────────
|
||
//
|
||
// probFirst/Second → count / N (1 team per sim)
|
||
// probThird/Fourth → count / (2 * N) (2 LCS losers per sim: AL + NL)
|
||
// probFifth–Eighth → count / (4 * N) (4 DS losers per sim: 2 per league)
|
||
// WC losers → 0 (all probs zero)
|
||
|
||
const N = effectiveN;
|
||
const results: SimulationResult[] = participantIds.map((participantId) => {
|
||
const c = championCounts.get(participantId) ?? 0;
|
||
const f = finalistCounts.get(participantId) ?? 0;
|
||
const lcs = lcsLoserCounts.get(participantId) ?? 0;
|
||
const ds = dsLoserCounts.get(participantId) ?? 0;
|
||
return {
|
||
participantId,
|
||
probabilities: {
|
||
probFirst: c / N,
|
||
probSecond: f / N,
|
||
probThird: lcs / (2 * N),
|
||
probFourth: lcs / (2 * N),
|
||
probFifth: ds / (4 * N),
|
||
probSixth: ds / (4 * N),
|
||
probSeventh: ds / (4 * N),
|
||
probEighth: ds / (4 * N),
|
||
},
|
||
source: "mlb_bracket_monte_carlo",
|
||
};
|
||
});
|
||
|
||
// ─── Per-position normalization ────────────────────────────────────────────
|
||
|
||
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;
|
||
}
|
||
}
|