From 8edb4293c5c65de85a9436cbbd1d16fa8223dfd1 Mon Sep 17 00:00:00 2001 From: chrisp Date: Mon, 17 Aug 2026 22:50:35 +0000 Subject: [PATCH] claude/indycar-ev-sims-probability-fpzfx6 (#139) Co-authored-by: Claude Reviewed-on: https://forge.brackt.com/chrisp/brackt/pulls/139 --- app/models/__tests__/season-races.test.ts | 192 +++++++++ app/models/season-races.ts | 99 +++++ app/models/simulator.ts | 17 + .../__tests__/probability-engine.test.ts | 59 +++ app/services/probability-engine.ts | 59 +++ .../__tests__/auto-racing-simulator.test.ts | 373 +++++++++++++----- .../simulations/auto-racing-simulator.ts | 84 ++-- app/services/simulations/race-points.ts | 23 ++ app/services/simulations/registry.ts | 15 +- 9 files changed, 778 insertions(+), 143 deletions(-) create mode 100644 app/models/__tests__/season-races.test.ts create mode 100644 app/models/season-races.ts create mode 100644 app/services/simulations/race-points.ts diff --git a/app/models/__tests__/season-races.test.ts b/app/models/__tests__/season-races.test.ts new file mode 100644 index 0000000..4274c10 --- /dev/null +++ b/app/models/__tests__/season-races.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/app/models/season-races.ts b/app/models/season-races.ts new file mode 100644 index 0000000..4493aa8 --- /dev/null +++ b/app/models/season-races.ts @@ -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 +): Promise { + 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 }; +} diff --git a/app/models/simulator.ts b/app/models/simulator.ts index b41469f..7d03bb0 100644 --- a/app/models/simulator.ts +++ b/app/models/simulator.ts @@ -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."); } diff --git a/app/services/__tests__/probability-engine.test.ts b/app/services/__tests__/probability-engine.test.ts index 0be8743..524efec 100644 --- a/app/services/__tests__/probability-engine.test.ts +++ b/app/services/__tests__/probability-engine.test.ts @@ -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% diff --git a/app/services/probability-engine.ts b/app/services/probability-engine.ts index 313bb14..1ceea61 100644 --- a/app/services/probability-engine.ts +++ b/app/services/probability-engine.ts @@ -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 * diff --git a/app/services/simulations/__tests__/auto-racing-simulator.test.ts b/app/services/simulations/__tests__/auto-racing-simulator.test.ts index 9b193c5..ff7f5ba 100644 --- a/app/services/simulations/__tests__/auto-racing-simulator.test.ts +++ b/app/services/simulations/__tests__/auto-racing-simulator.test.ts @@ -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 1–10) ───────────────────────────────────────── -const F1_RACE_POINTS: Record = { - 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[]) { +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; +async function setStandings(results: ReturnType[]) { + const { getSeasonResults } = await import("~/models/participant-season-result"); + (getSeasonResults as unknown as MockInstance).mockResolvedValue(results); +} + +async function setOdds(evs: ReturnType[]) { + 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; d2–d4 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); + }); + + 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("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)), + 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]]); + } + } + }); +}); diff --git a/app/services/simulations/auto-racing-simulator.ts b/app/services/simulations/auto-racing-simulator.ts index 1e5e838..c0b1988 100644 --- a/app/services/simulations/auto-racing-simulator.ts +++ b/app/services/simulations/auto-racing-simulator.ts @@ -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( 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(); + const pricedImplied = 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); + 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( + participants.map((p, i) => [p.id, devigged[i]]) + ); // 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0) const baseProbs = new Map(); @@ -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(); for (const id of ids) { const oddsW = baseProbs.get(id) ?? fallbackProb; diff --git a/app/services/simulations/race-points.ts b/app/services/simulations/race-points.ts new file mode 100644 index 0000000..cf5e9ca --- /dev/null +++ b/app/services/simulations/race-points.ts @@ -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 1–10. */ +export const F1_RACE_POINTS: Record = { + 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 1–26. */ +export const INDYCAR_RACE_POINTS: Record = { + 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, +}; diff --git a/app/services/simulations/registry.ts b/app/services/simulations/registry.ts index 2d825e2..d616255 100644 --- a/app/services/simulations/registry.ts +++ b/app/services/simulations/registry.ts @@ -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 1–10. */ -const F1_RACE_POINTS: Record = { - 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 1–26. */ -const INDYCAR_RACE_POINTS: Record = { - 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;