Fix EPL simulator parity config (#421)
This commit is contained in:
parent
af64a29cfa
commit
06339fd4b6
8 changed files with 186 additions and 34 deletions
|
|
@ -462,7 +462,15 @@ export async function clearSourceOddsForParticipants(
|
||||||
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
|
* Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries.
|
||||||
*/
|
*/
|
||||||
export async function batchSaveSourceElos(
|
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> {
|
): Promise<void> {
|
||||||
if (inputs.length === 0) return;
|
if (inputs.length === 0) return;
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
@ -505,6 +513,9 @@ export async function batchSaveSourceElos(
|
||||||
sportsSeasonId: input.sportsSeasonId,
|
sportsSeasonId: input.sportsSeasonId,
|
||||||
sourceElo: input.sourceElo,
|
sourceElo: input.sourceElo,
|
||||||
worldRanking: input.worldRanking ?? null,
|
worldRanking: input.worldRanking ?? null,
|
||||||
|
projectedWins: input.projectedWins ?? null,
|
||||||
|
projectedTablePoints: input.projectedTablePoints ?? null,
|
||||||
|
metadata: input.metadata ?? null,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -126,15 +126,24 @@ function isFallbackMethod(method: string): boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
const GENERATED_RATING_METHODS = ["sourceOdds", "fallbackRating", "averageKnown", "worstKnownMinus"] as const;
|
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 {
|
function isGeneratedRatingMethod(method: unknown): boolean {
|
||||||
return typeof method === "string" && GENERATED_RATING_METHODS.includes(method as (typeof GENERATED_RATING_METHODS)[number]);
|
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 {
|
function isGeneratedRatingInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
|
||||||
return isGeneratedRatingMethod(input?.metadata?.ratingMethod);
|
return isGeneratedRatingMethod(input?.metadata?.ratingMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGeneratedSourceEloInput(input: typeof schema.seasonParticipantSimulatorInputs.$inferSelect | undefined): boolean {
|
||||||
|
return isGeneratedSourceEloMethod(input?.metadata?.sourceEloMethod);
|
||||||
|
}
|
||||||
|
|
||||||
function inputRequirementLabel(
|
function inputRequirementLabel(
|
||||||
key: SimulatorInputKey,
|
key: SimulatorInputKey,
|
||||||
profile: SimulatorManifestProfile
|
profile: SimulatorManifestProfile
|
||||||
|
|
@ -277,7 +286,9 @@ export async function getParticipantSimulatorInputs(
|
||||||
participantId: participant.id,
|
participantId: participant.id,
|
||||||
sportsSeasonId,
|
sportsSeasonId,
|
||||||
sourceOdds: input?.sourceOdds ?? legacy?.sourceOdds ?? null,
|
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,
|
worldRanking: input?.worldRanking ?? legacy?.worldRanking ?? null,
|
||||||
rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.rating),
|
rating: isGeneratedRatingInput(input) ? null : parseDecimal(input?.rating),
|
||||||
projectedWins: parseDecimal(input?.projectedWins),
|
projectedWins: parseDecimal(input?.projectedWins),
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
import { normalizeName } from '~/lib/fuzzy-match';
|
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 {
|
import {
|
||||||
eloToProjectedTablePoints,
|
eloToProjectedTablePoints,
|
||||||
eloToProjectedWins,
|
eloToProjectedWins,
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
projectedWinsToElo,
|
projectedWinsToElo,
|
||||||
} from '~/services/probability-engine';
|
} from '~/services/probability-engine';
|
||||||
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
import { runSportsSeasonSimulation } from '~/services/simulations/runner';
|
||||||
|
import { getSportsSeasonSimulatorConfig } from '~/models/simulator';
|
||||||
|
|
||||||
// Simulator types that use worldRanking in addition to sourceElo
|
// Simulator types that use worldRanking in addition to sourceElo
|
||||||
const RANKING_SIMULATOR_TYPES = new Set(['darts_bracket', 'cs2_major_qualifying_points', 'college_hockey_bracket']);
|
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';
|
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 {
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
||||||
return [{ title: `Elo Ratings — ${data?.sportsSeason?.name ?? "Sports Season"} - Brackt Admin` }];
|
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 usesRanking = RANKING_SIMULATOR_TYPES.has(sportsSeason.sport?.simulatorType ?? '');
|
||||||
|
|
||||||
const simulatorConfig = sportsSeason.sport?.simulatorType
|
const simulatorConfig = sportsSeason.sport?.simulatorType
|
||||||
? getSimulatorConfig(sportsSeason.sport.simulatorType as SimulatorType)
|
? await getProjectionSimulatorConfig(sportsSeasonId, sportsSeason.sport.simulatorType as SimulatorType)
|
||||||
: null;
|
: null;
|
||||||
const canUseProjectedWins = sportsSeason.sport?.simulatorType
|
const canUseProjectedWins = sportsSeason.sport?.simulatorType
|
||||||
? supportsProjectedWins(sportsSeason.sport.simulatorType as 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 rawMode = formData.get('inputMode');
|
||||||
const inputMode = rawMode === 'projectedWins' ? 'projectedWins' : 'elo';
|
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) {
|
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) {
|
if (!config) {
|
||||||
return { success: false, message: 'This sport does not support projection input.' };
|
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'
|
const elo = config.projectionInput === 'tablePoints'
|
||||||
? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo)
|
? projectedTablePointsToElo(projectedWins, config.seasonGames, config.parityFactor, config.averageOpponentElo)
|
||||||
: projectedWinsToElo(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',
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ vi.mock("~/models/regular-season-standings", () => ({
|
||||||
getRegularSeasonStandings: vi.fn(),
|
getRegularSeasonStandings: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/models/simulator", () => ({
|
||||||
|
getSportsSeasonSimulatorConfig: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const EPL_TEAMS = [
|
const EPL_TEAMS = [
|
||||||
"Arsenal", "Manchester City", "Liverpool", "Chelsea", "Tottenham Hotspur",
|
"Arsenal", "Manchester City", "Liverpool", "Chelsea", "Tottenham Hotspur",
|
||||||
"Manchester United", "Newcastle United", "Aston Villa", "Brighton & Hove Albion", "West Ham United",
|
"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", () => {
|
describe("EPL match helpers", () => {
|
||||||
it("returns 0.5 win probability for equal Elo ratings", () => {
|
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", () => {
|
it("equal Elo teams draw at a realistic rate", () => {
|
||||||
let draws = 0;
|
let draws = 0;
|
||||||
const N = 5000;
|
const N = 5000;
|
||||||
for (let i = 0; i < N; i++) {
|
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;
|
const drawRate = draws / N;
|
||||||
expect(drawRate).toBeGreaterThan(0.21);
|
expect(drawRate).toBeGreaterThan(0.21);
|
||||||
|
|
@ -57,10 +69,14 @@ describe("EPLSimulator.simulate()", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const { database } = await import("~/database/context");
|
const { database } = await import("~/database/context");
|
||||||
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
|
const { getRegularSeasonStandings } = await import("~/models/regular-season-standings");
|
||||||
|
const { getSportsSeasonSimulatorConfig } = await import("~/models/simulator");
|
||||||
|
|
||||||
mockDb = { select: mockSelects() };
|
mockDb = { select: mockSelects() };
|
||||||
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
(database as unknown as MockInstance).mockReturnValue(mockDb);
|
||||||
(getRegularSeasonStandings as unknown as MockInstance).mockResolvedValue([]);
|
(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 () => {
|
it("throws if no participants found", async () => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ const profile = {
|
||||||
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedWins", "sourceOdds"] },
|
||||||
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||||
|
|
||||||
|
const tablePointsProfile = {
|
||||||
|
derivableInputs: { sourceElo: ["projectedTablePoints"] },
|
||||||
|
} as Pick<SimulatorManifestProfile, "derivableInputs">;
|
||||||
|
|
||||||
describe("simulator input policy", () => {
|
describe("simulator input policy", () => {
|
||||||
it("keeps direct Elo ahead of derived values", () => {
|
it("keeps direct Elo ahead of derived values", () => {
|
||||||
const resolved = resolveSourceElos(
|
const resolved = resolveSourceElos(
|
||||||
|
|
@ -28,6 +32,28 @@ describe("simulator input policy", () => {
|
||||||
expect(resolved.get("team-1")?.sourceElo).toBeGreaterThan(1500);
|
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", () => {
|
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 }];
|
const inputs = [{ participantId: "team-1", sourceElo: null, rating: null, sourceOdds: null, projectedWins: null, projectedTablePoints: null }];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,13 @@ import { getRegularSeasonStandings } from "~/models/regular-season-standings";
|
||||||
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
|
import { convertFuturesToElo, eloWinProbabilityWithParity } from "~/services/probability-engine";
|
||||||
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
import { normalizeSimulationResultColumns } from "./simulation-probabilities";
|
||||||
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
|
import { simulateEloSoccerMatch, simulateSimpleSoccerGoals } from "./soccer-helpers";
|
||||||
|
import { getSportsSeasonSimulatorConfig } from "~/models/simulator";
|
||||||
|
|
||||||
const NUM_SIMULATIONS = 10_000;
|
const DEFAULT_NUM_SIMULATIONS = 10_000;
|
||||||
const EPL_REGULAR_SEASON_GAMES = 38;
|
const DEFAULT_EPL_REGULAR_SEASON_GAMES = 38;
|
||||||
const AVERAGE_OPPONENT_ELO = 1500;
|
const DEFAULT_AVERAGE_OPPONENT_ELO = 1500;
|
||||||
const PARITY_FACTOR = 400;
|
const DEFAULT_BASE_DRAW_RATE = 0.26;
|
||||||
const BASE_DRAW_RATE = 0.26;
|
const DEFAULT_DRAW_DECAY = 0.002;
|
||||||
const DRAW_DECAY = 0.002;
|
|
||||||
|
|
||||||
interface TeamEntry {
|
interface TeamEntry {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -44,17 +44,36 @@ interface SimulatedTableRow {
|
||||||
tiebreaker: 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. */
|
/** EPL single-match win probability using the sport-specific parity factor. */
|
||||||
export function eplWinProbability(eloA: number, eloB: number): number {
|
export function eplWinProbability(
|
||||||
return eloWinProbabilityWithParity(eloA, eloB, PARITY_FACTOR);
|
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. */
|
/** 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, {
|
return simulateEloSoccerMatch(eloA, eloB, {
|
||||||
baseDrawRate: BASE_DRAW_RATE,
|
baseDrawRate: options?.baseDrawRate ?? DEFAULT_BASE_DRAW_RATE,
|
||||||
drawDecay: DRAW_DECAY,
|
drawDecay: options?.drawDecay ?? DEFAULT_DRAW_DECAY,
|
||||||
parityFactor: PARITY_FACTOR,
|
parityFactor: options.matchParityFactor,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,7 +81,7 @@ export class EPLSimulator implements Simulator {
|
||||||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||||||
const db = database();
|
const db = database();
|
||||||
|
|
||||||
const [participantRows, evRows, standings] = await Promise.all([
|
const [participantRows, evRows, standings, simulatorConfig] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
.select({ id: schema.seasonParticipants.id, name: schema.seasonParticipants.name })
|
||||||
.from(schema.seasonParticipants)
|
.from(schema.seasonParticipants)
|
||||||
|
|
@ -76,8 +95,19 @@ export class EPLSimulator implements Simulator {
|
||||||
.from(schema.seasonParticipantExpectedValues)
|
.from(schema.seasonParticipantExpectedValues)
|
||||||
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
.where(eq(schema.seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)),
|
||||||
getRegularSeasonStandings(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) {
|
if (participantRows.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No participants found for sports season ${sportsSeasonId}. Add all 20 EPL clubs before running simulation.`
|
`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,
|
currentGoalsFor: goalsFor,
|
||||||
currentGoalsAgainst: goalsAgainst,
|
currentGoalsAgainst: goalsAgainst,
|
||||||
currentGoalDifference: goalDifference,
|
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)])
|
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) => ({
|
const tableRows: SimulatedTableRow[] = teams.map((team) => ({
|
||||||
team,
|
team,
|
||||||
points: team.currentPoints,
|
points: team.currentPoints,
|
||||||
|
|
@ -153,7 +183,7 @@ export class EPLSimulator implements Simulator {
|
||||||
|
|
||||||
for (const row of tableRows) {
|
for (const row of tableRows) {
|
||||||
for (let game = 0; game < row.team.remainingGames; game++) {
|
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);
|
const goals = simulateSimpleSoccerGoals(result);
|
||||||
row.goalsFor += goals.gf;
|
row.goalsFor += goals.gf;
|
||||||
row.goalsAgainst += goals.ga;
|
row.goalsAgainst += goals.ga;
|
||||||
|
|
@ -182,14 +212,14 @@ export class EPLSimulator implements Simulator {
|
||||||
return {
|
return {
|
||||||
participantId,
|
participantId,
|
||||||
probabilities: {
|
probabilities: {
|
||||||
probFirst: counts[0] / NUM_SIMULATIONS,
|
probFirst: counts[0] / numSimulations,
|
||||||
probSecond: counts[1] / NUM_SIMULATIONS,
|
probSecond: counts[1] / numSimulations,
|
||||||
probThird: counts[2] / NUM_SIMULATIONS,
|
probThird: counts[2] / numSimulations,
|
||||||
probFourth: counts[3] / NUM_SIMULATIONS,
|
probFourth: counts[3] / numSimulations,
|
||||||
probFifth: counts[4] / NUM_SIMULATIONS,
|
probFifth: counts[4] / numSimulations,
|
||||||
probSixth: counts[5] / NUM_SIMULATIONS,
|
probSixth: counts[5] / numSimulations,
|
||||||
probSeventh: counts[6] / NUM_SIMULATIONS,
|
probSeventh: counts[6] / numSimulations,
|
||||||
probEighth: counts[7] / NUM_SIMULATIONS,
|
probEighth: counts[7] / numSimulations,
|
||||||
},
|
},
|
||||||
source: "epl_standings_monte_carlo",
|
source: "epl_standings_monte_carlo",
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,15 @@ const PROFILES: Record<SimulatorType, Omit<SimulatorManifestProfile, "simulatorT
|
||||||
setupSections: ["participants", "eloRatings", "regularStandings"],
|
setupSections: ["participants", "eloRatings", "regularStandings"],
|
||||||
},
|
},
|
||||||
epl_standings: {
|
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"],
|
requiredInputs: ["sourceElo"],
|
||||||
optionalInputs: ["sourceOdds", "projectedTablePoints"],
|
optionalInputs: ["sourceOdds", "projectedTablePoints"],
|
||||||
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
derivableInputs: { sourceElo: ["projectedTablePoints", "sourceOdds"] },
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue