The IndyCar simulator gave a near-clinched championship leader ~60% to win the title. It was reporting the futures odds and nothing else. `event_type` has no race value, so a season_standings calendar (F1, IndyCar) is stored as `schedule_event` rows — the admin default for that scoring pattern. The simulator skipped exactly those rows when counting races, so it saw zero remaining races, took the branch labelled "pre-season", and never looked at `participant_season_results`. Since `sourceOdds` is an admin input that is never auto-refreshed, the output was whatever the market said months ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races left, stale futures at -300) the old path returns ~55%; counting the races returns 100.0%. - Add `countSeasonRaces` in a new leaf model. A race is every event except `final_standings`, and completion is inferred from the event date, since nobody marks rows labelled "Non-Scoring" complete. Its own module because `scoring-event.ts` reaches back into `simulator.ts` through `scoring-calculator`. - Split "season over" from "pre-season". Both had `remainingRaces === 0`, so a finished season reverted to an odds draw instead of reporting the final standings. - Warn when a season has championship points but no calendar, in the simulator and as a non-blocking readiness warning on the setup page. - Replace proportional vig removal with a power devig. Dividing every runner by the same book sum guts the favourite in a 27-driver market: a 75% implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the market instead of being handed 1/N. - Move the race points tables to their own module so tests can read them without tripping the manifest/registry import cycle. The existing tests missed all of this because their event fixtures used `eventType: "race"`, which is not a value the enum has. Rebuilt on real enum values, plus a regression test for the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
363 lines
16 KiB
TypeScript
363 lines
16 KiB
TypeScript
/**
|
|
* 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 completed/remaining races (see `countSeasonRaces`)
|
|
* 3. Convert sourceOdds → vig-removed probability weights
|
|
* 4. Three paths:
|
|
* a. season complete (no races left, some run): standings are the answer
|
|
* b. pre-season (no races at all yet): pure weighted draws from odds
|
|
* c. 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 are floored at the bottom of the priced market
|
|
* - 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 { countSeasonRaces } from "~/models/season-races";
|
|
import { devigPower } from "~/services/probability-engine";
|
|
import type { Simulator, SimulationResult } from "./types";
|
|
import { positiveConfigNumber } from "./config-access";
|
|
|
|
// ─── Simulation parameters (mirrors Python constants) ────────────────────────
|
|
|
|
const DEFAULT_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;
|
|
|
|
/**
|
|
* How much PARTICIPANT_VOLATILITY shrinks as the season progresses.
|
|
* effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - progress * VOLATILITY_DECAY_FACTOR).
|
|
* At 0.7, effective volatility reaches 30% of its baseline with one race left,
|
|
* preventing large standing swings when the championship is nearly decided.
|
|
*/
|
|
const VOLATILITY_DECAY_FACTOR = 0.7;
|
|
|
|
/**
|
|
* 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<number, number>, 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<number, number>,
|
|
private readonly source: string,
|
|
) {}
|
|
|
|
async simulate(sportsSeasonId: string, config: Record<string, unknown> = {}): Promise<SimulationResult[]> {
|
|
const numSimulations = Math.round(positiveConfigNumber(config, "iterations", DEFAULT_NUM_SIMULATIONS));
|
|
const db = database();
|
|
|
|
// 1. Load all participants for this sports season
|
|
const participants = await db.query.seasonParticipants.findMany({
|
|
where: eq(schema.seasonParticipants.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")])
|
|
);
|
|
const totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
|
|
|
|
// 3. Count remaining and completed races
|
|
const { completed: completedRaces, remaining: remainingRaces, total: totalRaces } =
|
|
await countSeasonRaces(sportsSeasonId);
|
|
|
|
// A season with championship points but no calendar cannot be simulated
|
|
// forward — it silently degrades into "whatever the futures odds said",
|
|
// which ignores a runaway leader's points lead entirely.
|
|
if (totalRaces === 0 && totalCurrentPoints > 0) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[AutoRacingSimulator] Season ${sportsSeasonId} has championship points but no race calendar — ` +
|
|
`add the schedule on the admin events page. Falling back to futures odds, which ignores the standings.`
|
|
);
|
|
}
|
|
|
|
// 0.0 = pre-season, 1.0 = all races done
|
|
const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0;
|
|
|
|
// 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 (the field sums well over 1.0), so the
|
|
// priced field is devigged with a power transform rather than proportional
|
|
// division — see devigPower. Only drivers who actually have odds go into the
|
|
// devig: mixing in a 1/N placeholder for unpriced drivers would both inflate
|
|
// the book sum (distorting the solved exponent) and rate an unpriced driver
|
|
// above most of the real longshots.
|
|
const fallbackProb = 1 / participants.length;
|
|
const pricedIds: string[] = [];
|
|
const pricedImplied: number[] = [];
|
|
|
|
for (const p of participants) {
|
|
const odds = evMap.get(p.id)?.sourceOdds;
|
|
if (odds !== null && odds !== undefined) {
|
|
pricedIds.push(p.id);
|
|
pricedImplied.push(americanToImpliedProb(odds));
|
|
}
|
|
}
|
|
|
|
const rawProbs = new Map<string, number>();
|
|
if (pricedIds.length === 0) {
|
|
for (const p of participants) rawProbs.set(p.id, fallbackProb);
|
|
} else {
|
|
const devigged = devigPower(pricedImplied);
|
|
pricedIds.forEach((id, i) => rawProbs.set(id, devigged[i]));
|
|
|
|
// Unpriced drivers sit at the bottom of the market, then renormalize.
|
|
const marketFloor = Math.min(...devigged);
|
|
for (const p of participants) {
|
|
if (!rawProbs.has(p.id)) rawProbs.set(p.id, marketFloor);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
// 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, Array.from({ length: 8 }, () => 0));
|
|
}
|
|
|
|
// Every race has run and at least one result is in: the championship is
|
|
// decided, so the standings *are* the answer — there is nothing to simulate.
|
|
// getSeasonResults already sorts by currentPosition (nulls last), then points
|
|
// descending.
|
|
const settledOrder =
|
|
remainingRaces === 0 && completedRaces > 0
|
|
? seasonResults.map((r) => r.participant.id).filter((id) => rankCounts.has(id))
|
|
: [];
|
|
|
|
if (settledOrder.length > 0) {
|
|
for (let rank = 0; rank < Math.min(8, settledOrder.length); rank++) {
|
|
const counts = rankCounts.get(settledOrder[rank]);
|
|
if (counts) counts[rank] = numSimulations;
|
|
}
|
|
} else if (remainingRaces === 0) {
|
|
// Pre-season: no races to simulate, derive placement probabilities
|
|
// from sourceOdds via pure weighted draws.
|
|
if (completedRaces > 0) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[AutoRacingSimulator] Season ${sportsSeasonId} has no races left but no standings rows — ` +
|
|
`falling back to futures odds instead of the final championship order.`
|
|
);
|
|
}
|
|
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
|
for (let sim = 0; sim < numSimulations; 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.
|
|
|
|
// Warn if standings data is incomplete — missing rows distort each driver's
|
|
// share-of-points weight and silently degrade the blending accuracy.
|
|
const participantsWithPoints = ids.filter((id) => currentPointsMap.has(id));
|
|
if (participantsWithPoints.length < ids.length) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
`[AutoRacingSimulator] ${ids.length - participantsWithPoints.length} participant(s) missing from standings for season ${sportsSeasonId} — blending may be inaccurate`
|
|
);
|
|
}
|
|
|
|
// Build blended probability weights that combine futures-odds strength
|
|
// with standings-based strength, weighted by season progress.
|
|
// - Early season (low seasonProgress): mostly futures odds
|
|
// - Mid/late season: standings dominate, reducing the distortion from
|
|
// championship futures (which penalize 2nd-place drivers whose odds of
|
|
// *winning* the title are weak, even though they'll likely finish top 3)
|
|
const blendedProbs = new Map<string, number>();
|
|
for (const id of ids) {
|
|
const oddsW = baseProbs.get(id) ?? fallbackProb;
|
|
const pts = currentPointsMap.get(id) ?? 0;
|
|
// Drivers with 0 pts (new entry, early DNF) fall back to odds strength
|
|
const standingsW = totalCurrentPoints > 0 && pts > 0 ? pts / totalCurrentPoints : oddsW;
|
|
blendedProbs.set(id, (1 - seasonProgress) * oddsW + seasonProgress * standingsW);
|
|
}
|
|
|
|
// Volatility shrinks as the season progresses — late-season standings are
|
|
// much more predictive than early-season odds.
|
|
const effectiveVolatility = PARTICIPANT_VOLATILITY * (1 - seasonProgress * VOLATILITY_DECAY_FACTOR);
|
|
for (let sim = 0; sim < numSimulations; sim++) {
|
|
// 7a. Season-long performance multiplier per driver (uses blended strength)
|
|
const seasonWeights = new Map<string, number>();
|
|
for (const id of ids) {
|
|
const base = blendedProbs.get(id) ?? fallbackProb;
|
|
const mult = Math.max(
|
|
0.05,
|
|
1 - effectiveVolatility + Math.random() * effectiveVolatility * 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(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] / 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: 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<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;
|
|
}
|
|
}
|