/** * Model for Participant Expected Values * * Manages probability distributions and calculated EVs for seasonParticipants * in sports seasons. */ import { database } from "~/database/context"; import { seasonParticipantExpectedValues, seasonParticipants } from "~/database/schema"; import { eq, and, count, sql } from "drizzle-orm"; import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator"; import { calculateEV, normalizeProbabilities, calculateReplacementLevel, calculateVORP } 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; sourceElo?: number | null; worldRanking?: 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; } /** * Recalculate and persist VORP for every participant in a sports season. * * VORP = participant EV βˆ’ replacement level EV * Replacement level = average EV of seasonParticipants ranked 12th–14th in this season. * * Call this after any operation that changes EVs for seasonParticipants in the season. */ export async function syncVorpForSeason( sportsSeasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); const allEvs = await getAllParticipantEVsForSeason(sportsSeasonId, db); if (allEvs.length === 0) return; const sorted = [...allEvs].toSorted( (a, b) => parseFloat(b.expectedValue) - parseFloat(a.expectedValue) ); const sortedEvNumbers = sorted.map((ev) => parseFloat(ev.expectedValue)); const replacementLevel = calculateReplacementLevel(sortedEvNumbers); const now = new Date(); // Build a single CASE expression to update all seasonParticipants in one query // instead of N individual UPDATE statements. const vorpCaseExpr = sql`CASE ${sql.join( sorted.map((ev) => { const vorp = calculateVORP(parseFloat(ev.expectedValue), replacementLevel); return sql`WHEN ${seasonParticipants.id} = ${ev.participantId} THEN ${vorp.toFixed(4)}::numeric`; }), sql` ` )} END`; const ids = sorted.map((ev) => ev.participantId); await db .update(seasonParticipants) .set({ vorpValue: vorpCaseExpr, updatedAt: now }) .where(sql`${seasonParticipants.id} = ANY(${sql`ARRAY[${sql.join(ids.map((id) => sql`${id}`), sql`, `)}]::uuid[]`})`); } /** * 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(seasonParticipantExpectedValues) .where( and( eq(seasonParticipantExpectedValues.participantId, participantId), eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) ) ) .limit(1); const now = new Date(); let result: ParticipantEV; if (existing.length > 0) { // Update existing const updated = await db .update(seasonParticipantExpectedValues) .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(seasonParticipantExpectedValues.id, existing[0].id)) .returning(); result = updated[0]; } else { // Create new const created = await db .insert(seasonParticipantExpectedValues) .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 seasonParticipants table for draft room ranking await db .update(seasonParticipants) .set({ expectedValue: expectedValue.toString(), updatedAt: now }) .where(eq(seasonParticipants.id, participantId)); // Recalculate VORP for all seasonParticipants in this season (replacement level may have shifted) await syncVorpForSeason(sportsSeasonId); 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(seasonParticipantExpectedValues); 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(seasonParticipantExpectedValues) .where( and( eq(seasonParticipantExpectedValues.participantId, participantId), eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) ) ) .limit(1); return result[0] || null; } /** * Get all participant EVs for a sports season */ export async function getAllParticipantEVsForSeason( sportsSeasonId: string, providedDb?: ReturnType ): Promise { const db = providedDb || database(); return db .select() .from(seasonParticipantExpectedValues) .where(eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId)); } /** * Delete participant EV */ export async function deleteParticipantEV( participantId: string, sportsSeasonId: string ): Promise { const db = database(); await db .delete(seasonParticipantExpectedValues) .where( and( eq(seasonParticipantExpectedValues.participantId, participantId), eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) ) ); // Reset this participant's EV and VORP to 0 (no longer ranked) await db .update(seasonParticipants) .set({ expectedValue: "0", vorpValue: "0", updatedAt: new Date() }) .where(eq(seasonParticipants.id, participantId)); // Recalculate VORP for remaining seasonParticipants β€” replacement level may have shifted await syncVorpForSeason(sportsSeasonId); } /** * Batch upsert multiple participant EVs within a single transaction. * All records succeed or all fail together. */ export async function batchUpsertParticipantEVs( inputs: CreateProbabilityInput[], providedDb?: ReturnType ): Promise { const db = providedDb || 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: seasonParticipantExpectedValues.id }) .from(seasonParticipantExpectedValues) .where( and( eq(seasonParticipantExpectedValues.participantId, participantId), eq(seasonParticipantExpectedValues.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(seasonParticipantExpectedValues) // Only overwrite sourceOdds if explicitly provided; preserve existing value otherwise .set(sourceOdds !== undefined ? { ...baseValues, sourceOdds } : baseValues) .where(eq(seasonParticipantExpectedValues.id, existing[0].id)) .returning(); result = updated; } else { const [created] = await tx .insert(seasonParticipantExpectedValues) .values({ participantId, sportsSeasonId, ...baseValues, sourceOdds: sourceOdds ?? null }) .returning(); result = created; } await tx .update(seasonParticipants) .set({ expectedValue: expectedValue.toString(), updatedAt: now }) .where(eq(seasonParticipants.id, participantId)); results.push(result); } }); // Sync VORP for all affected seasons const uniqueSeasonIds = [...new Set(inputs.map((i) => i.sportsSeasonId))]; await Promise.all(uniqueSeasonIds.map((id) => syncVorpForSeason(id, db))); return results; } /** * Save American odds for a batch of seasonParticipants 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: seasonParticipantExpectedValues.id }) .from(seasonParticipantExpectedValues) .where( and( eq(seasonParticipantExpectedValues.participantId, participantId), eq(seasonParticipantExpectedValues.sportsSeasonId, sportsSeasonId) ) ) .limit(1); if (existing.length > 0) { await tx .update(seasonParticipantExpectedValues) .set({ sourceOdds, updatedAt: now }) .where(eq(seasonParticipantExpectedValues.id, existing[0].id)); } else { // Insert a stub record β€” probabilities/EV will be filled in by the simulator await tx.insert(seasonParticipantExpectedValues).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, }); } } }); } /** * Persist raw Elo ratings (and optional world rankings) for a batch of seasonParticipants. * Used by Elo-based simulators like snooker_bracket and darts_bracket. * Creates stub records if none exist; does not touch probabilities or EV. * Uses INSERT ... ON CONFLICT DO UPDATE to avoid N+1 queries. */ export async function batchSaveSourceElos( inputs: Array<{ participantId: string; sportsSeasonId: string; sourceElo: number; worldRanking?: number | null }> ): Promise { if (inputs.length === 0) return; const db = database(); const now = new Date(); await db .insert(seasonParticipantExpectedValues) .values( inputs.map(({ participantId, sportsSeasonId, sourceElo, worldRanking }) => ({ participantId, sportsSeasonId, probFirst: "0", probSecond: "0", probThird: "0", probFourth: "0", probFifth: "0", probSixth: "0", probSeventh: "0", probEighth: "0", expectedValue: "0", source: "elo_simulation" as const, sourceElo, worldRanking: worldRanking ?? null, calculatedAt: now, updatedAt: now, })) ) .onConflictDoUpdate({ target: [seasonParticipantExpectedValues.participantId, seasonParticipantExpectedValues.sportsSeasonId], set: { sourceElo: sql`excluded.source_elo`, worldRanking: sql`excluded.world_ranking`, updatedAt: sql`excluded.updated_at`, }, }); } /** * 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. * * NOTE: This function does NOT sync VORP. If calling this directly (outside * of recalculateAllEVsForSeason), call syncVorpForSeason(sportsSeasonId) * afterwards to keep draft order correct. */ 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(seasonParticipantExpectedValues) .set({ expectedValue: newEV.toString(), calculatedAt: now, updatedAt: now, }) .where(eq(seasonParticipantExpectedValues.id, existing.id)) .returning(); // Sync recalculated EV to seasonParticipants table await db .update(seasonParticipants) .set({ expectedValue: newEV.toString(), updatedAt: now }) .where(eq(seasonParticipants.id, participantId)); return updated[0]; } /** * Recalculate EVs for all seasonParticipants 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) ) ); // Sync VORP once after all EVs are updated await syncVorpForSeason(sportsSeasonId); return allEVs.length; }