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