Fix MLS simulator: config loading, sourceOdds fallback, normalization

Three issues found in code review comparing against EPL and NFL simulators:

1. Load simulator config from DB via getSportsSeasonSimulatorConfig so
   admins can override iterations, seasonGames, parityFactor, drawRates
   per season without code changes. Previously all constants were hardcoded.

2. Add sourceOdds → convertFuturesToElo fallback when no sourceElo is
   present. EPL and NFL both do this; MLS was throwing immediately instead
   of attempting the odds conversion that the manifest declares as optional.

3. Call normalizeSimulationResultColumns before returning results, matching
   EPL's local normalization pattern for consistency (runner also normalizes
   globally, but EPL calls it locally too).

https://claude.ai/code/session_015wkBJ3SYGcMGjsddKKGkwa
This commit is contained in:
Claude 2026-05-14 19:03:08 +00:00
parent 4e0c4aa524
commit 43c9128e59
No known key found for this signature in database

View file

@ -11,8 +11,8 @@
* Simulates the playoff bracket directly from those seeds.
*
* Mode 2: Regular-Season Projection (default)
* Simulates remaining 34-game regular-season games per team using Elo vs the
* average opponent (1500), per conference. Top 9 teams per conference qualify.
* Simulates remaining regular-season games per team using Elo vs the average
* opponent (1500), per conference. Top 9 teams per conference qualify.
* Then simulates the MLS Cup Playoffs bracket.
*
* Conference assignment resolution order:
@ -40,21 +40,30 @@ import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import type { Simulator, SimulationResult } from "./types";
import { eloWinProbabilityWithParity } from "~/services/probability-engine";
import { eloWinProbabilityWithParity, convertFuturesToElo } from "~/services/probability-engine";
import { getRegularSeasonStandings } from "~/models/regular-season-standings";
import { getParticipantSimulatorInputs } from "~/models/simulator";
import { getParticipantSimulatorInputs, getSportsSeasonSimulatorConfig } from "~/models/simulator";
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
import type { EloSoccerMatchOptions } from "./soccer-helpers";
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
// ─── Constants ────────────────────────────────────────────────────────────────
// ─── Default constants (overridable via season simulator config) ───────────────
const DEFAULT_NUM_SIMULATIONS = 50_000;
const DEFAULT_SEASON_GAMES = 34;
const DEFAULT_PARITY_FACTOR = 400;
const DEFAULT_AVERAGE_OPPONENT_ELO = 1500;
const DEFAULT_BASE_DRAW_RATE = 0.26;
const DEFAULT_DRAW_DECAY = 0.002;
const NUM_SIMULATIONS = 50_000;
const MLS_REGULAR_SEASON_GAMES = 34;
const MLS_PLAYOFF_TEAMS_PER_CONF = 9;
const PARITY_FACTOR = 400;
const AVERAGE_OPPONENT_ELO = 1500;
const BASE_DRAW_RATE = 0.26;
const DRAW_DECAY = 0.002;
// ─── Config helpers ────────────────────────────────────────────────────────────
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
// ─── Public types ─────────────────────────────────────────────────────────────
@ -86,14 +95,14 @@ export interface MlsPlayoffResult {
// ─── Match helpers ────────────────────────────────────────────────────────────
export function mlsWinProbability(eloA: number, eloB: number, parityFactor = PARITY_FACTOR): number {
export function mlsWinProbability(eloA: number, eloB: number, parityFactor = DEFAULT_PARITY_FACTOR): number {
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
}
const DEFAULT_MATCH_OPTIONS: EloSoccerMatchOptions = {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
parityFactor: PARITY_FACTOR,
baseDrawRate: DEFAULT_BASE_DRAW_RATE,
drawDecay: DEFAULT_DRAW_DECAY,
parityFactor: DEFAULT_PARITY_FACTOR,
};
/**
@ -110,7 +119,7 @@ export function simulateMlsKnockoutGame(
if (result === "win") return { winner: teamA, loser: teamB };
if (result === "loss") return { winner: teamB, loser: teamA };
// Draw → penalty shootout (Elo-biased coin flip)
const pA = mlsWinProbability(teamA.elo, teamB.elo, matchOptions.parityFactor ?? PARITY_FACTOR);
const pA = mlsWinProbability(teamA.elo, teamB.elo, matchOptions.parityFactor ?? DEFAULT_PARITY_FACTOR);
return Math.random() < pA
? { winner: teamA, loser: teamB }
: { winner: teamB, loser: teamA };
@ -148,7 +157,8 @@ export function simulateMlsBestOfThree(
*/
export function simulateMlsRegularSeason(
teams: MlsTeamEntry[],
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS
matchOptions: EloSoccerMatchOptions = DEFAULT_MATCH_OPTIONS,
averageOpponentElo = DEFAULT_AVERAGE_OPPONENT_ELO
): { east: MlsConferenceSeed[]; west: MlsConferenceSeed[] } {
const jitter = new Map(teams.map((t) => [t.participantId, Math.random()]));
@ -158,7 +168,7 @@ export function simulateMlsRegularSeason(
let gf = t.currentGoalsFor;
for (let g = 0; g < t.remainingGames; g++) {
const result = simulateEloSoccerMatch(t.elo, AVERAGE_OPPONENT_ELO, matchOptions);
const result = simulateEloSoccerMatch(t.elo, averageOpponentElo, matchOptions);
const goals = simulateSimpleSoccerGoals(result);
gf += goals.gf;
gd += goals.gf - goals.ga;
@ -247,12 +257,9 @@ export function simulateMlsPlayoffs(
// MLS Cup: East champion vs West champion
const cup = simulateMlsKnockoutGame(eastResult.champion, westResult.champion, matchOptions);
// Determine finalist (MLS Cup loser)
const cupLoser = cup.loser;
return {
champion: cup.winner.participantId,
finalist: cupLoser.participantId,
finalist: cup.loser.participantId,
confFinalsLosers: [eastResult.finalist.participantId, westResult.finalist.participantId],
confSemiLosers: [
...eastResult.confSemiLosers,
@ -267,43 +274,63 @@ export class MLSSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// Load participants
const participants = await db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
});
const [participants, evRows, standingsRows, simInputs, simulatorConfig] = await Promise.all([
db.query.seasonParticipants.findMany({
where: eq(schema.seasonParticipants.sportsSeasonId, sportsSeasonId),
}),
db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
sourceOdds: schema.seasonParticipantExpectedValues.sourceOdds,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
getRegularSeasonStandings(sportsSeasonId),
getParticipantSimulatorInputs(sportsSeasonId),
getSportsSeasonSimulatorConfig(sportsSeasonId),
]);
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// Load Elo ratings from EV table (materialized by prepareSimulatorInputsForRun)
const evRows = await db
.select({
participantId: schema.seasonParticipantExpectedValues.participantId,
sourceElo: schema.seasonParticipantExpectedValues.sourceElo,
})
.from(schema.seasonParticipantExpectedValues)
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId));
// Extract config values (admin-overridable; fall back to defaults)
const config = simulatorConfig?.config;
const numSimulations = Math.round(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions: EloSoccerMatchOptions = {
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
parityFactor: configNumber(config, "matchParityFactor", DEFAULT_PARITY_FACTOR),
};
// Build Elo map: prefer sourceElo; fall back to converting sourceOdds
const eloMap = new Map<string, number>();
for (const r of evRows) {
if (r.sourceElo !== null && r.sourceElo !== undefined) {
eloMap.set(r.participantId, r.sourceElo);
const hasSourceElo = evRows.some((r) => r.sourceElo !== null);
if (hasSourceElo) {
for (const r of evRows) {
if (r.sourceElo !== null) eloMap.set(r.participantId, r.sourceElo);
}
} else {
const hasOdds = evRows.some((r) => r.sourceOdds !== null);
if (!hasOdds) {
throw new Error(
`No Elo ratings or futures odds found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo, projectedTablePoints, or sourceOdds via Admin → Elo Ratings before simulating.`
);
}
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds as number }));
const converted = convertFuturesToElo(oddsInput);
for (const [id, elo] of converted) eloMap.set(id, elo);
}
if (eloMap.size === 0) {
throw new Error(
`No Elo ratings found for sports season ${sportsSeasonId}. ` +
`Enter sourceElo or projectedWins via Admin → Elo Ratings before simulating.`
);
}
// Load simulator inputs (seed + region)
const simInputs = await getParticipantSimulatorInputs(sportsSeasonId);
// Build simulator input maps (seed + region)
const seedMap = new Map<string, number>();
const regionMap = new Map<string, string>();
for (const input of simInputs) {
if (input.seed !== null && input.seed !== undefined) {
seedMap.set(input.participantId, input.seed);
@ -313,11 +340,8 @@ export class MLSSimulator implements Simulator {
}
}
// Load regular season standings (for current points, GD, GF, conference)
const standingsRows = await getRegularSeasonStandings(sportsSeasonId, db);
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
// Resolve conference for every participant
const standingsMap = new Map(standingsRows.map((r) => [r.participantId, r]));
const conferenceMap = new Map<string, MlsConference>();
for (const p of participants) {
const standing = standingsMap.get(p.id);
@ -366,7 +390,7 @@ export class MLSSimulator implements Simulator {
westSeedSet.size === MLS_PLAYOFF_TEAMS_PER_CONF;
if (isKnownSeed) {
return this.simulateKnownSeeds(allIds, eastSeeds, westSeeds);
return this.simulateKnownSeeds(allIds, eastSeeds, westSeeds, numSimulations, matchOptions);
}
// Mode 2: Regular-season projection (default, including preseason)
@ -385,7 +409,7 @@ export class MLSSimulator implements Simulator {
currentPoints: standing?.tablePoints ?? wins * 3 + draws,
currentGoalsFor: standing?.goalsFor ?? 0,
currentGoalDifference: standing?.goalDifference ?? 0,
remainingGames: Math.max(0, MLS_REGULAR_SEASON_GAMES - gamesPlayed),
remainingGames: Math.max(0, seasonGames - gamesPlayed),
}];
});
@ -393,7 +417,7 @@ export class MLSSimulator implements Simulator {
throw new Error(`No participants with Elo ratings found for season ${sportsSeasonId}.`);
}
return this.simulateRegularSeason(allIds, teamsWithElo);
return this.simulateRegularSeason(allIds, teamsWithElo, numSimulations, matchOptions, averageOpponentElo);
}
// ── Mode 1: Known-Seed ──────────────────────────────────────────────────────
@ -401,33 +425,38 @@ export class MLSSimulator implements Simulator {
private simulateKnownSeeds(
allIds: string[],
east: MlsConferenceSeed[],
west: MlsConferenceSeed[]
west: MlsConferenceSeed[],
numSimulations: number,
matchOptions: EloSoccerMatchOptions
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const result = simulateMlsPlayoffs(east, west);
for (let s = 0; s < numSimulations; s++) {
const result = simulateMlsPlayoffs(east, west, matchOptions);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts);
return this.buildResults(allIds, counts, numSimulations);
}
// ── Mode 2: Regular-Season Projection ──────────────────────────────────────
private simulateRegularSeason(
allIds: string[],
teams: MlsTeamEntry[]
teams: MlsTeamEntry[],
numSimulations: number,
matchOptions: EloSoccerMatchOptions,
averageOpponentElo: number
): SimulationResult[] {
const counts = this.zeroCounts(allIds);
for (let s = 0; s < NUM_SIMULATIONS; s++) {
const { east, west } = simulateMlsRegularSeason(teams);
for (let s = 0; s < numSimulations; s++) {
const { east, west } = simulateMlsRegularSeason(teams, matchOptions, averageOpponentElo);
if (east.length < MLS_PLAYOFF_TEAMS_PER_CONF || west.length < MLS_PLAYOFF_TEAMS_PER_CONF) {
// Not enough teams in a conference — skip this iteration
continue;
}
const result = simulateMlsPlayoffs(east, west);
const result = simulateMlsPlayoffs(east, west, matchOptions);
this.recordResult(counts, result);
}
return this.buildResults(allIds, counts);
return this.buildResults(allIds, counts, numSimulations);
}
// ── Helpers ─────────────────────────────────────────────────────────────────
@ -457,10 +486,11 @@ export class MLSSimulator implements Simulator {
private buildResults(
allIds: string[],
counts: ReturnType<MLSSimulator["zeroCounts"]>
counts: ReturnType<MLSSimulator["zeroCounts"]>,
numSimulations: number
): SimulationResult[] {
const N = NUM_SIMULATIONS;
return allIds.map((id) => ({
const N = numSimulations;
const results: SimulationResult[] = allIds.map((id) => ({
participantId: id,
probabilities: {
probFirst: (counts.champion.get(id) ?? 0) / N,
@ -476,5 +506,7 @@ export class MLSSimulator implements Simulator {
},
source: "mls_bracket_monte_carlo",
}));
normalizeSimulationResultColumns(results);
return results;
}
}