Make the futures-vs-Elo relationship an explicit, configurable rule and fix futures odds failing to override stored Elo. Root causes addressed: - resolveSourceElos/resolveRatings used a hardcoded precedence (direct Elo -> projectedWins -> projectedTablePoints -> odds). The prior fix nulled sourceElo on futures entry but not projections, which still outranked odds. - The override relied on a destructive null-on-save hack that also wiped manually entered Elo. - Blended simulators buried their Elo/odds weight in module constants. - Futures odds were not surfaced on the /admin/simulators inventory. Changes: - Add a configurable source policy: sourceEloPriority (ordered) and oddsWeight, parsed/clamped in getSimulatorInputPolicy. resolveSourceElos/resolveRatings now resolve each participant by the configured priority instead of a fixed order. Futures-centric simulators (ncaam, ncaaw, world_cup, ncaa_football, college_hockey) default to a futures-override priority. - Drop the destructive nulling in batchSaveFuturesOddsForSimulator and batchSaveSourceOdds; override is now governed by policy, preserving stored Elo. - Thread an optional SimulationContext (oddsWeight) through the Simulator interface so blended sims (UCL, World Cup, NCAA FB, MLB) read the blend weight from the season policy; defaults preserve prior calibration when no context is passed. - Add a "Futures vs. Elo" strategy control and Odds Blend Weight input to the Simulator Setup input-policy card, persisted via save-input-policy. - Surface futures on /admin/simulators: a source badge and a Futures Odds quick link; extend listSportsSeasonSimulatorSummaries with odds source info. - Tests: configurable priority override (Elo/projections/rating), oddsWeight parsing/clamping, prefersFuturesOdds, manifest defaults, an NCAA Football context-blend behavioral test, and an updated non-destructive save test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QhNeB7gN6VKene7sdbBVQT
294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
import { Form, Link, useActionData, useNavigation, useSearchParams } from "react-router";
|
|
import type { Route } from "./+types/admin.simulators";
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
|
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 { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
|
|
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
|
import { Activity, CheckCircle2, Loader2, Play, SlidersHorizontal, XCircle } from "lucide-react";
|
|
|
|
export function meta(): Route.MetaDescriptors {
|
|
return [{ title: "Simulators - Brackt Admin" }];
|
|
}
|
|
|
|
export async function loader() {
|
|
const simulators = (await listSportsSeasonSimulatorSummaries())
|
|
.filter((simulator) => simulator.seasonStatus !== "completed");
|
|
const sports = [...new Set(simulators.map((sim) => sim.sportName))].toSorted();
|
|
const simulatorTypes = [...new Set(simulators.map((sim) => sim.simulatorType))].toSorted();
|
|
return { simulators, sports, simulatorTypes };
|
|
}
|
|
|
|
interface ActionData {
|
|
success?: boolean;
|
|
message: string;
|
|
results?: Array<{ sportsSeasonId: string; ok: boolean; message: string }>;
|
|
}
|
|
|
|
export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
|
|
const formData = await request.formData();
|
|
const intent = formData.get("intent");
|
|
const ids = intent === "run-selected"
|
|
? formData.getAll("sportsSeasonId").filter((id): id is string => typeof id === "string")
|
|
: [formData.get("sportsSeasonId")].filter((id): id is string => typeof id === "string");
|
|
|
|
if (ids.length === 0) {
|
|
return { success: false, message: "Select at least one simulator to run." };
|
|
}
|
|
|
|
const results: ActionData["results"] = [];
|
|
for (const sportsSeasonId of ids) {
|
|
try {
|
|
const result = await runSportsSeasonSimulation(sportsSeasonId);
|
|
results.push({
|
|
sportsSeasonId,
|
|
ok: true,
|
|
message: `Simulated ${result.simulatedParticipants} participant(s).`,
|
|
});
|
|
} catch (error) {
|
|
results.push({
|
|
sportsSeasonId,
|
|
ok: false,
|
|
message: error instanceof Error ? error.message : "Simulation failed.",
|
|
});
|
|
}
|
|
}
|
|
|
|
const succeeded = results.filter((result) => result.ok).length;
|
|
return {
|
|
success: succeeded > 0 && succeeded === results.length,
|
|
message: `Simulation run complete: ${succeeded}/${results.length} succeeded.`,
|
|
results,
|
|
};
|
|
}
|
|
|
|
function statusBadge(status: string) {
|
|
if (status === "ready") return <Badge className="gap-1"><CheckCircle2 className="h-3 w-3" /> Ready</Badge>;
|
|
return <Badge variant="secondary" className="gap-1"><XCircle className="h-3 w-3" /> Needs setup</Badge>;
|
|
}
|
|
|
|
export default function AdminSimulators({ loaderData }: Route.ComponentProps) {
|
|
const { simulators, sports, simulatorTypes } = loaderData;
|
|
const actionData = useActionData<ActionData>();
|
|
const navigation = useNavigation();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const selectedSport = searchParams.get("sport") ?? "all";
|
|
const selectedType = searchParams.get("type") ?? "all";
|
|
const selectedReadiness = searchParams.get("readiness") ?? "all";
|
|
const selectedStatus = searchParams.get("status") ?? "all";
|
|
const isSubmitting = navigation.state === "submitting";
|
|
|
|
const filtered = simulators.filter((sim) => {
|
|
if (selectedSport !== "all" && sim.sportName !== selectedSport) return false;
|
|
if (selectedType !== "all" && sim.simulatorType !== selectedType) return false;
|
|
if (selectedReadiness !== "all" && sim.readiness.status !== selectedReadiness) return false;
|
|
if (selectedStatus !== "all" && sim.seasonStatus !== selectedStatus) return false;
|
|
return true;
|
|
});
|
|
|
|
function updateFilter(key: string, value: string) {
|
|
const next = new URLSearchParams(searchParams);
|
|
if (value === "all") next.delete(key);
|
|
else next.set(key, value);
|
|
setSearchParams(next);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8 space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold flex items-center gap-2">
|
|
<Activity className="h-7 w-7" />
|
|
Simulators
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1">
|
|
Inventory, validate, and run every sports-season EV simulator from one place.
|
|
</p>
|
|
</div>
|
|
|
|
{actionData && (
|
|
<Card className={actionData.success ? "border-emerald-500/40" : "border-amber-500/40"}>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{actionData.message}</CardTitle>
|
|
</CardHeader>
|
|
{actionData.results && (
|
|
<CardContent className="space-y-1 text-sm">
|
|
{actionData.results.map((result) => (
|
|
<div key={result.sportsSeasonId} className={result.ok ? "text-emerald-600" : "text-destructive"}>
|
|
{result.sportsSeasonId}: {result.message}
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Filters</CardTitle>
|
|
<CardDescription>{filtered.length} of {simulators.length} simulator seasons shown</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 md:grid-cols-4">
|
|
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedSport} onChange={(e) => updateFilter("sport", e.target.value)}>
|
|
<option value="all">All sports</option>
|
|
{sports.map((sport) => <option key={sport} value={sport}>{sport}</option>)}
|
|
</select>
|
|
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedType} onChange={(e) => updateFilter("type", e.target.value)}>
|
|
<option value="all">All simulator types</option>
|
|
{simulatorTypes.map((type) => <option key={type} value={type}>{type}</option>)}
|
|
</select>
|
|
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedReadiness} onChange={(e) => updateFilter("readiness", e.target.value)}>
|
|
<option value="all">All readiness</option>
|
|
<option value="ready">Ready</option>
|
|
<option value="not_ready">Needs setup</option>
|
|
</select>
|
|
<select className="h-9 rounded-md border bg-background px-3 text-sm" value={selectedStatus} onChange={(e) => updateFilter("status", e.target.value)}>
|
|
<option value="all">All season statuses</option>
|
|
<option value="upcoming">Upcoming</option>
|
|
<option value="active">Active</option>
|
|
</select>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<CardTitle>Sports-Season Simulators</CardTitle>
|
|
<CardDescription>
|
|
Bulk runs execute sequentially and skip nothing silently: failures are reported per season.
|
|
</CardDescription>
|
|
</div>
|
|
<Form method="post" id="bulk-run-simulators">
|
|
<input type="hidden" name="intent" value="run-selected" />
|
|
<Button type="submit" disabled={isSubmitting}>
|
|
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Play className="mr-2 h-4 w-4" />}
|
|
Run Selected
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-10" />
|
|
<TableHead>Season</TableHead>
|
|
<TableHead>Simulator</TableHead>
|
|
<TableHead>Status</TableHead>
|
|
<TableHead>Readiness</TableHead>
|
|
<TableHead>Inputs</TableHead>
|
|
<TableHead>Last Run</TableHead>
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filtered.map((sim) => {
|
|
const canRun = sim.readiness.canRun && sim.simulationStatus !== "running";
|
|
return (
|
|
<TableRow key={sim.sportsSeasonId}>
|
|
<TableCell>
|
|
<input
|
|
form="bulk-run-simulators"
|
|
type="checkbox"
|
|
name="sportsSeasonId"
|
|
value={sim.sportsSeasonId}
|
|
disabled={!canRun}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="font-medium">{sim.sportName} {sim.year}</div>
|
|
<div className="text-xs text-muted-foreground">{sim.seasonName}</div>
|
|
{sim.leagueName && (
|
|
<div className="text-xs text-muted-foreground">League: {sim.leagueName}</div>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<div>{sim.simulatorName}</div>
|
|
<code className="text-xs text-muted-foreground">{sim.simulatorType}</code>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<Badge variant={sim.seasonStatus === "active" ? "default" : "outline"}>{sim.seasonStatus}</Badge>
|
|
<Badge variant={sim.simulationStatus === "failed" ? "destructive" : "secondary"}>{sim.simulationStatus}</Badge>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
{statusBadge(sim.readiness.status)}
|
|
{sim.readiness.missingInputs.length > 0 && (
|
|
<div className="text-xs text-muted-foreground max-w-64">
|
|
Missing {sim.readiness.missingInputs.join(", ")}
|
|
</div>
|
|
)}
|
|
{sim.readiness.warnings.some((warning) => warning.includes("Derived") || warning.includes("fallback")) && (
|
|
<div className="text-xs text-muted-foreground max-w-64">
|
|
{sim.readiness.warnings
|
|
.filter((warning) => warning.includes("Derived") || warning.includes("fallback"))
|
|
.join(" ")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
<div>{sim.participantInputCount}/{sim.participantCount}</div>
|
|
{sim.supportsFuturesOdds && (
|
|
<Badge variant={sim.prefersFuturesOdds ? "default" : "outline"} className="mt-1 text-xs font-normal">
|
|
{sim.oddsParticipantCount > 0
|
|
? `Futures: ${sim.oddsParticipantCount}${sim.prefersFuturesOdds ? " (overrides Elo)" : " (fallback)"}`
|
|
: "No futures odds"}
|
|
</Badge>
|
|
)}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{sim.lastSimulatedDate ?? "Never"}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/simulator`}>
|
|
<SlidersHorizontal className="mr-2 h-4 w-4" />
|
|
Setup
|
|
</Link>
|
|
</Button>
|
|
{sim.supportsFuturesOdds && (
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/futures-odds`}>Futures</Link>
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" asChild>
|
|
<Link to={`/admin/sports-seasons/${sim.sportsSeasonId}/expected-values`}>EVs</Link>
|
|
</Button>
|
|
<Form method="post">
|
|
<input type="hidden" name="intent" value="run-one" />
|
|
<input type="hidden" name="sportsSeasonId" value={sim.sportsSeasonId} />
|
|
<Button type="submit" size="sm" disabled={!canRun || isSubmitting}>
|
|
Run
|
|
</Button>
|
|
</Form>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|