Clarify simulator blend weights and remove EV card from season detail
Show the futures blend as two synced weights that total 100% (Base Elo / Projections % and Futures %) on the simulator setup page, driven by the single stored oddsWeight. Update the /admin/simulators badge to show the full split (e.g. "70% Elo / 30% Futures") instead of just "30% blend". Remove the redundant "Expected Values" card from admin/sports-seasons/<id>: its setup links and Run Simulation action already live on the Simulator page. Drop the now-unused loader query, simulate action, and imports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017RvJnQPEJNRxGipSad7q2T
This commit is contained in:
parent
120056b0bd
commit
23904c9e84
4 changed files with 66 additions and 147 deletions
|
|
@ -8,7 +8,7 @@ vi.mock("~/services/simulations/runner", () => ({
|
||||||
runSportsSeasonSimulation: vi.fn(),
|
runSportsSeasonSimulation: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { action, loader } from "../admin.simulators";
|
import { action, futuresBlendLabel, loader } from "../admin.simulators";
|
||||||
import { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
|
import { listSportsSeasonSimulatorSummaries } from "~/models/simulator";
|
||||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
||||||
|
|
||||||
|
|
@ -102,6 +102,18 @@ describe("admin simulators loader", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("futuresBlendLabel", () => {
|
||||||
|
it("shows the full Elo/Futures split for an in-between weight", () => {
|
||||||
|
expect(futuresBlendLabel(0.3)).toBe("70% Elo / 30% Futures");
|
||||||
|
expect(futuresBlendLabel(0.5)).toBe("50% Elo / 50% Futures");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels the extremes without percentages", () => {
|
||||||
|
expect(futuresBlendLabel(0)).toBe("Elo only");
|
||||||
|
expect(futuresBlendLabel(1)).toBe("overrides Elo");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("admin simulators action", () => {
|
describe("admin simulators action", () => {
|
||||||
it("runs one simulator", async () => {
|
it("runs one simulator", async () => {
|
||||||
vi.mocked(runSportsSeasonSimulation).mockResolvedValue({
|
vi.mocked(runSportsSeasonSimulation).mockResolvedValue({
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,11 @@ export async function action({ request }: Route.ActionArgs): Promise<ActionData>
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function futuresBlendLabel(oddsWeight: number): string {
|
export function futuresBlendLabel(oddsWeight: number): string {
|
||||||
if (oddsWeight >= 1) return "overrides Elo";
|
if (oddsWeight >= 1) return "overrides Elo";
|
||||||
if (oddsWeight <= 0) return "Elo only";
|
if (oddsWeight <= 0) return "Elo only";
|
||||||
return `${Math.round(oddsWeight * 100)}% blend`;
|
const futuresPct = Math.round(oddsWeight * 100);
|
||||||
|
return `${100 - futuresPct}% Elo / ${futuresPct}% Futures`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(status: string) {
|
function statusBadge(status: string) {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from "react";
|
||||||
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
import { Form, Link, redirect, useActionData, useNavigation } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
import type { Route } from "./+types/admin.sports-seasons.$id.simulator";
|
||||||
|
|
||||||
|
|
@ -261,6 +262,18 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
const showsInputPolicy =
|
const showsInputPolicy =
|
||||||
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
config.profile.requiredInputs.includes("sourceElo") || config.profile.requiredInputs.includes("rating");
|
||||||
|
|
||||||
|
// Two synced blend weights that always total 100%. `oddsWeight` (0–1) stays
|
||||||
|
// the single stored source of truth; we present it (and its complement) as
|
||||||
|
// percentages here. Editing either field updates the other.
|
||||||
|
const [futuresPct, setFuturesPct] = useState(() =>
|
||||||
|
Math.round(Math.min(1, Math.max(0, inputPolicy.oddsWeight)) * 100)
|
||||||
|
);
|
||||||
|
const basePct = 100 - futuresPct;
|
||||||
|
function clampPct(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.min(100, Math.max(0, Math.round(value)));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -367,14 +380,42 @@ export default function AdminSportsSeasonSimulator({ loaderData }: Route.Compone
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
<Form method="post" className="grid gap-4 md:grid-cols-5">
|
||||||
<input type="hidden" name="intent" value="save-input-policy" />
|
<input type="hidden" name="intent" value="save-input-policy" />
|
||||||
|
{/* oddsWeight (0–1) is the stored value; the two percentage fields below drive it. */}
|
||||||
|
<input type="hidden" name="oddsWeight" value={futuresPct / 100} />
|
||||||
<div className="space-y-2 md:col-span-5">
|
<div className="space-y-2 md:col-span-5">
|
||||||
<Label htmlFor="oddsWeight">Futures vs. Elo — Odds Blend Weight (0–1)</Label>
|
<Label>Blend Weights — Base Elo vs. Futures (must total 100%)</Label>
|
||||||
<Input id="oddsWeight" name="oddsWeight" type="number" step="0.05" min="0" max="1" defaultValue={inputPolicy.oddsWeight} />
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="baseEloPct" className="text-xs font-normal text-muted-foreground">Base Elo / Projections weight (%)</Label>
|
||||||
|
<Input
|
||||||
|
id="baseEloPct"
|
||||||
|
type="number"
|
||||||
|
step={5}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={basePct}
|
||||||
|
onChange={(e) => setFuturesPct(100 - clampPct(e.target.valueAsNumber))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="futuresPct" className="text-xs font-normal text-muted-foreground">Futures Odds weight (%)</Label>
|
||||||
|
<Input
|
||||||
|
id="futuresPct"
|
||||||
|
type="number"
|
||||||
|
step={5}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={futuresPct}
|
||||||
|
onChange={(e) => setFuturesPct(clampPct(e.target.valueAsNumber))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
|
Every source (raw Elo, projections, futures odds) becomes an Elo, then they blend into
|
||||||
the single Elo that feeds the simulator. This is the weight given to futures odds:
|
the single Elo that feeds the simulator. The two weights always total 100% (editing one
|
||||||
<strong> 0</strong> = Elo / projections only, <strong>1</strong> = futures fully override,
|
updates the other): <strong>0% futures</strong> = Elo / projections only,
|
||||||
in between = blend (e.g. 0.3 = 70% Elo / 30% futures).
|
<strong> 100% futures</strong> = futures fully override, in between = blend
|
||||||
|
(e.g. 70% Elo / 30% futures).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{config.profile.requiredInputs.includes("sourceElo") && (
|
{config.profile.requiredInputs.includes("sourceElo") && (
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,8 @@ import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason, type NewS
|
||||||
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
import { processSeasonStandings, recalculateStandings } from "~/models/scoring-calculator";
|
||||||
import { createDailySnapshot } from "~/models/standings";
|
import { createDailySnapshot } from "~/models/standings";
|
||||||
import { database } from "~/database/context";
|
import { database } from "~/database/context";
|
||||||
import { participantEvSnapshots, seasonSports } from "~/database/schema";
|
import { seasonSports } from "~/database/schema";
|
||||||
import { eq, desc } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
|
||||||
import { runSportsSeasonSimulation } from "~/services/simulations/runner";
|
|
||||||
import { syncStandings } from "~/services/standings-sync/index";
|
import { syncStandings } from "~/services/standings-sync/index";
|
||||||
import {
|
import {
|
||||||
getPendingStandingsMappings,
|
getPendingStandingsMappings,
|
||||||
|
|
@ -48,7 +46,7 @@ import {
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "~/components/ui/select";
|
||||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
import { Trash2, Users, Trophy, CheckCircle2, AlertTriangle, Loader2, RefreshCw, Copy } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
const SELECT_CLASS = "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
|
||||||
|
|
@ -121,21 +119,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
// Get the most recent snapshot date (if any)
|
|
||||||
const db = database();
|
|
||||||
const lastSnapshot = await db
|
|
||||||
.select({ snapshotDate: participantEvSnapshots.snapshotDate })
|
|
||||||
.from(participantEvSnapshots)
|
|
||||||
.where(eq(participantEvSnapshots.sportsSeasonId, params.id))
|
|
||||||
.orderBy(desc(participantEvSnapshots.snapshotDate))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const lastSimulatedDate = lastSnapshot[0]?.snapshotDate ?? null;
|
|
||||||
|
|
||||||
const simulatorInfo = sportsSeason.sport?.simulatorType
|
|
||||||
? getSimulatorInfo(sportsSeason.sport.simulatorType as SimulatorType)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const lastStandingsSyncedAt =
|
const lastStandingsSyncedAt =
|
||||||
sportsSeason.sport?.type === "team"
|
sportsSeason.sport?.type === "team"
|
||||||
? await getLastSyncedAt(params.id)
|
? await getLastSyncedAt(params.id)
|
||||||
|
|
@ -148,8 +131,6 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
return {
|
return {
|
||||||
sportsSeason,
|
sportsSeason,
|
||||||
participants,
|
participants,
|
||||||
lastSimulatedDate,
|
|
||||||
simulatorInfo,
|
|
||||||
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
lastStandingsSyncedAt: lastStandingsSyncedAt?.toISOString() ?? null,
|
||||||
pendingMappings,
|
pendingMappings,
|
||||||
};
|
};
|
||||||
|
|
@ -293,17 +274,6 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === "simulate") {
|
|
||||||
try {
|
|
||||||
await runSportsSeasonSimulation(params.id);
|
|
||||||
return redirect(`/admin/sports-seasons/${params.id}/expected-values`);
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
simulateError: error instanceof Error ? error.message : "Simulation failed",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
const name = formData.get("name");
|
const name = formData.get("name");
|
||||||
const year = formData.get("year");
|
const year = formData.get("year");
|
||||||
|
|
@ -393,7 +363,7 @@ export async function action(args: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
const { sportsSeason, participants, lastStandingsSyncedAt, pendingMappings } = loaderData;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const isSyncingStandings =
|
const isSyncingStandings =
|
||||||
|
|
@ -649,111 +619,6 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<CardTitle>Expected Values</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{simulatorInfo
|
|
||||||
? simulatorInfo.name
|
|
||||||
: "Manage probability distributions and projected points"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<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
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/simulator`)}
|
|
||||||
>
|
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
|
||||||
Simulator Setup
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/futures-odds`)}
|
|
||||||
>
|
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
|
||||||
Futures Odds
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/elo-ratings`)}
|
|
||||||
>
|
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
|
||||||
Elo Ratings
|
|
||||||
</Button>
|
|
||||||
{sportsSeason.sport?.simulatorType === "tennis_qualifying_points" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/surface-elo`)}
|
|
||||||
>
|
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
|
||||||
Surface Elo
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{sportsSeason.sport?.simulatorType === "golf_qualifying_points" && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/golf-skills`)}
|
|
||||||
>
|
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
|
||||||
Golf Skills
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{simulatorInfo && (
|
|
||||||
<Form method="post">
|
|
||||||
<input type="hidden" name="intent" value="simulate" />
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
disabled={sportsSeason.simulationStatus === "running"}
|
|
||||||
>
|
|
||||||
{sportsSeason.simulationStatus === "running" ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
Running...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Zap className="mr-2 h-4 w-4" />
|
|
||||||
Run Simulation
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{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>
|
|
||||||
{"simulateError" in (actionData ?? {}) && actionData?.simulateError && (
|
|
||||||
<div className="mt-3 bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
||||||
{actionData.simulateError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue