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>
This commit is contained in:
Chris Parsons 2026-03-09 15:34:31 -07:00 committed by GitHub
parent e72c33d5b5
commit c6ba59b0e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 4745 additions and 865 deletions

256
app/models/ev-snapshot.ts Normal file
View file

@ -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<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),
}));
}

View file

@ -103,7 +103,7 @@ export async function upsertParticipantEV(
probEighth: probabilities.probEighth.toString(), probEighth: probabilities.probEighth.toString(),
expectedValue: expectedValue.toString(), expectedValue: expectedValue.toString(),
source, source,
sourceOdds: sourceOdds ?? null, ...(sourceOdds !== undefined ? { sourceOdds } : {}),
calculatedAt: now, calculatedAt: now,
updatedAt: now, updatedAt: now,
}) })
@ -227,27 +227,139 @@ export async function deleteParticipantEV(
} }
/** /**
* Batch upsert multiple participant EVs * Batch upsert multiple participant EVs within a single transaction.
* Useful for updating all participants after generating probabilities * All records succeed or all fail together.
*/ */
export async function batchUpsertParticipantEVs( export async function batchUpsertParticipantEVs(
inputs: CreateProbabilityInput[] inputs: CreateProbabilityInput[]
): Promise<ParticipantEV[]> { ): Promise<ParticipantEV[]> {
const db = database();
const results: ParticipantEV[] = []; const results: ParticipantEV[] = [];
// Process in batches to avoid overwhelming the database await db.transaction(async (tx) => {
const batchSize = 50; const now = new Date();
for (let i = 0; i < inputs.length; i += batchSize) {
const batch = inputs.slice(i, i + batchSize); for (const input of inputs) {
const batchResults = await Promise.all( const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
batch.map((input) => upsertParticipantEV(input))
); const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
results.push(...batchResults); 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; 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<void> {
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 * Convert database record to ProbabilityDistribution
*/ */

View file

@ -4,6 +4,9 @@ import * as schema from "~/database/schema";
export type SportsSeason = typeof schema.sportsSeasons.$inferSelect; export type SportsSeason = typeof schema.sportsSeasons.$inferSelect;
export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert; export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert;
export type SportsSeasonWithSport = SportsSeason & {
sport: typeof schema.sports.$inferSelect;
};
export type SportsSeasonStatus = "upcoming" | "active" | "completed"; export type SportsSeasonStatus = "upcoming" | "active" | "completed";
export type ScoringType = "playoffs" | "regular_season" | "majors"; export type ScoringType = "playoffs" | "regular_season" | "majors";
@ -16,14 +19,14 @@ export async function createSportsSeason(data: NewSportsSeason): Promise<SportsS
return sportsSeason; return sportsSeason;
} }
export async function findSportsSeasonById(id: string): Promise<SportsSeason | undefined> { export async function findSportsSeasonById(id: string): Promise<SportsSeasonWithSport | undefined> {
const db = database(); const db = database();
return await db.query.sportsSeasons.findFirst({ return await db.query.sportsSeasons.findFirst({
where: eq(schema.sportsSeasons.id, id), where: eq(schema.sportsSeasons.id, id),
with: { with: {
sport: true, sport: true,
}, },
}); }) as SportsSeasonWithSport | undefined;
} }
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> { export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {

View file

@ -81,14 +81,14 @@ export default [
"sports-seasons/:id/futures-odds", "sports-seasons/:id/futures-odds",
"routes/admin.sports-seasons.$id.futures-odds.tsx" "routes/admin.sports-seasons.$id.futures-odds.tsx"
), ),
route(
"sports-seasons/:id/recalculate-probabilities",
"routes/admin.sports-seasons.$id.recalculate-probabilities.tsx"
),
route( route(
"sports-seasons/:id/standings", "sports-seasons/:id/standings",
"routes/admin.sports-seasons.$id.standings.tsx" "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("participants", "routes/admin.participants.tsx"),
route("templates", "routes/admin.templates.tsx"), route("templates", "routes/admin.templates.tsx"),
route("templates/new", "routes/admin.templates.new.tsx"), route("templates/new", "routes/admin.templates.new.tsx"),

View file

@ -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 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 { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { import {
Card, Card,
CardContent, CardContent,
@ -18,17 +17,48 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "~/components/ui/table"; } 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 { sportsSeason, participants, existingEVs } = loaderData;
const totalEV = Array.from(existingEVs.values()).reduce( // Compute total and sort from stored prob columns, not stored expectedValue.
(sum, ev) => sum + parseFloat(ev.expectedValue), // The simulator normalizes per-position column sums to exactly 1.0 (step 10),
0 // 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 ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto p-6 space-y-6">
@ -48,154 +78,49 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
Expected Values: {sportsSeason.sport.name} {sportsSeason.year} Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Manage probability distributions and expected values for participants Probability distributions from the last simulation run. Sorted by EV descending.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent>
{actionData?.error && ( {participants.length === 0 ? (
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-md text-destructive"> <p className="text-center text-muted-foreground py-8">
{actionData.error} No participants found.
</div>
)}
{actionData?.success && (
<div className="p-4 bg-emerald-500/15 border border-emerald-500/30 rounded-md text-emerald-400">
All participants saved successfully!
</div>
)}
<div className="text-sm text-muted-foreground space-y-2">
<p><strong>Default Scoring Rules (for EV calculation):</strong></p>
<div className="grid grid-cols-4 gap-2">
<span>1st: 100 pts</span>
<span>2nd: 70 pts</span>
<span>3rd: 50 pts</span>
<span>4th: 40 pts</span>
<span>5th: 25 pts</span>
<span>6th: 25 pts</span>
<span>7th: 15 pts</span>
<span>8th: 15 pts</span>
</div>
<p className="text-xs text-muted-foreground italic">
Note: EVs will be recalculated with actual league scoring rules when used in fantasy leagues.
</p> </p>
</div> ) : existingEVs.size === 0 ? (
<p className="text-center text-muted-foreground py-8">
<Form method="post"> No EV data yet. Run a simulation from the sports season page.
</p>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Participant</TableHead> <TableHead>Participant</TableHead>
<TableHead className="text-center">1st %</TableHead> <TableHead className="text-center">1st</TableHead>
<TableHead className="text-center">2nd %</TableHead> <TableHead className="text-center">2nd</TableHead>
<TableHead className="text-center">3rd %</TableHead> <TableHead className="text-center">3rd</TableHead>
<TableHead className="text-center">4th %</TableHead> <TableHead className="text-center">4th</TableHead>
<TableHead className="text-center">5th %</TableHead> <TableHead className="text-center">5th</TableHead>
<TableHead className="text-center">6th %</TableHead> <TableHead className="text-center">6th</TableHead>
<TableHead className="text-center">7th %</TableHead> <TableHead className="text-center">7th</TableHead>
<TableHead className="text-center">8th %</TableHead> <TableHead className="text-center">8th</TableHead>
<TableHead className="text-center">EV</TableHead> <TableHead className="text-center">EV</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{participants.map((participant: { id: string; name: string }) => { {sorted.map((participant) => {
const existingEV = existingEVs.get(participant.id); const ev = existingEVs.get(participant.id);
const id = participant.id;
return ( return (
<TableRow key={id}> <TableRow key={participant.id}>
<TableCell className="font-medium">{participant.name}</TableCell> <TableCell className="font-medium">{participant.name}</TableCell>
<TableCell> <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFirst) : "—"}</TableCell>
<Input <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSecond) : "—"}</TableCell>
type="number" <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probThird) : "—"}</TableCell>
name={`probFirst_${id}`} <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFourth) : "—"}</TableCell>
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"} <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFifth) : "—"}</TableCell>
step="0.01" <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSixth) : "—"}</TableCell>
min="0" <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSeventh) : "—"}</TableCell>
max="100" <TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probEighth) : "—"}</TableCell>
className="w-20 text-center" <TableCell className="text-center font-semibold">{ev ? evFromProbs(ev).toFixed(2) : "—"}</TableCell>
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSecond_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probThird_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probFourth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probFifth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSixth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probSeventh_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell>
<Input
type="number"
name={`probEighth_${id}`}
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
step="0.01"
min="0"
max="100"
className="w-20 text-center"
/>
</TableCell>
<TableCell className="text-center font-semibold">
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
</TableCell>
</TableRow> </TableRow>
); );
})} })}
@ -207,20 +132,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
)} )}
</TableBody> </TableBody>
</Table> </Table>
)}
{participants.length === 0 ? (
<p className="text-center text-muted-foreground py-8">
No participants found. Please add participants to this sports season first.
</p>
) : (
<div className="flex justify-end pt-4">
<Button type="submit">
<Save className="h-4 w-4 mr-2" />
Save All
</Button>
</div>
)}
</Form>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -1,8 +1,11 @@
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router'; import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds'; 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 { 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 { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input'; import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label'; import { Label } from '~/components/ui/label';
@ -15,11 +18,6 @@ import {
CardTitle, CardTitle,
} from '~/components/ui/card'; } from '~/components/ui/card';
import { useState } from 'react'; 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'; import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
@ -34,15 +32,15 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId); const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
const existingEVs = await getAllParticipantEVsForSeason(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( const existingOdds = new Map(
existingEVs existingEVs
.filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null) .filter(ev => ev.sourceOdds !== null)
.map(ev => [ev.participantId, ev.sourceOdds]) .map(ev => [ev.participantId, ev.sourceOdds])
); );
return { return {
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } }, sportsSeason,
participants, participants,
existingOdds, existingOdds,
}; };
@ -51,18 +49,11 @@ export async function loader({ params }: Route.LoaderArgs) {
interface ActionData { interface ActionData {
success?: boolean; success?: boolean;
message?: string; message?: string;
preview?: {
participantId: string;
participantName: string;
odds: number;
probabilities: number[];
}[];
} }
export async function action({ request, params }: Route.ActionArgs) { export async function action({ request, params }: Route.ActionArgs) {
const sportsSeasonId = params.id; const sportsSeasonId = params.id;
const formData = await request.formData(); const formData = await request.formData();
const intent = formData.get('intent') as string;
const sportsSeason = await findSportsSeasonById(sportsSeasonId); 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' }; 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 { try {
// Calculate ICM probabilities from odds const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
const icmResults = calculateICMFromOdds( const results = await simulator.simulate(sportsSeasonId);
futuresOdds.map(({ participantId, odds }) => ({ participantId, odds }))
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') { const today = new Date().toISOString().slice(0, 10);
// Return preview data await batchUpsertParticipantEvSnapshots(
const preview = futuresOdds.map(({ participantId, odds, name }) => { results.map((r) => ({
const icmResult = icmResults.get(participantId)!; participantId: r.participantId,
return { sportsSeasonId,
participantId, snapshotDate: today,
participantName: name, probFirst: r.probabilities.probFirst,
odds, probSecond: r.probabilities.probSecond,
probabilities: icmResultToArray(icmResult), 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 }; await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
}
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' };
} catch (error) { } catch (error) {
console.error('Error generating probabilities:', error); await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
console.error('Error running simulation:', error);
return { return {
success: false, 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 { function normalizeName(name: string): string {
@ -204,18 +190,14 @@ export default function AdminSportsSeasonFuturesOdds() {
function findParticipantMatch(inputName: string) { function findParticipantMatch(inputName: string) {
const normalizedInput = normalizeName(inputName); const normalizedInput = normalizeName(inputName);
// Pre-compute normalized names once for all three passes
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) })); const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
// Exact match
const exact = normalized.find(({ n }) => n === normalizedInput); const exact = normalized.find(({ n }) => n === normalizedInput);
if (exact) return exact.p; if (exact) return exact.p;
// Contains match (one contains the other)
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n)); const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
if (contains) return contains.p; 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 inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
const overlap = normalized.find(({ n }) => { const overlap = normalized.find(({ n }) => {
const pWords = n.split(' ').filter(w => w.length > 2); const pWords = n.split(' ').filter(w => w.length > 2);
@ -228,7 +210,6 @@ export default function AdminSportsSeasonFuturesOdds() {
function parseBulkText() { function parseBulkText() {
const lines = bulkText.split('\n'); const lines = bulkText.split('\n');
// Match lines that end with an American-style odds number (+/- then 26 digits)
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/; const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = []; 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 isSubmitting = navigation.state === 'submitting';
const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview';
const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save';
return ( return (
<div className="container mx-auto py-8"> <div className="container mx-auto py-8">
@ -363,7 +342,7 @@ export default function AdminSportsSeasonFuturesOdds() {
<CardTitle>Championship Futures Odds</CardTitle> <CardTitle>Championship Futures Odds</CardTitle>
<CardDescription> <CardDescription>
Enter American odds (e.g., +550, -200) for each participant's championship probability. 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.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -403,18 +382,14 @@ export default function AdminSportsSeasonFuturesOdds() {
</div> </div>
</div> </div>
<div className="flex gap-2"> {actionData && !actionData.success && actionData.message && (
<Button <div className="text-sm text-destructive">{actionData.message}</div>
type="submit" )}
name="intent"
value="preview" <Button type="submit" disabled={isSubmitting}>
variant="outline" {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
disabled={isSubmitting} {isSubmitting ? 'Running...' : 'Run Simulation'}
> </Button>
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Preview
</Button>
</div>
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
@ -434,118 +409,6 @@ export default function AdminSportsSeasonFuturesOdds() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div>
{actionData?.preview && (
<Card>
<CardHeader>
<CardTitle>ICM Results</CardTitle>
<CardDescription>
Probability distributions calculated using Independent Chip Model
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2">Team</th>
<th className="text-right py-2">Odds</th>
<th className="text-right py-2">P(1st)</th>
<th className="text-right py-2">P(2nd)</th>
<th className="text-right py-2">P(3rd)</th>
<th className="text-right py-2">P(4th)</th>
<th className="text-right py-2">P(5th)</th>
<th className="text-right py-2">P(6th)</th>
<th className="text-right py-2">P(7th)</th>
<th className="text-right py-2">P(8th)</th>
</tr>
</thead>
<tbody>
{actionData.preview
.sort((a, b) => b.probabilities[0] - a.probabilities[0])
.map((result) => (
<tr key={result.participantId} className="border-b">
<td className="py-2">{result.participantName}</td>
<td className="text-right">
{result.odds > 0 ? '+' : ''}
{result.odds}
</td>
<td className="text-right">
{(result.probabilities[0] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[1] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[2] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[3] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[4] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[5] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[6] * 100).toFixed(1)}%
</td>
<td className="text-right">
{(result.probabilities[7] * 100).toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<Form method="post">
{/* Pass through odds and preview probabilities */}
{actionData.preview.map((result) => (
<div key={result.participantId}>
<input type="hidden" name={`odds_${result.participantId}`} value={result.odds} />
<input type="hidden" name={`prob_first_${result.participantId}`} value={result.probabilities[0]} />
<input type="hidden" name={`prob_second_${result.participantId}`} value={result.probabilities[1]} />
<input type="hidden" name={`prob_third_${result.participantId}`} value={result.probabilities[2]} />
<input type="hidden" name={`prob_fourth_${result.participantId}`} value={result.probabilities[3]} />
<input type="hidden" name={`prob_fifth_${result.participantId}`} value={result.probabilities[4]} />
<input type="hidden" name={`prob_sixth_${result.participantId}`} value={result.probabilities[5]} />
<input type="hidden" name={`prob_seventh_${result.participantId}`} value={result.probabilities[6]} />
<input type="hidden" name={`prob_eighth_${result.participantId}`} value={result.probabilities[7]} />
</div>
))}
<Button type="submit" name="intent" value="save" className="w-full" disabled={isSaving}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isSaving ? 'Saving...' : 'Save Probabilities'}
</Button>
</Form>
</div>
</CardContent>
</Card>
)}
{actionData && !actionData.success && actionData.message && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="text-destructive font-medium">Error</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
{actionData && actionData.success && !actionData.preview && (
<Card className="border-emerald-500/30 bg-emerald-500/10">
<CardContent className="pt-6">
<div className="text-emerald-400 font-medium">Success</div>
<div className="text-sm mt-2">{actionData.message}</div>
</CardContent>
</Card>
)}
</div>
</div> </div>
</div> </div>
); );

View file

@ -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<typeof loader>();
const actionData = useActionData<typeof action>();
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 (
<div className="container mx-auto py-8 px-4">
<div className="mb-6">
<Button variant="ghost" size="sm" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Sports Season
</Link>
</Button>
</div>
<div className="max-w-6xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">Recalculate Probabilities</h1>
<p className="text-muted-foreground">
{sportsSeason.name} {sportsSeason.year}
</p>
</div>
{invalidProbabilities.length > 0 && (
<Card className="border-amber-accent/30 bg-amber-accent/10">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-accent" />
<CardTitle className="text-amber-accent">
Invalid Probability Data Detected
</CardTitle>
</div>
<CardDescription>
{invalidProbabilities.length} participant(s) have probabilities greater than 100%, which indicates corrupted data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<p>
The following participants have invalid probabilities (marked with in the table below):
</p>
<ul className="list-disc list-inside">
{invalidProbabilities.slice(0, 5).map((p) => (
<li key={p.participantId}>
{p.participantName}: P(1st) = {formatProbability(p.before[0])}
</li>
))}
{invalidProbabilities.length > 5 && (
<li>... and {invalidProbabilities.length - 5} more</li>
)}
</ul>
<p className="mt-4 text-muted-foreground">
<strong>Solution:</strong> Go to the{" "}
<Link
to={`/admin/sports-seasons/${sportsSeason.id}/futures-odds`}
className="underline"
>
Futures Odds page
</Link>{" "}
and regenerate probabilities from current betting odds to fix this data.
</p>
</div>
</CardContent>
</Card>
)}
{actionData?.success ? (
<Card className="border-emerald-500/30 bg-emerald-500/10">
<CardHeader>
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-emerald-400" />
<CardTitle className="text-emerald-400">
Probabilities Updated Successfully
</CardTitle>
</div>
<CardDescription>
{actionData.result?.updated} participant
{actionData.result?.updated !== 1 ? "s" : ""} updated
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div>
Finished participants: {actionData.result?.finishedParticipants}
</div>
<div>
Recalculated participants: {actionData.result?.unfishedParticipants}
</div>
</div>
<Button className="mt-4" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}/expected-values`}>
View Expected Values
</Link>
</Button>
</CardContent>
</Card>
) : actionData?.error ? (
<Card className="border-destructive bg-destructive/10">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<CardTitle className="text-destructive">Error</CardTitle>
</div>
<CardDescription className="text-destructive/80">
{actionData.error}
</CardDescription>
</CardHeader>
</Card>
) : null}
<Card>
<CardHeader>
<CardTitle>Summary</CardTitle>
<CardDescription>
Current state of participant results and probabilities
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<div className="text-2xl font-bold">{finishedCount}</div>
<div className="text-sm text-muted-foreground">
Finished Participants
</div>
</div>
<div>
<div className="text-2xl font-bold">{unfinishedCount}</div>
<div className="text-sm text-muted-foreground">
Unfinished Participants
</div>
</div>
<div>
<div className="text-2xl font-bold">{changesCount}</div>
<div className="text-sm text-muted-foreground">
Changes to Apply
</div>
</div>
</div>
</CardContent>
</Card>
{changesCount === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Changes Needed</CardTitle>
<CardDescription>
All finished participants already have their probabilities set correctly.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Either no participants have finished yet, or their probabilities are
already up to date.
</p>
</CardContent>
</Card>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Probability Changes</CardTitle>
<CardDescription>
Preview of changes that will be applied
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Participant</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">P(1st) Before</TableHead>
<TableHead className="text-right">P(1st) After</TableHead>
<TableHead className="text-right">P(2nd) After</TableHead>
<TableHead className="text-right">P(3rd) After</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{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 (
<TableRow key={p.participantId}>
<TableCell className="font-medium">
{p.participantName}
</TableCell>
<TableCell>
<Badge variant="default">
Finished {finalPosition ? `${finalPosition}${
finalPosition === 1
? "st"
: finalPosition === 2
? "nd"
: finalPosition === 3
? "rd"
: "th"
}` : ""}
</Badge>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div className={p.before[0] > 1 ? "text-destructive" : ""}>
{formatProbability(p.before[0])}
{p.before[0] > 1 && " ⚠️"}
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[0])}</div>
<div className="text-xs text-muted-foreground">
{getProbabilityChange(p.before[0], p.after[0])}
</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[1])}</div>
</TableCell>
<TableCell className="text-right font-mono text-sm">
<div>{formatProbability(p.after[2])}</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Apply Changes</CardTitle>
<CardDescription>
Update probabilities based on real results
</CardDescription>
</CardHeader>
<CardContent>
<Form method="post" className="space-y-4">
<input type="hidden" name="intent" value="recalculate" />
<input
type="hidden"
name="recalculateUnfinished"
value="true"
/>
<div className="bg-electric/10 border border-electric/20 rounded-lg p-4">
<h4 className="font-semibold text-sm mb-2">What happens:</h4>
<ul className="text-sm space-y-1 text-muted-foreground">
<li>
Finished participants get 100% probability at their final
position
</li>
<li>
Unfinished participants get recalculated probabilities based
on remaining competition
</li>
<li>
Expected values will be automatically updated for all
affected leagues
</li>
</ul>
</div>
<div className="flex gap-4">
<Button
type="submit"
className="flex-1"
disabled={changesCount === 0}
>
<RefreshCw className="mr-2 h-4 w-4" />
Apply {changesCount} Change
{changesCount !== 1 ? "s" : ""}
</Button>
<Button type="button" variant="outline" asChild>
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
Cancel
</Link>
</Button>
</div>
</Form>
</CardContent>
</Card>
</>
)}
</div>
</div>
);
}

View file

@ -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`);
}

View file

@ -3,6 +3,10 @@ import type { Route } from "./+types/admin.sports-seasons.$id";
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season"; import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
import { findParticipantsBySportsSeasonId } from "~/models/participant"; import { findParticipantsBySportsSeasonId } from "~/models/participant";
import { processSeasonStandings } from "~/models/scoring-calculator"; 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 { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label"; import { Label } from "~/components/ui/label";
@ -32,7 +36,7 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { Badge } from "~/components/ui/badge"; 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"; import { useState } from "react";
export async function loader({ params }: Route.LoaderArgs) { export async function loader({ params }: Route.LoaderArgs) {
@ -44,10 +48,26 @@ export async function loader({ params }: Route.LoaderArgs) {
const participants = await findParticipantsBySportsSeasonId(params.id); const participants = await findParticipantsBySportsSeasonId(params.id);
// Type assertion since we know the sport relation is included // Get the most recent snapshot date (if any)
return { const db = database();
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } }, const lastSnapshot = await db
participants .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) { export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
const { sportsSeason, participants } = loaderData; const { sportsSeason, participants, lastSimulatedDate, simulatorInfo } = loaderData;
const navigate = useNavigate(); const navigate = useNavigate();
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || ""); const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
@ -345,10 +365,18 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<div> <div>
<CardTitle>Expected Values</CardTitle> <CardTitle>Expected Values</CardTitle>
<CardDescription> <CardDescription>
Manage probability distributions and projected points {simulatorInfo
? simulatorInfo.name
: "Manage probability distributions and projected points"}
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
{sportsSeason.simulationStatus === "failed" && (
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
<AlertTriangle className="mr-1 h-3 w-3" />
Last run failed
</Badge>
)}
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -357,27 +385,39 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
<Calculator className="mr-2 h-4 w-4" /> <Calculator className="mr-2 h-4 w-4" />
Futures Odds Futures Odds
</Button> </Button>
<Button {simulatorInfo && (
size="sm" <Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
variant="outline" <Button
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/expected-values`)} type="submit"
> size="sm"
<Calculator className="mr-2 h-4 w-4" /> disabled={sportsSeason.simulationStatus === "running"}
Manual Entry >
</Button> {sportsSeason.simulationStatus === "running" ? (
<Button <>
size="sm" <Loader2 className="mr-2 h-4 w-4 animate-spin" />
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/recalculate-probabilities`)} Running...
> </>
<Calculator className="mr-2 h-4 w-4" /> ) : (
Recalculate <>
</Button> <Zap className="mr-2 h-4 w-4" />
Run Simulation
</>
)}
</Button>
</Form>
)}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
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."}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>

View file

@ -38,6 +38,7 @@ export async function action({ request, params }: Route.ActionArgs) {
const type = formData.get("type"); const type = formData.get("type");
const slug = formData.get("slug"); const slug = formData.get("slug");
const description = formData.get("description"); const description = formData.get("description");
const simulatorType = formData.get("simulatorType");
const iconFile = formData.get("iconFile"); const iconFile = formData.get("iconFile");
const keepExistingIcon = formData.get("keepExistingIcon"); const keepExistingIcon = formData.get("keepExistingIcon");
@ -95,6 +96,13 @@ export async function action({ request, params }: Route.ActionArgs) {
iconUrl = null; 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 { try {
await updateSport(params.id, { await updateSport(params.id, {
name: name.trim(), name: name.trim(),
@ -102,6 +110,7 @@ export async function action({ request, params }: Route.ActionArgs) {
slug: slug.trim(), slug: slug.trim(),
description: typeof description === "string" ? description.trim() : null, description: typeof description === "string" ? description.trim() : null,
iconUrl, iconUrl,
simulatorType: parsedSimulatorType,
}); });
return redirect("/admin/sports"); return redirect("/admin/sports");
@ -177,6 +186,25 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
</p> </p>
</div> </div>
<div className="space-y-2">
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
<Select name="simulatorType" defaultValue={sport.simulatorType ?? "none"}>
<SelectTrigger id="simulatorType">
<SelectValue placeholder="No simulator" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No simulator</SelectItem>
<SelectItem value="f1_standings">F1 Standings Model</SelectItem>
<SelectItem value="indycar_standings">IndyCar Standings Model</SelectItem>
<SelectItem value="golf_qualifying_points">Golf Qualifying Points Model</SelectItem>
<SelectItem value="playoff_bracket">Bracket Monte Carlo</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
Algorithm used when running EV simulations for this sport
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description (Optional)</Label> <Label htmlFor="description">Description (Optional)</Label>
<Textarea <Textarea

View file

@ -66,7 +66,7 @@ export function calculateEV(
probabilities.probSeventh * scoringRules.pointsFor7th + probabilities.probSeventh * scoringRules.pointsFor7th +
probabilities.probEighth * scoringRules.pointsFor8th; probabilities.probEighth * scoringRules.pointsFor8th;
return Math.round(ev * 100) / 100; // Round to 2 decimal places return ev;
} }
/** /**

View file

@ -0,0 +1,98 @@
/**
* Bracket Simulator
*
* Wraps the existing Monte Carlo bracket simulator (app/services/bracket-simulator.ts)
* to conform to the Simulator interface. Loads current participant Elo ratings
* from participantExpectedValues and runs 100k simulations.
*
* The existing bracket simulator uses Elo ratings derived from futures odds
* (via the probability engine pipeline). These should be imported via the
* admin "Futures Odds" page before running simulation.
*/
import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema";
import { eq, and, isNotNull } from "drizzle-orm";
import { simulateBracket } from "~/services/bracket-simulator";
import { convertFuturesToElo } from "~/services/probability-engine";
import type { Simulator, SimulationResult } from "./types";
export class BracketSimulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// Load all participants with their current EVs (which hold probability distributions)
const evRows = await db
.select({
participantId: participantExpectedValues.participantId,
probFirst: participantExpectedValues.probFirst,
sourceOdds: participantExpectedValues.sourceOdds,
})
.from(participantExpectedValues)
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
if (evRows.length === 0) {
throw new Error(
`No participant EVs found for sports season ${sportsSeasonId}. ` +
`Import futures odds first via Admin → Futures Odds.`
);
}
// Build Elo ratings from source odds (American odds format) if available,
// otherwise fall back to using probFirst as a proxy for win probability.
let eloMap: Map<string, number>;
const hasOdds = evRows.some((r) => r.sourceOdds !== null);
if (hasOdds) {
const oddsInput = evRows
.filter((r) => r.sourceOdds !== null)
.map((r) => ({ participantId: r.participantId, odds: r.sourceOdds! }));
eloMap = convertFuturesToElo(oddsInput);
} else {
// Fall back: treat probFirst (as %) as championship win probability,
// convert to a rough Elo by mapping [min, max] prob → [1250, 1750]
const probs = evRows.map((r) => parseFloat(r.probFirst));
const minProb = Math.min(...probs);
const maxProb = Math.max(...probs);
const range = maxProb - minProb || 1;
eloMap = new Map(
evRows.map((r) => {
const prob = parseFloat(r.probFirst);
const normalised = (prob - minProb) / range;
const elo = 1250 + normalised * 500;
return [r.participantId, elo];
})
);
}
const teamsForSimulation = evRows
.filter((r) => eloMap.has(r.participantId))
.map((r) => ({
participantId: r.participantId,
elo: eloMap.get(r.participantId)!,
}));
if (teamsForSimulation.length === 0) {
throw new Error(`Could not build Elo ratings for sports season ${sportsSeasonId}.`);
}
const probMap = await simulateBracket(teamsForSimulation);
return Array.from(probMap.entries()).map(([participantId, probs]) => ({
participantId,
probabilities: {
probFirst: probs[0],
probSecond: probs[1],
probThird: probs[2],
probFourth: probs[3],
probFifth: probs[4],
probSixth: probs[5],
probSeventh: probs[6],
probEighth: probs[7],
},
source: "bracket_monte_carlo",
}));
}
}

View file

@ -0,0 +1,261 @@
/**
* F1 / Season Standings Simulator
*
* Algorithm:
* 1. Load participants + current championship points from DB
* 2. Count remaining races (incomplete non-schedule scoring events)
* 3. Convert sourceOdds vig-removed probability weights
* 4. Two simulation paths:
* a. remainingRaces === 0 (pre-season): pure weighted draws from odds
* b. remainingRaces > 0 (in-season): simulate each remaining race,
* starting from real standings, awarding F1 race points per finish
* 5. Convert finish counts probability distributions + normalize columns
*
* Notes:
* - Drivers without odds fall back to uniform probability (1/N)
* - PARTICIPANT_VOLATILITY and RACE_NOISE only apply to the in-season path
*/
import { database } from "~/database/context";
import { eq } from "drizzle-orm";
import * as schema from "~/database/schema";
import { getAllParticipantEVsForSeason } from "~/models/participant-expected-value";
import { getSeasonResults } from "~/models/participant-season-result";
import type { Simulator, SimulationResult } from "./types";
// ─── Simulation parameters (mirrors Python constants) ────────────────────────
const NUM_SIMULATIONS = 10000;
/** Per-race performance variance. 0 = no noise, 1 = fully random each race. */
const RACE_NOISE = 0.50;
/**
* Season-long multiplier range per driver.
* Each driver gets uniform(1 - V, 1 + V) applied to their base probability
* for the entire season, capturing "cars that over/underperform expectations".
*/
const PARTICIPANT_VOLATILITY = 1.5;
/**
* Optional smoothing toward the mean after vig removal.
* 0.0 = use vig-removed market odds exactly (recommended).
* Increase slightly (e.g. 0.1) to soften extreme probabilities.
*/
const UNCERTAINTY_FACTOR = 0.0;
// ─── F1 race points for finishing positions 110 ──────────────────────────────
const RACE_POINTS: Record<number, number> = {
1: 25, 2: 18, 3: 15, 4: 12, 5: 10, 6: 8, 7: 6, 8: 4, 9: 2, 10: 1,
};
function getRacePoints(position: number): number {
return RACE_POINTS[position] ?? 0;
}
// ─── Odds helpers ─────────────────────────────────────────────────────────────
/** Convert American odds to implied probability (no vig removal). */
function americanToImpliedProb(americanOdds: number): number {
if (americanOdds > 0) {
return 100 / (americanOdds + 100);
}
return Math.abs(americanOdds) / (Math.abs(americanOdds) + 100);
}
// ─── Core simulation helper ───────────────────────────────────────────────────
/**
* Weighted sequential draw without replacement.
* Returns all items in a simulated finishing order.
* Each draw is proportional to remaining weights.
*/
function weightedDrawWithoutReplacement(ids: string[], weights: number[]): string[] {
const pool = ids.slice();
const w = weights.slice();
const result: string[] = [];
while (pool.length > 0) {
const total = w.reduce((s, v) => s + v, 0);
let r = Math.random() * total;
let idx = 0;
while (idx < w.length - 1 && r > w[idx]) {
r -= w[idx];
idx++;
}
result.push(pool[idx]);
pool.splice(idx, 1);
w.splice(idx, 1);
}
return result;
}
// ─── Simulator ────────────────────────────────────────────────────────────────
export class F1Simulator implements Simulator {
async simulate(sportsSeasonId: string): Promise<SimulationResult[]> {
const db = database();
// 1. Load all participants for this sports season
const participants = await db.query.participants.findMany({
where: eq(schema.participants.sportsSeasonId, sportsSeasonId),
});
if (participants.length === 0) {
throw new Error(`No participants found for sports season ${sportsSeasonId}.`);
}
// 2. Load current championship standings (existing points earned this season)
const seasonResults = await getSeasonResults(sportsSeasonId);
const currentPointsMap = new Map<string, number>(
seasonResults.map((r) => [r.participant.id, parseFloat(r.currentPoints ?? "0")])
);
// 3. Count remaining races: incomplete scoring events, excluding schedule_event entries
const allEvents = await db.query.scoringEvents.findMany({
where: eq(schema.scoringEvents.sportsSeasonId, sportsSeasonId),
});
const remainingRaces = allEvents.filter(
(e) => !e.isComplete && e.eventType !== "schedule_event"
).length;
// 4. Load EV data for championship win probabilities
const evs = await getAllParticipantEVsForSeason(sportsSeasonId);
const evMap = new Map(evs.map((ev) => [ev.participantId, ev]));
const ids = participants.map((p) => p.id);
// 5. Build raw implied championship win probabilities from odds.
// americanToImpliedProb includes vig (sum > 1.0), so we normalize to sum = 1.0
// before using as weights. This is standard "vig removal" and ensures a driver
// with -200 odds (~66.7% implied) gets ~55% weight when the total vig is ~1.2.
const fallbackProb = 1 / participants.length;
const rawProbs = new Map<string, number>();
for (const p of participants) {
const ev = evMap.get(p.id);
rawProbs.set(p.id, ev?.sourceOdds != null ? americanToImpliedProb(ev.sourceOdds) : fallbackProb);
}
// Normalize to remove vig
const rawSum = [...rawProbs.values()].reduce((a, b) => a + b, 0);
for (const [id, prob] of rawProbs) {
rawProbs.set(id, prob / rawSum);
}
// 6. Optionally smooth toward the mean (no-op when UNCERTAINTY_FACTOR = 0)
const baseProbs = new Map<string, number>();
if (UNCERTAINTY_FACTOR === 0) {
for (const [id, prob] of rawProbs) baseProbs.set(id, prob);
} else {
const avgProb = [...rawProbs.values()].reduce((a, b) => a + b, 0) / participants.length;
for (const [id, prob] of rawProbs) {
baseProbs.set(id, prob * (1 - UNCERTAINTY_FACTOR) + avgProb * UNCERTAINTY_FACTOR);
}
}
// 7. Accumulate finish counts across simulations
// rankCounts[id][0..7] = number of times driver finished 1st..8th
const rankCounts = new Map<string, number[]>();
for (const id of ids) {
rankCounts.set(id, new Array(8).fill(0));
}
if (remainingRaces === 0) {
// Pre-season: no races to simulate, derive placement probabilities
// from sourceOdds via pure weighted draws.
const weights = ids.map((id) => baseProbs.get(id) ?? fallbackProb);
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
const finishOrder = weightedDrawWithoutReplacement(ids, weights);
for (let rank = 0; rank < Math.min(8, finishOrder.length); rank++) {
rankCounts.get(finishOrder[rank])![rank]++;
}
}
} else {
// In-season: simulate remaining races from current standings.
for (let sim = 0; sim < NUM_SIMULATIONS; sim++) {
// 7a. Season-long performance multiplier per driver
const seasonWeights = new Map<string, number>();
for (const id of ids) {
const base = baseProbs.get(id) ?? fallbackProb;
const mult = Math.max(
0.05,
1 - PARTICIPANT_VOLATILITY + Math.random() * PARTICIPANT_VOLATILITY * 2
);
seasonWeights.set(id, base * mult);
}
// 7b. Start from current championship points
const simPoints = new Map<string, number>(
ids.map((id) => [id, currentPointsMap.get(id) ?? 0])
);
// 7c. Simulate each remaining race
for (let race = 0; race < remainingRaces; race++) {
const raceWeights = ids.map((id) => {
const sw = seasonWeights.get(id) ?? fallbackProb;
const noise = Math.max(0.01, 1 - RACE_NOISE + Math.random() * RACE_NOISE * 2);
return sw * noise;
});
const finishOrder = weightedDrawWithoutReplacement(ids, raceWeights);
for (let pos = 0; pos < finishOrder.length; pos++) {
const pts = getRacePoints(pos + 1);
if (pts === 0) break; // positions 11+ earn no F1 points
simPoints.set(finishOrder[pos], (simPoints.get(finishOrder[pos]) ?? 0) + pts);
}
}
// 7d. Sort by final championship points, record top-8 finishes
const finalOrder = [...simPoints.entries()]
.sort((a, b) => b[1] - a[1])
.map(([id]) => id);
for (let rank = 0; rank < Math.min(8, finalOrder.length); rank++) {
rankCounts.get(finalOrder[rank])![rank]++;
}
}
}
// 8. Convert counts → probability distributions
const results: SimulationResult[] = participants.map((p) => ({
participantId: p.id,
probabilities: {
probFirst: rankCounts.get(p.id)![0] / NUM_SIMULATIONS,
probSecond: rankCounts.get(p.id)![1] / NUM_SIMULATIONS,
probThird: rankCounts.get(p.id)![2] / NUM_SIMULATIONS,
probFourth: rankCounts.get(p.id)![3] / NUM_SIMULATIONS,
probFifth: rankCounts.get(p.id)![4] / NUM_SIMULATIONS,
probSixth: rankCounts.get(p.id)![5] / NUM_SIMULATIONS,
probSeventh: rankCounts.get(p.id)![6] / NUM_SIMULATIONS,
probEighth: rankCounts.get(p.id)![7] / NUM_SIMULATIONS,
},
source: "f1_standings_model",
}));
// 9. Per-position normalization: each column should sum to exactly 1.0 but
// floating-point division (count / 10000) accumulates small errors across
// ~20 drivers, causing the total EV to drift (e.g. 340.02 instead of 340).
// Fix: add the residual (1.0 - colSum) to the largest probability in each
// column so the sum is exactly 1.0 in IEEE 754 arithmetic.
const positionKeys: Array<keyof typeof results[0]["probabilities"]> = [
"probFirst", "probSecond", "probThird", "probFourth",
"probFifth", "probSixth", "probSeventh", "probEighth",
];
for (const key of positionKeys) {
const colSum = results.reduce((s, r) => s + r.probabilities[key], 0);
const residual = 1.0 - colSum;
if (residual !== 0) {
const maxResult = results.reduce((best, r) =>
r.probabilities[key] > best.probabilities[key] ? r : best
);
maxResult.probabilities[key] += residual;
}
}
return results;
}
}

View file

@ -0,0 +1,32 @@
/**
* Golf / Qualifying Points Simulator
*
* TODO: Port the Python golf/majors simulator here.
*
* Input data available:
* - Current QP standings: getQPStandings(sportsSeasonId)
* Returns { participant.id, participant.name, totalQualifyingPoints, eventsScored }
* - Remaining majors: sportsSeason.totalMajors - sportsSeason.majorsCompleted
* - Per-major QP config: qualifyingPointConfig table (points per placement for each major)
*
* Expected output: SimulationResult[] one entry per participant with
* probabilities (01) for finishing 1st through 8th in the final QP standings.
*
* Algorithm sketch (replace with the Python model logic):
* 1. Get current QP totals for all participants
* 2. For each remaining major, model each participant's probability of
* finishing at each placement (using world rankings, recent form, etc.)
* 3. Monte Carlo: simulate remaining majors N times, add QP, tally final standings
* 4. Convert tally counts probabilities
*/
import type { Simulator, SimulationResult } from "./types";
export class GolfSimulator implements Simulator {
async simulate(_sportsSeasonId: string): Promise<SimulationResult[]> {
throw new Error(
"GolfSimulator not yet implemented. " +
"Port the Python golf/majors model to TypeScript and implement this method."
);
}
}

View file

@ -0,0 +1,58 @@
/**
* Simulator Registry
*
* Maps a sport's simulatorType to the appropriate Simulator implementation.
* Set simulatorType on the sport record in the admin UI.
* Add new simulators here as new sports are supported.
*/
import type { Simulator } from "./types";
import { BracketSimulator } from "./bracket-simulator";
import { F1Simulator } from "./f1-simulator";
import { GolfSimulator } from "./golf-simulator";
export type SimulatorType =
| "f1_standings"
| "indycar_standings"
| "golf_qualifying_points"
| "playoff_bracket";
export interface SimulatorInfo {
name: string;
description: string;
}
const REGISTRY: Record<SimulatorType, { info: SimulatorInfo; create: () => Simulator }> = {
f1_standings: {
info: { name: "F1 Standings Model", description: "Simulates remaining races using current F1 standings and futures odds" },
create: () => new F1Simulator(),
},
indycar_standings: {
info: { name: "IndyCar Standings Model", description: "Simulates remaining races using current IndyCar standings and futures odds" },
create: () => new F1Simulator(),
},
golf_qualifying_points: {
info: { name: "Qualifying Points Model", description: "Simulates remaining majors using current QP standings and futures odds" },
create: () => new GolfSimulator(),
},
playoff_bracket: {
info: { name: "Bracket Monte Carlo", description: "Simulates playoff bracket outcomes using Elo ratings" },
create: () => new BracketSimulator(),
},
};
export function getSimulator(simulatorType: SimulatorType): Simulator {
const entry = REGISTRY[simulatorType];
if (!entry) {
throw new Error(
`No simulator registered for type: "${simulatorType}". ` +
`Add a simulator to app/services/simulations/registry.ts.`
);
}
return entry.create();
}
export function getSimulatorInfo(simulatorType: SimulatorType): SimulatorInfo | null {
return REGISTRY[simulatorType]?.info ?? null;
}

View file

@ -0,0 +1,35 @@
/**
* Simulator interface and shared types for sport-specific EV simulation.
*
* Each simulator takes a sportsSeasonId, reads the data it needs from the DB,
* runs its algorithm, and returns a probability distribution for every participant.
*/
export interface SimulationProbabilities {
probFirst: number;
probSecond: number;
probThird: number;
probFourth: number;
probFifth: number;
probSixth: number;
probSeventh: number;
probEighth: number;
}
/**
* Result for a single participant from a simulation run.
* Probabilities are 0-1 decimals (not percentages).
*/
export interface SimulationResult {
participantId: string;
probabilities: SimulationProbabilities;
/** Human-readable name of the algorithm used, e.g. 'bracket_monte_carlo' */
source: string;
}
/**
* Common interface all sport simulators must implement.
*/
export interface Simulator {
simulate(sportsSeasonId: string): Promise<SimulationResult[]>;
}

View file

@ -70,6 +70,19 @@ export const probabilitySourceEnum = pgEnum("probability_source", [
"performance_model", "performance_model",
]); ]);
export const simulationStatusEnum = pgEnum("simulation_status", [
"idle",
"running",
"failed",
]);
export const simulatorTypeEnum = pgEnum("simulator_type", [
"f1_standings",
"indycar_standings",
"golf_qualifying_points",
"playoff_bracket",
]);
export const leagues = pgTable("leagues", { export const leagues = pgTable("leagues", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
@ -229,6 +242,7 @@ export const sports = pgTable("sports", {
slug: varchar("slug", { length: 255 }).notNull().unique(), slug: varchar("slug", { length: 255 }).notNull().unique(),
description: text("description"), description: text("description"),
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/ iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
simulatorType: simulatorTypeEnum("simulator_type"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
@ -254,6 +268,8 @@ export const sportsSeasons = pgTable("sports_seasons", {
eloCalibrationExponent: decimal("elo_calibration_exponent", { precision: 3, scale: 2 }), // e.g., 0.33 eloCalibrationExponent: decimal("elo_calibration_exponent", { precision: 3, scale: 2 }), // e.g., 0.33
eloMinRating: integer("elo_min_rating").default(1250), eloMinRating: integer("elo_min_rating").default(1250),
eloMaxRating: integer("elo_max_rating").default(1750), eloMaxRating: integer("elo_max_rating").default(1750),
// Simulation status (prevents concurrent simulation runs)
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
@ -266,7 +282,7 @@ export const participants = pgTable("participants", {
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
shortName: varchar("short_name", { length: 100 }), shortName: varchar("short_name", { length: 100 }),
externalId: varchar("external_id", { length: 255 }), externalId: varchar("external_id", { length: 255 }),
expectedValue: decimal("expected_value", { precision: 10, scale: 2 }).notNull().default("0"), expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
createdAt: timestamp("created_at").defaultNow().notNull(), createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
@ -487,17 +503,17 @@ export const participantExpectedValues = pgTable("participant_expected_values",
sportsSeasonId: uuid("sports_season_id") sportsSeasonId: uuid("sports_season_id")
.notNull() .notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }), .references(() => sportsSeasons.id, { onDelete: "cascade" }),
// Probability distribution (stored as percentages) // Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"), // e.g., 15.50 = 15.5% probFirst: decimal("prob_first", { precision: 6, scale: 4 }).notNull().default("0"),
probSecond: decimal("prob_second", { precision: 5, scale: 2 }).notNull().default("0"), probSecond: decimal("prob_second", { precision: 6, scale: 4 }).notNull().default("0"),
probThird: decimal("prob_third", { precision: 5, scale: 2 }).notNull().default("0"), probThird: decimal("prob_third", { precision: 6, scale: 4 }).notNull().default("0"),
probFourth: decimal("prob_fourth", { precision: 5, scale: 2 }).notNull().default("0"), probFourth: decimal("prob_fourth", { precision: 6, scale: 4 }).notNull().default("0"),
probFifth: decimal("prob_fifth", { precision: 5, scale: 2 }).notNull().default("0"), probFifth: decimal("prob_fifth", { precision: 6, scale: 4 }).notNull().default("0"),
probSixth: decimal("prob_sixth", { precision: 5, scale: 2 }).notNull().default("0"), probSixth: decimal("prob_sixth", { precision: 6, scale: 4 }).notNull().default("0"),
probSeventh: decimal("prob_seventh", { precision: 5, scale: 2 }).notNull().default("0"), probSeventh: decimal("prob_seventh", { precision: 6, scale: 4 }).notNull().default("0"),
probEighth: decimal("prob_eighth", { precision: 5, scale: 2 }).notNull().default("0"), probEighth: decimal("prob_eighth", { precision: 6, scale: 4 }).notNull().default("0"),
// Calculated EV // Calculated EV
expectedValue: decimal("expected_value", { precision: 10, scale: 2 }).notNull().default("0"), expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
// Metadata // Metadata
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format) sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format)
@ -542,6 +558,54 @@ export const participantSeasonResults = pgTable("participant_season_results", {
updatedAt: timestamp("updated_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(),
}); });
// EV snapshots — historical record of participant EV over time (one per participant per sport season per day)
export const participantEvSnapshots = pgTable("participant_ev_snapshots", {
id: uuid("id").primaryKey().defaultRandom(),
participantId: uuid("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
sportsSeasonId: uuid("sports_season_id")
.notNull()
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
snapshotDate: date("snapshot_date").notNull(),
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"),
probSecond: decimal("prob_second", { precision: 5, scale: 2 }).notNull().default("0"),
probThird: decimal("prob_third", { precision: 5, scale: 2 }).notNull().default("0"),
probFourth: decimal("prob_fourth", { precision: 5, scale: 2 }).notNull().default("0"),
probFifth: decimal("prob_fifth", { precision: 5, scale: 2 }).notNull().default("0"),
probSixth: decimal("prob_sixth", { precision: 5, scale: 2 }).notNull().default("0"),
probSeventh: decimal("prob_seventh", { precision: 5, scale: 2 }).notNull().default("0"),
probEighth: decimal("prob_eighth", { precision: 5, scale: 2 }).notNull().default("0"),
calculatedEV: decimal("calculated_ev", { precision: 10, scale: 4 }).notNull().default("0"),
source: varchar("source", { length: 100 }).notNull(), // e.g. 'bracket_monte_carlo', 'f1_standings_model'
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}, (t) => ({
uniqueParticipantSeasonDate: uniqueIndex("participant_ev_snapshots_unique").on(
t.participantId, t.sportsSeasonId, t.snapshotDate
),
}));
// Team EV snapshots — historical projected points per team per fantasy season per day
export const teamEvSnapshots = pgTable("team_ev_snapshots", {
id: uuid("id").primaryKey().defaultRandom(),
teamId: uuid("team_id")
.notNull()
.references(() => teams.id, { onDelete: "cascade" }),
seasonId: uuid("season_id")
.notNull()
.references(() => seasons.id, { onDelete: "cascade" }),
snapshotDate: date("snapshot_date").notNull(),
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }).notNull().default("0"),
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }).notNull().default("0"),
createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => ({
uniqueTeamSeasonDate: uniqueIndex("team_ev_snapshots_unique").on(
t.teamId, t.seasonId, t.snapshotDate
),
}));
// Relations // Relations
export const sportsRelations = relations(sports, ({ many }) => ({ export const sportsRelations = relations(sports, ({ many }) => ({
sportsSeasons: many(sportsSeasons), sportsSeasons: many(sportsSeasons),
@ -818,3 +882,25 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
references: [participants.id], references: [participants.id],
}), }),
})); }));
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
participant: one(participants, {
fields: [participantEvSnapshots.participantId],
references: [participants.id],
}),
sportsSeason: one(sportsSeasons, {
fields: [participantEvSnapshots.sportsSeasonId],
references: [sportsSeasons.id],
}),
}));
export const teamEvSnapshotsRelations = relations(teamEvSnapshots, ({ one }) => ({
team: one(teams, {
fields: [teamEvSnapshots.teamId],
references: [teams.id],
}),
season: one(seasons, {
fields: [teamEvSnapshots.seasonId],
references: [seasons.id],
}),
}));

View file

@ -1,20 +1,31 @@
-- Consolidate single_elimination_playoff and page_playoff → playoff_bracket -- Consolidate single_elimination_playoff and page_playoff → playoff_bracket
-- PostgreSQL does not support DROP VALUE on enums, so we recreate the type. -- PostgreSQL does not support DROP VALUE on enums, so we recreate the type.
-- Wrapped in DO block so re-running is safe if already applied.
-- Step 1: Create new consolidated enum DO $$
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points'); BEGIN
-- Only migrate if the old enum values still exist
IF EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'single_elimination_playoff'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'scoring_pattern')
) THEN
-- Step 1: Create new consolidated enum
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points');
-- Step 2: Migrate column data, mapping old values to playoff_bracket -- Step 2: Migrate column data, mapping old values to playoff_bracket
ALTER TABLE "sports_seasons" ALTER TABLE "sports_seasons"
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new" ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
USING ( USING (
CASE scoring_pattern::text CASE scoring_pattern::text
WHEN 'single_elimination_playoff' THEN 'playoff_bracket'::scoring_pattern_new WHEN 'single_elimination_playoff' THEN 'playoff_bracket'::scoring_pattern_new
WHEN 'page_playoff' THEN 'playoff_bracket'::scoring_pattern_new WHEN 'page_playoff' THEN 'playoff_bracket'::scoring_pattern_new
ELSE scoring_pattern::text::scoring_pattern_new ELSE scoring_pattern::text::scoring_pattern_new
END END
); );
-- Step 3: Replace old type -- Step 3: Replace old type
DROP TYPE "scoring_pattern"; DROP TYPE "scoring_pattern";
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern"; ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
END IF;
END $$;

View file

@ -0,0 +1,46 @@
-- Phase 2: EV Simulation Framework
-- Add simulation_status enum + column to sports_seasons
-- Add participant_ev_snapshots and team_ev_snapshots tables
-- Wrapped in IF NOT EXISTS guards so re-running is safe if partially applied.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'simulation_status') THEN
CREATE TYPE "simulation_status" AS ENUM('idle', 'running', 'failed');
END IF;
END $$;
ALTER TABLE "sports_seasons" ADD COLUMN IF NOT EXISTS "simulation_status" "simulation_status" NOT NULL DEFAULT 'idle';
CREATE TABLE IF NOT EXISTS "participant_ev_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"participant_id" uuid NOT NULL REFERENCES "participants"("id") ON DELETE CASCADE,
"sports_season_id" uuid NOT NULL REFERENCES "sports_seasons"("id") ON DELETE CASCADE,
"snapshot_date" date NOT NULL,
"prob_first" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_second" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_third" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_fourth" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_fifth" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_sixth" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_seventh" numeric(5, 4) NOT NULL DEFAULT '0',
"prob_eighth" numeric(5, 4) NOT NULL DEFAULT '0',
"calculated_ev" numeric(10, 2) NOT NULL DEFAULT '0',
"source" varchar(100) NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "participant_ev_snapshots_unique" ON "participant_ev_snapshots" ("participant_id", "sports_season_id", "snapshot_date");
CREATE TABLE IF NOT EXISTS "team_ev_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"team_id" uuid NOT NULL REFERENCES "teams"("id") ON DELETE CASCADE,
"season_id" uuid NOT NULL REFERENCES "seasons"("id") ON DELETE CASCADE,
"snapshot_date" date NOT NULL,
"projected_points" numeric(10, 2) NOT NULL DEFAULT '0',
"actual_points" numeric(10, 2) NOT NULL DEFAULT '0',
"created_at" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "team_ev_snapshots_unique" ON "team_ev_snapshots" ("team_id", "season_id", "snapshot_date");

View file

@ -0,0 +1,24 @@
-- Increase probability column precision from numeric(5,2) to numeric(6,4)
-- numeric(5,2) only stores 2 decimal places (e.g. 0.05), causing EV sums to drift
-- from the expected 340 due to rounding. numeric(6,4) stores 4 decimal places
-- (e.g. 0.0457), sufficient for 10,000-simulation output (min step = 0.0001).
ALTER TABLE "participant_expected_values"
ALTER COLUMN "prob_first" TYPE numeric(6,4) USING prob_first::numeric(6,4),
ALTER COLUMN "prob_second" TYPE numeric(6,4) USING prob_second::numeric(6,4),
ALTER COLUMN "prob_third" TYPE numeric(6,4) USING prob_third::numeric(6,4),
ALTER COLUMN "prob_fourth" TYPE numeric(6,4) USING prob_fourth::numeric(6,4),
ALTER COLUMN "prob_fifth" TYPE numeric(6,4) USING prob_fifth::numeric(6,4),
ALTER COLUMN "prob_sixth" TYPE numeric(6,4) USING prob_sixth::numeric(6,4),
ALTER COLUMN "prob_seventh" TYPE numeric(6,4) USING prob_seventh::numeric(6,4),
ALTER COLUMN "prob_eighth" TYPE numeric(6,4) USING prob_eighth::numeric(6,4);
ALTER TABLE "participant_ev_snapshots"
ALTER COLUMN "prob_first" TYPE numeric(6,4) USING prob_first::numeric(6,4),
ALTER COLUMN "prob_second" TYPE numeric(6,4) USING prob_second::numeric(6,4),
ALTER COLUMN "prob_third" TYPE numeric(6,4) USING prob_third::numeric(6,4),
ALTER COLUMN "prob_fourth" TYPE numeric(6,4) USING prob_fourth::numeric(6,4),
ALTER COLUMN "prob_fifth" TYPE numeric(6,4) USING prob_fifth::numeric(6,4),
ALTER COLUMN "prob_sixth" TYPE numeric(6,4) USING prob_sixth::numeric(6,4),
ALTER COLUMN "prob_seventh" TYPE numeric(6,4) USING prob_seventh::numeric(6,4),
ALTER COLUMN "prob_eighth" TYPE numeric(6,4) USING prob_eighth::numeric(6,4);

View file

@ -0,0 +1,13 @@
-- Increase expected_value precision from numeric(10,2) to numeric(10,4)
-- numeric(10,2) rounds each participant's EV to 2dp; across 20 drivers the
-- accumulated rounding can exceed 0.05, pushing the total above 340.
-- numeric(10,4) limits per-participant error to ±0.00005 (total <0.001).
ALTER TABLE "participants"
ALTER COLUMN "expected_value" TYPE numeric(10,4) USING expected_value::numeric(10,4);
ALTER TABLE "participant_expected_values"
ALTER COLUMN "expected_value" TYPE numeric(10,4) USING expected_value::numeric(10,4);
ALTER TABLE "participant_ev_snapshots"
ALTER COLUMN "calculated_ev" TYPE numeric(10,4) USING calculated_ev::numeric(10,4);

View file

@ -0,0 +1,3 @@
CREATE TYPE "public"."simulator_type" AS ENUM('f1_standings', 'indycar_standings', 'golf_qualifying_points', 'playoff_bracket');
--> statement-breakpoint
ALTER TABLE "sports" ADD COLUMN "simulator_type" "simulator_type";

File diff suppressed because it is too large Load diff

View file

@ -229,16 +229,44 @@
{ {
"idx": 32, "idx": 32,
"version": "7", "version": "7",
"when": 1741305600000, "when": 1763284000000,
"tag": "0032_consolidate_playoff_pattern", "tag": "0032_consolidate_playoff_pattern",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 33, "idx": 33,
"version": "7", "version": "7",
"when": 1741392000000, "when": 1763285000000,
"tag": "0033_add_schedule_event_type", "tag": "0033_add_schedule_event_type",
"breakpoints": true "breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1763286000000,
"tag": "0034_add_ev_snapshots",
"breakpoints": true
},
{
"idx": 35,
"version": "7",
"when": 1763287000000,
"tag": "0035_increase_prob_precision",
"breakpoints": true
},
{
"idx": 36,
"version": "7",
"when": 1763288000000,
"tag": "0036_increase_ev_precision",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1763289000000,
"tag": "0037_add_simulator_type_to_sports",
"breakpoints": true
} }
] ]
} }