brackt/app/models/participant-expected-value.ts
Chris Parsons c6ba59b0e6
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>
2026-03-09 15:34:31 -07:00

435 lines
13 KiB
TypeScript

/**
* Model for Participant Expected Values
*
* Manages probability distributions and calculated EVs for participants
* in sports seasons.
*/
import { database } from "~/database/context";
import { participantExpectedValues, participants } from "~/database/schema";
import { eq, and, count } from "drizzle-orm";
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
import { calculateEV, normalizeProbabilities } from "~/services/ev-calculator";
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
export interface ParticipantEV {
id: string;
participantId: string;
sportsSeasonId: string;
probFirst: string;
probSecond: string;
probThird: string;
probFourth: string;
probFifth: string;
probSixth: string;
probSeventh: string;
probEighth: string;
expectedValue: string;
source: ProbabilitySource | null;
sourceOdds: number | null;
calculatedAt: Date;
updatedAt: Date;
}
export interface CreateProbabilityInput {
participantId: string;
sportsSeasonId: string;
probabilities: ProbabilityDistribution;
scoringRules: ScoringRules;
source?: ProbabilitySource;
sourceOdds?: number; // American odds if source is futures_odds
}
export interface UpdateProbabilityInput {
probabilities: ProbabilityDistribution;
scoringRules: ScoringRules;
source?: ProbabilitySource;
}
/**
* Create or update participant probabilities and calculate EV
*
* @param input - Probabilities, scoring rules, and metadata
* @returns Created/updated participant EV record
* @throws Error if probabilities don't sum to 1.0 (within tolerance)
*/
export async function upsertParticipantEV(
input: CreateProbabilityInput
): Promise<ParticipantEV> {
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
// Always validate probabilities don't exceed 1.0
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
if (sum > 1.01) {
throw new Error(
`Probabilities cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)`
);
}
// Calculate EV
const expectedValue = calculateEV(probabilities, scoringRules);
const db = database();
// Check if record exists
const existing = await db
.select()
.from(participantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
const now = new Date();
let result: ParticipantEV;
if (existing.length > 0) {
// Update existing
const updated = await db
.update(participantExpectedValues)
.set({
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,
...(sourceOdds !== undefined ? { sourceOdds } : {}),
calculatedAt: now,
updatedAt: now,
})
.where(eq(participantExpectedValues.id, existing[0].id))
.returning();
result = updated[0];
} else {
// Create new
const created = await db
.insert(participantExpectedValues)
.values({
participantId,
sportsSeasonId,
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,
sourceOdds: sourceOdds ?? null,
calculatedAt: now,
updatedAt: now,
})
.returning();
result = created[0];
}
// Sync calculated EV to participants table for draft room ranking
await db
.update(participants)
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
return result;
}
/**
* Create or update with auto-normalization
* Automatically normalizes probabilities if they don't sum to 100%
*/
export async function upsertParticipantEVWithNormalization(
input: CreateProbabilityInput
): Promise<ParticipantEV> {
const normalized = normalizeProbabilities(input.probabilities);
return upsertParticipantEV({
...input,
probabilities: normalized,
});
}
export async function countAllParticipantEVs(): Promise<number> {
const db = database();
const result = await db.select({ value: count() }).from(participantExpectedValues);
return result[0].value;
}
/**
* Get participant EV for a specific sports season
*/
export async function getParticipantEV(
participantId: string,
sportsSeasonId: string
): Promise<ParticipantEV | null> {
const db = database();
const result = await db
.select()
.from(participantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
return result[0] || null;
}
/**
* Get all participant EVs for a sports season
*/
export async function getAllParticipantEVsForSeason(
sportsSeasonId: string
): Promise<ParticipantEV[]> {
const db = database();
return db
.select()
.from(participantExpectedValues)
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
}
/**
* Delete participant EV
*/
export async function deleteParticipantEV(
participantId: string,
sportsSeasonId: string
): Promise<void> {
const db = database();
await db
.delete(participantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
)
);
// Reset participant EV to 0
await db
.update(participants)
.set({ expectedValue: "0", updatedAt: new Date() })
.where(eq(participants.id, participantId));
}
/**
* Batch upsert multiple participant EVs within a single transaction.
* All records succeed or all fail together.
*/
export async function batchUpsertParticipantEVs(
inputs: CreateProbabilityInput[]
): Promise<ParticipantEV[]> {
const db = database();
const results: ParticipantEV[] = [];
await db.transaction(async (tx) => {
const now = new Date();
for (const input of inputs) {
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
const sum = Object.values(probabilities).reduce((a, b) => a + b, 0);
if (sum > 1.01) {
throw new Error(
`Probabilities for participant ${participantId} cannot sum to more than 1.0. Current sum: ${sum.toFixed(4)}`
);
}
const expectedValue = calculateEV(probabilities, scoringRules);
const existing = await tx
.select({ id: participantExpectedValues.id })
.from(participantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
const baseValues = {
probFirst: probabilities.probFirst.toString(),
probSecond: probabilities.probSecond.toString(),
probThird: probabilities.probThird.toString(),
probFourth: probabilities.probFourth.toString(),
probFifth: probabilities.probFifth.toString(),
probSixth: probabilities.probSixth.toString(),
probSeventh: probabilities.probSeventh.toString(),
probEighth: probabilities.probEighth.toString(),
expectedValue: expectedValue.toString(),
source,
calculatedAt: now,
updatedAt: now,
};
let result: ParticipantEV;
if (existing.length > 0) {
const [updated] = await tx
.update(participantExpectedValues)
// Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise
.set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues)
.where(eq(participantExpectedValues.id, existing[0].id))
.returning();
result = updated;
} else {
const [created] = await tx
.insert(participantExpectedValues)
.values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null })
.returning();
result = created;
}
await tx
.update(participants)
.set({ expectedValue: expectedValue.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
results.push(result);
}
});
return results;
}
/**
* Save American odds for a batch of participants without touching probabilities or EV.
* Used by the futures-odds admin page to persist odds before running the full simulation.
*/
export async function batchSaveSourceOdds(
inputs: Array<{ participantId: string; sportsSeasonId: string; sourceOdds: number }>
): Promise<void> {
const db = database();
await db.transaction(async (tx) => {
const now = new Date();
for (const { participantId, sportsSeasonId, sourceOdds } of inputs) {
const existing = await tx
.select({ id: participantExpectedValues.id })
.from(participantExpectedValues)
.where(
and(
eq(participantExpectedValues.participantId, participantId),
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
)
)
.limit(1);
if (existing.length > 0) {
await tx
.update(participantExpectedValues)
.set({ sourceOdds, updatedAt: now })
.where(eq(participantExpectedValues.id, existing[0].id));
} else {
// Insert a stub record — probabilities/EV will be filled in by the simulator
await tx.insert(participantExpectedValues).values({
participantId,
sportsSeasonId,
probFirst: "0",
probSecond: "0",
probThird: "0",
probFourth: "0",
probFifth: "0",
probSixth: "0",
probSeventh: "0",
probEighth: "0",
expectedValue: "0",
source: "futures_odds",
sourceOdds,
calculatedAt: now,
updatedAt: now,
});
}
}
});
}
/**
* Convert database record to ProbabilityDistribution
*/
export function toProbabilityDistribution(ev: ParticipantEV): ProbabilityDistribution {
return {
probFirst: parseFloat(ev.probFirst),
probSecond: parseFloat(ev.probSecond),
probThird: parseFloat(ev.probThird),
probFourth: parseFloat(ev.probFourth),
probFifth: parseFloat(ev.probFifth),
probSixth: parseFloat(ev.probSixth),
probSeventh: parseFloat(ev.probSeventh),
probEighth: parseFloat(ev.probEighth),
};
}
/**
* Recalculate EV for a participant with new scoring rules
* Keeps probabilities the same, only updates EV based on new scoring
*/
export async function recalculateEV(
participantId: string,
sportsSeasonId: string,
newScoringRules: ScoringRules
): Promise<ParticipantEV | null> {
const existing = await getParticipantEV(participantId, sportsSeasonId);
if (!existing) {
return null;
}
const probabilities = toProbabilityDistribution(existing);
const newEV = calculateEV(probabilities, newScoringRules);
const db = database();
const now = new Date();
const updated = await db
.update(participantExpectedValues)
.set({
expectedValue: newEV.toString(),
calculatedAt: now,
updatedAt: now,
})
.where(eq(participantExpectedValues.id, existing.id))
.returning();
// Sync recalculated EV to participants table
await db
.update(participants)
.set({ expectedValue: newEV.toString(), updatedAt: now })
.where(eq(participants.id, participantId));
return updated[0];
}
/**
* Recalculate EVs for all participants in a sports season
* Used when scoring rules change
*/
export async function recalculateAllEVsForSeason(
sportsSeasonId: string,
newScoringRules: ScoringRules
): Promise<number> {
const allEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
await Promise.all(
allEVs.map((ev) =>
recalculateEV(ev.participantId, sportsSeasonId, newScoringRules)
)
);
return allEVs.length;
}