From c6ba59b0e6e5d2a311551adce6b19fa749398bc6 Mon Sep 17 00:00:00 2001 From: Chris Parsons <438676+chrisparsons83@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:34:31 -0700 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Sonnet 4.6 --- app/models/ev-snapshot.ts | 256 ++ app/models/participant-expected-value.ts | 136 +- app/models/sports-season.ts | 7 +- app/routes.ts | 8 +- ...min.sports-seasons.$id.expected-values.tsx | 230 +- .../admin.sports-seasons.$id.futures-odds.tsx | 301 +- ...-seasons.$id.recalculate-probabilities.tsx | 416 --- .../admin.sports-seasons.$id.simulate.tsx | 108 + app/routes/admin.sports-seasons.$id.tsx | 88 +- app/routes/admin.sports.$id.tsx | 28 + app/services/ev-calculator.ts | 2 +- app/services/simulations/bracket-simulator.ts | 98 + app/services/simulations/f1-simulator.ts | 261 ++ app/services/simulations/golf-simulator.ts | 32 + app/services/simulations/registry.ts | 58 + app/services/simulations/types.ts | 35 + database/schema.ts | 108 +- drizzle/0032_consolidate_playoff_pattern.sql | 41 +- drizzle/0034_add_ev_snapshots.sql | 46 + drizzle/0035_increase_prob_precision.sql | 24 + drizzle/0036_increase_ev_precision.sql | 13 + drizzle/0037_add_simulator_type_to_sports.sql | 3 + drizzle/meta/0035_snapshot.json | 3279 +++++++++++++++++ drizzle/meta/_journal.json | 32 +- 24 files changed, 4745 insertions(+), 865 deletions(-) create mode 100644 app/models/ev-snapshot.ts delete mode 100644 app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx create mode 100644 app/routes/admin.sports-seasons.$id.simulate.tsx create mode 100644 app/services/simulations/bracket-simulator.ts create mode 100644 app/services/simulations/f1-simulator.ts create mode 100644 app/services/simulations/golf-simulator.ts create mode 100644 app/services/simulations/registry.ts create mode 100644 app/services/simulations/types.ts create mode 100644 drizzle/0034_add_ev_snapshots.sql create mode 100644 drizzle/0035_increase_prob_precision.sql create mode 100644 drizzle/0036_increase_ev_precision.sql create mode 100644 drizzle/0037_add_simulator_type_to_sports.sql create mode 100644 drizzle/meta/0035_snapshot.json diff --git a/app/models/ev-snapshot.ts b/app/models/ev-snapshot.ts new file mode 100644 index 0000000..5184347 --- /dev/null +++ b/app/models/ev-snapshot.ts @@ -0,0 +1,256 @@ +/** + * 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 { + 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 { + 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 { + 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 { + 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 { + 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), + })); +} diff --git a/app/models/participant-expected-value.ts b/app/models/participant-expected-value.ts index 38a8937..5c1010e 100644 --- a/app/models/participant-expected-value.ts +++ b/app/models/participant-expected-value.ts @@ -103,7 +103,7 @@ export async function upsertParticipantEV( probEighth: probabilities.probEighth.toString(), expectedValue: expectedValue.toString(), source, - sourceOdds: sourceOdds ?? null, + ...(sourceOdds !== undefined ? { sourceOdds } : {}), calculatedAt: now, updatedAt: now, }) @@ -227,27 +227,139 @@ export async function deleteParticipantEV( } /** - * Batch upsert multiple participant EVs - * Useful for updating all participants after generating probabilities + * Batch upsert multiple participant EVs within a single transaction. + * All records succeed or all fail together. */ export async function batchUpsertParticipantEVs( inputs: CreateProbabilityInput[] ): Promise { + const db = database(); const results: ParticipantEV[] = []; - // Process in batches to avoid overwhelming the database - const batchSize = 50; - for (let i = 0; i < inputs.length; i += batchSize) { - const batch = inputs.slice(i, i + batchSize); - const batchResults = await Promise.all( - batch.map((input) => upsertParticipantEV(input)) - ); - results.push(...batchResults); - } + await db.transaction(async (tx) => { + const now = new Date(); + + for (const input of inputs) { + const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input; + + const sum = Object.values(probabilities).reduce((a, b) => a + b, 0); + if (sum > 1.01) { + throw new Error( + `Probabilities for participant ${participantId} cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)}` + ); + } + + const expectedValue = calculateEV(probabilities, scoringRules); + + const existing = await tx + .select({ id: participantExpectedValues.id }) + .from(participantExpectedValues) + .where( + and( + eq(participantExpectedValues.participantId, participantId), + eq(participantExpectedValues.sportsSeasonId, sportsSeasonId) + ) + ) + .limit(1); + + const baseValues = { + probFirst: probabilities.probFirst.toString(), + probSecond: probabilities.probSecond.toString(), + probThird: probabilities.probThird.toString(), + probFourth: probabilities.probFourth.toString(), + probFifth: probabilities.probFifth.toString(), + probSixth: probabilities.probSixth.toString(), + probSeventh: probabilities.probSeventh.toString(), + probEighth: probabilities.probEighth.toString(), + expectedValue: expectedValue.toString(), + source, + calculatedAt: now, + updatedAt: now, + }; + + let result: ParticipantEV; + + if (existing.length > 0) { + const [updated] = await tx + .update(participantExpectedValues) + // Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise + .set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues) + .where(eq(participantExpectedValues.id, existing[0].id)) + .returning(); + result = updated; + } else { + const [created] = await tx + .insert(participantExpectedValues) + .values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null }) + .returning(); + result = created; + } + + await tx + .update(participants) + .set({ expectedValue: expectedValue.toString(), updatedAt: now }) + .where(eq(participants.id, participantId)); + + results.push(result); + } + }); return results; } +/** + * Save American odds for a batch of participants without touching probabilities or EV. + * Used by the futures-odds admin page to persist odds before running the full simulation. + */ +export async function batchSaveSourceOdds( + inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }> +): Promise { + const db = database(); + + await db.transaction(async (tx) => { + const now = new Date(); + + for (const { participantId, sportsSeasonId, sourceOdds } of inputs) { + const existing = await tx + .select({ id: participantExpectedValues.id }) + .from(participantExpectedValues) + .where( + and( + eq(participantExpectedValues.participantId, participantId), + eq(participantExpectedValues.sportsSeasonId, sportsSeasonId) + ) + ) + .limit(1); + + if (existing.length > 0) { + await tx + .update(participantExpectedValues) + .set({ sourceOdds, updatedAt: now }) + .where(eq(participantExpectedValues.id, existing[0].id)); + } else { + // Insert a stub record — probabilities/EV will be filled in by the simulator + await tx.insert(participantExpectedValues).values({ + participantId, + sportsSeasonId, + probFirst: "0", + probSecond: "0", + probThird: "0", + probFourth: "0", + probFifth: "0", + probSixth: "0", + probSeventh: "0", + probEighth: "0", + expectedValue: "0", + source: "futures_odds", + sourceOdds, + calculatedAt: now, + updatedAt: now, + }); + } + } + }); +} + /** * Convert database record to ProbabilityDistribution */ diff --git a/app/models/sports-season.ts b/app/models/sports-season.ts index e0dbe77..507d285 100644 --- a/app/models/sports-season.ts +++ b/app/models/sports-season.ts @@ -4,6 +4,9 @@ import * as schema from "~/database/schema"; export type SportsSeason = typeof schema.sportsSeasons.$inferSelect; export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert; +export type SportsSeasonWithSport = SportsSeason & { + sport: typeof schema.sports.$inferSelect; +}; export type SportsSeasonStatus = "upcoming" | "active" | "completed"; export type ScoringType = "playoffs" | "regular_season" | "majors"; @@ -16,14 +19,14 @@ export async function createSportsSeason(data: NewSportsSeason): Promise { +export async function findSportsSeasonById(id: string): Promise { const db = database(); return await db.query.sportsSeasons.findFirst({ where: eq(schema.sportsSeasons.id, id), with: { sport: true, }, - }); + }) as SportsSeasonWithSport | undefined; } export async function findSportsSeasonsBySportId(sportId: string): Promise { diff --git a/app/routes.ts b/app/routes.ts index 2e95f87..74782c6 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -81,14 +81,14 @@ export default [ "sports-seasons/:id/futures-odds", "routes/admin.sports-seasons.$id.futures-odds.tsx" ), - route( - "sports-seasons/:id/recalculate-probabilities", - "routes/admin.sports-seasons.$id.recalculate-probabilities.tsx" - ), route( "sports-seasons/:id/standings", "routes/admin.sports-seasons.$id.standings.tsx" ), + route( + "sports-seasons/:id/simulate", + "routes/admin.sports-seasons.$id.simulate.tsx" + ), route("participants", "routes/admin.participants.tsx"), route("templates", "routes/admin.templates.tsx"), route("templates/new", "routes/admin.templates.new.tsx"), diff --git a/app/routes/admin.sports-seasons.$id.expected-values.tsx b/app/routes/admin.sports-seasons.$id.expected-values.tsx index 5036128..4812c2f 100644 --- a/app/routes/admin.sports-seasons.$id.expected-values.tsx +++ b/app/routes/admin.sports-seasons.$id.expected-values.tsx @@ -1,8 +1,7 @@ -import { Form, Link } from "react-router"; +import { Link } from "react-router"; import type { Route } from "./+types/admin.sports-seasons.$id.expected-values"; -import { loader, action } from "./admin.sports-seasons.$id.expected-values.server"; +import { loader } from "./admin.sports-seasons.$id.expected-values.server"; import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; import { Card, CardContent, @@ -18,17 +17,48 @@ import { TableHeader, TableRow, } from "~/components/ui/table"; -import { ArrowLeft, Save, Calculator } from "lucide-react"; +import { ArrowLeft, Calculator } from "lucide-react"; -export { loader, action }; +export { loader }; -export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) { +const SCORING = [100, 70, 50, 40, 25, 25, 15, 15] as const; + +function evFromProbs(ev: { + probFirst: string; probSecond: string; probThird: string; probFourth: string; + probFifth: string; probSixth: string; probSeventh: string; probEighth: string; +}): number { + return parseFloat(ev.probFirst) * SCORING[0] + + parseFloat(ev.probSecond) * SCORING[1] + + parseFloat(ev.probThird) * SCORING[2] + + parseFloat(ev.probFourth) * SCORING[3] + + parseFloat(ev.probFifth) * SCORING[4] + + parseFloat(ev.probSixth) * SCORING[5] + + parseFloat(ev.probSeventh) * SCORING[6] + + parseFloat(ev.probEighth) * SCORING[7]; +} + +function fmt(val: string | number) { + return (parseFloat(val as string) * 100).toFixed(1) + "%"; +} + +export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) { const { sportsSeason, participants, existingEVs } = loaderData; - const totalEV = Array.from(existingEVs.values()).reduce( - (sum, ev) => sum + parseFloat(ev.expectedValue), - 0 - ); + // Compute total and sort from stored prob columns, not stored expectedValue. + // The simulator normalizes per-position column sums to exactly 1.0 (step 10), + // so the total EV should always equal the sum of scoring values (340). + // Sum only over participants shown in the table — excludes orphan EV records + // from prior simulation runs for participants no longer in this season. + const sorted = [...participants].sort((a, b) => { + const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0; + const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0; + return evB - evA; + }); + + const totalEV = sorted.reduce((sum, p) => { + const ev = existingEVs.get(p.id); + return ev ? sum + evFromProbs(ev) : sum; + }, 0); return (
@@ -48,154 +78,49 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com Expected Values: {sportsSeason.sport.name} {sportsSeason.year} - Manage probability distributions and expected values for participants + Probability distributions from the last simulation run. Sorted by EV descending. - - {actionData?.error && ( -
- {actionData.error} -
- )} - {actionData?.success && ( -
- All participants saved successfully! -
- )} - -
-

Default Scoring Rules (for EV calculation):

-
- 1st: 100 pts - 2nd: 70 pts - 3rd: 50 pts - 4th: 40 pts - 5th: 25 pts - 6th: 25 pts - 7th: 15 pts - 8th: 15 pts -
-

- Note: EVs will be recalculated with actual league scoring rules when used in fantasy leagues. + + {participants.length === 0 ? ( +

+ No participants found.

-
- -
- + ) : existingEVs.size === 0 ? ( +

+ No EV data yet. Run a simulation from the sports season page. +

+ ) : ( Participant - 1st % - 2nd % - 3rd % - 4th % - 5th % - 6th % - 7th % - 8th % + 1st + 2nd + 3rd + 4th + 5th + 6th + 7th + 8th EV - {participants.map((participant: { id: string; name: string }) => { - const existingEV = existingEVs.get(participant.id); - const id = participant.id; - + {sorted.map((participant) => { + const ev = existingEVs.get(participant.id); return ( - + {participant.name} - - - - - - - - - - - - - - - - - - - - - - - - - - {existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"} - + {ev ? fmt(ev.probFirst) : "—"} + {ev ? fmt(ev.probSecond) : "—"} + {ev ? fmt(ev.probThird) : "—"} + {ev ? fmt(ev.probFourth) : "—"} + {ev ? fmt(ev.probFifth) : "—"} + {ev ? fmt(ev.probSixth) : "—"} + {ev ? fmt(ev.probSeventh) : "—"} + {ev ? fmt(ev.probEighth) : "—"} + {ev ? evFromProbs(ev).toFixed(2) : "—"} ); })} @@ -207,20 +132,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com )}
- - {participants.length === 0 ? ( -

- No participants found. Please add participants to this sports season first. -

- ) : ( -
- -
- )} -
+ )}
diff --git a/app/routes/admin.sports-seasons.$id.futures-odds.tsx b/app/routes/admin.sports-seasons.$id.futures-odds.tsx index d68f5fc..9c3cc1b 100644 --- a/app/routes/admin.sports-seasons.$id.futures-odds.tsx +++ b/app/routes/admin.sports-seasons.$id.futures-odds.tsx @@ -1,8 +1,11 @@ import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; -import { findSportsSeasonById } from '~/models/sports-season'; +import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season'; import { findParticipantsBySportsSeasonId } from '~/models/participant'; -import { getAllParticipantEVsForSeason } from '~/models/participant-expected-value'; +import { getAllParticipantEVsForSeason, batchSaveSourceOdds, batchUpsertParticipantEVs } from '~/models/participant-expected-value'; +import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot'; +import { getSimulator, type SimulatorType } from '~/services/simulations/registry'; +import { calculateEV } from '~/services/ev-calculator'; import { Button } from '~/components/ui/button'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; @@ -15,11 +18,6 @@ import { CardTitle, } from '~/components/ui/card'; import { useState } from 'react'; -import { - calculateICMFromOdds, - icmResultToArray, -} from '~/services/icm-calculator'; -import { upsertParticipantEV } from '~/models/participant-expected-value'; import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react'; export async function loader({ params }: Route.LoaderArgs) { @@ -34,15 +32,15 @@ export async function loader({ params }: Route.LoaderArgs) { const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId); - // Create map of participant ID to existing odds (if source is futures_odds) + // Show any saved odds regardless of what simulation ran afterwards const existingOdds = new Map( existingEVs - .filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null) + .filter(ev => ev.sourceOdds !== null) .map(ev => [ev.participantId, ev.sourceOdds]) ); return { - sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } }, + sportsSeason, participants, existingOdds, }; @@ -51,18 +49,11 @@ export async function loader({ params }: Route.LoaderArgs) { interface ActionData { success?: boolean; message?: string; - preview?: { - participantId: string; - participantName: string; - odds: number; - probabilities: number[]; - }[]; } export async function action({ request, params }: Route.ActionArgs) { const sportsSeasonId = params.id; const formData = await request.formData(); - const intent = formData.get('intent') as string; const sportsSeason = await findSportsSeasonById(sportsSeasonId); @@ -93,85 +84,80 @@ export async function action({ request, params }: Route.ActionArgs) { return { success: false, message: 'Please enter odds for at least one participant' }; } + if (!sportsSeason.sport?.simulatorType) { + return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' }; + } + + if (sportsSeason.simulationStatus === 'running') { + return { success: false, message: 'A simulation is already running. Please wait.' }; + } + + const scoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, + }; + + // Persist the odds first so the simulator can read them + await batchSaveSourceOdds( + futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds })) + ); + + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' }); + try { - // Calculate ICM probabilities from odds - const icmResults = calculateICMFromOdds( - futuresOdds.map(({ participantId, odds }) => ({ participantId, odds })) + const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType); + const results = await simulator.simulate(sportsSeasonId); + + if (results.length === 0) { + throw new Error('Simulation returned no results.'); + } + + await batchUpsertParticipantEVs( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + probabilities: r.probabilities, + scoringRules, + source: 'elo_simulation' as const, + })) ); - if (intent === 'preview') { - // Return preview data - const preview = futuresOdds.map(({ participantId, odds, name }) => { - const icmResult = icmResults.get(participantId)!; - return { - participantId, - participantName: name, - odds, - probabilities: icmResultToArray(icmResult), - }; - }); + const today = new Date().toISOString().slice(0, 10); + await batchUpsertParticipantEvSnapshots( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + snapshotDate: today, + probFirst: r.probabilities.probFirst, + probSecond: r.probabilities.probSecond, + probThird: r.probabilities.probThird, + probFourth: r.probabilities.probFourth, + probFifth: r.probabilities.probFifth, + probSixth: r.probabilities.probSixth, + probSeventh: r.probabilities.probSeventh, + probEighth: r.probabilities.probEighth, + calculatedEV: calculateEV(r.probabilities, scoringRules), + source: r.source, + })) + ); - return { success: true, preview }; - } - - if (intent === 'save') { - // Use default scoring rules (EVs will be recalculated per league) - const scoringRules = { - pointsFor1st: 100, - pointsFor2nd: 70, - pointsFor3rd: 50, - pointsFor4th: 40, - pointsFor5th: 25, - pointsFor6th: 25, - pointsFor7th: 15, - pointsFor8th: 15, - }; - - // Save probabilities from preview (passed through form) - // This ensures saved values match exactly what user saw in preview - for (const { participantId, odds } of futuresOdds) { - // Read probabilities from form data (preview results) - const probFirst = parseFloat(formData.get(`prob_first_${participantId}`) as string) || 0; - const probSecond = parseFloat(formData.get(`prob_second_${participantId}`) as string) || 0; - const probThird = parseFloat(formData.get(`prob_third_${participantId}`) as string) || 0; - const probFourth = parseFloat(formData.get(`prob_fourth_${participantId}`) as string) || 0; - const probFifth = parseFloat(formData.get(`prob_fifth_${participantId}`) as string) || 0; - const probSixth = parseFloat(formData.get(`prob_sixth_${participantId}`) as string) || 0; - const probSeventh = parseFloat(formData.get(`prob_seventh_${participantId}`) as string) || 0; - const probEighth = parseFloat(formData.get(`prob_eighth_${participantId}`) as string) || 0; - - // Use upsertParticipantEV (not WithNormalization) because ICM probabilities - // are already correct - they sum to <1.0 for teams unlikely to finish top 8 - await upsertParticipantEV({ - participantId, - sportsSeasonId: sportsSeasonId, - probabilities: { - probFirst, - probSecond, - probThird, - probFourth, - probFifth, - probSixth, - probSeventh, - probEighth, - }, - scoringRules, - source: 'futures_odds', - sourceOdds: odds, - }); - } - - return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); - } - - return { success: false, message: 'Invalid intent' }; + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' }); } catch (error) { - console.error('Error generating probabilities:', error); + await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' }); + console.error('Error running simulation:', error); return { success: false, - message: error instanceof Error ? error.message : 'Failed to generate probabilities', + message: error instanceof Error ? error.message : 'Simulation failed', }; } + + return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); } function normalizeName(name: string): string { @@ -204,18 +190,14 @@ export default function AdminSportsSeasonFuturesOdds() { function findParticipantMatch(inputName: string) { const normalizedInput = normalizeName(inputName); - // Pre-compute normalized names once for all three passes const normalized = participants.map(p => ({ p, n: normalizeName(p.name) })); - // Exact match const exact = normalized.find(({ n }) => n === normalizedInput); if (exact) return exact.p; - // Contains match (one contains the other) const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n)); if (contains) return contains.p; - // Word-overlap match (at least half the significant words overlap) const inputWords = normalizedInput.split(' ').filter(w => w.length > 2); const overlap = normalized.find(({ n }) => { const pWords = n.split(' ').filter(w => w.length > 2); @@ -228,7 +210,6 @@ export default function AdminSportsSeasonFuturesOdds() { function parseBulkText() { const lines = bulkText.split('\n'); - // Match lines that end with an American-style odds number (+/- then 2–6 digits) const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/; const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = []; @@ -270,8 +251,6 @@ export default function AdminSportsSeasonFuturesOdds() { } const isSubmitting = navigation.state === 'submitting'; - const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview'; - const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save'; return (
@@ -363,7 +342,7 @@ export default function AdminSportsSeasonFuturesOdds() { Championship Futures Odds Enter American odds (e.g., +550, -200) for each participant's championship probability. - Generates probabilities for all participants using ICM (Independent Chip Model). + Enter American odds, then run the ICM simulation to compute and save probability distributions. @@ -403,18 +382,14 @@ export default function AdminSportsSeasonFuturesOdds() {
-
- -
+ {actionData && !actionData.success && actionData.message && ( +
{actionData.message}
+ )} + + @@ -434,118 +409,6 @@ export default function AdminSportsSeasonFuturesOdds() { - -
- {actionData?.preview && ( - - - ICM Results - - Probability distributions calculated using Independent Chip Model - - - -
-
- - - - - - - - - - - - - - - - - {actionData.preview - .sort((a, b) => b.probabilities[0] - a.probabilities[0]) - .map((result) => ( - - - - - - - - - - - - - ))} - -
TeamOddsP(1st)P(2nd)P(3rd)P(4th)P(5th)P(6th)P(7th)P(8th)
{result.participantName} - {result.odds > 0 ? '+' : ''} - {result.odds} - - {(result.probabilities[0] * 100).toFixed(1)}% - - {(result.probabilities[1] * 100).toFixed(1)}% - - {(result.probabilities[2] * 100).toFixed(1)}% - - {(result.probabilities[3] * 100).toFixed(1)}% - - {(result.probabilities[4] * 100).toFixed(1)}% - - {(result.probabilities[5] * 100).toFixed(1)}% - - {(result.probabilities[6] * 100).toFixed(1)}% - - {(result.probabilities[7] * 100).toFixed(1)}% -
-
- -
- {/* Pass through odds and preview probabilities */} - {actionData.preview.map((result) => ( -
- - - - - - - - - -
- ))} - - -
-
-
-
- )} - - {actionData && !actionData.success && actionData.message && ( - - -
Error
-
{actionData.message}
-
-
- )} - - {actionData && actionData.success && !actionData.preview && ( - - -
Success
-
{actionData.message}
-
-
- )} -
); diff --git a/app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx b/app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx deleted file mode 100644 index 496cb87..0000000 --- a/app/routes/admin.sports-seasons.$id.recalculate-probabilities.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { Form, Link, useLoaderData, useActionData } from "react-router"; -import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router"; -import { findSportsSeasonById } from "~/models/sports-season"; -import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; -import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value"; -import { - updateProbabilitiesAfterResult, - previewProbabilityUpdate, - type ProbabilityComparison, -} from "~/services/probability-updater"; -import { Button } from "~/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "~/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "~/components/ui/table"; -import { Badge } from "~/components/ui/badge"; -import { ArrowLeft, RefreshCw, CheckCircle, AlertTriangle } from "lucide-react"; -import { database } from "~/database/context"; -import * as schema from "~/database/schema"; -import { eq } from "drizzle-orm"; - -export async function loader({ params }: LoaderFunctionArgs) { - if (!params.id) { - throw new Response("Missing season ID", { status: 400 }); - } - - const sportsSeason = await findSportsSeasonById(params.id); - - if (!sportsSeason) { - throw new Response("Sports season not found", { status: 404 }); - } - - const results = await findParticipantResultsBySportsSeasonId(params.id); - const existingEVs = await getAllParticipantEVsForSeason(params.id); - - // Get preview of what would change - const preview = await previewProbabilityUpdate(params.id); - - // Get participant names for display - const db = database(); - const participants = await db.query.participants.findMany({ - where: (participants, { inArray }) => - inArray( - participants.id, - preview.map((p: ProbabilityComparison) => p.participantId) - ), - }); - - const participantMap = new Map(participants.map((p: typeof participants[0]) => [p.id, p])); - - return { - sportsSeason, - results, - existingEVs, - preview: preview.map((p: ProbabilityComparison) => ({ - ...p, - participantName: participantMap.get(p.participantId)?.name || "Unknown", - })), - }; -} - -export async function action({ params, request }: ActionFunctionArgs) { - const formData = await request.formData(); - const intent = formData.get("intent"); - - if (intent === "recalculate") { - const recalculateUnfinished = formData.get("recalculateUnfinished") === "true"; - - if (!params.id) { - return { success: false, error: "Missing season ID" }; - } - - const result = await updateProbabilitiesAfterResult( - params.id, - recalculateUnfinished - ); - - if (result.errors.length > 0) { - return { - success: false, - error: result.errors.join("; "), - result, - }; - } - - return { - success: true, - result, - }; - } - - return { success: false, error: "Invalid intent" }; -} - -export default function RecalculateProbabilities() { - const { sportsSeason, results, existingEVs, preview } = useLoaderData(); - const actionData = useActionData(); - - const finishedCount = results.filter((r) => r.finalPosition !== null).length; - const totalCount = existingEVs.length; - const unfinishedCount = totalCount - finishedCount; - - const changesCount = preview.filter((p) => p.status === "finished").length; - - // Check for invalid probabilities (> 1.0) - const invalidProbabilities = preview.filter((p) => - p.before.some((prob) => prob > 1.0) - ); - - function formatProbability(prob: number): string { - return (prob * 100).toFixed(1) + "%"; - } - - function getProbabilityChange(before: number, after: number): string { - const diff = (after - before) * 100; - if (Math.abs(diff) < 0.1) return "—"; - const sign = diff > 0 ? "+" : ""; - return sign + diff.toFixed(1) + "%"; - } - - return ( -
-
- -
- -
-
-

Recalculate Probabilities

-

- {sportsSeason.name} — {sportsSeason.year} -

-
- - {invalidProbabilities.length > 0 && ( - - -
- - - Invalid Probability Data Detected - -
- - {invalidProbabilities.length} participant(s) have probabilities greater than 100%, which indicates corrupted data - -
- -
-

- The following participants have invalid probabilities (marked with ⚠️ in the table below): -

-
    - {invalidProbabilities.slice(0, 5).map((p) => ( -
  • - {p.participantName}: P(1st) = {formatProbability(p.before[0])} -
  • - ))} - {invalidProbabilities.length > 5 && ( -
  • ... and {invalidProbabilities.length - 5} more
  • - )} -
-

- Solution: Go to the{" "} - - Futures Odds page - {" "} - and regenerate probabilities from current betting odds to fix this data. -

-
-
-
- )} - - {actionData?.success ? ( - - -
- - - Probabilities Updated Successfully - -
- - {actionData.result?.updated} participant - {actionData.result?.updated !== 1 ? "s" : ""} updated - -
- -
-
- Finished participants: {actionData.result?.finishedParticipants} -
-
- Recalculated participants: {actionData.result?.unfishedParticipants} -
-
- -
-
- ) : actionData?.error ? ( - - -
- - Error -
- - {actionData.error} - -
-
- ) : null} - - - - Summary - - Current state of participant results and probabilities - - - -
-
-
{finishedCount}
-
- Finished Participants -
-
-
-
{unfinishedCount}
-
- Unfinished Participants -
-
-
-
{changesCount}
-
- Changes to Apply -
-
-
-
-
- - {changesCount === 0 ? ( - - - No Changes Needed - - All finished participants already have their probabilities set correctly. - - - -

- Either no participants have finished yet, or their probabilities are - already up to date. -

-
-
- ) : ( - <> - - - Probability Changes - - Preview of changes that will be applied - - - - - - - Participant - Status - P(1st) Before - P(1st) After - P(2nd) After - P(3rd) After - - - - {preview - .filter((p: ProbabilityComparison) => p.status === "finished") - .map((p: ProbabilityComparison) => { - const result = results.find( - (r: typeof results[0]) => r.participantId === p.participantId - ); - const finalPosition = result?.finalPosition; - - return ( - - - {p.participantName} - - - - Finished {finalPosition ? `${finalPosition}${ - finalPosition === 1 - ? "st" - : finalPosition === 2 - ? "nd" - : finalPosition === 3 - ? "rd" - : "th" - }` : ""} - - - -
1 ? "text-destructive" : ""}> - {formatProbability(p.before[0])} - {p.before[0] > 1 && " ⚠️"} -
-
- -
{formatProbability(p.after[0])}
-
- {getProbabilityChange(p.before[0], p.after[0])} -
-
- -
{formatProbability(p.after[1])}
-
- -
{formatProbability(p.after[2])}
-
-
- ); - })} -
-
-
-
- - - - Apply Changes - - Update probabilities based on real results - - - -
- - - -
-

What happens:

-
    -
  • - • Finished participants get 100% probability at their final - position -
  • -
  • - • Unfinished participants get recalculated probabilities based - on remaining competition -
  • -
  • - • Expected values will be automatically updated for all - affected leagues -
  • -
-
- -
- - -
-
-
-
- - )} -
-
- ); -} diff --git a/app/routes/admin.sports-seasons.$id.simulate.tsx b/app/routes/admin.sports-seasons.$id.simulate.tsx new file mode 100644 index 0000000..1458bfe --- /dev/null +++ b/app/routes/admin.sports-seasons.$id.simulate.tsx @@ -0,0 +1,108 @@ +/** + * Admin: Run EV Simulation for a Sports Season + * + * Action-only route. POSTing here: + * 1. Guards against concurrent runs via simulationStatus + * 2. Runs the appropriate simulator based on the sport's simulatorType + * 3. Persists updated EVs to participantExpectedValues (transactional) + * 4. Takes an EV snapshot from simulation output (upsert for today's date) + * 5. Redirects back to the sports season admin page + */ + +import { redirect } from "react-router"; +import type { Route } from "./+types/admin.sports-seasons.$id.simulate"; +import { findSportsSeasonById, updateSportsSeason } from "~/models/sports-season"; +import { batchUpsertParticipantEVs } from "~/models/participant-expected-value"; +import { batchUpsertParticipantEvSnapshots } from "~/models/ev-snapshot"; +import { getSimulator, type SimulatorType } from "~/services/simulations/registry"; +import { calculateEV, type ScoringRules } from "~/services/ev-calculator"; + +// Default scoring rules — matches the season table defaults. +// Per-league EV is recalculated from probabilities using league-specific rules in standings. +const DEFAULT_SCORING_RULES: ScoringRules = { + pointsFor1st: 100, + pointsFor2nd: 70, + pointsFor3rd: 50, + pointsFor4th: 40, + pointsFor5th: 25, + pointsFor6th: 25, + pointsFor7th: 15, + pointsFor8th: 15, +}; + +export async function action({ params }: Route.ActionArgs) { + const sportsSeasonId = params.id; + + const sportsSeason = await findSportsSeasonById(sportsSeasonId); + if (!sportsSeason) { + throw new Response("Sports season not found", { status: 404 }); + } + + if (!sportsSeason.sport?.simulatorType) { + throw new Response( + "This sport has no simulator type configured. Set a simulator type on the sport in the admin panel before running a simulation.", + { status: 400 } + ); + } + + // Guard against concurrent simulation runs + if (sportsSeason.simulationStatus === "running") { + throw new Response( + "A simulation is already running for this sports season. Please wait for it to complete.", + { status: 409 } + ); + } + + // Mark as running + await updateSportsSeason(sportsSeasonId, { simulationStatus: "running" }); + + try { + const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType); + + const results = await simulator.simulate(sportsSeasonId); + + if (results.length === 0) { + throw new Error("Simulation returned no results. Check that participants have EV data."); + } + + // Persist updated EVs (transactional) + await batchUpsertParticipantEVs( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + probabilities: r.probabilities, + scoringRules: DEFAULT_SCORING_RULES, + source: "elo_simulation", + })) + ); + + // Take EV snapshot from simulation output (not re-read from DB) + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + await batchUpsertParticipantEvSnapshots( + results.map((r) => ({ + participantId: r.participantId, + sportsSeasonId, + snapshotDate: today, + probFirst: r.probabilities.probFirst, + probSecond: r.probabilities.probSecond, + probThird: r.probabilities.probThird, + probFourth: r.probabilities.probFourth, + probFifth: r.probabilities.probFifth, + probSixth: r.probabilities.probSixth, + probSeventh: r.probabilities.probSeventh, + probEighth: r.probabilities.probEighth, + calculatedEV: calculateEV(r.probabilities, DEFAULT_SCORING_RULES), + source: r.source, + })) + ); + + // Mark as idle + await updateSportsSeason(sportsSeasonId, { simulationStatus: "idle" }); + } catch (error) { + // Mark as failed so the UI shows the error state + await updateSportsSeason(sportsSeasonId, { simulationStatus: "failed" }); + throw error; + } + + return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`); +} diff --git a/app/routes/admin.sports-seasons.$id.tsx b/app/routes/admin.sports-seasons.$id.tsx index a9a263e..cdaf733 100644 --- a/app/routes/admin.sports-seasons.$id.tsx +++ b/app/routes/admin.sports-seasons.$id.tsx @@ -3,6 +3,10 @@ import type { Route } from "./+types/admin.sports-seasons.$id"; import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { processSeasonStandings } from "~/models/scoring-calculator"; +import { database } from "~/database/context"; +import { participantEvSnapshots } from "~/database/schema"; +import { eq, desc } from "drizzle-orm"; +import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { Label } from "~/components/ui/label"; @@ -32,7 +36,7 @@ import { AlertDialogTrigger, } from "~/components/ui/alert-dialog"; import { Badge } from "~/components/ui/badge"; -import { Trash2, Users, Trophy, Calculator, CheckCircle2 } from "lucide-react"; +import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2 } from "lucide-react"; import { useState } from "react"; export async function loader({ params }: Route.LoaderArgs) { @@ -44,10 +48,26 @@ export async function loader({ params }: Route.LoaderArgs) { const participants = await findParticipantsBySportsSeasonId(params.id); - // Type assertion since we know the sport relation is included - return { - sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } }, - participants + // Get the most recent snapshot date (if any) + const db = database(); + const lastSnapshot = await db + .select({ snapshotDate: participantEvSnapshots.snapshotDate }) + .from(participantEvSnapshots) + .where(eq(participantEvSnapshots.sportsSeasonId, params.id)) + .orderBy(desc(participantEvSnapshots.snapshotDate)) + .limit(1); + + const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null; + + const simulatorInfo = sportsSeason.sport?.simulatorType + ? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType) + : null; + + return { + sportsSeason, + participants, + lastSimulatedDate, + simulatorInfo, }; } @@ -139,7 +159,7 @@ export async function action({ request, params }: Route.ActionArgs) { } export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) { - const { sportsSeason, participants } = loaderData; + const { sportsSeason, participants, lastSimulatedDate, simulatorInfo } = loaderData; const navigate = useNavigate(); const [scoringPattern, setScoringPattern] = useState(sportsSeason.scoringPattern || ""); @@ -345,10 +365,18 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
Expected Values - Manage probability distributions and projected points + {simulatorInfo + ? simulatorInfo.name + : "Manage probability distributions and projected points"}
-
+
+ {sportsSeason.simulationStatus === "failed" && ( + + + Last run failed + + )} - - + {simulatorInfo && ( +
+ +
+ )}

- Generate probabilities from betting odds (Futures Odds), manually enter them (Manual Entry), or recalculate based on results (Recalculate). + {simulatorInfo + ? <> + {simulatorInfo.description}.{" "} + {lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."} + {" "}Import futures odds first, then run the simulation to update EVs and save a snapshot. + + : "Import futures odds to set probability distributions."}

diff --git a/app/routes/admin.sports.$id.tsx b/app/routes/admin.sports.$id.tsx index 86b28ba..ce58f6e 100644 --- a/app/routes/admin.sports.$id.tsx +++ b/app/routes/admin.sports.$id.tsx @@ -38,6 +38,7 @@ export async function action({ request, params }: Route.ActionArgs) { const type = formData.get("type"); const slug = formData.get("slug"); const description = formData.get("description"); + const simulatorType = formData.get("simulatorType"); const iconFile = formData.get("iconFile"); const keepExistingIcon = formData.get("keepExistingIcon"); @@ -95,6 +96,13 @@ export async function action({ request, params }: Route.ActionArgs) { iconUrl = null; } + const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket"] as const; + type ValidSimulatorType = typeof validSimulatorTypes[number]; + const parsedSimulatorType: ValidSimulatorType | null = + typeof simulatorType === "string" && validSimulatorTypes.includes(simulatorType as ValidSimulatorType) + ? (simulatorType as ValidSimulatorType) + : null; + try { await updateSport(params.id, { name: name.trim(), @@ -102,6 +110,7 @@ export async function action({ request, params }: Route.ActionArgs) { slug: slug.trim(), description: typeof description === "string" ? description.trim() : null, iconUrl, + simulatorType: parsedSimulatorType, }); return redirect("/admin/sports"); @@ -177,6 +186,25 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro

+
+ + +

+ Algorithm used when running EV simulations for this sport +

+
+