brackt/app/services/simulations/epl-simulator.ts

202 lines
7.3 KiB
TypeScript
Raw Normal View History

/**
* 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 (P1P8) 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";
const NUM_SIMULATIONS = 10_000;
const EPL_REGULAR_SEASON_GAMES = 38;
const AVERAGE_OPPONENT_ELO = 1500;
const PARITY_FACTOR = 400;
const BASE_DRAW_RATE = 0.26;
const 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): number {
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
}
/** Simulate an EPL match result for team A vs team B. Exported for tests. */
export function simEplMatch(eloA: number, eloB: number): "win" | "draw" | "loss" {
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
parityFactor: PARITY_FACTOR,
});
}
export class EPLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const [participantRows, evRows, standings] = 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),
]);
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);
}
}
if (dbEloMap.size === 0) {
const oddsInput = evRows
.filter((row) => row.sourceOdds !== null && row.sourceOdds !== undefined)
.map((row) => ({ participantId: row.participantId, odds: row.sourceOdds as number }));
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, EPL_REGULAR_SEASON_GAMES - gamesPlayed),
};
});
const rankCounts = new Map<string, number[]>(
participantIds.map((id) => [id, Array.from({ length: 8 }, () => 0)])
);
for (let sim = 0; sim < NUM_SIMULATIONS; 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, AVERAGE_OPPONENT_ELO);
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] / NUM_SIMULATIONS,
probSecond: counts[1] / NUM_SIMULATIONS,
probThird: counts[2] / NUM_SIMULATIONS,
probFourth: counts[3] / NUM_SIMULATIONS,
probFifth: counts[4] / NUM_SIMULATIONS,
probSixth: counts[5] / NUM_SIMULATIONS,
probSeventh: counts[6] / NUM_SIMULATIONS,
probEighth: counts[7] / NUM_SIMULATIONS,
},
source: "epl_standings_monte_carlo",
};
});
normalizeSimulationResultColumns(results);
return results;
}
}