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:
parent
e72c33d5b5
commit
c6ba59b0e6
24 changed files with 4745 additions and 865 deletions
256
app/models/ev-snapshot.ts
Normal file
256
app/models/ev-snapshot.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
|
|
@ -103,7 +103,7 @@ export async function upsertParticipantEV(
|
|||
probEighth: probabilities.probEighth.toString(),
|
||||
expectedValue: expectedValue.toString(),
|
||||
source,
|
||||
sourceOdds: sourceOdds ?? null,
|
||||
...(sourceOdds !== undefined ? { sourceOdds } : {}),
|
||||
calculatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
|
@ -227,27 +227,139 @@ export async function deleteParticipantEV(
|
|||
}
|
||||
|
||||
/**
|
||||
* Batch upsert multiple participant EVs
|
||||
* Useful for updating all participants after generating probabilities
|
||||
* Batch upsert multiple participant EVs within a single transaction.
|
||||
* All records succeed or all fail together.
|
||||
*/
|
||||
export async function batchUpsertParticipantEVs(
|
||||
inputs: CreateProbabilityInput[]
|
||||
): Promise<ParticipantEV[]> {
|
||||
const db = database();
|
||||
const results: ParticipantEV[] = [];
|
||||
|
||||
// Process in batches to avoid overwhelming the database
|
||||
const batchSize = 50;
|
||||
for (let i = 0; i < inputs.length; i += batchSize) {
|
||||
const batch = inputs.slice(i, i + batchSize);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((input) => upsertParticipantEV(input))
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
await db.transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
|
||||
for (const input of inputs) {
|
||||
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
||||
|
||||
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
||||
if (sum > 1.01) {
|
||||
throw new Error(
|
||||
`Probabilities for participant ${participantId} cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)}`
|
||||
);
|
||||
}
|
||||
|
||||
const expectedValue = calculateEV(probabilities, scoringRules);
|
||||
|
||||
const existing = await tx
|
||||
.select({ id: participantExpectedValues.id })
|
||||
.from(participantExpectedValues)
|
||||
.where(
|
||||
and(
|
||||
eq(participantExpectedValues.participantId, participantId),
|
||||
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const baseValues = {
|
||||
probFirst: probabilities.probFirst.toString(),
|
||||
probSecond: probabilities.probSecond.toString(),
|
||||
probThird: probabilities.probThird.toString(),
|
||||
probFourth: probabilities.probFourth.toString(),
|
||||
probFifth: probabilities.probFifth.toString(),
|
||||
probSixth: probabilities.probSixth.toString(),
|
||||
probSeventh: probabilities.probSeventh.toString(),
|
||||
probEighth: probabilities.probEighth.toString(),
|
||||
expectedValue: expectedValue.toString(),
|
||||
source,
|
||||
calculatedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
let result: ParticipantEV;
|
||||
|
||||
if (existing.length > 0) {
|
||||
const [updated] = await tx
|
||||
.update(participantExpectedValues)
|
||||
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
|
||||
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
|
||||
.where(eq(participantExpectedValues.id, existing[0].id))
|
||||
.returning();
|
||||
result = updated;
|
||||
} else {
|
||||
const [created] = await tx
|
||||
.insert(participantExpectedValues)
|
||||
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
|
||||
.returning();
|
||||
result = created;
|
||||
}
|
||||
|
||||
await tx
|
||||
.update(participants)
|
||||
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
||||
.where(eq(participants.id, participantId));
|
||||
|
||||
results.push(result);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save American odds for a batch of participants without touching probabilities or EV.
|
||||
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
||||
*/
|
||||
export async function batchSaveSourceOdds(
|
||||
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||
): Promise<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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import * as schema from "~/database/schema";
|
|||
|
||||
export type SportsSeason = typeof schema.sportsSeasons.$inferSelect;
|
||||
export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert;
|
||||
export type SportsSeasonWithSport = SportsSeason & {
|
||||
sport: typeof schema.sports.$inferSelect;
|
||||
};
|
||||
export type SportsSeasonStatus = "upcoming" | "active" | "completed";
|
||||
export type ScoringType = "playoffs" | "regular_season" | "majors";
|
||||
|
||||
|
|
@ -16,14 +19,14 @@ export async function createSportsSeason(data: NewSportsSeason): Promise<SportsS
|
|||
return sportsSeason;
|
||||
}
|
||||
|
||||
export async function findSportsSeasonById(id: string): Promise<SportsSeason | undefined> {
|
||||
export async function findSportsSeasonById(id: string): Promise<SportsSeasonWithSport | undefined> {
|
||||
const db = database();
|
||||
return await db.query.sportsSeasons.findFirst({
|
||||
where: eq(schema.sportsSeasons.id, id),
|
||||
with: {
|
||||
sport: true,
|
||||
},
|
||||
});
|
||||
}) as SportsSeasonWithSport | undefined;
|
||||
}
|
||||
|
||||
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
|
||||
|
|
|
|||
|
|
@ -81,14 +81,14 @@ export default [
|
|||
"sports-seasons/:id/futures-odds",
|
||||
"routes/admin.sports-seasons.$id.futures-odds.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/recalculate-probabilities",
|
||||
"routes/admin.sports-seasons.$id.recalculate-probabilities.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/standings",
|
||||
"routes/admin.sports-seasons.$id.standings.tsx"
|
||||
),
|
||||
route(
|
||||
"sports-seasons/:id/simulate",
|
||||
"routes/admin.sports-seasons.$id.simulate.tsx"
|
||||
),
|
||||
route("participants", "routes/admin.participants.tsx"),
|
||||
route("templates", "routes/admin.templates.tsx"),
|
||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { Form, Link } from "react-router";
|
||||
import { Link } from "react-router";
|
||||
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
|
||||
import { loader, action } from "./admin.sports-seasons.$id.expected-values.server";
|
||||
import { loader } from "./admin.sports-seasons.$id.expected-values.server";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
|
|
@ -18,17 +17,48 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { ArrowLeft, Save, Calculator } from "lucide-react";
|
||||
import { ArrowLeft, Calculator } from "lucide-react";
|
||||
|
||||
export { loader, action };
|
||||
export { loader };
|
||||
|
||||
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const SCORING = [100, 70, 50, 40, 25, 25, 15, 15] as const;
|
||||
|
||||
function evFromProbs(ev: {
|
||||
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||
}): number {
|
||||
return parseFloat(ev.probFirst) * SCORING[0]
|
||||
+ parseFloat(ev.probSecond) * SCORING[1]
|
||||
+ parseFloat(ev.probThird) * SCORING[2]
|
||||
+ parseFloat(ev.probFourth) * SCORING[3]
|
||||
+ parseFloat(ev.probFifth) * SCORING[4]
|
||||
+ parseFloat(ev.probSixth) * SCORING[5]
|
||||
+ parseFloat(ev.probSeventh) * SCORING[6]
|
||||
+ parseFloat(ev.probEighth) * SCORING[7];
|
||||
}
|
||||
|
||||
function fmt(val: string | number) {
|
||||
return (parseFloat(val as string) * 100).toFixed(1) + "%";
|
||||
}
|
||||
|
||||
export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) {
|
||||
const { sportsSeason, participants, existingEVs } = loaderData;
|
||||
|
||||
const totalEV = Array.from(existingEVs.values()).reduce(
|
||||
(sum, ev) => sum + parseFloat(ev.expectedValue),
|
||||
0
|
||||
);
|
||||
// Compute total and sort from stored prob columns, not stored expectedValue.
|
||||
// The simulator normalizes per-position column sums to exactly 1.0 (step 10),
|
||||
// so the total EV should always equal the sum of scoring values (340).
|
||||
// Sum only over participants shown in the table — excludes orphan EV records
|
||||
// from prior simulation runs for participants no longer in this season.
|
||||
const sorted = [...participants].sort((a, b) => {
|
||||
const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0;
|
||||
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0;
|
||||
return evB - evA;
|
||||
});
|
||||
|
||||
const totalEV = sorted.reduce((sum, p) => {
|
||||
const ev = existingEVs.get(p.id);
|
||||
return ev ? sum + evFromProbs(ev) : sum;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<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}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage probability distributions and expected values for participants
|
||||
Probability distributions from the last simulation run. Sorted by EV descending.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{actionData?.error && (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-md text-destructive">
|
||||
{actionData.error}
|
||||
</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.
|
||||
<CardContent>
|
||||
{participants.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-8">
|
||||
No participants found.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form method="post">
|
||||
|
||||
) : 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">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>
|
||||
{participants.map((participant: { id: string; name: string }) => {
|
||||
const existingEV = existingEVs.get(participant.id);
|
||||
const id = participant.id;
|
||||
|
||||
{sorted.map((participant) => {
|
||||
const ev = existingEVs.get(participant.id);
|
||||
return (
|
||||
<TableRow key={id}>
|
||||
<TableRow key={participant.id}>
|
||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
name={`probFirst_${id}`}
|
||||
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="100"
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
|
|
@ -207,20 +132,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
|||
)}
|
||||
</TableBody>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
||||
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
||||
import { findSportsSeasonById } from '~/models/sports-season';
|
||||
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||
import { findParticipantsBySportsSeasonId } from '~/models/participant';
|
||||
import { getAllParticipantEVsForSeason } from '~/models/participant-expected-value';
|
||||
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||
import { calculateEV } from '~/services/ev-calculator';
|
||||
import { Button } from '~/components/ui/button';
|
||||
import { Input } from '~/components/ui/input';
|
||||
import { Label } from '~/components/ui/label';
|
||||
|
|
@ -15,11 +18,6 @@ import {
|
|||
CardTitle,
|
||||
} from '~/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
calculateICMFromOdds,
|
||||
icmResultToArray,
|
||||
} from '~/services/icm-calculator';
|
||||
import { upsertParticipantEV } from '~/models/participant-expected-value';
|
||||
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
|
|
@ -34,15 +32,15 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||
|
||||
// Create map of participant ID to existing odds (if source is futures_odds)
|
||||
// Show any saved odds regardless of what simulation ran afterwards
|
||||
const existingOdds = new Map(
|
||||
existingEVs
|
||||
.filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null)
|
||||
.filter(ev => ev.sourceOdds !== null)
|
||||
.map(ev => [ev.participantId, ev.sourceOdds])
|
||||
);
|
||||
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
||||
sportsSeason,
|
||||
participants,
|
||||
existingOdds,
|
||||
};
|
||||
|
|
@ -51,18 +49,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
interface ActionData {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
preview?: {
|
||||
participantId: string;
|
||||
participantName: string;
|
||||
odds: number;
|
||||
probabilities: number[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export async function action({ request, params }: Route.ActionArgs) {
|
||||
const sportsSeasonId = params.id;
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get('intent') as string;
|
||||
|
||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||
|
||||
|
|
@ -93,85 +84,80 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
return { success: false, message: 'Please enter odds for at least one participant' };
|
||||
}
|
||||
|
||||
if (!sportsSeason.sport?.simulatorType) {
|
||||
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
|
||||
}
|
||||
|
||||
if (sportsSeason.simulationStatus === 'running') {
|
||||
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||||
}
|
||||
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
// Persist the odds first so the simulator can read them
|
||||
await batchSaveSourceOdds(
|
||||
futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }))
|
||||
);
|
||||
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
|
||||
|
||||
try {
|
||||
// Calculate ICM probabilities from odds
|
||||
const icmResults = calculateICMFromOdds(
|
||||
futuresOdds.map(({ participantId, odds }) => ({ participantId, odds }))
|
||||
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
||||
const results = await simulator.simulate(sportsSeasonId);
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error('Simulation returned no results.');
|
||||
}
|
||||
|
||||
await batchUpsertParticipantEVs(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
probabilities: r.probabilities,
|
||||
scoringRules,
|
||||
source: 'elo_simulation' as const,
|
||||
}))
|
||||
);
|
||||
|
||||
if (intent === 'preview') {
|
||||
// Return preview data
|
||||
const preview = futuresOdds.map(({ participantId, odds, name }) => {
|
||||
const icmResult = icmResults.get(participantId)!;
|
||||
return {
|
||||
participantId,
|
||||
participantName: name,
|
||||
odds,
|
||||
probabilities: icmResultToArray(icmResult),
|
||||
};
|
||||
});
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await batchUpsertParticipantEvSnapshots(
|
||||
results.map((r) => ({
|
||||
participantId: r.participantId,
|
||||
sportsSeasonId,
|
||||
snapshotDate: today,
|
||||
probFirst: r.probabilities.probFirst,
|
||||
probSecond: r.probabilities.probSecond,
|
||||
probThird: r.probabilities.probThird,
|
||||
probFourth: r.probabilities.probFourth,
|
||||
probFifth: r.probabilities.probFifth,
|
||||
probSixth: r.probabilities.probSixth,
|
||||
probSeventh: r.probabilities.probSeventh,
|
||||
probEighth: r.probabilities.probEighth,
|
||||
calculatedEV: calculateEV(r.probabilities, scoringRules),
|
||||
source: r.source,
|
||||
}))
|
||||
);
|
||||
|
||||
return { success: true, preview };
|
||||
}
|
||||
|
||||
if (intent === 'save') {
|
||||
// Use default scoring rules (EVs will be recalculated per league)
|
||||
const scoringRules = {
|
||||
pointsFor1st: 100,
|
||||
pointsFor2nd: 70,
|
||||
pointsFor3rd: 50,
|
||||
pointsFor4th: 40,
|
||||
pointsFor5th: 25,
|
||||
pointsFor6th: 25,
|
||||
pointsFor7th: 15,
|
||||
pointsFor8th: 15,
|
||||
};
|
||||
|
||||
// Save probabilities from preview (passed through form)
|
||||
// This ensures saved values match exactly what user saw in preview
|
||||
for (const { participantId, odds } of futuresOdds) {
|
||||
// Read probabilities from form data (preview results)
|
||||
const probFirst = parseFloat(formData.get(`prob_first_${participantId}`) as string) || 0;
|
||||
const probSecond = parseFloat(formData.get(`prob_second_${participantId}`) as string) || 0;
|
||||
const probThird = parseFloat(formData.get(`prob_third_${participantId}`) as string) || 0;
|
||||
const probFourth = parseFloat(formData.get(`prob_fourth_${participantId}`) as string) || 0;
|
||||
const probFifth = parseFloat(formData.get(`prob_fifth_${participantId}`) as string) || 0;
|
||||
const probSixth = parseFloat(formData.get(`prob_sixth_${participantId}`) as string) || 0;
|
||||
const probSeventh = parseFloat(formData.get(`prob_seventh_${participantId}`) as string) || 0;
|
||||
const probEighth = parseFloat(formData.get(`prob_eighth_${participantId}`) as string) || 0;
|
||||
|
||||
// Use upsertParticipantEV (not WithNormalization) because ICM probabilities
|
||||
// are already correct - they sum to <1.0 for teams unlikely to finish top 8
|
||||
await upsertParticipantEV({
|
||||
participantId,
|
||||
sportsSeasonId: sportsSeasonId,
|
||||
probabilities: {
|
||||
probFirst,
|
||||
probSecond,
|
||||
probThird,
|
||||
probFourth,
|
||||
probFifth,
|
||||
probSixth,
|
||||
probSeventh,
|
||||
probEighth,
|
||||
},
|
||||
scoringRules,
|
||||
source: 'futures_odds',
|
||||
sourceOdds: odds,
|
||||
});
|
||||
}
|
||||
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
}
|
||||
|
||||
return { success: false, message: 'Invalid intent' };
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
|
||||
} catch (error) {
|
||||
console.error('Error generating probabilities:', error);
|
||||
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
|
||||
console.error('Error running simulation:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to generate probabilities',
|
||||
message: error instanceof Error ? error.message : 'Simulation failed',
|
||||
};
|
||||
}
|
||||
|
||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||
}
|
||||
|
||||
function normalizeName(name: string): string {
|
||||
|
|
@ -204,18 +190,14 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
|
||||
function findParticipantMatch(inputName: string) {
|
||||
const normalizedInput = normalizeName(inputName);
|
||||
// Pre-compute normalized names once for all three passes
|
||||
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
|
||||
|
||||
// Exact match
|
||||
const exact = normalized.find(({ n }) => n === normalizedInput);
|
||||
if (exact) return exact.p;
|
||||
|
||||
// Contains match (one contains the other)
|
||||
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
|
||||
if (contains) return contains.p;
|
||||
|
||||
// Word-overlap match (at least half the significant words overlap)
|
||||
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
|
||||
const overlap = normalized.find(({ n }) => {
|
||||
const pWords = n.split(' ').filter(w => w.length > 2);
|
||||
|
|
@ -228,7 +210,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
|
||||
function parseBulkText() {
|
||||
const lines = bulkText.split('\n');
|
||||
// Match lines that end with an American-style odds number (+/- then 2–6 digits)
|
||||
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
||||
|
||||
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
|
||||
|
|
@ -270,8 +251,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
}
|
||||
|
||||
const isSubmitting = navigation.state === 'submitting';
|
||||
const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview';
|
||||
const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save';
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
|
|
@ -363,7 +342,7 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
<CardTitle>Championship Futures Odds</CardTitle>
|
||||
<CardDescription>
|
||||
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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -403,18 +382,14 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value="preview"
|
||||
variant="outline"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Generate Preview
|
||||
</Button>
|
||||
</div>
|
||||
{actionData && !actionData.success && actionData.message && (
|
||||
<div className="text-sm text-destructive">{actionData.message}</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isSubmitting ? 'Running...' : 'Run Simulation'}
|
||||
</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -434,118 +409,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
108
app/routes/admin.sports-seasons.$id.simulate.tsx
Normal file
108
app/routes/admin.sports-seasons.$id.simulate.tsx
Normal 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`);
|
||||
}
|
||||
|
|
@ -3,6 +3,10 @@ import type { Route } from "./+types/admin.sports-seasons.$id";
|
|||
import { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||
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 { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
|
|
@ -32,7 +36,7 @@ import {
|
|||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
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";
|
||||
|
||||
export async function loader({ params }: Route.LoaderArgs) {
|
||||
|
|
@ -44,10 +48,26 @@ export async function loader({ params }: Route.LoaderArgs) {
|
|||
|
||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||
|
||||
// Type assertion since we know the sport relation is included
|
||||
return {
|
||||
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
||||
participants
|
||||
// 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;
|
||||
|
||||
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) {
|
||||
const { sportsSeason, participants } = loaderData;
|
||||
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo } = loaderData;
|
||||
const navigate = useNavigate();
|
||||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||||
|
||||
|
|
@ -345,10 +365,18 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
<div>
|
||||
<CardTitle>Expected Values</CardTitle>
|
||||
<CardDescription>
|
||||
Manage probability distributions and projected points
|
||||
{simulatorInfo
|
||||
? simulatorInfo.name
|
||||
: "Manage probability distributions and projected points"}
|
||||
</CardDescription>
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
@ -357,27 +385,39 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
|||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Futures Odds
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/expected-values`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Manual Entry
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/recalculate-probabilities`)}
|
||||
>
|
||||
<Calculator className="mr-2 h-4 w-4" />
|
||||
Recalculate
|
||||
</Button>
|
||||
{simulatorInfo && (
|
||||
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/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">
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
const type = formData.get("type");
|
||||
const slug = formData.get("slug");
|
||||
const description = formData.get("description");
|
||||
const simulatorType = formData.get("simulatorType");
|
||||
const iconFile = formData.get("iconFile");
|
||||
const keepExistingIcon = formData.get("keepExistingIcon");
|
||||
|
||||
|
|
@ -95,6 +96,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
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 {
|
||||
await updateSport(params.id, {
|
||||
name: name.trim(),
|
||||
|
|
@ -102,6 +110,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
|||
slug: slug.trim(),
|
||||
description: typeof description === "string" ? description.trim() : null,
|
||||
iconUrl,
|
||||
simulatorType: parsedSimulatorType,
|
||||
});
|
||||
|
||||
return redirect("/admin/sports");
|
||||
|
|
@ -177,6 +186,25 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
|||
</p>
|
||||
</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">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export function calculateEV(
|
|||
probabilities.probSeventh * scoringRules.pointsFor7th +
|
||||
probabilities.probEighth * scoringRules.pointsFor8th;
|
||||
|
||||
return Math.round(ev * 100) / 100; // Round to 2 decimal places
|
||||
return ev;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
98
app/services/simulations/bracket-simulator.ts
Normal file
98
app/services/simulations/bracket-simulator.ts
Normal 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",
|
||||
}));
|
||||
}
|
||||
}
|
||||
261
app/services/simulations/f1-simulator.ts
Normal file
261
app/services/simulations/f1-simulator.ts
Normal 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 1–10 ──────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
32
app/services/simulations/golf-simulator.ts
Normal file
32
app/services/simulations/golf-simulator.ts
Normal 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 (0–1) 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
58
app/services/simulations/registry.ts
Normal file
58
app/services/simulations/registry.ts
Normal 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;
|
||||
}
|
||||
|
||||
35
app/services/simulations/types.ts
Normal file
35
app/services/simulations/types.ts
Normal 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[]>;
|
||||
}
|
||||
|
|
@ -70,6 +70,19 @@ export const probabilitySourceEnum = pgEnum("probability_source", [
|
|||
"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", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
|
|
@ -229,6 +242,7 @@ export const sports = pgTable("sports", {
|
|||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
|
||||
simulatorType: simulatorTypeEnum("simulator_type"),
|
||||
createdAt: timestamp("created_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
|
||||
eloMinRating: integer("elo_min_rating").default(1250),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -266,7 +282,7 @@ export const participants = pgTable("participants", {
|
|||
name: varchar("name", { length: 255 }).notNull(),
|
||||
shortName: varchar("short_name", { length: 100 }),
|
||||
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(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
|
@ -487,17 +503,17 @@ export const participantExpectedValues = pgTable("participant_expected_values",
|
|||
sportsSeasonId: uuid("sports_season_id")
|
||||
.notNull()
|
||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||
// Probability distribution (stored as percentages)
|
||||
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"), // e.g., 15.50 = 15.5%
|
||||
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"),
|
||||
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
|
||||
probFirst: decimal("prob_first", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probSecond: decimal("prob_second", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probThird: decimal("prob_third", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probFourth: decimal("prob_fourth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probFifth: decimal("prob_fifth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probSixth: decimal("prob_sixth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probSeventh: decimal("prob_seventh", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
probEighth: decimal("prob_eighth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||
// 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
|
||||
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
|
||||
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(),
|
||||
});
|
||||
|
||||
// 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
|
||||
export const sportsRelations = relations(sports, ({ many }) => ({
|
||||
sportsSeasons: many(sportsSeasons),
|
||||
|
|
@ -818,3 +882,25 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
|
|||
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],
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,20 +1,31 @@
|
|||
-- Consolidate single_elimination_playoff and page_playoff → playoff_bracket
|
||||
-- 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
|
||||
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points');
|
||||
DO $$
|
||||
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
|
||||
ALTER TABLE "sports_seasons"
|
||||
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
|
||||
USING (
|
||||
CASE scoring_pattern::text
|
||||
WHEN 'single_elimination_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||
WHEN 'page_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||
ELSE scoring_pattern::text::scoring_pattern_new
|
||||
END
|
||||
);
|
||||
-- Step 2: Migrate column data, mapping old values to playoff_bracket
|
||||
ALTER TABLE "sports_seasons"
|
||||
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
|
||||
USING (
|
||||
CASE scoring_pattern::text
|
||||
WHEN 'single_elimination_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||
WHEN 'page_playoff' THEN 'playoff_bracket'::scoring_pattern_new
|
||||
ELSE scoring_pattern::text::scoring_pattern_new
|
||||
END
|
||||
);
|
||||
|
||||
-- Step 3: Replace old type
|
||||
DROP TYPE "scoring_pattern";
|
||||
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
|
||||
-- Step 3: Replace old type
|
||||
DROP TYPE "scoring_pattern";
|
||||
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
|
||||
END IF;
|
||||
END $$;
|
||||
|
|
|
|||
46
drizzle/0034_add_ev_snapshots.sql
Normal file
46
drizzle/0034_add_ev_snapshots.sql
Normal 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");
|
||||
24
drizzle/0035_increase_prob_precision.sql
Normal file
24
drizzle/0035_increase_prob_precision.sql
Normal 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);
|
||||
13
drizzle/0036_increase_ev_precision.sql
Normal file
13
drizzle/0036_increase_ev_precision.sql
Normal 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);
|
||||
3
drizzle/0037_add_simulator_type_to_sports.sql
Normal file
3
drizzle/0037_add_simulator_type_to_sports.sql
Normal 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";
|
||||
3279
drizzle/meta/0035_snapshot.json
Normal file
3279
drizzle/meta/0035_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -229,16 +229,44 @@
|
|||
{
|
||||
"idx": 32,
|
||||
"version": "7",
|
||||
"when": 1741305600000,
|
||||
"when": 1763284000000,
|
||||
"tag": "0032_consolidate_playoff_pattern",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 33,
|
||||
"version": "7",
|
||||
"when": 1741392000000,
|
||||
"when": 1763285000000,
|
||||
"tag": "0033_add_schedule_event_type",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue