@ -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 < string , MlbTeamData > = {
// ── American League East ───────────────────────────────────────────────────
"New York Yankees" : { league : "AL" , division : "AL East" , rdif : 67 , p_div : 0.374 , p_wc : 0.358 } ,
"Baltimore Orioles" : { league : "AL" , division : "AL East" , rdif : 23 , p_div : 0.128 , p_wc : 0.310 } ,
"Boston Red Sox" : { league : "AL" , division : "AL East" , rdif : 48 , p_div : 0.247 , p_wc : 0.373 } ,
"Tampa Bay Rays" : { league : "AL" , division : "AL East" , rdif : 2 , p_div : 0.066 , p_wc : 0.229 } ,
"Toronto Blue Jays" : { league : "AL" , division : "AL East" , rdif : 37 , p_div : 0.184 , p_wc : 0.349 } ,
"New York Yankees" : { league : "AL" , division : "AL East" , rdif : 67 } ,
"Baltimore Orioles" : { league : "AL" , division : "AL East" , rdif : 23 } ,
"Boston Red Sox" : { league : "AL" , division : "AL East" , rdif : 48 } ,
"Tampa Bay Rays" : { league : "AL" , division : "AL East" , rdif : 2 } ,
"Toronto Blue Jays" : { league : "AL" , division : "AL East" , rdif : 37 } ,
// ── American League Central ────────────────────────────────────────────────
"Kansas City Royals" : { league : "AL" , division : "AL Central" , rdif : 8 , p_div : 0.297 , p_wc : 0.157 } ,
"Cleveland Guardians" : { league : "AL" , division : "AL Central" , rdif : - 38 , p_div : 0.084 , p_wc : 0.075 } ,
"Minnesota Twins" : { league : "AL" , division : "AL Central" , rdif : - 15 , p_div : 0.166 , p_wc : 0.120 } ,
"Detroit Tigers" : { league : "AL" , division : "AL Central" , rdif : 26 , p_div : 0.449 , p_wc : 0.155 } ,
"Chicago White Sox" : { league : "AL" , division : "AL Central" , rdif : - 112 , p_div : 0.005 , p_wc : 0.004 } ,
"Kansas City Royals" : { league : "AL" , division : "AL Central" , rdif : 8 } ,
"Cleveland Guardians" : { league : "AL" , division : "AL Central" , rdif : - 38 } ,
"Minnesota Twins" : { league : "AL" , division : "AL Central" , rdif : - 15 } ,
"Detroit Tigers" : { league : "AL" , division : "AL Central" , rdif : 26 } ,
"Chicago White Sox" : { league : "AL" , division : "AL Central" , rdif : - 112 } ,
// ── American League West ───────────────────────────────────────────────────
"Houston Astros" : { league : "AL" , division : "AL West" , rdif : - 1 , p_div : 0.126 , p_wc : 0.213 } ,
"Seattle Mariners" : { league : "AL" , division : "AL West" , rdif : 66 , p_div : 0.595 , p_wc : 0.205 } ,
"Texas Rangers" : { league : "AL" , division : "AL West" , rdif : 20 , p_div : 0.201 , p_wc : 0.270 } ,
"Los Angeles Angels" : { league : "AL" , division : "AL West" , rdif : - 65 , p_div : 0.011 , p_wc : 0.035 } ,
"Athletics" : { league : "AL" , division : "AL West" , rdif : - 17 , p_div : 0.068 , p_wc : 0.146 } ,
"Houston Astros" : { league : "AL" , division : "AL West" , rdif : - 1 } ,
"Seattle Mariners" : { league : "AL" , division : "AL West" , rdif : 66 } ,
"Texas Rangers" : { league : "AL" , division : "AL West" , rdif : 20 } ,
"Los Angeles Angels" : { league : "AL" , division : "AL West" , rdif : - 65 } ,
"Athletics" : { league : "AL" , division : "AL West" , rdif : - 17 } ,
// ── National League East ───────────────────────────────────────────────────
"Philadelphia Phillies" : { league : "NL" , division : "NL East" , rdif : 51 , p_div : 0.243 , p_wc : 0.444 } ,
"Atlanta Braves" : { league : "NL" , division : "NL East" , rdif : 67 , p_div : 0.357 , p_wc : 0.425 } ,
"New York Mets" : { league : "NL" , division : "NL East" , rdif : 71 , p_div : 0.389 , p_wc : 0.413 } ,
"Washington Nationals" : { league : "NL" , division : "NL East" , rdif : - 113 , p_div : 0.001 , p_wc : 0.006 } ,
"Miami Marlins" : { league : "NL" , division : "NL East" , rdif : - 48 , p_div : 0.011 , p_wc : 0.075 } ,
"Philadelphia Phillies" : { league : "NL" , division : "NL East" , rdif : 51 } ,
"Atlanta Braves" : { league : "NL" , division : "NL East" , rdif : 67 } ,
"New York Mets" : { league : "NL" , division : "NL East" , rdif : 71 } ,
"Washington Nationals" : { league : "NL" , division : "NL East" , rdif : - 113 } ,
"Miami Marlins" : { league : "NL" , division : "NL East" , rdif : - 48 } ,
// ── National League Central ────────────────────────────────────────────────
"Milwaukee Brewers" : { league : "NL" , division : "NL Central" , rdif : 9 , p_div : 0.243 , p_wc : 0.170 } ,
"Chicago Cubs" : { league : "NL" , division : "NL Central" , rdif : 23 , p_div : 0.347 , p_wc : 0.183 } ,
"St. Louis Cardinals" : { league : "NL" , division : "NL Central" , rdif : - 55 , p_div : 0.038 , p_wc : 0.049 } ,
"Cincinnati Reds" : { league : "NL" , division : "NL Central" , rdif : - 31 , p_div : 0.086 , p_wc : 0.095 } ,
"Pittsburgh Pirates" : { league : "NL" , division : "NL Central" , rdif : 13 , p_div : 0.285 , p_wc : 0.178 } ,
"Milwaukee Brewers" : { league : "NL" , division : "NL Central" , rdif : 9 } ,
"Chicago Cubs" : { league : "NL" , division : "NL Central" , rdif : 23 } ,
"St. Louis Cardinals" : { league : "NL" , division : "NL Central" , rdif : - 55 } ,
"Cincinnati Reds" : { league : "NL" , division : "NL Central" , rdif : - 31 } ,
"Pittsburgh Pirates" : { league : "NL" , division : "NL Central" , rdif : 13 } ,
// ── National League West ───────────────────────────────────────────────────
"Los Angeles Dodgers" : { league : "NL" , division : "NL West" , rdif : 137 , p_div : 0.900 , p_wc : 0.082 } ,
"San Diego Padres" : { league : "NL" , division : "NL West" , rdif : - 9 , p_div : 0.023 , p_wc : 0.240 } ,
"Arizona Diamondbacks" : { league : "NL" , division : "NL West" , rdif : 5 , p_div : 0.039 , p_wc : 0.322 } ,
"San Francisco Giants" : { league : "NL" , division : "NL West" , rdif : 3 , p_div : 0.038 , p_wc : 0.317 } ,
"Colorado Rockies" : { league : "NL" , division : "NL West" , rdif : - 173 , p_div : 0.001 , p_wc : 0.001 } ,
"Los Angeles Dodgers" : { league : "NL" , division : "NL West" , rdif : 137 } ,
"San Diego Padres" : { league : "NL" , division : "NL West" , rdif : - 9 } ,
"Arizona Diamondbacks" : { league : "NL" , division : "NL West" , rdif : 5 } ,
"San Francisco Giants" : { league : "NL" , division : "NL West" , rdif : 3 } ,
"Colorado Rockies" : { league : "NL" , division : "NL West" , rdif : - 173 } ,
} ;
// ─── Public helpers (exported for unit testing) ───────────────────────────────
@ -180,7 +185,7 @@ export function getTeamData(name: string): MlbTeamData | undefined {
}
/ * *
* Convert projected run differential to a compressed win rate for matchup probability .
* Convert projected run differential to a compressed win rate for playoff matchup probability .
* Uses RDIF_DIVISOR to control how much team strength spreads away from . 500 .
* Clamped to [ 0.01 , 0.99 ] to avoid degenerate log5 values .
* Exported for unit testing .
@ -189,6 +194,26 @@ export function winRateFromRDif(rdif: number): number {
return Math . min ( 0.99 , Math . max ( 0.01 , 0.5 + rdif / RDIF_DIVISOR ) ) ;
}
/ * *
* Convert projected run differential to a raw per - game win rate for regular - season
* seeding simulation . Uses SEEDING_RDIF_SCALE ( ~ 10 runs / win × 162 games ) which
* gives a realistic season win percentage rather than the playoff - compressed value .
* Exported for unit testing .
* /
export function rawWinRateFromRDif ( rdif : number ) : number {
return Math . min ( 0.99 , Math . max ( 0.01 , 0.5 + rdif / SEEDING_RDIF_SCALE ) ) ;
}
/ * *
* Derive a raw per - game win rate directly from an Elo rating .
* This is the inverse Elo formula , returning the same win probability
* that was originally used to compute the Elo from projected wins .
* Exported for unit testing .
* /
export function rawWinRateFromElo ( elo : number ) : number {
return 1 / ( 1 + Math . pow ( 10 , ( 1500 - elo ) / 400 ) ) ;
}
/ * *
* Convert an Elo rating to an equivalent projected run differential .
* Uses the standard Elo win probability formula ( parity factor 400 , average Elo 1500 ) ,
@ -196,8 +221,7 @@ export function winRateFromRDif(rdif: number): number {
* Exported for unit testing .
* /
export function eloToRDif ( elo : number ) : number {
const winRate = 1 / ( 1 + Math . pow ( 10 , ( 1500 - elo ) / 400 ) ) ;
return ( winRate - 0.5 ) * RDIF_DIVISOR ;
return ( rawWinRateFromElo ( elo ) - 0.5 ) * RDIF_DIVISOR ;
}
/ * *
@ -213,6 +237,39 @@ export function rdifWinProbability(rdifA: number, rdifB: number): number {
return ( wA - wA * wB ) / ( wA + wB - 2 * wA * wB ) ;
}
/ * *
* Sample the number of wins from n independent Bernoulli trials each with
* probability p . Used to project remaining regular - season wins per team .
*
* For n ≥ 30 ( where CLT applies well : n · p ≥ 5 and n · ( 1 - p ) ≥ 5 for any
* realistic win rate ) , uses a Box - Muller normal approximation — 2 Math . random ( )
* calls per team instead of n , cutting the seeding phase from ~ 243 M to ~ 3 M
* Math . random ( ) calls per 50 K - simulation run . For small n the exact Bernoulli
* loop is used . Both paths produce integer output clamped to [ 0 , n ] .
* Exported for unit testing .
* /
export function sampleBinomial ( n : number , p : number ) : number {
if ( n <= 0 ) return 0 ;
if ( p <= 0 ) return 0 ;
if ( p >= 1 ) return n ;
if ( n >= 30 ) {
// Normal approximation via Box-Muller transform.
// Guard u1 > 0 to avoid log(0) = -Infinity.
const u1 = Math . max ( Number . EPSILON , Math . random ( ) ) ;
const u2 = Math . random ( ) ;
const z = Math . sqrt ( - 2 * Math . log ( u1 ) ) * Math . cos ( 2 * Math . PI * u2 ) ;
return Math . round ( Math . min ( n , Math . max ( 0 , n * p + Math . sqrt ( n * p * ( 1 - p ) ) * z ) ) ) ;
}
// Exact Bernoulli trials for small n.
let wins = 0 ;
for ( let i = 0 ; i < n ; i ++ ) {
if ( Math . random ( ) < p ) wins ++ ;
}
return wins ;
}
// ─── Internal types ───────────────────────────────────────────────────────────
interface TeamEntry {
@ -220,6 +277,8 @@ interface TeamEntry {
name : string ;
data : MlbTeamData | undefined ;
originalSeed? : number ;
currentWins : number ; // from regularSeasonStandings (0 pre-season)
remainingGames : number ; // TOTAL_SEASON_GAMES - gamesPlayed
}
/** Get projected RDif for a team entry. Fallback 0 (league-average) for unknown teams. */
@ -227,30 +286,6 @@ function getEntryRDif(entry: TeamEntry): number {
return entry . data ? . rdif ? ? 0 ;
}
/ * *
* Weighted pick without replacement using the given weight function .
* Returns undefined if no eligible team has positive weight .
* /
function weightedPickByKey (
pool : TeamEntry [ ] ,
excluded : Set < TeamEntry > ,
getWeight : ( t : TeamEntry ) = > number
) : TeamEntry | undefined {
const eligible = pool . filter ( ( t ) = > ! excluded . has ( t ) ) ;
if ( eligible . length === 0 ) return undefined ;
const weights = eligible . map ( getWeight ) ;
const total = weights . reduce ( ( s , w ) = > s + w , 0 ) ;
if ( total === 0 ) return undefined ;
let r = Math . random ( ) * total ;
for ( let i = 0 ; i < eligible . length ; i ++ ) {
r -= weights [ i ] ;
if ( r <= 0 ) return eligible [ i ] ;
}
return eligible [ eligible . length - 1 ] ;
}
// ─── Series simulators ─────────────────────────────────────────────────────────
type SeriesResult = { winner : TeamEntry ; loser : TeamEntry } ;
@ -301,21 +336,23 @@ export function simBo7(
// ─── League bracket builder ───────────────────────────────────────────────────
/ * *
* Draws the 6 - team playoff field for one league ( AL or NL ) .
* Draws the 6 - team playoff field for one league ( AL or NL ) by simulating the
* remaining regular season for each team .
*
* Steps :
* 1 . For each of the 3 divisions , draw 1 division winner weighted by p_div .
* 2 . From the remaining non - division - winners , draw 3 WC teams weighted by p_wc .
* 3 . Rank division winners 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 [ ] ,
get RDif : ( t : TeamEntry ) = > number = getEntryRDif
get SeedingWinRate : ( t : TeamEntry ) = > number
) : TeamEntry [ ] | undefined {
// Group by division
const divMap = new Map < string , TeamEntry [ ] > ( ) ;
@ -325,33 +362,42 @@ function drawLeaguePlayoffField(
divMap . get ( div ) ? . push ( t ) ;
}
// Simulate remaining games for each team; tiny noise breaks integer win ties
const finalWins = new Map < string , number > ( ) ;
for ( const t of leagueTeams ) {
finalWins . set (
t . id ,
t . currentWins + sampleBinomial ( t . remainingGames , getSeedingWinRate ( t ) ) + Math . random ( ) * 0.001
) ;
}
// Division winners: best record per division
const divisionWinners : TeamEntry [ ] = [ ] ;
const allDivisionWinnerSet = new Set < TeamEntry > ( ) ;
const div WinnerSet = new Set < TeamEntry > ( ) ;
for ( const divTeams of divMap . values ( ) ) {
const winner = weightedPickByKey ( divTeams , new Set ( ) , ( t ) = > t . data ? . p_div ? ? 0 ) ;
if ( ! winner ) return undefined ;
const winner = divTeams . reduce ( ( best , t ) = >
( finalWins . get ( t . id ) ? ? 0 ) > ( finalWins . get ( best . id ) ? ? 0 ) ? t : best
) ;
divisionWinners . push ( winner ) ;
allDivisionWinnerSet . add ( winner ) ;
div WinnerSet. add ( winner ) ;
}
// Draw 3 WC teams from non-division-winners, weighted by p_wc
const wcPool = leagueTeams . filter ( ( t ) = > ! allDivisionWinnerSet . has ( t ) ) ;
const wcTaken = new Set < TeamEntry > ( ) ;
const wcTeams : TeamEntry [ ] = [ ] ;
// Wild card: top 3 non-division-winners by final wins
const wcTeams = leagueTeams
. filter ( ( t ) = > ! divWinnerSet . has ( t ) )
. toSorted ( ( a , b ) = > ( finalWins . get ( b . id ) ? ? 0 ) - ( finalWins . get ( a . id ) ? ? 0 ) )
. slice ( 0 , 3 ) ;
for ( let i = 0 ; i < 3 ; i ++ ) {
const wc = weightedPickByKey ( wcPool , wcTaken , ( t ) = > t . data ? . p_wc ? ? 0 ) ;
if ( ! wc ) return undefined ;
wcTeams . push ( wc ) ;
wcTaken . add ( wc ) ;
}
if ( wcTeams . length < 3 ) return undefined ;
// Rank division winners 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 < string , number > ( ) ;
// Build a map of raw (uncompressed) win rates from sourceElo for regular-season seeding.
const rawWinRateMap = new Map < string , number > ( ) ;
for ( const r of evRows ) {
if ( r . sourceElo !== null && r . sourceElo !== undefined && participantIdSet . has ( r . participantId ) ) {
sourceEloRDifMap . set ( r . participantId , eloToRDif ( r . sourceElo ) ) ;
rawWinRateMap . set ( r . participantId , rawWinRateFromElo ( r . sourceElo ) ) ;
}
}
// ─── Helpers ──────────────────────────────────────────────────────────────
/** Effective RDif : prefer sourceElo-derived value over hardcoded TEAMS_DATA. rdif. */
/** Effective RDif for playoff series : prefer sourceElo-derived value over hardcoded rdif. */
const effectiveRDif = ( entry : TeamEntry ) : number = >
sourceEloRDifMap . get ( entry . id ) ? ? getEntryRDif ( entry ) ;
/ * *
* Blended per - game win probability for team A over team B .
* Raw per - game win rate for regular - season seeding simulation .
* Uses sourceElo - derived rate if available ; falls back to hardcoded rdif
* with SEEDING_RDIF_SCALE ( Pythagorean approximation ) .
* /
const seedingWinRate = ( entry : TeamEntry ) : number = >
rawWinRateMap . get ( entry . id ) ? ? rawWinRateFromRDif ( getEntryRDif ( entry ) ) ;
/ * *
* Blended per - game win probability for team A over team B in a playoff series .
* When odds are available : 70 % RDif log5 + 30 % vig - removed futures head - to - head .
* /
const gameWinProb = ( a : TeamEntry , b : TeamEntry ) : number = > {
@ -519,8 +603,8 @@ export class MLBSimulator implements Simulator {
let effectiveN = 0 ;
for ( let s = 0 ; s < NUM_SIMULATIONS ; s ++ ) {
const alField = drawLeaguePlayoffField ( alTeams , effectiveRDif ) ;
const nlField = drawLeaguePlayoffField ( nlTeams , effectiveRDif ) ;
const alField = drawLeaguePlayoffField ( alTeams , seedingWinRate ) ;
const nlField = drawLeaguePlayoffField ( nlTeams , seedingWinRate ) ;
if ( ! alField || ! nlField ) continue ; // degenerate draw — skip
effectiveN ++ ;