2026-05-07 11:48:57 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* 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";
|
2026-05-13 15:02:23 -07:00
|
|
|
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
2026-06-30 23:24:48 +00:00
|
|
|
|
import { positiveConfigNumber, requiredConfigNumber } from "./config-access";
|
2026-05-07 11:48:57 -07:00
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
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;
|
2026-05-07 11:48:57 -07:00
|
|
|
|
|
|
|
|
|
|
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. */
|
2026-05-13 15:02:23 -07:00
|
|
|
|
export function eplWinProbability(
|
|
|
|
|
|
eloA: number,
|
|
|
|
|
|
eloB: number,
|
|
|
|
|
|
parityFactor: number
|
|
|
|
|
|
): number {
|
|
|
|
|
|
return eloWinProbabilityWithParity(eloA, eloB, parityFactor);
|
2026-05-07 11:48:57 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Simulate an EPL match result for team A vs team B. Exported for tests. */
|
2026-05-13 15:02:23 -07:00
|
|
|
|
export function simEplMatch(
|
|
|
|
|
|
eloA: number,
|
|
|
|
|
|
eloB: number,
|
|
|
|
|
|
options: { baseDrawRate?: number; drawDecay?: number; matchParityFactor: number }
|
|
|
|
|
|
): "win" | "draw" | "loss" {
|
2026-05-07 11:48:57 -07:00
|
|
|
|
return simulateEloSoccerMatch(eloA, eloB, {
|
2026-05-13 15:02:23 -07:00
|
|
|
|
baseDrawRate: options?.baseDrawRate ?? DEFAULT_BASE_DRAW_RATE,
|
|
|
|
|
|
drawDecay: options?.drawDecay ?? DEFAULT_DRAW_DECAY,
|
|
|
|
|
|
parityFactor: options.matchParityFactor,
|
2026-05-07 11:48:57 -07:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export class EPLSimulator implements Simulator {
|
|
|
|
|
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
const [participantRows, evRows, standings, simulatorConfig] = await Promise.all([
|
2026-05-07 11:48:57 -07:00
|
|
|
|
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),
|
2026-05-13 15:02:23 -07:00
|
|
|
|
getSportsSeasonSimulatorConfig(sportsSeasonId),
|
2026-05-07 11:48:57 -07:00
|
|
|
|
]);
|
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
const config = simulatorConfig?.config;
|
2026-06-30 23:24:48 +00:00
|
|
|
|
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);
|
2026-05-13 15:02:23 -07:00
|
|
|
|
const matchOptions = {
|
2026-06-30 23:24:48 +00:00
|
|
|
|
baseDrawRate: positiveConfigNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
|
|
|
|
|
|
drawDecay: positiveConfigNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
|
|
|
|
|
|
matchParityFactor: requiredConfigNumber(config, "matchParityFactor", "EPL simulator"),
|
2026-05-13 15:02:23 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-07 11:48:57 -07:00
|
|
|
|
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<string, number>();
|
|
|
|
|
|
for (const row of evRows) {
|
|
|
|
|
|
if (row.sourceElo !== null && row.sourceElo !== undefined) {
|
|
|
|
|
|
dbEloMap.set(row.participantId, row.sourceElo);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-31 17:39:43 +00:00
|
|
|
|
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) {
|
2026-05-07 11:48:57 -07:00
|
|
|
|
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,
|
2026-05-13 15:02:23 -07:00
|
|
|
|
remainingGames: Math.max(0, seasonGames - gamesPlayed),
|
2026-05-07 11:48:57 -07:00
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const rankCounts = new Map<string, number[]>(
|
|
|
|
|
|
participantIds.map((id) => [id, Array.from({ length: 8 }, () => 0)])
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-05-13 15:02:23 -07:00
|
|
|
|
for (let sim = 0; sim < numSimulations; sim++) {
|
2026-05-07 11:48:57 -07:00
|
|
|
|
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++) {
|
2026-05-13 15:02:23 -07:00
|
|
|
|
const result = simEplMatch(row.team.elo, averageOpponentElo, matchOptions);
|
2026-05-07 11:48:57 -07:00
|
|
|
|
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: {
|
2026-05-13 15:02:23 -07:00
|
|
|
|
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,
|
2026-05-07 11:48:57 -07:00
|
|
|
|
},
|
|
|
|
|
|
source: "epl_standings_monte_carlo",
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
normalizeSimulationResultColumns(results);
|
|
|
|
|
|
return results;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|