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(),
|
probEighth: probabilities.probEighth.toString(),
|
||||||
expectedValue: expectedValue.toString(),
|
expectedValue: expectedValue.toString(),
|
||||||
source,
|
source,
|
||||||
sourceOdds: sourceOdds ?? null,
|
...(sourceOdds !== undefined ? { sourceOdds } : {}),
|
||||||
calculatedAt: now,
|
calculatedAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
})
|
})
|
||||||
|
|
@ -227,27 +227,139 @@ export async function deleteParticipantEV(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch upsert multiple participant EVs
|
* Batch upsert multiple participant EVs within a single transaction.
|
||||||
* Useful for updating all participants after generating probabilities
|
* All records succeed or all fail together.
|
||||||
*/
|
*/
|
||||||
export async function batchUpsertParticipantEVs(
|
export async function batchUpsertParticipantEVs(
|
||||||
inputs: CreateProbabilityInput[]
|
inputs: CreateProbabilityInput[]
|
||||||
): Promise<ParticipantEV[]> {
|
): Promise<ParticipantEV[]> {
|
||||||
|
const db = database();
|
||||||
const results: ParticipantEV[] = [];
|
const results: ParticipantEV[] = [];
|
||||||
|
|
||||||
// Process in batches to avoid overwhelming the database
|
await db.transaction(async (tx) => {
|
||||||
const batchSize = 50;
|
const now = new Date();
|
||||||
for (let i = 0; i < inputs.length; i += batchSize) {
|
|
||||||
const batch = inputs.slice(i, i + batchSize);
|
for (const input of inputs) {
|
||||||
const batchResults = await Promise.all(
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
||||||
batch.map((input) => upsertParticipantEV(input))
|
|
||||||
|
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
|
||||||
|
if (sum > 1.01) {
|
||||||
|
throw new Error(
|
||||||
|
`Probabilities for participant ${participantId} cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)}`
|
||||||
);
|
);
|
||||||
results.push(...batchResults);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expectedValue = calculateEV(probabilities, scoringRules);
|
||||||
|
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: participantExpectedValues.id })
|
||||||
|
.from(participantExpectedValues)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participantExpectedValues.participantId, participantId),
|
||||||
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const baseValues = {
|
||||||
|
probFirst: probabilities.probFirst.toString(),
|
||||||
|
probSecond: probabilities.probSecond.toString(),
|
||||||
|
probThird: probabilities.probThird.toString(),
|
||||||
|
probFourth: probabilities.probFourth.toString(),
|
||||||
|
probFifth: probabilities.probFifth.toString(),
|
||||||
|
probSixth: probabilities.probSixth.toString(),
|
||||||
|
probSeventh: probabilities.probSeventh.toString(),
|
||||||
|
probEighth: probabilities.probEighth.toString(),
|
||||||
|
expectedValue: expectedValue.toString(),
|
||||||
|
source,
|
||||||
|
calculatedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result: ParticipantEV;
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
const [updated] = await tx
|
||||||
|
.update(participantExpectedValues)
|
||||||
|
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
|
||||||
|
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
|
||||||
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
||||||
|
.returning();
|
||||||
|
result = updated;
|
||||||
|
} else {
|
||||||
|
const [created] = await tx
|
||||||
|
.insert(participantExpectedValues)
|
||||||
|
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
|
||||||
|
.returning();
|
||||||
|
result = created;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx
|
||||||
|
.update(participants)
|
||||||
|
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
|
||||||
|
.where(eq(participants.id, participantId));
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save American odds for a batch of participants without touching probabilities or EV.
|
||||||
|
* Used by the futures-odds admin page to persist odds before running the full simulation.
|
||||||
|
*/
|
||||||
|
export async function batchSaveSourceOdds(
|
||||||
|
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
|
||||||
|
): Promise<void> {
|
||||||
|
const db = database();
|
||||||
|
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
|
||||||
|
const existing = await tx
|
||||||
|
.select({ id: participantExpectedValues.id })
|
||||||
|
.from(participantExpectedValues)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(participantExpectedValues.participantId, participantId),
|
||||||
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await tx
|
||||||
|
.update(participantExpectedValues)
|
||||||
|
.set({ sourceOdds, updatedAt: now })
|
||||||
|
.where(eq(participantExpectedValues.id, existing[0].id));
|
||||||
|
} else {
|
||||||
|
// Insert a stub record — probabilities/EV will be filled in by the simulator
|
||||||
|
await tx.insert(participantExpectedValues).values({
|
||||||
|
participantId,
|
||||||
|
sportsSeasonId,
|
||||||
|
probFirst: "0",
|
||||||
|
probSecond: "0",
|
||||||
|
probThird: "0",
|
||||||
|
probFourth: "0",
|
||||||
|
probFifth: "0",
|
||||||
|
probSixth: "0",
|
||||||
|
probSeventh: "0",
|
||||||
|
probEighth: "0",
|
||||||
|
expectedValue: "0",
|
||||||
|
source: "futures_odds",
|
||||||
|
sourceOdds,
|
||||||
|
calculatedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert database record to ProbabilityDistribution
|
* Convert database record to ProbabilityDistribution
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import * as schema from "~/database/schema";
|
||||||
|
|
||||||
export type SportsSeason = typeof schema.sportsSeasons.$inferSelect;
|
export type SportsSeason = typeof schema.sportsSeasons.$inferSelect;
|
||||||
export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert;
|
export type NewSportsSeason = typeof schema.sportsSeasons.$inferInsert;
|
||||||
|
export type SportsSeasonWithSport = SportsSeason & {
|
||||||
|
sport: typeof schema.sports.$inferSelect;
|
||||||
|
};
|
||||||
export type SportsSeasonStatus = "upcoming" | "active" | "completed";
|
export type SportsSeasonStatus = "upcoming" | "active" | "completed";
|
||||||
export type ScoringType = "playoffs" | "regular_season" | "majors";
|
export type ScoringType = "playoffs" | "regular_season" | "majors";
|
||||||
|
|
||||||
|
|
@ -16,14 +19,14 @@ export async function createSportsSeason(data: NewSportsSeason): Promise<SportsS
|
||||||
return sportsSeason;
|
return sportsSeason;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findSportsSeasonById(id: string): Promise<SportsSeason | undefined> {
|
export async function findSportsSeasonById(id: string): Promise<SportsSeasonWithSport | undefined> {
|
||||||
const db = database();
|
const db = database();
|
||||||
return await db.query.sportsSeasons.findFirst({
|
return await db.query.sportsSeasons.findFirst({
|
||||||
where: eq(schema.sportsSeasons.id, id),
|
where: eq(schema.sportsSeasons.id, id),
|
||||||
with: {
|
with: {
|
||||||
sport: true,
|
sport: true,
|
||||||
},
|
},
|
||||||
});
|
}) as SportsSeasonWithSport | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
|
export async function findSportsSeasonsBySportId(sportId: string): Promise<SportsSeason[]> {
|
||||||
|
|
|
||||||
|
|
@ -81,14 +81,14 @@ export default [
|
||||||
"sports-seasons/:id/futures-odds",
|
"sports-seasons/:id/futures-odds",
|
||||||
"routes/admin.sports-seasons.$id.futures-odds.tsx"
|
"routes/admin.sports-seasons.$id.futures-odds.tsx"
|
||||||
),
|
),
|
||||||
route(
|
|
||||||
"sports-seasons/:id/recalculate-probabilities",
|
|
||||||
"routes/admin.sports-seasons.$id.recalculate-probabilities.tsx"
|
|
||||||
),
|
|
||||||
route(
|
route(
|
||||||
"sports-seasons/:id/standings",
|
"sports-seasons/:id/standings",
|
||||||
"routes/admin.sports-seasons.$id.standings.tsx"
|
"routes/admin.sports-seasons.$id.standings.tsx"
|
||||||
),
|
),
|
||||||
|
route(
|
||||||
|
"sports-seasons/:id/simulate",
|
||||||
|
"routes/admin.sports-seasons.$id.simulate.tsx"
|
||||||
|
),
|
||||||
route("participants", "routes/admin.participants.tsx"),
|
route("participants", "routes/admin.participants.tsx"),
|
||||||
route("templates", "routes/admin.templates.tsx"),
|
route("templates", "routes/admin.templates.tsx"),
|
||||||
route("templates/new", "routes/admin.templates.new.tsx"),
|
route("templates/new", "routes/admin.templates.new.tsx"),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import { Form, Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
|
import type { Route } from "./+types/admin.sports-seasons.$id.expected-values";
|
||||||
import { loader, action } from "./admin.sports-seasons.$id.expected-values.server";
|
import { loader } from "./admin.sports-seasons.$id.expected-values.server";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
|
|
@ -18,17 +17,48 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "~/components/ui/table";
|
} from "~/components/ui/table";
|
||||||
import { ArrowLeft, Save, Calculator } from "lucide-react";
|
import { ArrowLeft, Calculator } from "lucide-react";
|
||||||
|
|
||||||
export { loader, action };
|
export { loader };
|
||||||
|
|
||||||
export default function ExpectedValuesPage({ loaderData, actionData }: Route.ComponentProps) {
|
const SCORING = [100, 70, 50, 40, 25, 25, 15, 15] as const;
|
||||||
|
|
||||||
|
function evFromProbs(ev: {
|
||||||
|
probFirst: string; probSecond: string; probThird: string; probFourth: string;
|
||||||
|
probFifth: string; probSixth: string; probSeventh: string; probEighth: string;
|
||||||
|
}): number {
|
||||||
|
return parseFloat(ev.probFirst) * SCORING[0]
|
||||||
|
+ parseFloat(ev.probSecond) * SCORING[1]
|
||||||
|
+ parseFloat(ev.probThird) * SCORING[2]
|
||||||
|
+ parseFloat(ev.probFourth) * SCORING[3]
|
||||||
|
+ parseFloat(ev.probFifth) * SCORING[4]
|
||||||
|
+ parseFloat(ev.probSixth) * SCORING[5]
|
||||||
|
+ parseFloat(ev.probSeventh) * SCORING[6]
|
||||||
|
+ parseFloat(ev.probEighth) * SCORING[7];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(val: string | number) {
|
||||||
|
return (parseFloat(val as string) * 100).toFixed(1) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExpectedValuesPage({ loaderData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants, existingEVs } = loaderData;
|
const { sportsSeason, participants, existingEVs } = loaderData;
|
||||||
|
|
||||||
const totalEV = Array.from(existingEVs.values()).reduce(
|
// Compute total and sort from stored prob columns, not stored expectedValue.
|
||||||
(sum, ev) => sum + parseFloat(ev.expectedValue),
|
// The simulator normalizes per-position column sums to exactly 1.0 (step 10),
|
||||||
0
|
// so the total EV should always equal the sum of scoring values (340).
|
||||||
);
|
// Sum only over participants shown in the table — excludes orphan EV records
|
||||||
|
// from prior simulation runs for participants no longer in this season.
|
||||||
|
const sorted = [...participants].sort((a, b) => {
|
||||||
|
const evA = existingEVs.has(a.id) ? evFromProbs(existingEVs.get(a.id)!) : 0;
|
||||||
|
const evB = existingEVs.has(b.id) ? evFromProbs(existingEVs.get(b.id)!) : 0;
|
||||||
|
return evB - evA;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEV = sorted.reduce((sum, p) => {
|
||||||
|
const ev = existingEVs.get(p.id);
|
||||||
|
return ev ? sum + evFromProbs(ev) : sum;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
|
@ -48,154 +78,49 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
||||||
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
|
Expected Values: {sportsSeason.sport.name} {sportsSeason.year}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage probability distributions and expected values for participants
|
Probability distributions from the last simulation run. Sorted by EV descending.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent>
|
||||||
{actionData?.error && (
|
{participants.length === 0 ? (
|
||||||
<div className="p-4 bg-destructive/10 border border-destructive/30 rounded-md text-destructive">
|
<p className="text-center text-muted-foreground py-8">
|
||||||
{actionData.error}
|
No participants found.
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{actionData?.success && (
|
|
||||||
<div className="p-4 bg-emerald-500/15 border border-emerald-500/30 rounded-md text-emerald-400">
|
|
||||||
All participants saved successfully!
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-sm text-muted-foreground space-y-2">
|
|
||||||
<p><strong>Default Scoring Rules (for EV calculation):</strong></p>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
<span>1st: 100 pts</span>
|
|
||||||
<span>2nd: 70 pts</span>
|
|
||||||
<span>3rd: 50 pts</span>
|
|
||||||
<span>4th: 40 pts</span>
|
|
||||||
<span>5th: 25 pts</span>
|
|
||||||
<span>6th: 25 pts</span>
|
|
||||||
<span>7th: 15 pts</span>
|
|
||||||
<span>8th: 15 pts</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground italic">
|
|
||||||
Note: EVs will be recalculated with actual league scoring rules when used in fantasy leagues.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
) : existingEVs.size === 0 ? (
|
||||||
|
<p className="text-center text-muted-foreground py-8">
|
||||||
<Form method="post">
|
No EV data yet. Run a simulation from the sports season page.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Participant</TableHead>
|
<TableHead>Participant</TableHead>
|
||||||
<TableHead className="text-center">1st %</TableHead>
|
<TableHead className="text-center">1st</TableHead>
|
||||||
<TableHead className="text-center">2nd %</TableHead>
|
<TableHead className="text-center">2nd</TableHead>
|
||||||
<TableHead className="text-center">3rd %</TableHead>
|
<TableHead className="text-center">3rd</TableHead>
|
||||||
<TableHead className="text-center">4th %</TableHead>
|
<TableHead className="text-center">4th</TableHead>
|
||||||
<TableHead className="text-center">5th %</TableHead>
|
<TableHead className="text-center">5th</TableHead>
|
||||||
<TableHead className="text-center">6th %</TableHead>
|
<TableHead className="text-center">6th</TableHead>
|
||||||
<TableHead className="text-center">7th %</TableHead>
|
<TableHead className="text-center">7th</TableHead>
|
||||||
<TableHead className="text-center">8th %</TableHead>
|
<TableHead className="text-center">8th</TableHead>
|
||||||
<TableHead className="text-center">EV</TableHead>
|
<TableHead className="text-center">EV</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{participants.map((participant: { id: string; name: string }) => {
|
{sorted.map((participant) => {
|
||||||
const existingEV = existingEVs.get(participant.id);
|
const ev = existingEVs.get(participant.id);
|
||||||
const id = participant.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={id}>
|
<TableRow key={participant.id}>
|
||||||
<TableCell className="font-medium">{participant.name}</TableCell>
|
<TableCell className="font-medium">{participant.name}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFirst) : "—"}</TableCell>
|
||||||
<Input
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSecond) : "—"}</TableCell>
|
||||||
type="number"
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probThird) : "—"}</TableCell>
|
||||||
name={`probFirst_${id}`}
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFourth) : "—"}</TableCell>
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFirst) * 100).toFixed(2) : "0"}
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probFifth) : "—"}</TableCell>
|
||||||
step="0.01"
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSixth) : "—"}</TableCell>
|
||||||
min="0"
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probSeventh) : "—"}</TableCell>
|
||||||
max="100"
|
<TableCell className="text-center font-mono text-sm">{ev ? fmt(ev.probEighth) : "—"}</TableCell>
|
||||||
className="w-20 text-center"
|
<TableCell className="text-center font-semibold">{ev ? evFromProbs(ev).toFixed(2) : "—"}</TableCell>
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probSecond_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSecond) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probThird_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probThird) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probFourth_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFourth) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probFifth_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probFifth) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probSixth_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSixth) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probSeventh_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probSeventh) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
name={`probEighth_${id}`}
|
|
||||||
defaultValue={existingEV ? (parseFloat(existingEV.probEighth) * 100).toFixed(2) : "0"}
|
|
||||||
step="0.01"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
className="w-20 text-center"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-center font-semibold">
|
|
||||||
{existingEV ? parseFloat(existingEV.expectedValue).toFixed(2) : "-"}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -207,20 +132,7 @@ export default function ExpectedValuesPage({ loaderData, actionData }: Route.Com
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{participants.length === 0 ? (
|
|
||||||
<p className="text-center text-muted-foreground py-8">
|
|
||||||
No participants found. Please add participants to this sports season first.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex justify-end pt-4">
|
|
||||||
<Button type="submit">
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
Save All
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
import { Form, redirect, useLoaderData, useActionData, useNavigation } from 'react-router';
|
||||||
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
import type { Route } from './+types/admin.sports-seasons.$id.futures-odds';
|
||||||
import { findSportsSeasonById } from '~/models/sports-season';
|
import { findSportsSeasonById, updateSportsSeason } from '~/models/sports-season';
|
||||||
import { findParticipantsBySportsSeasonId } from '~/models/participant';
|
import { findParticipantsBySportsSeasonId } from '~/models/participant';
|
||||||
import { getAllParticipantEVsForSeason } from '~/models/participant-expected-value';
|
import { getAllParticipantEVsForSeason, batchSaveSourceOdds, batchUpsertParticipantEVs } from '~/models/participant-expected-value';
|
||||||
|
import { batchUpsertParticipantEvSnapshots } from '~/models/ev-snapshot';
|
||||||
|
import { getSimulator, type SimulatorType } from '~/services/simulations/registry';
|
||||||
|
import { calculateEV } from '~/services/ev-calculator';
|
||||||
import { Button } from '~/components/ui/button';
|
import { Button } from '~/components/ui/button';
|
||||||
import { Input } from '~/components/ui/input';
|
import { Input } from '~/components/ui/input';
|
||||||
import { Label } from '~/components/ui/label';
|
import { Label } from '~/components/ui/label';
|
||||||
|
|
@ -15,11 +18,6 @@ import {
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '~/components/ui/card';
|
} from '~/components/ui/card';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
|
||||||
calculateICMFromOdds,
|
|
||||||
icmResultToArray,
|
|
||||||
} from '~/services/icm-calculator';
|
|
||||||
import { upsertParticipantEV } from '~/models/participant-expected-value';
|
|
||||||
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
import { Loader2, Info, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
@ -34,15 +32,15 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
const participants = await findParticipantsBySportsSeasonId(sportsSeasonId);
|
||||||
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
||||||
|
|
||||||
// Create map of participant ID to existing odds (if source is futures_odds)
|
// Show any saved odds regardless of what simulation ran afterwards
|
||||||
const existingOdds = new Map(
|
const existingOdds = new Map(
|
||||||
existingEVs
|
existingEVs
|
||||||
.filter(ev => ev.source === 'futures_odds' && ev.sourceOdds !== null)
|
.filter(ev => ev.sourceOdds !== null)
|
||||||
.map(ev => [ev.participantId, ev.sourceOdds])
|
.map(ev => [ev.participantId, ev.sourceOdds])
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
sportsSeason,
|
||||||
participants,
|
participants,
|
||||||
existingOdds,
|
existingOdds,
|
||||||
};
|
};
|
||||||
|
|
@ -51,18 +49,11 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
interface ActionData {
|
interface ActionData {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
preview?: {
|
|
||||||
participantId: string;
|
|
||||||
participantName: string;
|
|
||||||
odds: number;
|
|
||||||
probabilities: number[];
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request, params }: Route.ActionArgs) {
|
export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const sportsSeasonId = params.id;
|
const sportsSeasonId = params.id;
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const intent = formData.get('intent') as string;
|
|
||||||
|
|
||||||
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
const sportsSeason = await findSportsSeasonById(sportsSeasonId);
|
||||||
|
|
||||||
|
|
@ -93,29 +84,14 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
return { success: false, message: 'Please enter odds for at least one participant' };
|
return { success: false, message: 'Please enter odds for at least one participant' };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!sportsSeason.sport?.simulatorType) {
|
||||||
// Calculate ICM probabilities from odds
|
return { success: false, message: 'This sport has no simulator type configured. Set one on the sport in the admin panel.' };
|
||||||
const icmResults = calculateICMFromOdds(
|
}
|
||||||
futuresOdds.map(({ participantId, odds }) => ({ participantId, odds }))
|
|
||||||
);
|
if (sportsSeason.simulationStatus === 'running') {
|
||||||
|
return { success: false, message: 'A simulation is already running. Please wait.' };
|
||||||
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),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true, preview };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (intent === 'save') {
|
|
||||||
// Use default scoring rules (EVs will be recalculated per league)
|
|
||||||
const scoringRules = {
|
const scoringRules = {
|
||||||
pointsFor1st: 100,
|
pointsFor1st: 100,
|
||||||
pointsFor2nd: 70,
|
pointsFor2nd: 70,
|
||||||
|
|
@ -127,51 +103,61 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
pointsFor8th: 15,
|
pointsFor8th: 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save probabilities from preview (passed through form)
|
// Persist the odds first so the simulator can read them
|
||||||
// This ensures saved values match exactly what user saw in preview
|
await batchSaveSourceOdds(
|
||||||
for (const { participantId, odds } of futuresOdds) {
|
futuresOdds.map(({ participantId, odds }) => ({ participantId, sportsSeasonId, sourceOdds: odds }))
|
||||||
// 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
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'running' });
|
||||||
// are already correct - they sum to <1.0 for teams unlikely to finish top 8
|
|
||||||
await upsertParticipantEV({
|
try {
|
||||||
participantId,
|
const simulator = getSimulator(sportsSeason.sport.simulatorType as SimulatorType);
|
||||||
sportsSeasonId: sportsSeasonId,
|
const results = await simulator.simulate(sportsSeasonId);
|
||||||
probabilities: {
|
|
||||||
probFirst,
|
if (results.length === 0) {
|
||||||
probSecond,
|
throw new Error('Simulation returned no results.');
|
||||||
probThird,
|
}
|
||||||
probFourth,
|
|
||||||
probFifth,
|
await batchUpsertParticipantEVs(
|
||||||
probSixth,
|
results.map((r) => ({
|
||||||
probSeventh,
|
participantId: r.participantId,
|
||||||
probEighth,
|
sportsSeasonId,
|
||||||
},
|
probabilities: r.probabilities,
|
||||||
scoringRules,
|
scoringRules,
|
||||||
source: 'futures_odds',
|
source: 'elo_simulation' as const,
|
||||||
sourceOdds: odds,
|
}))
|
||||||
});
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'idle' });
|
||||||
|
} catch (error) {
|
||||||
|
await updateSportsSeason(sportsSeasonId, { simulationStatus: 'failed' });
|
||||||
|
console.error('Error running simulation:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error instanceof Error ? error.message : 'Simulation failed',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
return redirect(`/admin/sports-seasons/${sportsSeasonId}/expected-values`);
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, message: 'Invalid intent' };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating probabilities:', error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Failed to generate probabilities',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeName(name: string): string {
|
function normalizeName(name: string): string {
|
||||||
|
|
@ -204,18 +190,14 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
|
|
||||||
function findParticipantMatch(inputName: string) {
|
function findParticipantMatch(inputName: string) {
|
||||||
const normalizedInput = normalizeName(inputName);
|
const normalizedInput = normalizeName(inputName);
|
||||||
// Pre-compute normalized names once for all three passes
|
|
||||||
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
|
const normalized = participants.map(p => ({ p, n: normalizeName(p.name) }));
|
||||||
|
|
||||||
// Exact match
|
|
||||||
const exact = normalized.find(({ n }) => n === normalizedInput);
|
const exact = normalized.find(({ n }) => n === normalizedInput);
|
||||||
if (exact) return exact.p;
|
if (exact) return exact.p;
|
||||||
|
|
||||||
// Contains match (one contains the other)
|
|
||||||
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
|
const contains = normalized.find(({ n }) => n.includes(normalizedInput) || normalizedInput.includes(n));
|
||||||
if (contains) return contains.p;
|
if (contains) return contains.p;
|
||||||
|
|
||||||
// Word-overlap match (at least half the significant words overlap)
|
|
||||||
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
|
const inputWords = normalizedInput.split(' ').filter(w => w.length > 2);
|
||||||
const overlap = normalized.find(({ n }) => {
|
const overlap = normalized.find(({ n }) => {
|
||||||
const pWords = n.split(' ').filter(w => w.length > 2);
|
const pWords = n.split(' ').filter(w => w.length > 2);
|
||||||
|
|
@ -228,7 +210,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
|
|
||||||
function parseBulkText() {
|
function parseBulkText() {
|
||||||
const lines = bulkText.split('\n');
|
const lines = bulkText.split('\n');
|
||||||
// Match lines that end with an American-style odds number (+/- then 2–6 digits)
|
|
||||||
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
const oddsPattern = /^(.+?)\s+([+-]\d{2,6})\s*$/;
|
||||||
|
|
||||||
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
|
const matched: Array<{ participantId: string; name: string; odds: number; inputName: string }> = [];
|
||||||
|
|
@ -270,8 +251,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSubmitting = navigation.state === 'submitting';
|
const isSubmitting = navigation.state === 'submitting';
|
||||||
const isGenerating = isSubmitting && navigation.formData?.get('intent') === 'preview';
|
|
||||||
const isSaving = isSubmitting && navigation.formData?.get('intent') === 'save';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto py-8">
|
<div className="container mx-auto py-8">
|
||||||
|
|
@ -363,7 +342,7 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
<CardTitle>Championship Futures Odds</CardTitle>
|
<CardTitle>Championship Futures Odds</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter American odds (e.g., +550, -200) for each participant's championship probability.
|
Enter American odds (e.g., +550, -200) for each participant's championship probability.
|
||||||
Generates probabilities for all participants using ICM (Independent Chip Model).
|
Enter American odds, then run the ICM simulation to compute and save probability distributions.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -403,18 +382,14 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
{actionData && !actionData.success && actionData.message && (
|
||||||
<Button
|
<div className="text-sm text-destructive">{actionData.message}</div>
|
||||||
type="submit"
|
)}
|
||||||
name="intent"
|
|
||||||
value="preview"
|
<Button type="submit" disabled={isSubmitting}>
|
||||||
variant="outline"
|
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
disabled={isSubmitting}
|
{isSubmitting ? 'Running...' : 'Run Simulation'}
|
||||||
>
|
|
||||||
{isGenerating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Generate Preview
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -434,118 +409,6 @@ export default function AdminSportsSeasonFuturesOdds() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
{actionData?.preview && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>ICM Results</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Probability distributions calculated using Independent Chip Model
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b">
|
|
||||||
<th className="text-left py-2">Team</th>
|
|
||||||
<th className="text-right py-2">Odds</th>
|
|
||||||
<th className="text-right py-2">P(1st)</th>
|
|
||||||
<th className="text-right py-2">P(2nd)</th>
|
|
||||||
<th className="text-right py-2">P(3rd)</th>
|
|
||||||
<th className="text-right py-2">P(4th)</th>
|
|
||||||
<th className="text-right py-2">P(5th)</th>
|
|
||||||
<th className="text-right py-2">P(6th)</th>
|
|
||||||
<th className="text-right py-2">P(7th)</th>
|
|
||||||
<th className="text-right py-2">P(8th)</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{actionData.preview
|
|
||||||
.sort((a, b) => b.probabilities[0] - a.probabilities[0])
|
|
||||||
.map((result) => (
|
|
||||||
<tr key={result.participantId} className="border-b">
|
|
||||||
<td className="py-2">{result.participantName}</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{result.odds > 0 ? '+' : ''}
|
|
||||||
{result.odds}
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[0] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[1] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[2] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[3] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[4] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[5] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[6] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
<td className="text-right">
|
|
||||||
{(result.probabilities[7] * 100).toFixed(1)}%
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form method="post">
|
|
||||||
{/* Pass through odds and preview probabilities */}
|
|
||||||
{actionData.preview.map((result) => (
|
|
||||||
<div key={result.participantId}>
|
|
||||||
<input type="hidden" name={`odds_${result.participantId}`} value={result.odds} />
|
|
||||||
<input type="hidden" name={`prob_first_${result.participantId}`} value={result.probabilities[0]} />
|
|
||||||
<input type="hidden" name={`prob_second_${result.participantId}`} value={result.probabilities[1]} />
|
|
||||||
<input type="hidden" name={`prob_third_${result.participantId}`} value={result.probabilities[2]} />
|
|
||||||
<input type="hidden" name={`prob_fourth_${result.participantId}`} value={result.probabilities[3]} />
|
|
||||||
<input type="hidden" name={`prob_fifth_${result.participantId}`} value={result.probabilities[4]} />
|
|
||||||
<input type="hidden" name={`prob_sixth_${result.participantId}`} value={result.probabilities[5]} />
|
|
||||||
<input type="hidden" name={`prob_seventh_${result.participantId}`} value={result.probabilities[6]} />
|
|
||||||
<input type="hidden" name={`prob_eighth_${result.participantId}`} value={result.probabilities[7]} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Button type="submit" name="intent" value="save" className="w-full" disabled={isSaving}>
|
|
||||||
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
{isSaving ? 'Saving...' : 'Save Probabilities'}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionData && !actionData.success && actionData.message && (
|
|
||||||
<Card className="border-destructive">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-destructive font-medium">Error</div>
|
|
||||||
<div className="text-sm mt-2">{actionData.message}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{actionData && actionData.success && !actionData.preview && (
|
|
||||||
<Card className="border-emerald-500/30 bg-emerald-500/10">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="text-emerald-400 font-medium">Success</div>
|
|
||||||
<div className="text-sm mt-2">{actionData.message}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 { findSportsSeasonById, updateSportsSeason, deleteSportsSeason } from "~/models/sports-season";
|
||||||
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
import { findParticipantsBySportsSeasonId } from "~/models/participant";
|
||||||
import { processSeasonStandings } from "~/models/scoring-calculator";
|
import { processSeasonStandings } from "~/models/scoring-calculator";
|
||||||
|
import { database } from "~/database/context";
|
||||||
|
import { participantEvSnapshots } from "~/database/schema";
|
||||||
|
import { eq, desc } from "drizzle-orm";
|
||||||
|
import { getSimulatorInfo, type SimulatorType } from "~/services/simulations/registry";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
|
|
@ -32,7 +36,7 @@ import {
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "~/components/ui/alert-dialog";
|
} from "~/components/ui/alert-dialog";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { Trash2, Users, Trophy, Calculator, CheckCircle2 } from "lucide-react";
|
import { Trash2, Users, Trophy, Calculator, CheckCircle2, Zap, AlertTriangle, Loader2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export async function loader({ params }: Route.LoaderArgs) {
|
export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
@ -44,10 +48,26 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||||
|
|
||||||
const participants = await findParticipantsBySportsSeasonId(params.id);
|
const participants = await findParticipantsBySportsSeasonId(params.id);
|
||||||
|
|
||||||
// Type assertion since we know the sport relation is included
|
// Get the most recent snapshot date (if any)
|
||||||
|
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 {
|
return {
|
||||||
sportsSeason: sportsSeason as typeof sportsSeason & { sport: { id: string; name: string; type: string; slug: string } },
|
sportsSeason,
|
||||||
participants
|
participants,
|
||||||
|
lastSimulatedDate,
|
||||||
|
simulatorInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,7 +159,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
export default function EditSportsSeason({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
const { sportsSeason, participants } = loaderData;
|
const { sportsSeason, participants, lastSimulatedDate, simulatorInfo } = loaderData;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
const [scoringPattern, setScoringPattern] = useState<string>(sportsSeason.scoringPattern || "");
|
||||||
|
|
||||||
|
|
@ -345,10 +365,18 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>Expected Values</CardTitle>
|
<CardTitle>Expected Values</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Manage probability distributions and projected points
|
{simulatorInfo
|
||||||
|
? simulatorInfo.name
|
||||||
|
: "Manage probability distributions and projected points"}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{sportsSeason.simulationStatus === "failed" && (
|
||||||
|
<Badge variant="outline" className="bg-destructive/15 text-destructive border-destructive/30">
|
||||||
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||||
|
Last run failed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -357,27 +385,39 @@ export default function EditSportsSeason({ loaderData, actionData }: Route.Compo
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
<Calculator className="mr-2 h-4 w-4" />
|
||||||
Futures Odds
|
Futures Odds
|
||||||
</Button>
|
</Button>
|
||||||
|
{simulatorInfo && (
|
||||||
|
<Form method="post" action={`/admin/sports-seasons/${sportsSeason.id}/simulate`}>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
disabled={sportsSeason.simulationStatus === "running"}
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/expected-values`)}
|
|
||||||
>
|
>
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
{sportsSeason.simulationStatus === "running" ? (
|
||||||
Manual Entry
|
<>
|
||||||
</Button>
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
<Button
|
Running...
|
||||||
size="sm"
|
</>
|
||||||
onClick={() => navigate(`/admin/sports-seasons/${sportsSeason.id}/recalculate-probabilities`)}
|
) : (
|
||||||
>
|
<>
|
||||||
<Calculator className="mr-2 h-4 w-4" />
|
<Zap className="mr-2 h-4 w-4" />
|
||||||
Recalculate
|
Run Simulation
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Generate probabilities from betting odds (Futures Odds), manually enter them (Manual Entry), or recalculate based on results (Recalculate).
|
{simulatorInfo
|
||||||
|
? <>
|
||||||
|
{simulatorInfo.description}.{" "}
|
||||||
|
{lastSimulatedDate ? `Last simulated: ${lastSimulatedDate}.` : "No simulation has been run yet."}
|
||||||
|
{" "}Import futures odds first, then run the simulation to update EVs and save a snapshot.
|
||||||
|
</>
|
||||||
|
: "Import futures odds to set probability distributions."}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
const type = formData.get("type");
|
const type = formData.get("type");
|
||||||
const slug = formData.get("slug");
|
const slug = formData.get("slug");
|
||||||
const description = formData.get("description");
|
const description = formData.get("description");
|
||||||
|
const simulatorType = formData.get("simulatorType");
|
||||||
const iconFile = formData.get("iconFile");
|
const iconFile = formData.get("iconFile");
|
||||||
const keepExistingIcon = formData.get("keepExistingIcon");
|
const keepExistingIcon = formData.get("keepExistingIcon");
|
||||||
|
|
||||||
|
|
@ -95,6 +96,13 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
iconUrl = null;
|
iconUrl = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validSimulatorTypes = ["f1_standings", "indycar_standings", "golf_qualifying_points", "playoff_bracket"] as const;
|
||||||
|
type ValidSimulatorType = typeof validSimulatorTypes[number];
|
||||||
|
const parsedSimulatorType: ValidSimulatorType | null =
|
||||||
|
typeof simulatorType === "string" && validSimulatorTypes.includes(simulatorType as ValidSimulatorType)
|
||||||
|
? (simulatorType as ValidSimulatorType)
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateSport(params.id, {
|
await updateSport(params.id, {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
|
|
@ -102,6 +110,7 @@ export async function action({ request, params }: Route.ActionArgs) {
|
||||||
slug: slug.trim(),
|
slug: slug.trim(),
|
||||||
description: typeof description === "string" ? description.trim() : null,
|
description: typeof description === "string" ? description.trim() : null,
|
||||||
iconUrl,
|
iconUrl,
|
||||||
|
simulatorType: parsedSimulatorType,
|
||||||
});
|
});
|
||||||
|
|
||||||
return redirect("/admin/sports");
|
return redirect("/admin/sports");
|
||||||
|
|
@ -177,6 +186,25 @@ export default function EditSport({ loaderData, actionData }: Route.ComponentPro
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="simulatorType">Simulator Type (Optional)</Label>
|
||||||
|
<Select name="simulatorType" defaultValue={sport.simulatorType ?? "none"}>
|
||||||
|
<SelectTrigger id="simulatorType">
|
||||||
|
<SelectValue placeholder="No simulator" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No simulator</SelectItem>
|
||||||
|
<SelectItem value="f1_standings">F1 Standings Model</SelectItem>
|
||||||
|
<SelectItem value="indycar_standings">IndyCar Standings Model</SelectItem>
|
||||||
|
<SelectItem value="golf_qualifying_points">Golf Qualifying Points Model</SelectItem>
|
||||||
|
<SelectItem value="playoff_bracket">Bracket Monte Carlo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Algorithm used when running EV simulations for this sport
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">Description (Optional)</Label>
|
<Label htmlFor="description">Description (Optional)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ export function calculateEV(
|
||||||
probabilities.probSeventh * scoringRules.pointsFor7th +
|
probabilities.probSeventh * scoringRules.pointsFor7th +
|
||||||
probabilities.probEighth * scoringRules.pointsFor8th;
|
probabilities.probEighth * scoringRules.pointsFor8th;
|
||||||
|
|
||||||
return Math.round(ev * 100) / 100; // Round to 2 decimal places
|
return ev;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
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",
|
"performance_model",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const simulationStatusEnum = pgEnum("simulation_status", [
|
||||||
|
"idle",
|
||||||
|
"running",
|
||||||
|
"failed",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const simulatorTypeEnum = pgEnum("simulator_type", [
|
||||||
|
"f1_standings",
|
||||||
|
"indycar_standings",
|
||||||
|
"golf_qualifying_points",
|
||||||
|
"playoff_bracket",
|
||||||
|
]);
|
||||||
|
|
||||||
export const leagues = pgTable("leagues", {
|
export const leagues = pgTable("leagues", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
|
|
@ -229,6 +242,7 @@ export const sports = pgTable("sports", {
|
||||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
|
iconUrl: varchar("icon_url", { length: 255 }), // Filename of icon in /public/sports-icons/
|
||||||
|
simulatorType: simulatorTypeEnum("simulator_type"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -254,6 +268,8 @@ export const sportsSeasons = pgTable("sports_seasons", {
|
||||||
eloCalibrationExponent: decimal("elo_calibration_exponent", { precision: 3, scale: 2 }), // e.g., 0.33
|
eloCalibrationExponent: decimal("elo_calibration_exponent", { precision: 3, scale: 2 }), // e.g., 0.33
|
||||||
eloMinRating: integer("elo_min_rating").default(1250),
|
eloMinRating: integer("elo_min_rating").default(1250),
|
||||||
eloMaxRating: integer("elo_max_rating").default(1750),
|
eloMaxRating: integer("elo_max_rating").default(1750),
|
||||||
|
// Simulation status (prevents concurrent simulation runs)
|
||||||
|
simulationStatus: simulationStatusEnum("simulation_status").notNull().default("idle"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -266,7 +282,7 @@ export const participants = pgTable("participants", {
|
||||||
name: varchar("name", { length: 255 }).notNull(),
|
name: varchar("name", { length: 255 }).notNull(),
|
||||||
shortName: varchar("short_name", { length: 100 }),
|
shortName: varchar("short_name", { length: 100 }),
|
||||||
externalId: varchar("external_id", { length: 255 }),
|
externalId: varchar("external_id", { length: 255 }),
|
||||||
expectedValue: decimal("expected_value", { precision: 10, scale: 2 }).notNull().default("0"),
|
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
@ -487,17 +503,17 @@ export const participantExpectedValues = pgTable("participant_expected_values",
|
||||||
sportsSeasonId: uuid("sports_season_id")
|
sportsSeasonId: uuid("sports_season_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||||
// Probability distribution (stored as percentages)
|
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
|
||||||
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"), // e.g., 15.50 = 15.5%
|
probFirst: decimal("prob_first", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probSecond: decimal("prob_second", { precision: 5, scale: 2 }).notNull().default("0"),
|
probSecond: decimal("prob_second", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probThird: decimal("prob_third", { precision: 5, scale: 2 }).notNull().default("0"),
|
probThird: decimal("prob_third", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probFourth: decimal("prob_fourth", { precision: 5, scale: 2 }).notNull().default("0"),
|
probFourth: decimal("prob_fourth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probFifth: decimal("prob_fifth", { precision: 5, scale: 2 }).notNull().default("0"),
|
probFifth: decimal("prob_fifth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probSixth: decimal("prob_sixth", { precision: 5, scale: 2 }).notNull().default("0"),
|
probSixth: decimal("prob_sixth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probSeventh: decimal("prob_seventh", { precision: 5, scale: 2 }).notNull().default("0"),
|
probSeventh: decimal("prob_seventh", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
probEighth: decimal("prob_eighth", { precision: 5, scale: 2 }).notNull().default("0"),
|
probEighth: decimal("prob_eighth", { precision: 6, scale: 4 }).notNull().default("0"),
|
||||||
// Calculated EV
|
// Calculated EV
|
||||||
expectedValue: decimal("expected_value", { precision: 10, scale: 2 }).notNull().default("0"),
|
expectedValue: decimal("expected_value", { precision: 10, scale: 4 }).notNull().default("0"),
|
||||||
// Metadata
|
// Metadata
|
||||||
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
|
source: probabilitySourceEnum("source").default("manual"), // How probabilities were generated
|
||||||
sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format)
|
sourceOdds: integer("source_odds"), // Original odds if source is futures_odds (American odds format)
|
||||||
|
|
@ -542,6 +558,54 @@ export const participantSeasonResults = pgTable("participant_season_results", {
|
||||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// EV snapshots — historical record of participant EV over time (one per participant per sport season per day)
|
||||||
|
export const participantEvSnapshots = pgTable("participant_ev_snapshots", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
participantId: uuid("participant_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => participants.id, { onDelete: "cascade" }),
|
||||||
|
sportsSeasonId: uuid("sports_season_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => sportsSeasons.id, { onDelete: "cascade" }),
|
||||||
|
snapshotDate: date("snapshot_date").notNull(),
|
||||||
|
// Probability distribution (stored as fractions, e.g. 0.1550 = 15.5%)
|
||||||
|
probFirst: decimal("prob_first", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probSecond: decimal("prob_second", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probThird: decimal("prob_third", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probFourth: decimal("prob_fourth", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probFifth: decimal("prob_fifth", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probSixth: decimal("prob_sixth", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probSeventh: decimal("prob_seventh", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
probEighth: decimal("prob_eighth", { precision: 5, scale: 2 }).notNull().default("0"),
|
||||||
|
calculatedEV: decimal("calculated_ev", { precision: 10, scale: 4 }).notNull().default("0"),
|
||||||
|
source: varchar("source", { length: 100 }).notNull(), // e.g. 'bracket_monte_carlo', 'f1_standings_model'
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
uniqueParticipantSeasonDate: uniqueIndex("participant_ev_snapshots_unique").on(
|
||||||
|
t.participantId, t.sportsSeasonId, t.snapshotDate
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Team EV snapshots — historical projected points per team per fantasy season per day
|
||||||
|
export const teamEvSnapshots = pgTable("team_ev_snapshots", {
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
teamId: uuid("team_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => teams.id, { onDelete: "cascade" }),
|
||||||
|
seasonId: uuid("season_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => seasons.id, { onDelete: "cascade" }),
|
||||||
|
snapshotDate: date("snapshot_date").notNull(),
|
||||||
|
projectedPoints: decimal("projected_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
||||||
|
actualPoints: decimal("actual_points", { precision: 10, scale: 2 }).notNull().default("0"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
}, (t) => ({
|
||||||
|
uniqueTeamSeasonDate: uniqueIndex("team_ev_snapshots_unique").on(
|
||||||
|
t.teamId, t.seasonId, t.snapshotDate
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
export const sportsRelations = relations(sports, ({ many }) => ({
|
export const sportsRelations = relations(sports, ({ many }) => ({
|
||||||
sportsSeasons: many(sportsSeasons),
|
sportsSeasons: many(sportsSeasons),
|
||||||
|
|
@ -818,3 +882,25 @@ export const tournamentGroupMembersRelations = relations(tournamentGroupMembers,
|
||||||
references: [participants.id],
|
references: [participants.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const participantEvSnapshotsRelations = relations(participantEvSnapshots, ({ one }) => ({
|
||||||
|
participant: one(participants, {
|
||||||
|
fields: [participantEvSnapshots.participantId],
|
||||||
|
references: [participants.id],
|
||||||
|
}),
|
||||||
|
sportsSeason: one(sportsSeasons, {
|
||||||
|
fields: [participantEvSnapshots.sportsSeasonId],
|
||||||
|
references: [sportsSeasons.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const teamEvSnapshotsRelations = relations(teamEvSnapshots, ({ one }) => ({
|
||||||
|
team: one(teams, {
|
||||||
|
fields: [teamEvSnapshots.teamId],
|
||||||
|
references: [teams.id],
|
||||||
|
}),
|
||||||
|
season: one(seasons, {
|
||||||
|
fields: [teamEvSnapshots.seasonId],
|
||||||
|
references: [seasons.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
-- Consolidate single_elimination_playoff and page_playoff → playoff_bracket
|
-- Consolidate single_elimination_playoff and page_playoff → playoff_bracket
|
||||||
-- PostgreSQL does not support DROP VALUE on enums, so we recreate the type.
|
-- PostgreSQL does not support DROP VALUE on enums, so we recreate the type.
|
||||||
|
-- Wrapped in DO block so re-running is safe if already applied.
|
||||||
|
|
||||||
-- Step 1: Create new consolidated enum
|
DO $$
|
||||||
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points');
|
BEGIN
|
||||||
|
-- Only migrate if the old enum values still exist
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum
|
||||||
|
WHERE enumlabel = 'single_elimination_playoff'
|
||||||
|
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'scoring_pattern')
|
||||||
|
) THEN
|
||||||
|
-- Step 1: Create new consolidated enum
|
||||||
|
CREATE TYPE "scoring_pattern_new" AS ENUM('playoff_bracket', 'season_standings', 'qualifying_points');
|
||||||
|
|
||||||
-- Step 2: Migrate column data, mapping old values to playoff_bracket
|
-- Step 2: Migrate column data, mapping old values to playoff_bracket
|
||||||
ALTER TABLE "sports_seasons"
|
ALTER TABLE "sports_seasons"
|
||||||
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
|
ALTER COLUMN "scoring_pattern" TYPE "scoring_pattern_new"
|
||||||
USING (
|
USING (
|
||||||
CASE scoring_pattern::text
|
CASE scoring_pattern::text
|
||||||
|
|
@ -15,6 +24,8 @@ ALTER TABLE "sports_seasons"
|
||||||
END
|
END
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Step 3: Replace old type
|
-- Step 3: Replace old type
|
||||||
DROP TYPE "scoring_pattern";
|
DROP TYPE "scoring_pattern";
|
||||||
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
|
ALTER TYPE "scoring_pattern_new" RENAME TO "scoring_pattern";
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
|
||||||
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,
|
"idx": 32,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1741305600000,
|
"when": 1763284000000,
|
||||||
"tag": "0032_consolidate_playoff_pattern",
|
"tag": "0032_consolidate_playoff_pattern",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"idx": 33,
|
"idx": 33,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1741392000000,
|
"when": 1763285000000,
|
||||||
"tag": "0033_add_schedule_event_type",
|
"tag": "0033_add_schedule_event_type",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763286000000,
|
||||||
|
"tag": "0034_add_ev_snapshots",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 35,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763287000000,
|
||||||
|
"tag": "0035_increase_prob_precision",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763288000000,
|
||||||
|
"tag": "0036_increase_ev_precision",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 37,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1763289000000,
|
||||||
|
"tag": "0037_add_simulator_type_to_sports",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue