claude/indycar-ev-sims-probability-fpzfx6 #139

Merged
chrisp merged 2 commits from claude/indycar-ev-sims-probability-fpzfx6 into main 2026-08-17 22:50:36 +00:00
4 changed files with 129 additions and 79 deletions
Showing only changes of commit 95f895a715 - Show all commits

View file

@ -52,13 +52,13 @@ describe("hasRaceRun", () => {
});
it("prefers eventStartsAt over eventDate", () => {
// Same day, but the green flag already dropped this morning.
// Started yesterday and long finished, even though eventDate is unset.
expect(
hasRaceRun(
{
isComplete: false,
eventDate: TODAY,
eventStartsAt: new Date("2026-08-17T09:00:00.000Z"),
eventDate: null,
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
},
NOW,
TODAY
@ -78,6 +78,29 @@ describe("hasRaceRun", () => {
).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(
@ -154,8 +177,9 @@ describe("countSeasonRaces", () => {
...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({ eventStartsAt: new Date("2026-09-13T18:00:00.000Z") }),
makeEvent({ eventType: "final_standings" }),
];
await mockEvents(calendar);

View file

@ -17,6 +17,17 @@ export interface SeasonRaceCounts {
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?
*
@ -39,7 +50,9 @@ export function hasRaceRun(
today: string
): boolean {
if (event.isComplete) return true;
if (event.eventStartsAt) return new Date(event.eventStartsAt) < now;
if (event.eventStartsAt) {
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
}
if (event.eventDate) return event.eventDate < today;
return false;
}

View file

@ -136,9 +136,9 @@ describe("AutoRacingSimulator", () => {
}
});
it("floors an unpriced driver at the bottom of the priced market", async () => {
// d5 has no odds at all; d2d4 are +1000 long shots. Before power devig
// an unpriced driver was handed 1/N, which rated them above the field.
it("prices an unpriced driver at the longest price in the book", async () => {
// d5 has no odds; d2d4 are +1000 long shots. An unpriced driver used to
// be handed 1/N, which rated them above most of the priced field.
await setOdds([
makeEv("d1", -500),
makeEv("d2", 1000),
@ -146,14 +146,24 @@ describe("AutoRacingSimulator", () => {
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
);
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);
});
});
@ -182,21 +192,50 @@ describe("AutoRacingSimulator", () => {
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(() => {});
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");
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1", {
iterations: 500,
});
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();
});
});

View file

@ -9,15 +9,15 @@
* 1. Load participants + current championship points from DB
* 2. Count completed/remaining races (see `countSeasonRaces`)
* 3. Convert sourceOdds vig-removed probability weights
* 4. Three paths:
* a. season complete (no races left, some run): standings are the answer
* b. pre-season (no races at all yet): pure weighted draws from odds
* c. in-season: simulate each remaining race, starting from real standings,
* awarding series-specific points per finish
* 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 are floored at the bottom of the priced market
* - Drivers without odds are priced at the longest price in the book
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/
@ -157,40 +157,32 @@ export class AutoRacingSimulator implements Simulator {
// 5. Build raw implied championship win probabilities from odds.
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
// priced field is devigged with a power transform rather than proportional
// division — see devigPower. Only drivers who actually have odds go into the
// devig: mixing in a 1/N placeholder for unpriced drivers would both inflate
// the book sum (distorting the solved exponent) and rate an unpriced driver
// above most of the real longshots.
// 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 pricedIds: string[] = [];
const pricedImplied: number[] = [];
const pricedImplied = new Map<string, number>();
for (const p of participants) {
const odds = evMap.get(p.id)?.sourceOdds;
if (odds !== null && odds !== undefined) {
pricedIds.push(p.id);
pricedImplied.push(americanToImpliedProb(odds));
pricedImplied.set(p.id, americanToImpliedProb(odds));
}
}
const rawProbs = new Map<string, number>();
if (pricedIds.length === 0) {
for (const p of participants) rawProbs.set(p.id, fallbackProb);
} else {
const devigged = devigPower(pricedImplied);
pricedIds.forEach((id, i) => rawProbs.set(id, devigged[i]));
// Unpriced drivers sit at the bottom of the market, then renormalize.
const marketFloor = Math.min(...devigged);
for (const p of participants) {
if (!rawProbs.has(p.id)) rawProbs.set(p.id, marketFloor);
}
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawProbs) {
rawProbs.set(id, prob / rawSum);
}
}
const unpricedImplied =
pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
const devigged = devigPower(
participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
);
const rawProbs = new Map<string, number>(
participants.map((p, i) => [p.id, devigged[i]])
);
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>();
@ -210,30 +202,12 @@ export class AutoRacingSimulator implements Simulator {
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
}
// Every race has run and at least one result is in: the championship is
// decided, so the standings *are* the answer — there is nothing to simulate.
// getSeasonResults already sorts by currentPosition (nulls last), then points
// descending.
const settledOrder =
remainingRaces === 0 && completedRaces > 0
? seasonResults.map((r) => r.participant.id).filter((id) => rankCounts.has(id))
: [];
if (settledOrder.length > 0) {
for (let rank = 0; rank < Math.min(8, settledOrder.length); rank++) {
const counts = rankCounts.get(settledOrder[rank]);
if (counts) counts[rank] = numSimulations;
}
} else if (remainingRaces === 0) {
// Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws.
if (completedRaces > 0) {
// eslint-disable-next-line no-console
console.warn(
`[AutoRacingSimulator] Season ${sportsSeasonId} has no races left but no standings rows — ` +
`falling back to futures odds instead of the final championship order.`
);
}
// 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);