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 && (
-
- Note: EVs will be recalculated with actual league scoring rules when used in fantasy leagues.
+
+ {participants.length === 0 ? (
+
+ No participants found.
-
-
-
+ )}
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() {
- 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."}