diff --git a/app/services/simulations/mls-simulator.ts b/app/services/simulations/mls-simulator.ts index c5355e2..3904d38 100644 --- a/app/services/simulations/mls-simulator.ts +++ b/app/services/simulations/mls-simulator.ts @@ -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 | 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 { 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(); - 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(); const regionMap = new Map(); - 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(); 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 + counts: ReturnType, + 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; } }