brackt/app/services/simulations/epl-simulator.ts
chrisp 03726f1a66
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 2m27s
🚀 Deploy / ʦ TypeScript (push) Successful in 1m18s
🚀 Deploy / 🔍 Lint (push) Successful in 47s
🚀 Deploy / 🐳 Build (push) Successful in 15m5s
🚀 Deploy / 🚀 Deploy (push) Successful in 32s
Fix simulator correctness bugs and reduce test iteration counts (#61)
## Summary

- **Bug fix**: EPL Elo/odds fallback was gated on \`dbEloMap.size === 0\` — if any team had a direct \`sourceElo\`, teams with only \`sourceOdds\` were never populated and the simulator threw "Missing Elo" instead of using their odds data
- **Iterations**: WorldCupSimulator default 50k→10k; manifest \`defaultConfig\` for \`world_cup\` and \`epl_standings\` now explicitly override \`BASE_CONFIG\` with 10k so the production fallback matches; test iterations reduced (WorldCup 500→100, EPL 10k→500) to fix timeout failures on slower CI
- **Cleanup**: Extracted \`runKnockoutRound\` out of the 10k-iteration simulation loop; replaced two hot-loop \`toSorted()\` calls on 2-element arrays with a conditional string comparison; removed local \`normalizeProbabilities\` in favour of the shared \`normalizeSimulationResultColumns\`

## Test plan

- [ ] \`npm run test:run\` — all simulator unit tests pass without timeout
- [ ] Typecheck passes (\`npm run typecheck\`)
- [ ] EPL simulate with mixed Elo+odds data no longer throws

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Chris Parsons <chrisparsons1127@gmail.com>
Reviewed-on: #61
2026-05-31 17:39:43 +00:00

231 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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";
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
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;
}
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;
}
function requiredConfigNumber(config: Record<string, unknown> | undefined, key: string): number {
const value = config?.[key];
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
throw new Error(`EPL simulator config is missing positive numeric ${key}.`);
}
/** 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<SimulationResult[]> {
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(configNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
const seasonGames = Math.round(configNumber(config, "seasonGames", DEFAULT_EPL_REGULAR_SEASON_GAMES));
const averageOpponentElo = configNumber(config, "averageOpponentElo", DEFAULT_AVERAGE_OPPONENT_ELO);
const matchOptions = {
baseDrawRate: configNumber(config, "baseDrawRate", DEFAULT_BASE_DRAW_RATE),
drawDecay: configNumber(config, "drawDecay", DEFAULT_DRAW_DECAY),
matchParityFactor: requiredConfigNumber(config, "matchParityFactor"),
};
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);
}
}
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<string, number[]>(
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;
}
}