/** * Model for Participant Expected Values * * Manages probability distributions and calculated EVs for participants * in sports seasons. */ import { database } from "~/database/context"; import { participantExpectedValues } from "~/database/schema"; import { eq, and } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, validateProbabilities, 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; // Validate probabilities (only for manual entry) // For futures_odds/ICM, probabilities may sum to <1.0 for teams unlikely to finish top 8 if (source === "manual" && !validateProbabilities(probabilities)) { const sum = Object.values(probabilities).reduce((a, b) => a + b, 0); throw new Error( `Probabilities must sum to 1.0 (±0.01). Current sum: ${sum.toFixed(4)} (${(sum * 100).toFixed(1)}%)` ); } // 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(); 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: sourceOdds ?? null, calculatedAt: now, updatedAt: now, }) .where(eq(participantExpectedValues.id, existing[0].id)) .returning(); return 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(); return created[0]; } } /** * 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, }); } /** * 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) ) ); } /** * Batch upsert multiple participant EVs * Useful for updating all participants after generating probabilities */ export async function batchUpsertParticipantEVs( inputs: CreateProbabilityInput[] ): Promise { const results: ParticipantEV[] = []; // Process in batches to avoid overwhelming the database const batchSize = 50; for (let i = 0; i < inputs.length; i += batchSize) { const batch = inputs.slice(i, i + batchSize); const batchResults = await Promise.all( batch.map((input) => upsertParticipantEV(input)) ); results.push(...batchResults); } return results; } /** * 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 updated = await db .update(participantExpectedValues) .set({ expectedValue: newEV.toString(), calculatedAt: new Date(), updatedAt: new Date(), }) .where(eq(participantExpectedValues.id, existing.id)) .returning(); 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; }