* feat: EV simulation framework with F1 Monte Carlo simulator - Add EV snapshot tables (participant_ev_snapshots, team_ev_snapshots) and simulation_status column on sports seasons - Add ev-snapshot model with upsert and history query functions - Add simulator framework: types, bracket/F1/golf simulators, registry - F1 simulator: vig-removed ICM weighted draw (pre-season) + race-by-race Monte Carlo from current standings (in-season); per-position column normalization to prevent floating-point EV drift - Add admin simulate route and Run Simulation button on sports season page - Rework futures-odds admin page to save odds then run simulation in one action - Remove recalculate-probabilities route (superseded by simulate route) - Remove EV trend chart panel and associated DB queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: map simulators to sports via simulatorType field Adds a `simulator_type` enum column to the `sports` table so each sport can be assigned a specific simulation algorithm rather than deriving it from the sports season's scoring pattern. - Add `simulatorTypeEnum` (f1_standings, indycar_standings, golf_qualifying_points, playoff_bracket) + `simulatorType` nullable column on `sports` table; migration 0037 - Rewrite simulator registry to key off `SimulatorType` instead of `ScoringPattern`; indycar_standings shares F1Simulator for now - `findSportsSeasonById` now returns `SportsSeasonWithSport` so callers have typed access to `sport.simulatorType` - Simulate and futures-odds actions read `sport.simulatorType`; guard fires before setting `simulationStatus: running` - Admin sport edit page gains a Simulator Type dropdown Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
261 lines
10 KiB
TypeScript
261 lines
10 KiB
TypeScript
/**
|
||
* F1 / Season Standings Simulator
|
||
*
|
||
* 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 F1 race 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;
|
||
|
||
// ─── F1 race points for finishing positions 1–10 ──────────────────────────────
|
||
|
||
const RACE_POINTS: Record<number, number> = {
|
||
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
|
||
};
|
||
|
||
function getRacePoints(position: number): number {
|
||
return RACE_POINTS[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 F1Simulator implements Simulator {
|
||
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
|
||
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<string, number>(
|
||
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<string, number>();
|
||
|
||
for (const p of participants) {
|
||
const ev = evMap.get(p.id);
|
||
rawProbs.set(p.id, ev?.sourceOdds != null ? 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<string, number>();
|
||
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<string, number[]>();
|
||
for (const id of ids) {
|
||
rankCounts.set(id, new Array(8).fill(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++) {
|
||
rankCounts.get(finishOrder[rank])![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<string, number>();
|
||
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<string, number>(
|
||
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(pos + 1);
|
||
if (pts === 0) break; // positions 11+ earn no F1 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()]
|
||
.sort((a, b) => b[1] - a[1])
|
||
.map(([id]) => id);
|
||
|
||
for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) {
|
||
rankCounts.get(finalOrder[rank])![rank]++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 8. Convert counts → probability distributions
|
||
const results: SimulationResult[] = participants.map((p) => ({
|
||
participantId: p.id,
|
||
probabilities: {
|
||
probFirst: rankCounts.get(p.id)![0] / NUM_SIMULATIONS,
|
||
probSecond: rankCounts.get(p.id)![1] / NUM_SIMULATIONS,
|
||
probThird: rankCounts.get(p.id)![2] / NUM_SIMULATIONS,
|
||
probFourth: rankCounts.get(p.id)![3] / NUM_SIMULATIONS,
|
||
probFifth: rankCounts.get(p.id)![4] / NUM_SIMULATIONS,
|
||
probSixth: rankCounts.get(p.id)![5] / NUM_SIMULATIONS,
|
||
probSeventh: rankCounts.get(p.id)![6] / NUM_SIMULATIONS,
|
||
probEighth: rankCounts.get(p.id)![7] / NUM_SIMULATIONS,
|
||
},
|
||
source: "f1_standings_model",
|
||
}));
|
||
|
||
// 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<keyof typeof results[0]["probabilities"]> = [
|
||
"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;
|
||
}
|
||
}
|