/** * Auto Racing Season Standings Simulator * * Generic simulator for points-based auto racing championships (F1, IndyCar, etc.). * The race points table is injected at construction time so different series can * use their own scoring systems. * * Algorithm: * 1. Load participants + current championship points from DB * 2. Count remaining races (incomplete non-schedule scoring events) * 3. Convert sourceOdds → vig-removed probability weights * 4. Two simulation paths: * a. remainingRaces === 0 (pre-season): pure weighted draws from odds * b. remainingRaces > 0 (in-season): simulate each remaining race, * starting from real standings, awarding series-specific points per finish * 5. Convert finish counts → probability distributions + normalize columns * * Notes: * - Drivers without odds fall back to uniform probability (1/N) * - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path */ import { database } from "~/database/context"; import { eq } from "drizzle-orm"; import * as schema from "~/database/schema"; import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; import { getSeasonResults } from "~/models/participant-season-result"; import type { Simulator, SimulationResult } from "./types"; // ─── Simulation parameters (mirrors Python constants) ──────────────────────── const NUM_SIMULATIONS = 10000; /** Per-race performance variance. 0 = no noise, 1 = fully random each race. */ const RACE_NOISE = 0.50; /** * Season-long multiplier range per driver. * Each driver gets uniform(1 - V, 1 + V) applied to their base probability * for the entire season, capturing "cars that over/underperform expectations". */ const PARTICIPANT_VOLATILITY = 1.5; /** * Optional smoothing toward the mean after vig removal. * 0.0 = use vig-removed market odds exactly (recommended). * Increase slightly (e.g. 0.1) to soften extreme probabilities. */ const UNCERTAINTY_FACTOR = 0.0; /** Lookup points for a finishing position; returns 0 for unscored positions. */ function getRacePoints(racePoints: Record, position: number): number { return racePoints[position] ?? 0; } // ─── Odds helpers ───────────────────────────────────────────────────────────── /** Convert American odds to implied probability (no vig removal). */ function americanToImpliedProb(americanOdds: number): number { if (americanOdds > 0) { return 100 / (americanOdds + 100); } return Math.abs(americanOdds) / (Math.abs(americanOdds) + 100); } // ─── Core simulation helper ─────────────────────────────────────────────────── /** * Weighted sequential draw without replacement. * Returns all items in a simulated finishing order. * Each draw is proportional to remaining weights. */ function weightedDrawWithoutReplacement(ids: string[], weights: number[]): string[] { const pool = ids.slice(); const w = weights.slice(); const result: string[] = []; while (pool.length > 0) { const total = w.reduce((s, v) => s + v, 0); let r = Math.random() * total; let idx = 0; while (idx < w.length - 1 && r > w[idx]) { r -= w[idx]; idx++; } result.push(pool[idx]); pool.splice(idx, 1); w.splice(idx, 1); } return result; } // ─── Simulator ──────────────────────────────────────────────────────────────── export class AutoRacingSimulator implements Simulator { constructor( private readonly racePoints: Record, private readonly source: string, ) {} async simulate(sportsSeasonId: string): Promise { const db = database(); // 1. Load all participants for this sports season const participants = await db.query.participants.findMany({ where: eq(schema.participants.sportsSeasonId, sportsSeasonId), }); if (participants.length === 0) { throw new Error(`No participants found for sports season ${sportsSeasonId}.`); } // 2. Load current championship standings (existing points earned this season) const seasonResults = await getSeasonResults(sportsSeasonId); const currentPointsMap = new Map( seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")]) ); // 3. Count remaining races: incomplete scoring events, excluding schedule_event entries const allEvents = await db.query.scoringEvents.findMany({ where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId), }); const remainingRaces = allEvents.filter( (e) => !e.isComplete && e.eventType !== "schedule_event" ).length; // 4. Load EV data for championship win probabilities const evs = await getAllParticipantEVsForSeason(sportsSeasonId); const evMap = new Map(evs.map((ev) => [ev.participantId, ev])); const ids = participants.map((p) => p.id); // 5. Build raw implied championship win probabilities from odds. // americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0 // before using as weights. This is standard "vig removal" and ensures a driver // with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2. const fallbackProb = 1 / participants.length; const rawProbs = new Map(); for (const p of participants) { const ev = evMap.get(p.id); rawProbs.set(p.id, ev !== undefined && ev.sourceOdds !== null && ev.sourceOdds !== undefined ? americanToImpliedProb(ev.sourceOdds) : fallbackProb); } // Normalize to remove vig const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0); for (const [id, prob] of rawProbs) { rawProbs.set(id, prob / rawSum); } // 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0) const baseProbs = new Map(); if (UNCERTAINTY_FACTOR === 0) { for (const [id, prob] of rawProbs) baseProbs.set(id, prob); } else { const avgProb = [...rawProbs.values()].reduce((a, b) => a + b, 0) / participants.length; for (const [id, prob] of rawProbs) { baseProbs.set(id, prob * (1 - UNCERTAINTY_FACTOR) + avgProb * UNCERTAINTY_FACTOR); } } // 7. Accumulate finish counts across simulations // rankCounts[id][0..7] = number of times driver finished 1st..8th const rankCounts = new Map(); for (const id of ids) { rankCounts.set(id, Array.from({ length: 8 }, () => 0)); } if (remainingRaces === 0) { // Pre-season: no races to simulate, derive placement probabilities // from sourceOdds via pure weighted draws. const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb); for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { const finishOrder = weightedDrawWithoutReplacement(ids, weights); for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) { const counts = rankCounts.get(finishOrder[rank]); if (counts) counts[rank]++; } } } else { // In-season: simulate remaining races from current standings. for (let sim = 0; sim < NUM_SIMULATIONS; sim++) { // 7a. Season-long performance multiplier per driver const seasonWeights = new Map(); for (const id of ids) { const base = baseProbs.get(id) ?? fallbackProb; const mult = Math.max( 0.05, 1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2 ); seasonWeights.set(id, base * mult); } // 7b. Start from current championship points const simPoints = new Map( ids.map((id) => [id, currentPointsMap.get(id) ?? 0]) ); // 7c. Simulate each remaining race for (let race = 0; race < remainingRaces; race++) { const raceWeights = ids.map((id) => { const sw = seasonWeights.get(id) ?? fallbackProb; const noise = Math.max(0.01, 1 - RACE_NOISE + Math.random() * RACE_NOISE * 2); return sw * noise; }); const finishOrder = weightedDrawWithoutReplacement(ids, raceWeights); for (let pos = 0; pos < finishOrder.length; pos++) { const pts = getRacePoints(this.racePoints, pos + 1); if (pts === 0) break; // unscored positions earn no points simPoints.set(finishOrder[pos], (simPoints.get(finishOrder[pos]) ?? 0) + pts); } } // 7d. Sort by final championship points, record top-8 finishes const finalOrder = [...simPoints.entries()] .toSorted((a, b) => b[1] - a[1]) .map(([id]) => id); for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) { const counts = rankCounts.get(finalOrder[rank]); if (counts) counts[rank]++; } } } // 8. Convert counts → probability distributions const results: SimulationResult[] = participants.map((p) => { const counts = rankCounts.get(p.id) ?? [0,0,0,0,0,0,0,0]; return { participantId: p.id, 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, }, source: this.source, }; }); // 9. Per-position normalization: each column should sum to exactly 1.0 but // floating-point division (count / 10000) accumulates small errors across // ~20 drivers, causing the total EV to drift (e.g. 340.02 instead of 340). // Fix: add the residual (1.0 - colSum) to the largest probability in each // column so the sum is exactly 1.0 in IEEE 754 arithmetic. const positionKeys: Array = [ "probFirst", "probSecond", "probThird", "probFourth", "probFifth", "probSixth", "probSeventh", "probEighth", ]; for (const key of positionKeys) { const colSum = results.reduce((s, r) => s + r.probabilities[key], 0); const residual = 1.0 - colSum; if (residual !== 0) { const maxResult = results.reduce((best, r) => r.probabilities[key] > best.probabilities[key] ? r : best ); maxResult.probabilities[key] += residual; } } return results; } }