Fix EPL simulator parity config (#421)

This commit is contained in:
Chris Parsons 2026-05-13 15:02:23 -07:00 committed by GitHub
parent af64a29cfa
commit 06339fd4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 186 additions and 34 deletions

View file

@ -462,7 +462,15 @@ export async function clearSourceOddsForParticipants(
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
*/
export async function batchSaveSourceElos(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number | null; worldRanking?: number | null }>
inputs: Array<{
participantId: string;
sportsSeasonId: string;
sourceElo: number | null;
worldRanking?: number | null;
projectedWins?: number | null;
projectedTablePoints?: number | null;
metadata?: Record<string, unknown> | null;
}>
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
@ -505,6 +513,9 @@ export async function batchSaveSourceElos(
sportsSeasonId: input.sportsSeasonId,
sourceElo: input.sourceElo,
worldRanking: input.worldRanking ?? null,
projectedWins: input.projectedWins ?? null,
projectedTablePoints: input.projectedTablePoints ?? null,
metadata: input.metadata ?? null,
}))
);
}

View file

@ -126,15 +126,24 @@ function isFallbackMethod(method: string): boolean {
}
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
const GENERATED_SOURCE_ELO_METHODS = ["projectedWins", "projectedTablePoints", "sourceOdds", "fallbackElo", "averageKnown", "worstKnownMinus"] as const;
function isGeneratedRatingMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
}
function isGeneratedSourceEloMethod(method: unknown): boolean {
return typeof method === "string" && GENERATED_SOURCE_ELO_METHODS.includes(method as (typeof GENERATED_SOURCE_ELO_METHODS)[number]);
}
function isGeneratedRatingInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
return isGeneratedRatingMethod(input?.metadata?.ratingMethod);
}
function isGeneratedSourceEloInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
return isGeneratedSourceEloMethod(input?.metadata?.sourceEloMethod);
}
function inputRequirementLabel(
key: SimulatorInputKey,
profile: SimulatorManifestProfile
@ -277,7 +286,9 @@ export async function getParticipantSimulatorInputs(
participantId: participant.id,
sportsSeasonId,
sourceOdds: input?.sourceOdds ?? legacy?.sourceOdds ?? null,
sourceElo: input?.sourceElo ?? legacy?.sourceElo ?? null,
sourceElo: input
? isGeneratedSourceEloInput(input) ? null : input.sourceElo
: legacy?.sourceElo ?? null,
worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null,
rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.rating),
projectedWins: parseDecimal(input?.projectedWins),

View file

@ -23,7 +23,7 @@ import {
import { useState } from 'react';
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
import { normalizeName } from '~/lib/fuzzy-match';
import { getSimulatorConfig, supportsProjectedWins } from '~/services/simulations/simulator-config';
import { getSimulatorConfig, supportsProjectedWins, type SimulatorConfig } from '~/services/simulations/simulator-config';
import {
eloToProjectedTablePoints,
eloToProjectedWins,
@ -31,6 +31,7 @@ import {
projectedWinsToElo,
} from '~/services/probability-engine';
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
// Simulator types that use worldRanking in addition to sourceElo
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
@ -42,6 +43,28 @@ function rankingLabel(simulatorType: string | null | undefined): string {
return 'World Rank';
}
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number): number {
const value = config?.[key];
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
async function getProjectionSimulatorConfig(
sportsSeasonId: string,
simulatorType: SimulatorType
): Promise<SimulatorConfig | null> {
const baseConfig = getSimulatorConfig(simulatorType);
if (!baseConfig) return null;
const runtimeConfig = await getSportsSeasonSimulatorConfig(sportsSeasonId);
const config = runtimeConfig?.config;
return {
...baseConfig,
seasonGames: configNumber(config, 'seasonGames', baseConfig.seasonGames),
parityFactor: configNumber(config, 'parityFactor', baseConfig.parityFactor),
averageOpponentElo: configNumber(config, 'averageOpponentElo', baseConfig.averageOpponentElo),
};
}
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
}
@ -69,7 +92,7 @@ export async function loader({ params }: Route.LoaderArgs) {
const usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
const simulatorConfig = sportsSeason.sport?.simulatorType
? getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType)
? await getProjectionSimulatorConfig(sportsSeasonId, sportsSeason.sport.simulatorType as SimulatorType)
: null;
const canUseProjectedWins = sportsSeason.sport?.simulatorType
? supportsProjectedWins(sportsSeason.sport.simulatorType as SimulatorType)
@ -99,10 +122,18 @@ export async function action({ request, params }: Route.ActionArgs) {
const rawMode = formData.get('inputMode');
const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo';
const eloInputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number | null; worldRanking?: number | null }> = [];
const eloInputs: Array<{
participantId: string;
sportsSeasonId: string;
sourceElo: number | null;
worldRanking?: number | null;
projectedWins?: number | null;
projectedTablePoints?: number | null;
metadata?: Record<string, unknown> | null;
}> = [];
if (inputMode === 'projectedWins' && sportsSeason.sport?.simulatorType) {
const config = getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType);
const config = await getProjectionSimulatorConfig(sportsSeasonId, sportsSeason.sport.simulatorType as SimulatorType);
if (!config) {
return { success: false, message: 'This sport does not support projection input.' };
}
@ -116,7 +147,16 @@ export async function action({ request, params }: Route.ActionArgs) {
const elo = config.projectionInput === 'tablePoints'
? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo)
: projectedWinsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo);
eloInputs.push({ participantId: participant.id, sportsSeasonId, sourceElo: elo });
eloInputs.push({
participantId: participant.id,
sportsSeasonId,
sourceElo: elo,
projectedWins: config.projectionInput === 'tablePoints' ? null : projectedWins,
projectedTablePoints: config.projectionInput === 'tablePoints' ? projectedWins : null,
metadata: {
sourceEloMethod: config.projectionInput === 'tablePoints' ? 'projectedTablePoints' : 'projectedWins',
},
});
}
}
}

View file

@ -9,6 +9,10 @@ vi.mock("~/models/regular-season-standings", () => ({
getRegularSeasonStandings: vi.fn(),
}));
vi.mock("~/models/simulator", () => ({
getSportsSeasonSimulatorConfig: vi.fn(),
}));
const EPL_TEAMS = [
"Arsenal", "Manchester City", "Liverpool", "Chelsea", "Tottenham Hotspur",
"Manchester United", "Newcastle United", "Aston Villa", "Brighton & Hove Albion", "West Ham United",
@ -36,14 +40,22 @@ function mockSelects(participantRows = PARTICIPANT_ROWS, evRows = EV_ROWS) {
describe("EPL match helpers", () => {
it("returns 0.5 win probability for equal Elo ratings", () => {
expect(eplWinProbability(1500, 1500)).toBeCloseTo(0.5, 6);
expect(eplWinProbability(1500, 1500, 400)).toBeCloseTo(0.5, 6);
});
it("supports a configurable match parity factor", () => {
const standard = eplWinProbability(1700, 1500, 400);
const higherParity = eplWinProbability(1700, 1500, 1000);
expect(standard).toBeGreaterThan(higherParity);
expect(higherParity).toBeGreaterThan(0.5);
});
it("equal Elo teams draw at a realistic rate", () => {
let draws = 0;
const N = 5000;
for (let i = 0; i < N; i++) {
if (simEplMatch(1500, 1500) === "draw") draws++;
if (simEplMatch(1500, 1500, { matchParityFactor: 400 }) === "draw") draws++;
}
const drawRate = draws / N;
expect(drawRate).toBeGreaterThan(0.21);
@ -57,10 +69,14 @@ describe("EPLSimulator.simulate()", () => {
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
const { getSportsSeasonSimulatorConfig } = await import("~/models/simulator");
mockDb = { select: mockSelects() };
(database as unknown as MockInstance).mockReturnValue(mockDb);
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
(getSportsSeasonSimulatorConfig as unknown as MockInstance).mockResolvedValue({
config: { iterations: 10_000, matchParityFactor: 400 },
});
});
it("throws if no participants found", async () => {

View file

@ -6,6 +6,10 @@ const profile = {
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
} as Pick<SimulatorManifestProfile, "derivableInputs">;
const tablePointsProfile = {
derivableInputs: { sourceElo: ["projectedTablePoints"] },
} as Pick<SimulatorManifestProfile, "derivableInputs">;
describe("simulator input policy", () => {
it("keeps direct Elo ahead of derived values", () => {
const resolved = resolveSourceElos(
@ -28,6 +32,28 @@ describe("simulator input policy", () => {
expect(resolved.get("team-1")?.sourceElo).toBeGreaterThan(1500);
});
it("uses parityFactor as the projected table points to Elo spread", () => {
const input = {
participantId: "team-1",
sourceElo: null,
rating: null,
sourceOdds: null,
projectedWins: null,
projectedTablePoints: 76,
};
const compressed = resolveSourceElos([input], tablePointsProfile, {
seasonGames: 38,
parityFactor: 250,
});
const wider = resolveSourceElos([input], tablePointsProfile, {
seasonGames: 38,
parityFactor: 1000,
});
expect(compressed.get("team-1")?.sourceElo).toBeLessThan(wider.get("team-1")?.sourceElo ?? 0);
});
it("blocks missing Elo unless a fallback strategy is configured", () => {
const inputs = [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }];

View file

@ -39,4 +39,14 @@ describe("simulator manifest", () => {
}
}
});
it("keeps EPL projection and match parity as separate config knobs", () => {
expect(SIMULATOR_MANIFEST.epl_standings.defaultConfig).toMatchObject({
parityFactor: 400,
matchParityFactor: 400,
averageOpponentElo: 1500,
baseDrawRate: 0.26,
drawDecay: 0.002,
});
});
});

View file

@ -17,13 +17,13 @@ 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 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;
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;
@ -44,17 +44,36 @@ interface SimulatedTableRow {
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): number {
return eloWinProbabilityWithParity(eloA, eloB, 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): "win" | "draw" | "loss" {
export function simEplMatch(
eloA: number,
eloB: number,
options: { baseDrawRate?: number; drawDecay?: number; matchParityFactor: number }
): "win" | "draw" | "loss" {
return simulateEloSoccerMatch(eloA, eloB, {
baseDrawRate: BASE_DRAW_RATE,
drawDecay: DRAW_DECAY,
parityFactor: PARITY_FACTOR,
baseDrawRate: options?.baseDrawRate ?? DEFAULT_BASE_DRAW_RATE,
drawDecay: options?.drawDecay ?? DEFAULT_DRAW_DECAY,
parityFactor: options.matchParityFactor,
});
}
@ -62,7 +81,7 @@ export class EPLSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
const [participantRows, evRows, standings] = await Promise.all([
const [participantRows, evRows, standings, simulatorConfig] = await Promise.all([
db
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
.from(schema.seasonParticipants)
@ -76,8 +95,19 @@ export class EPLSimulator implements Simulator {
.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.`
@ -133,7 +163,7 @@ export class EPLSimulator implements Simulator {
currentGoalsFor: goalsFor,
currentGoalsAgainst: goalsAgainst,
currentGoalDifference: goalDifference,
remainingGames: Math.max(0, EPL_REGULAR_SEASON_GAMES - gamesPlayed),
remainingGames: Math.max(0, seasonGames - gamesPlayed),
};
});
@ -141,7 +171,7 @@ export class EPLSimulator implements Simulator {
participantIds.map((id) => [id, Array.from({ length: 8 }, () => 0)])
);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
for (let sim = 0; sim < numSimulations; sim++) {
const tableRows: SimulatedTableRow[] = teams.map((team) => ({
team,
points: team.currentPoints,
@ -153,7 +183,7 @@ export class EPLSimulator implements Simulator {
for (const row of tableRows) {
for (let game = 0; game < row.team.remainingGames; game++) {
const result = simEplMatch(row.team.elo, AVERAGE_OPPONENT_ELO);
const result = simEplMatch(row.team.elo, averageOpponentElo, matchOptions);
const goals = simulateSimpleSoccerGoals(result);
row.goalsFor += goals.gf;
row.goalsAgainst += goals.ga;
@ -182,14 +212,14 @@ export class EPLSimulator implements Simulator {
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,
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",
};

View file

@ -114,7 +114,15 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
setupSections: ["participants", "eloRatings", "regularStandings"],
},
epl_standings: {
defaultConfig: { ...BASE_CONFIG, seasonGames: 38, baseDrawRate: 0.26, drawDecay: 0.75 },
defaultConfig: {
...BASE_CONFIG,
seasonGames: 38,
parityFactor: 400,
matchParityFactor: 400,
averageOpponentElo: 1500,
baseDrawRate: 0.26,
drawDecay: 0.002,
},
requiredInputs: ["sourceElo"],
optionalInputs: ["sourceOdds", "projectedTablePoints"],
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },