/** * 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 { 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 { const normalized = normalizeProbabilities(input.probabilities); return upsertParticipantEV({ ...input, probabilities: normalized, }); } export async function countAllParticipantEVs(): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { const allEVs = await getAllParticipantEVsForSeason(sportsSeasonId); await Promise.all( allEVs.map((ev) => recalculateEV(ev.participantId, sportsSeasonId, newScoringRules) ) ); return allEVs.length; }