Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 00:50:30 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-08-17 00:50:30 +00:00
|
|
|
if (event.eventStartsAt) {
|
|
|
|
|
return new Date(event.eventStartsAt).getTime() + RACE_DURATION_MS < now.getTime();
|
|
|
|
|
}
|
Fix auto racing sims ignoring championship standings
The IndyCar simulator gave a near-clinched championship leader ~60% to win
the title. It was reporting the futures odds and nothing else.
`event_type` has no race value, so a season_standings calendar (F1, IndyCar)
is stored as `schedule_event` rows — the admin default for that scoring
pattern. The simulator skipped exactly those rows when counting races, so it
saw zero remaining races, took the branch labelled "pre-season", and never
looked at `participant_season_results`. Since `sourceOdds` is an admin input
that is never auto-refreshed, the output was whatever the market said months
ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races
left, stale futures at -300) the old path returns ~55%; counting the races
returns 100.0%.
- Add `countSeasonRaces` in a new leaf model. A race is every event except
`final_standings`, and completion is inferred from the event date, since
nobody marks rows labelled "Non-Scoring" complete. Its own module because
`scoring-event.ts` reaches back into `simulator.ts` through
`scoring-calculator`.
- Split "season over" from "pre-season". Both had `remainingRaces === 0`, so
a finished season reverted to an odds draw instead of reporting the final
standings.
- Warn when a season has championship points but no calendar, in the
simulator and as a non-blocking readiness warning on the setup page.
- Replace proportional vig removal with a power devig. Dividing every runner
by the same book sum guts the favourite in a 27-driver market: a 75%
implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig
gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the
market instead of being handed 1/N.
- Move the race points tables to their own module so tests can read them
without tripping the manifest/registry import cycle.
The existing tests missed all of this because their event fixtures used
`eventType: "race"`, which is not a value the enum has. Rebuilt on real enum
values, plus a regression test for the reported case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
|
|
|
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 };
|
|
|
|
|
}
|