claude/indycar-ev-sims-probability-fpzfx6 (#139)
All checks were successful
🚀 Deploy / 🧪 Test (push) Successful in 3m1s
🚀 Deploy / ʦ🔍 Typecheck & Lint (push) Successful in 1m21s
🚀 Deploy / 🐳 Build (push) Successful in 1m28s
🚀 Deploy / 🚀 Deploy (push) Successful in 25s

Co-authored-by: Claude <noreply@anthropic.com>
Reviewed-on: #139
This commit is contained in:
chrisp 2026-08-17 22:50:35 +00:00
parent 81d813d3f3
commit 8edb4293c5
9 changed files with 778 additions and 143 deletions

View file

@ -0,0 +1,192 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { countSeasonRaces, hasRaceRun } from "../season-races";
vi.mock("~/database/context", () => ({
database: vi.fn(),
}));
const NOW = new Date("2026-08-17T12:00:00.000Z");
const TODAY = "2026-08-17";
interface EventRow {
eventType: string;
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | null;
}
function makeEvent(overrides: Partial<EventRow> = {}): EventRow {
return {
eventType: "schedule_event",
isComplete: false,
eventDate: null,
eventStartsAt: null,
...overrides,
};
}
async function mockEvents(events: EventRow[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue({
query: {
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
},
},
});
}
beforeEach(async () => {
await mockEvents([]);
});
describe("hasRaceRun", () => {
it("trusts isComplete when an admin has set it", () => {
expect(
hasRaceRun(
{ isComplete: true, eventDate: "2026-12-31", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("prefers eventStartsAt over eventDate", () => {
// Started yesterday and long finished, even though eventDate is unset.
expect(
hasRaceRun(
{
isComplete: false,
eventDate: null,
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(true);
expect(
hasRaceRun(
{
isComplete: false,
eventDate: TODAY,
eventStartsAt: new Date("2026-08-17T18:00:00.000Z"),
},
NOW,
TODAY
)
).toBe(false);
});
it("does not call a race run the moment it goes green", () => {
// Declaring the finale finished at the green flag would publish the
// pre-race leader as champion at 100%, from standings without that race.
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(false);
});
it("counts a race run once it has had time to finish", () => {
const greenFlag = new Date(NOW.getTime() - 7 * 60 * 60 * 1000);
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("still honours isComplete for a race that just started", () => {
const greenFlag = new Date(NOW.getTime() - 30 * 60 * 1000);
expect(
hasRaceRun({ isComplete: true, eventDate: TODAY, eventStartsAt: greenFlag }, NOW, TODAY)
).toBe(true);
});
it("treats a past date as run even when nobody marked it complete", () => {
expect(
hasRaceRun(
{ isComplete: false, eventDate: "2026-08-16", eventStartsAt: null },
NOW,
TODAY
)
).toBe(true);
});
it("treats a race happening today as still upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: TODAY, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
it("treats an undated row as upcoming", () => {
expect(
hasRaceRun({ isComplete: false, eventDate: null, eventStartsAt: null }, NOW, TODAY)
).toBe(false);
});
});
describe("countSeasonRaces", () => {
it("counts schedule_event rows as races", async () => {
// This is the whole bug: a season_standings calendar is stored as
// schedule_event rows, and the simulator used to skip them.
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventDate: "2026-04-01" }),
makeEvent({ eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 2,
remaining: 1,
total: 3,
});
});
it("excludes the final_standings scoring row", async () => {
await mockEvents([
makeEvent({ eventDate: "2026-03-01" }),
makeEvent({ eventType: "final_standings", eventDate: "2026-11-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 0,
total: 1,
});
});
it("counts other event types too, whichever type the admin used", async () => {
await mockEvents([
makeEvent({ eventType: "major_tournament", eventDate: "2026-03-01" }),
makeEvent({ eventType: "playoff_game", eventDate: "2026-09-01" }),
]);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 1,
remaining: 1,
total: 2,
});
});
it("returns zeroes when the season has no events", async () => {
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 0,
remaining: 0,
total: 0,
});
});
it("counts a realistic late-season IndyCar calendar", async () => {
const calendar = [
...Array.from({ length: 15 }, (_, i) =>
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
),
// The next race goes green in a few hours — still remaining.
makeEvent({ eventStartsAt: new Date("2026-08-17T18:00:00.000Z") }),
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
makeEvent({ eventType: "final_standings" }),
];
await mockEvents(calendar);
expect(await countSeasonRaces("s1", NOW)).toEqual({
completed: 15,
remaining: 2,
total: 17,
});
});
});

View file

@ -0,0 +1,99 @@
/**
* Race-calendar state for season-standings sports (F1, IndyCar).
*
* Kept in its own leaf module rather than in `scoring-event.ts` so that
* `simulator.ts` can read it: `scoring-event.ts` pulls in `scoring-calculator`,
* which reaches `participant-expected-value` and back into `simulator`. This
* file imports nothing but the database.
*/
import { eq } from "drizzle-orm";
import { database } from "~/database/context";
import * as schema from "~/database/schema";
export interface SeasonRaceCounts {
completed: number;
remaining: number;
total: number;
}
/**
* How long after the green flag a race is assumed to have finished.
*
* `event_starts_at` is a start time, so treating it as "already run" would
* declare the season over the moment the finale goes green and the simulator
* would publish the pre-race leader as champion at 100%, from standings that do
* not yet include the race being run. No race in these series comes close to
* six hours, and the standings feed updates within hours of a finish.
*/
const RACE_DURATION_MS = 6 * 60 * 60 * 1000;
/**
* Has this race already been run?
*
* `is_complete` wins when an admin has set it, but a racing calendar is stored
* as "Non-Scoring" rows that nobody ever marks complete, so the date is the real
* signal. Mirrors the Upcoming / Results Pending badge on the admin events page.
* A race happening today is still upcoming, and a row with no date at all counts
* as upcoming.
*
* @param today `now` as a `YYYY-MM-DD` string, to compare against the date-only
* `event_date` column.
*/
export function hasRaceRun(
event: {
isComplete: boolean;
eventDate: string | null;
eventStartsAt: Date | string | null;
},
now: Date,
today: string
): boolean {
if (event.isComplete) return true;
if (event.eventStartsAt) {
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
}
if (event.eventDate) return event.eventDate < today;
return false;
}
/**
* Count the races on a season-standings calendar (F1, IndyCar).
*
* `event_type` has no race value, so a racing calendar is stored as
* `schedule_event` rows the admin default for the `season_standings` scoring
* pattern. The only other row such a season carries is the single
* `final_standings` event that assigns fantasy placements once the championship
* is settled. A race is therefore "every event except `final_standings`", not
* "every event except `schedule_event`" getting that backwards leaves the
* simulator with zero remaining races and no idea the season is in progress.
*/
export async function countSeasonRaces(
sportsSeasonId: string,
now: Date = new Date(),
providedDb?: ReturnType<typeof database>
): Promise<SeasonRaceCounts> {
const db = providedDb || database();
const events = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
columns: {
eventType: true,
isComplete: true,
eventDate: true,
eventStartsAt: true,
},
});
const today = now.toISOString().split("T")[0];
let completed = 0;
let remaining = 0;
for (const event of events) {
if (event.eventType === "final_standings") continue;
if (hasRaceRun(event, now, today)) completed++;
else remaining++;
}
return { completed, remaining, total: completed + remaining };
}

View file

@ -15,6 +15,10 @@ import {
sourceEloRequirementLabel,
} from "~/services/simulations/input-policy";
import { SIMULATOR_TYPES, type SimulatorType } from "~/services/simulations/registry";
import { countSeasonRaces } from "~/models/season-races";
/** Simulator types driven by a race calendar plus championship standings. */
const RACE_CALENDAR_SIMULATORS: SimulatorType[] = ["f1_standings", "indycar_standings"];
export interface SimulatorProfile extends SimulatorManifestProfile {
isActive: boolean;
@ -488,6 +492,19 @@ export async function validateSimulatorReadiness(
}
}
if (RACE_CALENDAR_SIMULATORS.includes(config.simulatorType)) {
// Without a calendar the simulator cannot tell how many races are left, so
// it falls back to futures odds and ignores the championship standings
// entirely. A warning, not a blocker — a season drafted before the schedule
// is published still needs to run.
const races = await countSeasonRaces(sportsSeasonId);
if (races.total === 0) {
warnings.push(
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
);
}
}
if (config.profile.setupSections.includes("regularStandings")) {
warnings.push("Regular-season standings may be needed for in-season accuracy.");
}

View file

@ -3,6 +3,7 @@ import {
convertAmericanOddsToProbability,
convertDecimalOddsToProbability,
normalizeProbabilities,
devigPower,
decompressProbability,
mapToElo,
eloWinProbability,
@ -94,6 +95,64 @@ describe('probability-engine', () => {
});
});
describe('devigPower', () => {
/** 27-driver championship market: one -300 favourite and a long tail. */
const CHAMPIONSHIP_MARKET = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
].map(convertAmericanOddsToProbability);
it('sums to exactly 1.0', () => {
const devigged = devigPower(CHAMPIONSHIP_MARKET);
expect(devigged.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10);
});
it('preserves a heavy favourite that proportional devig would gut', () => {
const proportional = normalizeProbabilities(CHAMPIONSHIP_MARKET);
const devigged = devigPower(CHAMPIONSHIP_MARKET);
// -300 is 75.0% implied. The book sums to ~1.36, so dividing everyone by
// the same overround drops the favourite to ~55%.
expect(CHAMPIONSHIP_MARKET[0]).toBeCloseTo(0.75, 4);
expect(proportional[0]).toBeCloseTo(0.553, 2);
expect(devigged[0]).toBeCloseTo(0.695, 2);
expect(devigged[0]).toBeGreaterThan(proportional[0]);
});
it('keeps a near-lock near-certain', () => {
const market = [-20000, ...Array(26).fill(50000)].map(convertAmericanOddsToProbability);
expect(normalizeProbabilities(market)[0]).toBeCloseTo(0.950, 2);
expect(devigPower(market)[0]).toBeCloseTo(0.993, 2);
});
it('preserves the ordering of the field', () => {
const devigged = devigPower(CHAMPIONSHIP_MARKET);
for (let i = 1; i < devigged.length; i++) {
expect(devigged[i]).toBeLessThanOrEqual(devigged[i - 1]);
}
});
it('normalizes a book that is already vig-free', () => {
const devigged = devigPower([0.5, 0.3, 0.2]);
expect(devigged[0]).toBeCloseTo(0.5, 6);
expect(devigged[1]).toBeCloseTo(0.3, 6);
expect(devigged[2]).toBeCloseTo(0.2, 6);
});
it('scales a single runner to certainty', () => {
expect(devigPower([0.8])).toEqual([1]);
});
it('returns an empty array for an empty market', () => {
expect(devigPower([])).toEqual([]);
});
it('returns a uniform field for an all-zero market', () => {
devigPower([0, 0, 0]).forEach(p => expect(p).toBeCloseTo(1 / 3, 6));
});
});
describe('decompressProbability', () => {
it('decompresses championship probabilities with default exponent', () => {
expect(decompressProbability(0.154)).toBeCloseTo(2.465, 2); // Colorado 15.4%

View file

@ -109,6 +109,65 @@ export function normalizeProbabilities(probabilities: number[]): number[] {
return probabilities.map(p => p / sum);
}
/**
* Remove vig with a power transform instead of proportional division.
*
* `normalizeProbabilities` divides every runner by the same book sum, which
* assumes the overround is spread evenly across the field. In a large futures
* market it is not the juice is concentrated in the longshots, so dividing
* proportionally guts the favourite. In a 27-driver championship market with a
* book sum of 1.36, a 75.0% implied favourite comes out at 55.3%; with a book
* sum of 1.05, a -20000 near-lock comes out at 95.0%.
*
* The power method instead solves for the exponent `k` where `Σ pᵢ^k = 1`. Since
* `p^k` shrinks small probabilities much harder than large ones, the favourite
* keeps its shape: the same two markets give 69.5% and 99.3%.
*
* Solved by bisection `Σ pᵢ^k` is monotonically decreasing in `k` for
* `pᵢ ∈ (0, 1)`, so 60 halvings of `[0.01, 10]` converge well past float
* precision.
*
* @param impliedProbs Raw implied probabilities (as decimals 0-1), vig included
* @returns Vig-free probabilities summing to 1.0
*
* @example
* devigPower([0.75, 0.18, 0.12, 0.09]) // favourite stays ~0.70, not ~0.65
*/
export function devigPower(impliedProbs: number[]): number[] {
if (impliedProbs.length === 0) return [];
// Clamp into the open interval: p^k is only monotonic in k for 0 < p < 1, and
// an exact 0 or 1 pins the bisection regardless of the rest of the field.
// Clamping also means an all-zero market cannot divide by zero: every runner
// ends up at the floor and the field comes back uniform.
const clamped = impliedProbs.map((p) =>
Math.min(1 - 1e-9, Math.max(1e-9, p))
);
const sum = clamped.reduce((acc, p) => acc + p, 0);
// A single runner, or a book with no overround to strip, has no exponent to
// find — fall through to proportional scaling.
if (clamped.length === 1 || sum <= 1) {
return normalizeProbabilities(clamped);
}
let low = 0.01;
let high = 10;
for (let i = 0; i < 60; i++) {
const mid = (low + high) / 2;
const total = clamped.reduce((acc, p) => acc + Math.pow(p, mid), 0);
if (total > 1) {
low = mid;
} else {
high = mid;
}
}
const k = (low + high) / 2;
// Renormalize: bisection lands within float noise of 1.0, not exactly on it.
return normalizeProbabilities(clamped.map((p) => Math.pow(p, k)));
}
/**
* Decompress championship probability to single-game strength
*

View file

@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, type MockInstance } from "vitest";
import { AutoRacingSimulator } from "../auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "../race-points";
vi.mock("~/database/context", () => ({
database: vi.fn(),
@ -13,18 +14,18 @@ vi.mock("~/models/participant-expected-value", () => ({
getAllParticipantEVsForSeason: vi.fn(),
}));
// ─── F1 race points (positions 110) ─────────────────────────────────────────
const F1_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,
};
vi.mock("~/models/season-races", () => ({
countSeasonRaces: vi.fn(),
}));
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const DRIVERS = ["d1", "d2", "d3", "d4", "d5"].map((id) => ({ id }));
function makeEvent(isComplete: boolean, eventType = "race") {
return { isComplete, eventType };
}
const PROB_KEYS = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
function makeSeasonResult(participantId: string, currentPoints: string) {
return { participant: { id: participantId }, currentPoints };
@ -34,53 +35,66 @@ function makeEv(participantId: string, sourceOdds: number | null) {
return { participantId, sourceOdds };
}
function mockDb(events: ReturnType<typeof makeEvent>[]) {
function mockDb(drivers: { id: string }[] = DRIVERS) {
return {
query: {
seasonParticipants: {
findMany: vi.fn().mockResolvedValue(DRIVERS),
},
scoringEvents: {
findMany: vi.fn().mockResolvedValue(events),
findMany: vi.fn().mockResolvedValue(drivers),
},
},
};
}
// ─── Setup ────────────────────────────────────────────────────────────────────
/** Set the race counts the simulator reads from the calendar. */
async function setRaceCounts(completed: number, remaining: number) {
const { countSeasonRaces } = await import("~/models/season-races");
(countSeasonRaces as unknown as MockInstance).mockResolvedValue({
completed,
remaining,
total: completed + remaining,
});
}
let db: ReturnType<typeof mockDb>;
async function setStandings(results: ReturnType<typeof makeSeasonResult>[]) {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(results);
}
async function setOdds(evs: ReturnType<typeof makeEv>[]) {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue(evs);
}
async function useDrivers(drivers: { id: string }[]) {
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(mockDb(drivers));
}
// ─── Setup ────────────────────────────────────────────────────────────────────
beforeEach(async () => {
const { database } = await import("~/database/context");
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
db = mockDb([]);
(database as unknown as MockInstance).mockReturnValue(db);
(getSeasonResults as unknown as MockInstance).mockResolvedValue([]);
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
(database as unknown as MockInstance).mockReturnValue(mockDb());
await setStandings([]);
await setOdds([]);
await setRaceCounts(0, 0);
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("AutoRacingSimulator", () => {
it("throws when no participants are found", async () => {
db.query.seasonParticipants.findMany.mockResolvedValue([]);
await useDrivers([]);
await expect(
new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1")
).rejects.toThrow(/No participants found/);
});
describe("pre-season path (remainingRaces === 0)", () => {
describe("pre-season path (no races run, none remaining)", () => {
beforeEach(async () => {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
// No scoring events → remainingRaces = 0
db = mockDb([]);
const { database } = await import("~/database/context");
(database as unknown as MockInstance).mockReturnValue(db);
await setRaceCounts(0, 0);
// Heavy favourite: d1 at 500, all others at +1000
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
@ -96,11 +110,7 @@ describe("AutoRacingSimulator", () => {
it("normalizes each position column to sum to 1.0", async () => {
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
@ -117,8 +127,7 @@ describe("AutoRacingSimulator", () => {
});
it("drivers without odds get equal fallback probability", async () => {
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([]);
await setOdds([]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// With equal weights all 5 drivers should finish 1st roughly equally
for (const r of results) {
@ -126,30 +135,145 @@ describe("AutoRacingSimulator", () => {
expect(r.probabilities.probFirst).toBeLessThan(0.3);
}
});
it("prices an unpriced driver at the longest price in the book", async () => {
// d5 has no odds; d2d4 are +1000 long shots. An unpriced driver used to
// be handed 1/N, which rated them above most of the priced field.
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
makeEv("d3", 1000),
makeEv("d4", 1000),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
const unpriced = byId.get("d5") ?? 0;
const longShot = byId.get("d2") ?? 0;
expect(unpriced).toBeCloseTo(longShot, 1);
expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
});
describe("in-season path (remainingRaces > 0)", () => {
beforeEach(async () => {
const { database } = await import("~/database/context");
// 10 completed races, 5 remaining
db = mockDb([
...Array.from({ length: 10 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
it("does not let a thinly priced book flatten the favourite", async () => {
// Only one driver is priced. Anchoring the rest to "the longest price"
// would make that price the whole book and hand out a uniform field, so
// a single-price book keeps the 1/N fallback for the others.
// (Readiness requires odds for every participant, so this is a fallback
// path rather than a supported configuration.)
await setOdds([makeEv("d1", -500)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
expect(byId.get("d1") ?? 0).toBeGreaterThan(0.4);
expect(byId.get("d2") ?? 0).toBeLessThan(0.2);
});
});
describe("season complete (races run, none remaining)", () => {
it("returns the final standings order deterministically", async () => {
await setRaceCounts(17, 0);
// getSeasonResults returns rows already sorted by championship position.
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
makeSeasonResult("d2", "420"),
makeSeasonResult("d4", "398"),
]);
(database as unknown as MockInstance).mockReturnValue(db);
// Futures odds disagree entirely — they must be ignored once it is over.
await setOdds([makeEv("d1", -10000), makeEv("d3", 20000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probFirst).toBe(0);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
expect(byId.get("d2")?.probFourth).toBe(1);
expect(byId.get("d4")?.probFifth).toBe(1);
});
it("ranks the whole field, not just the drivers with standings rows", async () => {
// The settled season still has to fill all eight placement columns. Only
// ranking the drivers who have a standings row leaves the trailing
// columns empty, and the residual normalization then dumps a full 1.0
// onto whichever driver happens to be first in the list.
await setRaceCounts(17, 0);
await useDrivers(Array.from({ length: 10 }, (_, i) => ({ id: `d${i + 1}` })));
await setStandings([
makeSeasonResult("d3", "601"),
makeSeasonResult("d1", "480"),
makeSeasonResult("d5", "446"),
]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
const byId = new Map(results.map((r) => [r.participantId, r.probabilities]));
expect(byId.get("d3")?.probFirst).toBe(1);
expect(byId.get("d1")?.probSecond).toBe(1);
expect(byId.get("d5")?.probThird).toBe(1);
// No driver may hold two placements at once.
for (const probs of byId.values()) {
const held = PROB_KEYS.filter((key) => probs[key] > 0.5);
expect(held.length).toBeLessThanOrEqual(1);
}
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
});
it("falls back to a points-ranked field when there are no standings rows", async () => {
await setRaceCounts(17, 0);
await setStandings([]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
// Still produces a usable distribution rather than all zeroes.
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
expect(total).toBeCloseTo(1.0, 6);
});
});
describe("no race calendar", () => {
it("warns when the season has championship points but no events", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([makeSeasonResult("d1", "400"), makeSeasonResult("d2", "300")]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("stays quiet for a genuine pre-season with no points yet", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
await setStandings([]);
await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
});
describe("in-season path (races remaining)", () => {
beforeEach(async () => {
await setRaceCounts(10, 5);
});
it("normalizes each position column to sum to 1.0", async () => {
const { getSeasonResults } = await import("~/models/participant-season-result");
(getSeasonResults as unknown as MockInstance).mockResolvedValue(
DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50)))
);
await setStandings(DRIVERS.map((d, i) => makeSeasonResult(d.id, String((5 - i) * 50))));
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const keys = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
] as const;
for (const key of keys) {
for (const key of PROB_KEYS) {
const sum = results.reduce((s, r) => s + r.probabilities[key], 0);
expect(sum, `${key} column sum`).toBeCloseTo(1.0, 6);
}
@ -157,17 +281,9 @@ describe("AutoRacingSimulator", () => {
it("standings leader ranks higher than a driver far behind when standings dominate", async () => {
// 20/25 races done → seasonProgress = 0.8 → standings weighted 80%
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 20 }, () => makeEvent(true)),
...Array.from({ length: 5 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
await setRaceCounts(20, 5);
// d1 leads with 400 pts; d2 is a distant 2nd with 50 pts
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
await setStandings([
makeSeasonResult("d1", "400"),
makeSeasonResult("d2", "50"),
makeSeasonResult("d3", "40"),
@ -175,7 +291,7 @@ describe("AutoRacingSimulator", () => {
makeSeasonResult("d5", "20"),
]);
// Futures odds heavily favour d2 (pretend markets disagree)
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
await setOdds([
makeEv("d1", 5000), // very long shot per futures
makeEv("d2", -500), // heavy favourite per futures
]);
@ -192,11 +308,7 @@ describe("AutoRacingSimulator", () => {
it("falls back to odds for all drivers when no standings data exists", async () => {
// totalCurrentPoints = 0 → standings signal disabled, odds take over
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d1", -500),
makeEv("d2", 1000),
]);
await setOdds([makeEv("d1", -500), makeEv("d2", 1000)]);
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
const fav = results.find((r) => r.participantId === "d1");
const longShot = results.find((r) => r.participantId === "d2");
@ -208,27 +320,17 @@ describe("AutoRacingSimulator", () => {
});
it("a driver with 0 points mid-season is not penalized beyond their odds weight", async () => {
// Use 2 completed / 20 remaining → early season, standings gap is small
const { database } = await import("~/database/context");
db = mockDb([
...Array.from({ length: 2 }, () => makeEvent(true)),
...Array.from({ length: 20 }, () => makeEvent(false)),
]);
(database as unknown as MockInstance).mockReturnValue(db);
const { getSeasonResults } = await import("~/models/participant-season-result");
// Early season → standings gap is small
await setRaceCounts(2, 20);
// d1-d4 have a modest lead; d5 is absent (0 pts, new entry)
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
await setStandings([
makeSeasonResult("d1", "10"),
makeSeasonResult("d2", "8"),
makeSeasonResult("d3", "6"),
makeSeasonResult("d4", "4"),
// d5 intentionally absent → falls back to odds weight
]);
const { getAllParticipantEVsForSeason } = await import("~/models/participant-expected-value");
(getAllParticipantEVsForSeason as unknown as MockInstance).mockResolvedValue([
makeEv("d5", -500), // strong odds favourite despite 0 pts
]);
await setOdds([makeEv("d5", -500)]); // strong odds favourite despite 0 pts
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
// d5 should win championships at a non-trivial rate given their strong odds weight
@ -240,9 +342,8 @@ describe("AutoRacingSimulator", () => {
it("emits a warning when participants are missing from standings", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { getSeasonResults } = await import("~/models/participant-season-result");
// Only 3 of 5 drivers have standings rows
(getSeasonResults as unknown as MockInstance).mockResolvedValue([
await setStandings([
makeSeasonResult("d1", "100"),
makeSeasonResult("d2", "80"),
makeSeasonResult("d3", "60"),
@ -253,19 +354,97 @@ describe("AutoRacingSimulator", () => {
);
warnSpy.mockRestore();
});
});
it("schedule_event entries are excluded from race counts", async () => {
const { database } = await import("~/database/context");
// 5 real races + 3 schedule_events (should be ignored)
db = mockDb([
...Array.from({ length: 5 }, () => makeEvent(true)),
...Array.from({ length: 3 }, () => makeEvent(false, "schedule_event")),
makeEvent(false), // 1 real remaining
]);
(database as unknown as MockInstance).mockReturnValue(db);
// Should not throw and should use seasonProgress = 5/6
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
expect(results).toHaveLength(5);
describe("IndyCar regression: near-clinched championship leader", () => {
// The reported bug. A 121-point lead with 2 races left is arithmetically
// unassailable (max 100 available, and the leader banks at least 10), but
// the simulator skipped `schedule_event` rows, saw zero remaining races,
// took the pre-season branch and echoed stale futures odds at ~55%.
const POINTS = [
601, 480, 446, 420, 398, 372, 350, 331, 315, 300, 288, 270, 255, 240,
228, 215, 200, 188, 175, 160, 148, 135, 120, 105, 90, 70, 55,
];
const ODDS = [
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
50000, 50000, 50000, 50000, 50000,
];
const FIELD = POINTS.map((_, i) => ({ id: `driver${i}` }));
beforeEach(async () => {
await useDrivers(FIELD);
await setStandings(FIELD.map((d, i) => makeSeasonResult(d.id, String(POINTS[i]))));
await setOdds(FIELD.map((d, i) => makeEv(d.id, ODDS[i])));
});
it("gives the leader ~100% with 2 of 17 races left", async () => {
await setRaceCounts(15, 2);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.99);
});
it("without a calendar it can only echo the stale odds — the shape of the bug", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await setRaceCounts(0, 0);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
// Nowhere near the truth, which is exactly why the no-calendar warning
// above exists. Power devig keeps the -300 favourite well clear of the
// 55% that proportional devig produced, but odds alone cannot see a
// 121-point lead.
expect(leader.probabilities.probFirst).toBeLessThan(0.9);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("championship points but no race calendar")
);
warnSpy.mockRestore();
});
it("still gives the leader a commanding lead with 5 races left", async () => {
await setRaceCounts(12, 5);
const results = await new AutoRacingSimulator(INDYCAR_RACE_POINTS, "indycar").simulate("s1", {
iterations: 2000,
});
const leader = results.find((r) => r.participantId === "driver0");
expect(leader).toBeDefined();
if (!leader) return;
expect(leader.probabilities.probFirst).toBeGreaterThan(0.9);
});
});
});
describe("race points tables", () => {
it("IndyCar pays 50 for a win and scores down to P26", () => {
expect(INDYCAR_RACE_POINTS[1]).toBe(50);
expect(INDYCAR_RACE_POINTS[2]).toBe(40);
expect(INDYCAR_RACE_POINTS[25]).toBe(5);
expect(INDYCAR_RACE_POINTS[26]).toBe(5);
expect(INDYCAR_RACE_POINTS[27]).toBeUndefined();
});
it("F1 pays 25 for a win and scores down to P10", () => {
expect(F1_RACE_POINTS[1]).toBe(25);
expect(F1_RACE_POINTS[10]).toBe(1);
expect(F1_RACE_POINTS[11]).toBeUndefined();
});
it("both tables decrease monotonically so the points loop never truncates early", () => {
for (const table of [F1_RACE_POINTS, INDYCAR_RACE_POINTS]) {
const positions = Object.keys(table).map(Number).toSorted((a, b) => a - b);
// Contiguous from P1, no gaps — the award loop breaks at the first 0.
positions.forEach((pos, i) => expect(pos).toBe(i + 1));
for (let i = 1; i < positions.length; i++) {
expect(table[positions[i]]).toBeLessThanOrEqual(table[positions[i - 1]]);
}
}
});
});

View file

@ -7,16 +7,17 @@
*
* Algorithm:
* 1. Load participants + current championship points from DB
* 2. Count remaining races (incomplete non-schedule scoring events)
* 2. Count completed/remaining races (see `countSeasonRaces`)
* 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
* 4. Two paths:
* a. pre-season (no races run yet): pure weighted draws from odds
* b. otherwise: simulate each remaining race, starting from real standings,
* awarding series-specific points per finish. With zero races left this
* awards nothing and simply ranks the final standings.
* 5. Convert finish counts probability distributions + normalize columns
*
* Notes:
* - Drivers without odds fall back to uniform probability (1/N)
* - Drivers without odds are priced at the longest price in the book
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/
@ -25,6 +26,8 @@ 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";
@ -126,20 +129,24 @@ export class AutoRacingSimulator implements Simulator {
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 in a single pass (exclude schedule_event entries)
const allEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
let remainingRaces = 0;
let completedRaces = 0;
for (const e of allEvents) {
if (e.eventType === "schedule_event") continue;
if (e.isComplete) completedRaces++;
else remainingRaces++;
// 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 totalRaces = completedRaces + remainingRaces;
const seasonProgress = totalRaces > 0 ? completedRaces / totalRaces : 0;
// 4. Load EV data for championship win probabilities
@ -149,22 +156,33 @@ export class AutoRacingSimulator implements Simulator {
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.
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
// field is devigged with a power transform rather than proportional division
// — see devigPower.
//
// Unpriced drivers are priced at the longest price in the book before the
// devig, not at 1/N: the market left them out because it did not rate them,
// and in a 27-car field 1/N (3.7%) rates them above most of the real
// longshots (+50000 is 0.2%). A book with a single price has no tail to
// anchor to, so that case keeps the 1/N fallback.
const fallbackProb = 1 / participants.length;
const rawProbs = new Map<string, number>();
const pricedImplied = new Map<string, number>();
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);
const odds = evMap.get(p.id)?.sourceOdds;
if (odds !== null && odds !== undefined) {
pricedImplied.set(p.id, americanToImpliedProb(odds));
}
}
// 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);
}
const unpricedImplied =
pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
const devigged = devigPower(
participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
);
const rawProbs = new Map<string, number>(
participants.map((p, i) => [p.id, devigged[i]])
);
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>();
@ -184,9 +202,12 @@ export class AutoRacingSimulator implements Simulator {
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.
// Pre-season only: no races run *and* none left, so there are no standings
// to build on and the odds are all there is. When races have already been
// run the in-season path below handles it — with zero races left it awards
// no points, so it just ranks the current standings, which is exactly the
// right answer for a finished season.
if (totalRaces === 0 || completedRaces === 0) {
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < numSimulations; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
@ -214,7 +235,6 @@ export class AutoRacingSimulator implements Simulator {
// - 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 totalCurrentPoints = [...currentPointsMap.values()].reduce((a, b) => a + b, 0);
const blendedProbs = new Map<string, number>();
for (const id of ids) {
const oddsW = baseProbs.get(id) ?? fallbackProb;

View file

@ -0,0 +1,23 @@
/**
* Championship points tables for auto racing series.
*
* Stable series rules, not refreshable season data, so they live in code (see
* the hardcoding rules in docs/agents/simulators.md). Kept out of `registry.ts`
* so tests and callers can read a table without pulling in every simulator
* importing the registry first also trips the manifest/registry import cycle.
*
* Each table must be contiguous from P1 and monotonically decreasing: the
* simulator's award loop stops at the first unscored position.
*/
/** F1 points: positions 110. */
export const F1_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,
};
/** IndyCar standard race points: positions 126. */
export const INDYCAR_RACE_POINTS: Record<number, number> = {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
};

View file

@ -9,6 +9,7 @@
import type { Simulator } from "./types";
import { BracketSimulator } from "./bracket-simulator";
import { AutoRacingSimulator } from "./auto-racing-simulator";
import { F1_RACE_POINTS, INDYCAR_RACE_POINTS } from "./race-points";
import { GolfSimulator } from "./golf-simulator";
import { UCLSimulator } from "./ucl-simulator";
import { NCAAMSimulator } from "./ncaam-simulator";
@ -62,20 +63,6 @@ export const SIMULATOR_TYPES = [
export type SimulatorType = typeof SIMULATOR_TYPES[number];
// ─── Race points tables ───────────────────────────────────────────────────────
/** F1 points: positions 110. */
const F1_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,
};
/** IndyCar standard race points: positions 126. */
const INDYCAR_RACE_POINTS: Record<number, number> = {
1: 50, 2: 40, 3: 35, 4: 32, 5: 30, 6: 28, 7: 26, 8: 24, 9: 22, 10: 20,
11: 19, 12: 18, 13: 17, 14: 16, 15: 15, 16: 14, 17: 13, 18: 12, 19: 11, 20: 10,
21: 9, 22: 8, 23: 7, 24: 6, 25: 5, 26: 5,
};
export interface SimulatorInfo {
name: string;
description: string;