claude/indycar-ev-sims-probability-fpzfx6 #139
9 changed files with 725 additions and 140 deletions
168
app/models/__tests__/season-races.test.ts
Normal file
168
app/models/__tests__/season-races.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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", () => {
|
||||
// Same day, but the green flag already dropped this morning.
|
||||
expect(
|
||||
hasRaceRun(
|
||||
{
|
||||
isComplete: false,
|
||||
eventDate: TODAY,
|
||||
eventStartsAt: new Date("2026-08-17T09: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("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}` })
|
||||
),
|
||||
makeEvent({ eventStartsAt: new Date("2026-08-30T18:00:00.000Z") }),
|
||||
makeEvent({ eventStartsAt: new Date("2026-09-13T18:00:00.000Z") }),
|
||||
makeEvent({ eventType: "final_standings" }),
|
||||
];
|
||||
await mockEvents(calendar);
|
||||
expect(await countSeasonRaces("s1", NOW)).toEqual({
|
||||
completed: 15,
|
||||
remaining: 2,
|
||||
total: 17,
|
||||
});
|
||||
});
|
||||
});
|
||||
86
app/models/season-races.ts
Normal file
86
app/models/season-races.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) < now;
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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%
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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<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,106 @@ describe("AutoRacingSimulator", () => {
|
|||
expect(r.probabilities.probFirst).toBeLessThan(0.3);
|
||||
}
|
||||
});
|
||||
|
||||
it("floors an unpriced driver at the bottom of the priced market", async () => {
|
||||
// d5 has no odds at all; d2–d4 are +1000 long shots. Before power devig
|
||||
// an unpriced driver was handed 1/N, which rated them above the 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 unpriced = results.find((r) => r.participantId === "d5");
|
||||
const longShot = results.find((r) => r.participantId === "d2");
|
||||
expect(unpriced).toBeDefined();
|
||||
expect(longShot).toBeDefined();
|
||||
if (!unpriced || !longShot) return;
|
||||
expect(unpriced.probabilities.probFirst).toBeLessThanOrEqual(
|
||||
longShot.probabilities.probFirst + 0.02
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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("warns and falls back to odds when there are no standings rows", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
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");
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("no races left but no standings rows")
|
||||
);
|
||||
// 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);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
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 +242,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 +252,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 +269,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 +281,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 +303,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 +315,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]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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. 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 fall back to uniform probability (1/N)
|
||||
* - Drivers without odds are floored at the bottom of the priced market
|
||||
* - 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,41 @@ 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
|
||||
// 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 rawProbs = new Map<string, number>();
|
||||
const pricedIds: string[] = [];
|
||||
const pricedImplied: 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) {
|
||||
pricedIds.push(p.id);
|
||||
pricedImplied.push(americanToImpliedProb(odds));
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize to remove vig
|
||||
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>();
|
||||
|
|
@ -184,9 +210,30 @@ export class AutoRacingSimulator implements Simulator {
|
|||
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
|
||||
}
|
||||
|
||||
if (remainingRaces === 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);
|
||||
|
|
@ -214,7 +261,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;
|
||||
|
|
|
|||
23
app/services/simulations/race-points.ts
Normal file
23
app/services/simulations/race-points.ts
Normal 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 1–10. */
|
||||
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 1–26. */
|
||||
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,
|
||||
};
|
||||
|
|
@ -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<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 1–26. */
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue