brackt/app/services/simulations/mlb-simulator.ts

653 lines
25 KiB
TypeScript
Raw Normal View History

Add MLB playoff simulator with AL/NL division standings, fixes #121 (#225) * Add MLB playoff simulator with AL/NL division standings, fixes #121 - New `mlb-simulator.ts`: 50k Monte Carlo sim drawing division winners (weighted by p_div) and 3 WC teams per league, then simulating WC best-of-3 → DS best-of-5 → LCS best-of-7 → World Series. Elo parity factor 350; blends Vegas odds 30/70 when sourceOdds available. - New `standings-sync/mlb.ts`: adapter for free statsapi.mlb.com API, mapping AL/NL conferences, division ranks, streaks, home/away/L10 splits. - New `mlb-divisions` display mode in RegularSeasonStandings: shows 1 division winner per division section + Wild Card section with 3-spot playoff line, headings read "AL/NL League" instead of "Conference". - Refactored buildNhlSections/buildMlbSections into shared buildDivisionSections (divisionSpots + wcSpots params); conferenceLabel now flows through group objects rather than being derived from displayMode in the render. - DB migration 0062 adds `mlb_bracket` to simulator_type enum. - 37 new tests across simulator, adapter, and component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix flaky tennis simulator test: increase trials and lower threshold 500 trials with a ~3–5% expected win rate had enough variance to occasionally land below the 0.03 threshold (~2% failure rate). Bumping to 2000 trials reduces the std dev by 2x; lowering the threshold to 0.02 keeps the assertion meaningful (still well above random 0.78%) while eliminating the flakiness. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:47:02 -07:00
/**
* 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 (Elo + seeding probabilities)
* 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 draws.
* b. Seed division winners 13 by Elo (best Elo = seed 1); WC teams 46 by Elo.
* 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 (Elo, PARITY_FACTOR = 350):
* P(A beats B) = 1 / (1 + 10^((eloB - eloA) / 350))
* MLB uses a lower parity factor than the NBA (400) or NHL (1000) to reflect
* that better teams win series at a somewhat higher rate in baseball. The short
* best-of-3 wildcard round introduces significant upset potential.
*
* Futures blending:
* If sourceOdds are stored in participantExpectedValues for this season,
* the per-game win probability is blended:
* P(game) = ELO_WEIGHT * eloProb + ODDS_WEIGHT * oddsProb
* where oddsProb = normalizedOdds(A) / (normalizedOdds(A) + normalizedOdds(B)).
* Normalized odds are vig-removed futures win probabilities.
* ELO_WEIGHT = 0.7, ODDS_WEIGHT = 0.3 (same calibration as NHL simulator).
* Falls back to Elo-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)
* probFifthprobEighth = 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:
* elo: Elo rating derived from FanGraphs projected wins (ExpW) via:
* elo = 1500 + 350 * log10(winRate / (1 - winRate))
* where winRate = ExpW / 162
* p_div: Probability of winning own division (FanGraphs divTitle).
* Must sum to ~1.0 within each 5-team division.
* p_wc: Probability of claiming one of 3 wildcard slots (FanGraphs wcTitle).
* Used as relative weights among non-division-winners in each league.
*
* Elo ratings and seeding probabilities are hardcoded below (2026 pre-season).
* Source: https://www.fangraphs.com/standings/playoff-odds/fg/div
* Update at the start of each season using the formula above.
*
* 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;
/**
* Elo parity factor. MLB uses 350 (below NHL's 1000 and NBA's 400) to reflect
* that teams of different quality win series at a higher rate in baseball
* compared to hockey, where single-game variance is very high.
*/
const PARITY_FACTOR = 350;
/**
* Blend weights for Elo vs. Vegas futures odds when sourceOdds are available.
* Same calibration as the NHL simulator (0.7 / 0.3).
*/
const ELO_WEIGHT = 0.7;
const ODDS_WEIGHT = 1 - ELO_WEIGHT;
// ─── Team data (2026 pre-season estimates) ────────────────────────────────────
//
// elo: Derived from FanGraphs projected wins (ExpW) via:
// elo = 1500 + 350 * log10(winRate / (1 - winRate))
// where winRate = ExpW / 162
// p_div: FanGraphs divTitle — probability of winning own division.
// Sum within each 5-team division should equal ~1.0.
// p_wc: FanGraphs wcTitle — probability of claiming a wildcard slot.
// Used as relative weights among non-division-winners per league.
// Does not need to sum to any specific value.
//
// Update these at the start of each season using the FanGraphs playoff odds page:
// https://www.fangraphs.com/standings/playoff-odds/fg/div
//
// Quick update formula (JavaScript):
// const winRate = ExpW / 162;
// const elo = Math.round(1500 + 350 * Math.log10(winRate / (1 - winRate)));
interface MlbTeamData {
league: "AL" | "NL";
division: "AL East" | "AL Central" | "AL West" | "NL East" | "NL Central" | "NL West";
elo: number;
p_div: number;
p_wc: number;
}
const TEAMS_DATA: Record<string, MlbTeamData> = {
// ── American League East ───────────────────────────────────────────────────
"New York Yankees": {
league: "AL", division: "AL East", elo: 1545,
p_div: 0.30, p_wc: 0.42,
},
"Baltimore Orioles": {
league: "AL", division: "AL East", elo: 1535,
p_div: 0.28, p_wc: 0.41,
},
"Boston Red Sox": {
league: "AL", division: "AL East", elo: 1520,
p_div: 0.22, p_wc: 0.35,
},
"Tampa Bay Rays": {
league: "AL", division: "AL East", elo: 1513,
p_div: 0.12, p_wc: 0.25,
},
"Toronto Blue Jays": {
league: "AL", division: "AL East", elo: 1506,
p_div: 0.08, p_wc: 0.18,
},
// ── American League Central ────────────────────────────────────────────────
"Kansas City Royals": {
league: "AL", division: "AL Central", elo: 1519,
p_div: 0.28, p_wc: 0.30,
},
"Cleveland Guardians": {
league: "AL", division: "AL Central", elo: 1516,
p_div: 0.27, p_wc: 0.29,
},
"Minnesota Twins": {
league: "AL", division: "AL Central", elo: 1514,
p_div: 0.23, p_wc: 0.25,
},
"Detroit Tigers": {
league: "AL", division: "AL Central", elo: 1510,
p_div: 0.15, p_wc: 0.18,
},
"Chicago White Sox": {
league: "AL", division: "AL Central", elo: 1440,
p_div: 0.07, p_wc: 0.03,
},
// ── American League West ───────────────────────────────────────────────────
"Houston Astros": {
league: "AL", division: "AL West", elo: 1537,
p_div: 0.35, p_wc: 0.40,
},
"Seattle Mariners": {
league: "AL", division: "AL West", elo: 1528,
p_div: 0.28, p_wc: 0.35,
},
"Texas Rangers": {
league: "AL", division: "AL West", elo: 1522,
p_div: 0.22, p_wc: 0.28,
},
"Los Angeles Angels": {
league: "AL", division: "AL West", elo: 1475,
p_div: 0.09, p_wc: 0.08,
},
"Athletics": {
league: "AL", division: "AL West", elo: 1455,
p_div: 0.06, p_wc: 0.04,
},
// ── National League East ───────────────────────────────────────────────────
"Philadelphia Phillies": {
league: "NL", division: "NL East", elo: 1553,
p_div: 0.35, p_wc: 0.42,
},
"Atlanta Braves": {
league: "NL", division: "NL East", elo: 1550,
p_div: 0.32, p_wc: 0.40,
},
"New York Mets": {
league: "NL", division: "NL East", elo: 1534,
p_div: 0.22, p_wc: 0.33,
},
"Washington Nationals": {
league: "NL", division: "NL East", elo: 1483,
p_div: 0.07, p_wc: 0.07,
},
"Miami Marlins": {
league: "NL", division: "NL East", elo: 1468,
p_div: 0.04, p_wc: 0.03,
},
// ── National League Central ────────────────────────────────────────────────
"Milwaukee Brewers": {
league: "NL", division: "NL Central", elo: 1529,
p_div: 0.35, p_wc: 0.38,
},
"Chicago Cubs": {
league: "NL", division: "NL Central", elo: 1515,
p_div: 0.27, p_wc: 0.28,
},
"St. Louis Cardinals": {
league: "NL", division: "NL Central", elo: 1507,
p_div: 0.20, p_wc: 0.20,
},
"Cincinnati Reds": {
league: "NL", division: "NL Central", elo: 1503,
p_div: 0.12, p_wc: 0.13,
},
"Pittsburgh Pirates": {
league: "NL", division: "NL Central", elo: 1498,
p_div: 0.06, p_wc: 0.07,
},
// ── National League West ───────────────────────────────────────────────────
"Los Angeles Dodgers": {
league: "NL", division: "NL West", elo: 1570,
p_div: 0.65, p_wc: 0.28,
},
"San Diego Padres": {
league: "NL", division: "NL West", elo: 1525,
p_div: 0.15, p_wc: 0.30,
},
"Arizona Diamondbacks": {
league: "NL", division: "NL West", elo: 1520,
p_div: 0.12, p_wc: 0.28,
},
"San Francisco Giants": {
league: "NL", division: "NL West", elo: 1510,
p_div: 0.06, p_wc: 0.20,
},
"Colorado Rockies": {
league: "NL", division: "NL West", elo: 1442,
p_div: 0.02, p_wc: 0.02,
},
};
// ─── 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;
}
/**
* Elo win probability for team A over team B.
* P(A) = 1 / (1 + 10^((eloB - eloA) / PARITY_FACTOR))
* Exported for unit testing.
*/
export function eloWinProbability(eloA: number, eloB: number): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / PARITY_FACTOR));
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
id: string;
name: string;
data: MlbTeamData | undefined;
originalSeed?: number;
}
/** Get Elo for a team entry. Fallback 1400 for unknown teams. */
function elo(entry: TeamEntry): number {
return entry.data?.elo ?? 1400;
}
/**
* Weighted pick without replacement using the given weight key.
* 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 13 by Elo (best Elo seed 1).
* 4. Rank WC teams 46 by Elo (best Elo 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[]
): 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
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 13 by Elo descending (best Elo = seed 1)
const sortedDivWinners = divisionWinners.toSorted((a, b) => elo(b) - elo(a));
// Rank WC teams 46 by Elo descending (best Elo = seed 4)
const sortedWcTeams = wcTeams.toSorted((a, b) => elo(b) - elo(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.participants.id, name: schema.participants.name })
.from(schema.participants)
.where(eq(schema.participants.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 < 5 || nlTeams.length < 5) {
throw new Error(
`Each league needs at least 5 recognized participants ` +
`(got AL: ${alTeams.length}, NL: ${nlTeams.length}). ` +
`Add MLB teams before running simulation.`
);
}
// ─── Futures odds blending ─────────────────────────────────────────────────
const evRows = await db
.select({
participantId: schema.participantExpectedValues.participantId,
sourceOdds: schema.participantExpectedValues.sourceOdds,
})
.from(schema.participantExpectedValues)
.where(eq(schema.participantExpectedValues.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]);
});
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Blended per-game win probability for team A over team B.
* When odds are available: 70% Elo + 30% vig-removed futures head-to-head.
*/
const gameWinProb = (a: TeamEntry, b: TeamEntry): number => {
const eloProb = eloWinProbability(elo(a), elo(b));
if (!hasOdds) return eloProb;
const o1 = normalizedOddsMap.get(a.id);
const o2 = normalizedOddsMap.get(b.id);
// Fall back to Elo if either team lacks odds — avoids inflating one side to 100%.
if (o1 === null || o1 === undefined || o2 === null || o2 === undefined) return eloProb;
const oddsProb = o1 + o2 > 0 ? o1 / (o1 + o2) : 0.5;
return ELO_WEIGHT * eloProb + 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);
const nlField = drawLeaguePlayoffField(nlTeams);
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 (5th8th 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 positive p_div 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)
// probFifthEighth → 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;
}
}