diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 1eea0ea..4a4f4bf 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -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 | null; + }> ): Promise { 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, })) ); } diff --git a/app/models/simulator.ts b/app/models/simulator.ts index 5cc5029..81a48d0 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -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), diff --git a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx index df085f6..0ef5ae9 100644 --- a/app/routes/admin.sports-seasons.$id.elo-ratings.tsx +++ b/app/routes/admin.sports-seasons.$id.elo-ratings.tsx @@ -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 | 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 { + 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 | 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', + }, + }); } } } diff --git a/app/services/simulations/__tests__/epl-simulator.test.ts b/app/services/simulations/__tests__/epl-simulator.test.ts index 607a9a8..546b9fa 100644 --- a/app/services/simulations/__tests__/epl-simulator.test.ts +++ b/app/services/simulations/__tests__/epl-simulator.test.ts @@ -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 () => { diff --git a/app/services/simulations/__tests__/input-policy.test.ts b/app/services/simulations/__tests__/input-policy.test.ts index 706d0d9..2661d09 100644 --- a/app/services/simulations/__tests__/input-policy.test.ts +++ b/app/services/simulations/__tests__/input-policy.test.ts @@ -6,6 +6,10 @@ const profile = { derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] }, } as Pick; +const tablePointsProfile = { + derivableInputs: { sourceElo: ["projectedTablePoints"] }, +} as Pick; + 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 }]; diff --git a/app/services/simulations/__tests__/manifest.test.ts b/app/services/simulations/__tests__/manifest.test.ts index fd86700..2bf7b3d 100644 --- a/app/services/simulations/__tests__/manifest.test.ts +++ b/app/services/simulations/__tests__/manifest.test.ts @@ -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, + }); + }); }); diff --git a/app/services/simulations/epl-simulator.ts b/app/services/simulations/epl-simulator.ts index 3c728c0..f2b4f49 100644 --- a/app/services/simulations/epl-simulator.ts +++ b/app/services/simulations/epl-simulator.ts @@ -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 | 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 | 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 { 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", }; diff --git a/app/services/simulations/manifest.ts b/app/services/simulations/manifest.ts index 34bbe62..278a8ff 100644 --- a/app/services/simulations/manifest.ts +++ b/app/services/simulations/manifest.ts @@ -114,7 +114,15 @@ const PROFILES: Record