* 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>
256 lines
7.7 KiB
TypeScript
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),
|
|
}));
|
|
}
|