* 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>
140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
import { Link } from "react-router";
|
|
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
|
|
import { loader } from "./admin.sports-seasons.$id.expected-values.server";
|
|
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 { ArrowLeft, Calculator } from "lucide-react";
|
|
|
|
export { loader };
|
|
|
|
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;
|
|
|
|
// Compute total and sort from stored prob columns, not stored expectedValue.
|
|
// The simulator normalizes per-position column sums to exactly 1.0 (step 10),
|
|
// so the total EV should always equal the sum of scoring values (340).
|
|
// Sum only over participants shown in the table — excludes orphan EV records
|
|
// from prior simulation runs for participants no longer in this season.
|
|
const sorted = [...participants].sort((a, b) => {
|
|
const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0;
|
|
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0;
|
|
return evB - evA;
|
|
});
|
|
|
|
const totalEV = sorted.reduce((sum, p) => {
|
|
const ev = existingEVs.get(p.id);
|
|
return ev ? sum + evFromProbs(ev) : sum;
|
|
}, 0);
|
|
|
|
return (
|
|
<div className="container mx-auto p-6 space-y-6">
|
|
<div className="flex items-center gap-4">
|
|
<Link to={`/admin/sports-seasons/${sportsSeason.id}`}>
|
|
<Button variant="ghost" size="sm">
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to Sports Season
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Calculator className="h-5 w-5" />
|
|
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Probability distributions from the last simulation run. Sorted by EV descending.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{participants.length === 0 ? (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No participants found.
|
|
</p>
|
|
) : existingEVs.size === 0 ? (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No EV data yet. Run a simulation from the sports season page.
|
|
</p>
|
|
) : (
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Participant</TableHead>
|
|
<TableHead className="text-center">1st</TableHead>
|
|
<TableHead className="text-center">2nd</TableHead>
|
|
<TableHead className="text-center">3rd</TableHead>
|
|
<TableHead className="text-center">4th</TableHead>
|
|
<TableHead className="text-center">5th</TableHead>
|
|
<TableHead className="text-center">6th</TableHead>
|
|
<TableHead className="text-center">7th</TableHead>
|
|
<TableHead className="text-center">8th</TableHead>
|
|
<TableHead className="text-center">EV</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{sorted.map((participant) => {
|
|
const ev = existingEVs.get(participant.id);
|
|
return (
|
|
<TableRow key={participant.id}>
|
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFirst) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSecond) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probThird) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFourth) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFifth) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSixth) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSeventh) : "—"}</TableCell>
|
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probEighth) : "—"}</TableCell>
|
|
<TableCell className="text-center font-semibold">{ev ? evFromProbs(ev).toFixed(2) : "—"}</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
{existingEVs.size > 0 && (
|
|
<TableRow className="border-t-2 font-bold bg-muted/50">
|
|
<TableCell colSpan={9} className="text-right">Total EV</TableCell>
|
|
<TableCell className="text-center">{totalEV.toFixed(2)}</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|