claude/indycar-ev-sims-probability-fpzfx6 #139
4 changed files with 129 additions and 79 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; d2–d4 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; 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),
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue