brackt/app/services/simulations/epl-simulator.ts
Claude 4142b21c55
Honor engine knobs across simulators, de-dupe odds, unify config UI
Two problems addressed:

1. Favorites' P(1st) was too sharp (e.g. NHL top teams ~20% vs ~12% implied).
   - The NHL simulator hardcoded its parity factor (1000) and ignored the
     season config's parityFactor, so the knob meant to flatten the
     distribution did nothing. It also re-blended raw futures odds into every
     game on top of the odds->Elo conversion, double-counting the same signal.
   - NHL now reads parityFactor/iterations/seasonGames/overtimeRate from config
     and no longer re-blends odds per game (odds enter once, via the central
     odds->Elo resolver). Honoring parity 2500 flattens a top team from ~29% to
     ~13% title odds.

2. "Season Config" and "Input Policy" were two forms over the same stored
   object that didn't reflect each other, and the engine-knob half was inert
   for many simulators.
   - Every simulator now reads its engine knobs (iterations everywhere;
     parityFactor for all Elo-based sims) from the merged config, passed in by
     the runner via the Simulator interface. Defaults equal the former
     hardcoded constants, so behavior is unchanged unless a season overrides.
   - The admin simulator page is now a single "Simulator Configuration" card
     with structured Engine and Input-derivation sections (profile-driven, so
     each sport shows only the knobs it honors) plus an Advanced raw-JSON
     escape hatch — all writing the same config.

Also: centralized the duplicated configNumber helpers into config-access.ts;
the central odds->Elo resolver now maps onto the configured Elo floor/ceiling
so those bounds set the odds-derived spread (a real flattening dial).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAFMogMkFJf52YpHyCDvuf
2026-06-30 22:00:33 +00:00

221 lines
8.4 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";
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<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(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<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;
}
}