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
86 lines
2.8 KiB
TypeScript
86 lines
2.8 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|