brackt/app/models/ev-snapshot.ts
Chris Parsons c6ba59b0e6
User/chris/ev f1 framework (#93)
* feat: EV simulation framework with F1 Monte Carlo simulator

- Add EV snapshot tables (participant_ev_snapshots, team_ev_snapshots) and simulation_status column on sports seasons
- Add ev-snapshot model with upsert and history query functions
- Add simulator framework: types, bracket/F1/golf simulators, registry
- F1 simulator: vig-removed ICM weighted draw (pre-season) + race-by-race Monte Carlo from current standings (in-season); per-position column normalization to prevent floating-point EV drift
- Add admin simulate route and Run Simulation button on sports season page
- Rework futures-odds admin page to save odds then run simulation in one action
- Remove recalculate-probabilities route (superseded by simulate route)
- Remove EV trend chart panel and associated DB queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: map simulators to sports via simulatorType field

Adds a `simulator_type` enum column to the `sports` table so each sport
can be assigned a specific simulation algorithm rather than deriving it
from the sports season's scoring pattern.

- Add `simulatorTypeEnum` (f1_standings, indycar_standings,
  golf_qualifying_points, playoff_bracket) + `simulatorType` nullable
  column on `sports` table; migration 0037
- Rewrite simulator registry to key off `SimulatorType` instead of
  `ScoringPattern`; indycar_standings shares F1Simulator for now
- `findSportsSeasonById` now returns `SportsSeasonWithSport` so callers
  have typed access to `sport.simulatorType`
- Simulate and futures-odds actions read `sport.simulatorType`; guard
  fires before setting `simulationStatus: running`
- Admin sport edit page gains a Simulator Type dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 15:34:31 -07:00

256 lines
7.7 KiB
TypeScript

/**
* Model for EV (Expected Value) Snapshots
*
* Historical record of participant EVs and team projected points over time.
* One snapshot per participant (or team) per sport season per day — upsert on conflict.
* Snapshots are taken directly from simulation output to avoid partial-capture issues.
*/
import { database } from "~/database/context";
import { participantEvSnapshots, teamEvSnapshots } from "~/database/schema";
import { eq, and, asc } from "drizzle-orm";
// ─── Participant EV Snapshots ─────────────────────────────────────────────────
export interface ParticipantEvSnapshotInput {
participantId: string;
sportsSeasonId: string;
snapshotDate: string; // YYYY-MM-DD
probFirst: number;
probSecond: number;
probThird: number;
probFourth: number;
probFifth: number;
probSixth: number;
probSeventh: number;
probEighth: number;
calculatedEV: number;
source: string;
}
export interface ParticipantEvHistoryPoint {
snapshotDate: string;
calculatedEV: number;
probFirst: number;
}
/**
* Upsert a single participant EV snapshot.
* If a snapshot already exists for this (participantId, sportsSeasonId, snapshotDate),
* it will be overwritten with the new values.
*/
export async function upsertParticipantEvSnapshot(
input: ParticipantEvSnapshotInput
): Promise<void> {
const db = database();
const now = new Date();
await db
.insert(participantEvSnapshots)
.values({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
snapshotDate: input.snapshotDate,
probFirst: input.probFirst.toString(),
probSecond: input.probSecond.toString(),
probThird: input.probThird.toString(),
probFourth: input.probFourth.toString(),
probFifth: input.probFifth.toString(),
probSixth: input.probSixth.toString(),
probSeventh: input.probSeventh.toString(),
probEighth: input.probEighth.toString(),
calculatedEV: input.calculatedEV.toString(),
source: input.source,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
participantEvSnapshots.participantId,
participantEvSnapshots.sportsSeasonId,
participantEvSnapshots.snapshotDate,
],
set: {
probFirst: input.probFirst.toString(),
probSecond: input.probSecond.toString(),
probThird: input.probThird.toString(),
probFourth: input.probFourth.toString(),
probFifth: input.probFifth.toString(),
probSixth: input.probSixth.toString(),
probSeventh: input.probSeventh.toString(),
probEighth: input.probEighth.toString(),
calculatedEV: input.calculatedEV.toString(),
source: input.source,
updatedAt: now,
},
});
}
/**
* Batch upsert participant EV snapshots within a single transaction.
* All snapshots succeed or all fail together.
*/
export async function batchUpsertParticipantEvSnapshots(
inputs: ParticipantEvSnapshotInput[]
): Promise<void> {
if (inputs.length === 0) return;
const db = database();
const now = new Date();
await db.transaction(async (tx) => {
for (const input of inputs) {
await tx
.insert(participantEvSnapshots)
.values({
participantId: input.participantId,
sportsSeasonId: input.sportsSeasonId,
snapshotDate: input.snapshotDate,
probFirst: input.probFirst.toString(),
probSecond: input.probSecond.toString(),
probThird: input.probThird.toString(),
probFourth: input.probFourth.toString(),
probFifth: input.probFifth.toString(),
probSixth: input.probSixth.toString(),
probSeventh: input.probSeventh.toString(),
probEighth: input.probEighth.toString(),
calculatedEV: input.calculatedEV.toString(),
source: input.source,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: [
participantEvSnapshots.participantId,
participantEvSnapshots.sportsSeasonId,
participantEvSnapshots.snapshotDate,
],
set: {
probFirst: input.probFirst.toString(),
probSecond: input.probSecond.toString(),
probThird: input.probThird.toString(),
probFourth: input.probFourth.toString(),
probFifth: input.probFifth.toString(),
probSixth: input.probSixth.toString(),
probSeventh: input.probSeventh.toString(),
probEighth: input.probEighth.toString(),
calculatedEV: input.calculatedEV.toString(),
source: input.source,
updatedAt: now,
},
});
}
});
}
/**
* Get EV history for a single participant in a sport season, ordered oldest first.
* Used for EV trend charts.
*/
export async function getParticipantEvHistory(
participantId: string,
sportsSeasonId: string
): Promise<ParticipantEvHistoryPoint[]> {
const db = database();
const rows = await db
.select({
snapshotDate: participantEvSnapshots.snapshotDate,
calculatedEV: participantEvSnapshots.calculatedEV,
probFirst: participantEvSnapshots.probFirst,
})
.from(participantEvSnapshots)
.where(
and(
eq(participantEvSnapshots.participantId, participantId),
eq(participantEvSnapshots.sportsSeasonId, sportsSeasonId)
)
)
.orderBy(asc(participantEvSnapshots.snapshotDate));
return rows.map((r) => ({
snapshotDate: r.snapshotDate,
calculatedEV: parseFloat(r.calculatedEV),
probFirst: parseFloat(r.probFirst),
}));
}
// ─── Team EV Snapshots ────────────────────────────────────────────────────────
export interface TeamEvSnapshotInput {
teamId: string;
seasonId: string;
snapshotDate: string; // YYYY-MM-DD
projectedPoints: number;
actualPoints: number;
}
export interface TeamEvHistoryPoint {
snapshotDate: string;
projectedPoints: number;
actualPoints: number;
}
/**
* Upsert a team EV snapshot for a given day.
*/
export async function upsertTeamEvSnapshot(
input: TeamEvSnapshotInput
): Promise<void> {
const db = database();
const now = new Date();
await db
.insert(teamEvSnapshots)
.values({
teamId: input.teamId,
seasonId: input.seasonId,
snapshotDate: input.snapshotDate,
projectedPoints: input.projectedPoints.toString(),
actualPoints: input.actualPoints.toString(),
createdAt: now,
})
.onConflictDoUpdate({
target: [
teamEvSnapshots.teamId,
teamEvSnapshots.seasonId,
teamEvSnapshots.snapshotDate,
],
set: {
projectedPoints: input.projectedPoints.toString(),
actualPoints: input.actualPoints.toString(),
},
});
}
/**
* Get projected points history for a team in a fantasy season, ordered oldest first.
* Used for team trend charts.
*/
export async function getTeamEvHistory(
teamId: string,
seasonId: string
): Promise<TeamEvHistoryPoint[]> {
const db = database();
const rows = await db
.select({
snapshotDate: teamEvSnapshots.snapshotDate,
projectedPoints: teamEvSnapshots.projectedPoints,
actualPoints: teamEvSnapshots.actualPoints,
})
.from(teamEvSnapshots)
.where(
and(
eq(teamEvSnapshots.teamId, teamId),
eq(teamEvSnapshots.seasonId, seasonId)
)
)
.orderBy(asc(teamEvSnapshots.snapshotDate));
return rows.map((r) => ({
snapshotDate: r.snapshotDate,
projectedPoints: parseFloat(r.projectedPoints),
actualPoints: parseFloat(r.actualPoints),
}));
}