Fix three defects found reviewing the auto racing sim change
- The settled-season branch only ranked drivers that had a standings row, so with fewer than eight rows the trailing placement columns were empty and step 9's residual normalization dumped a full 1.0 onto whichever driver came first. Ten drivers with five standings rows gave d1 probSecond, probSixth, probSeventh and probEighth all at 1. Removed the branch: the in-season path awards no points when no races remain, so it already ranks the final standings, and it ranks the whole field. - The unpriced-driver floor was taken from a distribution normalized over the priced subset alone. With one priced driver devigPower returns [1], so every unpriced driver was seeded at 1 and the field came back flat — a lone -500 favourite fell to 0.117. Floor on the implied scale and devig the whole field instead, keeping the 1/N fallback when a book has a single price and no tail to anchor to. - hasRaceRun treated a race as run at the green flag, so remainingRaces hit 0 the moment the finale started and the pre-race leader was published as champion at 100% from standings that did not include that race. Require the race to have had time to finish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
This commit is contained in:
parent
adceda3aca
commit
95f895a715
4 changed files with 129 additions and 79 deletions
|
|
@ -52,13 +52,13 @@ describe("hasRaceRun", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("prefers eventStartsAt over eventDate", () => {
|
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(
|
expect(
|
||||||
hasRaceRun(
|
hasRaceRun(
|
||||||
{
|
{
|
||||||
isComplete: false,
|
isComplete: false,
|
||||||
eventDate: TODAY,
|
eventDate: null,
|
||||||
eventStartsAt: new Date("2026-08-17T09:00:00.000Z"),
|
eventStartsAt: new Date("2026-08-16T18:00:00.000Z"),
|
||||||
},
|
},
|
||||||
NOW,
|
NOW,
|
||||||
TODAY
|
TODAY
|
||||||
|
|
@ -78,6 +78,29 @@ describe("hasRaceRun", () => {
|
||||||
).toBe(false);
|
).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", () => {
|
it("treats a past date as run even when nobody marked it complete", () => {
|
||||||
expect(
|
expect(
|
||||||
hasRaceRun(
|
hasRaceRun(
|
||||||
|
|
@ -154,8 +177,9 @@ describe("countSeasonRaces", () => {
|
||||||
...Array.from({ length: 15 }, (_, i) =>
|
...Array.from({ length: 15 }, (_, i) =>
|
||||||
makeEvent({ eventDate: `2026-0${((i % 6) + 3)}-0${(i % 9) + 1}` })
|
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-08-30T18:00:00.000Z") }),
|
||||||
makeEvent({ eventStartsAt: new Date("2026-09-13T18:00:00.000Z") }),
|
|
||||||
makeEvent({ eventType: "final_standings" }),
|
makeEvent({ eventType: "final_standings" }),
|
||||||
];
|
];
|
||||||
await mockEvents(calendar);
|
await mockEvents(calendar);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,17 @@ export interface SeasonRaceCounts {
|
||||||
total: 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?
|
* Has this race already been run?
|
||||||
*
|
*
|
||||||
|
|
@ -39,7 +50,9 @@ export function hasRaceRun(
|
||||||
today: string
|
today: string
|
||||||
): boolean {
|
): boolean {
|
||||||
if (event.isComplete) return true;
|
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;
|
if (event.eventDate) return event.eventDate < today;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,9 +136,9 @@ describe("AutoRacingSimulator", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("floors an unpriced driver at the bottom of the priced market", async () => {
|
it("prices an unpriced driver at the longest price in the book", async () => {
|
||||||
// d5 has no odds at all; d2–d4 are +1000 long shots. Before power devig
|
// d5 has no odds; d2–d4 are +1000 long shots. An unpriced driver used to
|
||||||
// an unpriced driver was handed 1/N, which rated them above the field.
|
// be handed 1/N, which rated them above most of the priced field.
|
||||||
await setOdds([
|
await setOdds([
|
||||||
makeEv("d1", -500),
|
makeEv("d1", -500),
|
||||||
makeEv("d2", 1000),
|
makeEv("d2", 1000),
|
||||||
|
|
@ -146,14 +146,24 @@ describe("AutoRacingSimulator", () => {
|
||||||
makeEv("d4", 1000),
|
makeEv("d4", 1000),
|
||||||
]);
|
]);
|
||||||
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
const results = await new AutoRacingSimulator(F1_RACE_POINTS, "f1").simulate("s1");
|
||||||
const unpriced = results.find((r) => r.participantId === "d5");
|
const byId = new Map(results.map((r) => [r.participantId, r.probabilities.probFirst]));
|
||||||
const longShot = results.find((r) => r.participantId === "d2");
|
const unpriced = byId.get("d5") ?? 0;
|
||||||
expect(unpriced).toBeDefined();
|
const longShot = byId.get("d2") ?? 0;
|
||||||
expect(longShot).toBeDefined();
|
expect(unpriced).toBeCloseTo(longShot, 1);
|
||||||
if (!unpriced || !longShot) return;
|
expect(byId.get("d1") ?? 0).toBeGreaterThan(longShot * 3);
|
||||||
expect(unpriced.probabilities.probFirst).toBeLessThanOrEqual(
|
});
|
||||||
longShot.probabilities.probFirst + 0.02
|
|
||||||
);
|
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);
|
expect(byId.get("d4")?.probFifth).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns and falls back to odds when there are no standings rows", async () => {
|
it("ranks the whole field, not just the drivers with standings rows", async () => {
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
// 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 setRaceCounts(17, 0);
|
||||||
await setStandings([]);
|
await setStandings([]);
|
||||||
await setOdds([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 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.
|
// Still produces a usable distribution rather than all zeroes.
|
||||||
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
const total = results.reduce((s, r) => s + r.probabilities.probFirst, 0);
|
||||||
expect(total).toBeCloseTo(1.0, 6);
|
expect(total).toBeCloseTo(1.0, 6);
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,15 @@
|
||||||
* 1. Load participants + current championship points from DB
|
* 1. Load participants + current championship points from DB
|
||||||
* 2. Count completed/remaining races (see `countSeasonRaces`)
|
* 2. Count completed/remaining races (see `countSeasonRaces`)
|
||||||
* 3. Convert sourceOdds → vig-removed probability weights
|
* 3. Convert sourceOdds → vig-removed probability weights
|
||||||
* 4. Three paths:
|
* 4. Two paths:
|
||||||
* a. season complete (no races left, some run): standings are the answer
|
* a. pre-season (no races run yet): pure weighted draws from odds
|
||||||
* b. pre-season (no races at all yet): pure weighted draws from odds
|
* b. otherwise: simulate each remaining race, starting from real standings,
|
||||||
* c. in-season: simulate each remaining race, starting from real standings,
|
* awarding series-specific points per finish. With zero races left this
|
||||||
* awarding series-specific points per finish
|
* awards nothing and simply ranks the final standings.
|
||||||
* 5. Convert finish counts → probability distributions + normalize columns
|
* 5. Convert finish counts → probability distributions + normalize columns
|
||||||
*
|
*
|
||||||
* Notes:
|
* 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
|
* - 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.
|
// 5. Build raw implied championship win probabilities from odds.
|
||||||
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
|
// americanToImpliedProb includes vig (the field sums well over 1.0), so the
|
||||||
// priced field is devigged with a power transform rather than proportional
|
// field is devigged with a power transform rather than proportional division
|
||||||
// division — see devigPower. Only drivers who actually have odds go into the
|
// — see devigPower.
|
||||||
// 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
|
// Unpriced drivers are priced at the longest price in the book before the
|
||||||
// above most of the real longshots.
|
// 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 fallbackProb = 1 / participants.length;
|
||||||
const pricedIds: string[] = [];
|
const pricedImplied = new Map<string, number>();
|
||||||
const pricedImplied: number[] = [];
|
|
||||||
|
|
||||||
for (const p of participants) {
|
for (const p of participants) {
|
||||||
const odds = evMap.get(p.id)?.sourceOdds;
|
const odds = evMap.get(p.id)?.sourceOdds;
|
||||||
if (odds !== null && odds !== undefined) {
|
if (odds !== null && odds !== undefined) {
|
||||||
pricedIds.push(p.id);
|
pricedImplied.set(p.id, americanToImpliedProb(odds));
|
||||||
pricedImplied.push(americanToImpliedProb(odds));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawProbs = new Map<string, number>();
|
const unpricedImplied =
|
||||||
if (pricedIds.length === 0) {
|
pricedImplied.size > 1 ? Math.min(...pricedImplied.values()) : fallbackProb;
|
||||||
for (const p of participants) rawProbs.set(p.id, fallbackProb);
|
const devigged = devigPower(
|
||||||
} else {
|
participants.map((p) => pricedImplied.get(p.id) ?? unpricedImplied)
|
||||||
const devigged = devigPower(pricedImplied);
|
);
|
||||||
pricedIds.forEach((id, i) => rawProbs.set(id, devigged[i]));
|
const rawProbs = new Map<string, number>(
|
||||||
|
participants.map((p, i) => [p.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)
|
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
|
||||||
const baseProbs = new Map<string, number>();
|
const baseProbs = new Map<string, number>();
|
||||||
|
|
@ -210,30 +202,12 @@ export class AutoRacingSimulator implements Simulator {
|
||||||
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
|
rankCounts.set(id, Array.from({ length: 8 }, () => 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every race has run and at least one result is in: the championship is
|
// Pre-season only: no races run *and* none left, so there are no standings
|
||||||
// decided, so the standings *are* the answer — there is nothing to simulate.
|
// to build on and the odds are all there is. When races have already been
|
||||||
// getSeasonResults already sorts by currentPosition (nulls last), then points
|
// run the in-season path below handles it — with zero races left it awards
|
||||||
// descending.
|
// no points, so it just ranks the current standings, which is exactly the
|
||||||
const settledOrder =
|
// right answer for a finished season.
|
||||||
remainingRaces === 0 && completedRaces > 0
|
if (totalRaces === 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);
|
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
|
||||||
for (let sim = 0; sim < numSimulations; sim++) {
|
for (let sim = 0; sim < numSimulations; sim++) {
|
||||||
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue