/** * EPL Season Standings Simulator * * Monte Carlo simulation of a Premier League table. Reads current actual * standings when present and simulates only the remaining season; if no current * standings exist, simulates a full 38-match season from 0 points. * * Results persist only placement probabilities (P1–P8) through the existing EV * pipeline. Simulated W/D/L/GF/GA/GD rows are internal and discarded. */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import type { Simulator, SimulationResult } from "./types"; import { getRegularSeasonStandings } from "~/models/regular-season-standings"; import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine"; import { normalizeSimulationResultColumns } from "./simulation-probabilities"; import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers"; import { getSportsSeasonSimulatorConfig } from "~/models/simulator"; import { positiveConfigNumber, requiredConfigNumber } from "./config-access"; const DEFAULT_NUM_SIMULATIONS = 10_000; const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38; const DEFAULT_AVERAGE_OPPONENT_ELO = 1500; const DEFAULT_BASE_DRAW_RATE = 0.26; const DEFAULT_DRAW_DECAY = 0.002; interface TeamEntry { id: string; elo: number; currentPoints: number; currentGoalsFor: number; currentGoalsAgainst: number; currentGoalDifference: number; remainingGames: number; } interface SimulatedTableRow { team: TeamEntry; points: number; goalsFor: number; goalsAgainst: number; goalDifference: number; tiebreaker: number; } /** EPL single-match win probability using the sport-specific parity factor. */ export function eplWinProbability( eloA: number, eloB: number, parityFactor: number ): number { return eloWinProbabilityWithParity(eloA, eloB, parityFactor); } /** Simulate an EPL match result for team A vs team B. Exported for tests. */ export function simEplMatch( eloA: number, eloB: number, options: { baseDrawRate?: number; drawDecay?: number; matchParityFactor: number } ): "win" | "draw" | "loss" { return simulateEloSoccerMatch(eloA, eloB, { baseDrawRate: options?.baseDrawRate ?? DEFAULT_BASE_DRAW_RATE, drawDecay: options?.drawDecay ?? DEFAULT_DRAW_DECAY, parityFactor: options.matchParityFactor, }); } export class EPLSimulator implements Simulator { async simulate(sportsSeasonId: string): Promise { const db = database(); const [participantRows, evRows, standings, simulatorConfig] = await Promise.all([ db .select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name }) .from(schema.seasonParticipants) .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), getSportsSeasonSimulatorConfig(sportsSeasonId), ]); const config = simulatorConfig?.config; const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS)); const seasonGames = Math.round(positiveConfigNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES)); const averageOpponentElo = positiveConfigNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO); const matchOptions = { baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE), drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY), matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"), }; if (participantRows.length === 0) { throw new Error( `No participants found for sports season ${sportsSeasonId}. Add all 20 EPL clubs before running simulation.` ); } if (participantRows.length < 8) { throw new Error( `EPL simulation requires at least 8 participants to fill top-eight probabilities (got ${participantRows.length}).` ); } const dbEloMap = new Map(); for (const row of evRows) { if (row.sourceElo !== null && row.sourceElo !== undefined) { dbEloMap.set(row.participantId, row.sourceElo); } } const oddsInput = evRows .filter((row) => !dbEloMap.has(row.participantId) && row.sourceOdds !== null && row.sourceOdds !== undefined) .map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number })); if (oddsInput.length > 0) { const converted = convertFuturesToElo(oddsInput); for (const [participantId, elo] of converted) dbEloMap.set(participantId, elo); } const missingElo = participantRows.filter((row) => !dbEloMap.has(row.id)); if (missingElo.length > 0) { throw new Error( `Missing Elo/projected points for ${missingElo.length} EPL participants: ${missingElo.map((p) => p.name).join(", ")}. ` + `Enter Elo ratings or projected points via Admin → Elo Ratings before simulating.` ); } const standingsMap = new Map(standings.map((standing) => [standing.participantId, standing])); const participantIds = participantRows.map((row) => row.id); const teams: TeamEntry[] = participantRows.map((row) => { const standing = standingsMap.get(row.id); const wins = standing?.wins ?? 0; const draws = standing?.ties ?? 0; const losses = standing?.losses ?? 0; const gamesPlayed = standing?.gamesPlayed ?? wins + draws + losses; const goalsFor = standing?.goalsFor ?? 0; const goalsAgainst = standing?.goalsAgainst ?? 0; const goalDifference = standing?.goalDifference ?? goalsFor - goalsAgainst; return { id: row.id, elo: dbEloMap.get(row.id) as number, currentPoints: standing?.tablePoints ?? wins * 3 + draws, currentGoalsFor: goalsFor, currentGoalsAgainst: goalsAgainst, currentGoalDifference: goalDifference, remainingGames: Math.max(0, seasonGames - gamesPlayed), }; }); const rankCounts = new Map( participantIds.map((id) => [id, Array.from({ length: 8 }, () => 0)]) ); for (let sim = 0; sim < numSimulations; sim++) { const tableRows: SimulatedTableRow[] = teams.map((team) => ({ team, points: team.currentPoints, goalsFor: team.currentGoalsFor, goalsAgainst: team.currentGoalsAgainst, goalDifference: team.currentGoalDifference, tiebreaker: Math.random(), })); for (const row of tableRows) { for (let game = 0; game < row.team.remainingGames; game++) { const result = simEplMatch(row.team.elo, averageOpponentElo, matchOptions); const goals = simulateSimpleSoccerGoals(result); row.goalsFor += goals.gf; row.goalsAgainst += goals.ga; row.goalDifference = row.goalsFor - row.goalsAgainst; if (result === "win") row.points += 3; else if (result === "draw") row.points += 1; } } const finalTable = tableRows.toSorted((a, b) => b.points - a.points || b.goalDifference - a.goalDifference || b.goalsFor - a.goalsFor || b.tiebreaker - a.tiebreaker ); for (let rank = 0; rank < Math.min(8, finalTable.length); rank++) { const counts = rankCounts.get(finalTable[rank].team.id); if (counts) counts[rank]++; } } const results: SimulationResult[] = participantIds.map((participantId) => { const counts = rankCounts.get(participantId) ?? Array.from({ length: 8 }, () => 0); return { participantId, probabilities: { probFirst: counts[0] / numSimulations, probSecond: counts[1] / numSimulations, probThird: counts[2] / numSimulations, probFourth: counts[3] / numSimulations, probFifth: counts[4] / numSimulations, probSixth: counts[5] / numSimulations, probSeventh: counts[6] / numSimulations, probEighth: counts[7] / numSimulations, }, source: "epl_standings_monte_carlo", }; }); normalizeSimulationResultColumns(results); return results; } }