/** * Probability Updater Service * * Updates probability distributions when real results come in. * * Key behaviors: * - Finished participants: Set to 100% at their placement, 0% elsewhere * - Unfinished participants: Re-run ICM calculation with remaining participants * - Handles partial results correctly */ import { findParticipantResultsBySportsSeasonId } from "~/models/participant-result"; import { getAllParticipantEVsForSeason, upsertParticipantEV, type ParticipantEV, } from "~/models/participant-expected-value"; import { calculateICMFromOdds } from "./icm-calculator"; import type { ProbabilityDistribution } from "./ev-calculator"; import { database } from "~/database/context"; import * as schema from "~/database/schema"; import { eq } from "drizzle-orm"; /** * Result of probability update operation */ export interface ProbabilityUpdateResult { finishedParticipants: number; unfishedParticipants: number; updated: number; errors: string[]; } /** * Before/after probabilities for display */ export interface ProbabilityComparison { participantId: string; participantName: string; before: number[]; // [P(1st), P(2nd), ..., P(8th)] after: number[]; // [P(1st), P(2nd), ..., P(8th)] status: 'finished' | 'recalculated' | 'unchanged'; } /** * Convert probability array to ProbabilityDistribution */ function arrayToProbabilityDistribution(probs: number[]): ProbabilityDistribution { return { probFirst: probs[0], probSecond: probs[1], probThird: probs[2], probFourth: probs[3], probFifth: probs[4], probSixth: probs[5], probSeventh: probs[6], probEighth: probs[7], }; } /** * Convert ParticipantEV to probability array */ function evToProbabilityArray(ev: ParticipantEV): number[] { return [ parseFloat(ev.probFirst), parseFloat(ev.probSecond), parseFloat(ev.probThird), parseFloat(ev.probFourth), parseFloat(ev.probFifth), parseFloat(ev.probSixth), parseFloat(ev.probSeventh), parseFloat(ev.probEighth), ]; } /** * Create a probability distribution where participant finished at a specific position * * @param finalPosition The position where participant finished * - 1-8: 100% at that position, 0% elsewhere * - 0: Eliminated (didn't make playoffs) - 0% for all positions * - >8: Finished outside scoring - 0% for all positions * @returns Array of probabilities with 100% at finalPosition (if 1-8), or all 0% */ function createFinishedProbabilities(finalPosition: number): number[] { const probs = [0, 0, 0, 0, 0, 0, 0, 0]; // Handle positions 1-8 if (finalPosition >= 1 && finalPosition <= 8) { probs[finalPosition - 1] = 1.0; // 100% at their position } // finalPosition = 0 (eliminated) or > 8 (finished outside top 8) // Keep all probabilities at 0% return probs; } /** * Update probabilities for a sports season after results come in * * Process: * 1. Get all participant results (finished participants) * 2. Get all existing participant EVs * 3. For finished participants: set 100% at their placement * 4. For unfinished participants: recalculate using ICM with remaining participants * * @param sportsSeasonId Sports season to update * @param recalculateUnfinished Whether to recalculate unfinished participants (default true) * @returns Update result summary */ export async function updateProbabilitiesAfterResult( sportsSeasonId: string, recalculateUnfinished: boolean = true ): Promise { const errors: string[] = []; let updated = 0; try { // Get all results (finished participants) const results = await findParticipantResultsBySportsSeasonId(sportsSeasonId); // Get all existing EVs const existingEVs = await getAllParticipantEVsForSeason(sportsSeasonId); // Create map of participantId -> finalPosition const finishedMap = new Map( results .filter(r => r.finalPosition !== null) .map(r => [r.participantId, r.finalPosition ?? 0]) ); // Update finished participants // Use default scoring rules (we only care about setting probabilities, not EV for finished) const defaultScoringRules = { pointsFor1st: 100, pointsFor2nd: 70, pointsFor3rd: 50, pointsFor4th: 40, pointsFor5th: 25, pointsFor6th: 25, pointsFor7th: 15, pointsFor8th: 15, }; for (const [participantId, finalPosition] of finishedMap.entries()) { try { const probs = createFinishedProbabilities(finalPosition); const probabilities = arrayToProbabilityDistribution(probs); await upsertParticipantEV({ participantId, sportsSeasonId, probabilities, scoringRules: defaultScoringRules, source: 'manual', // Result is from actual outcome }); updated++; } catch (error) { errors.push(`Failed to update participant ${participantId}: ${error}`); } } // Recalculate unfinished participants if requested if (recalculateUnfinished) { const unfinishedEVs = existingEVs.filter( ev => !finishedMap.has(ev.participantId) ); if (unfinishedEVs.length > 0) { // Get their current championship probabilities (use existing P(1st) as proxy) const unfinishedOdds = unfinishedEVs.map(ev => { const pFirst = parseFloat(ev.probFirst); // Convert probability back to odds (approximate) // probability = 100 / (odds + 100) => odds = (100 / probability) - 100 const odds = pFirst > 0 ? Math.round((100 / pFirst) - 100) : 100000; return { participantId: ev.participantId, odds: odds, }; }); // Recalculate ICM for unfinished participants const icmResults = calculateICMFromOdds(unfinishedOdds); // Update each unfinished participant for (const [participantId, icmResult] of icmResults.entries()) { try { const probs = [ icmResult.probabilities.first, icmResult.probabilities.second, icmResult.probabilities.third, icmResult.probabilities.fourth, icmResult.probabilities.fifth, icmResult.probabilities.sixth, icmResult.probabilities.seventh, icmResult.probabilities.eighth, ]; const probabilities = arrayToProbabilityDistribution(probs); await upsertParticipantEV({ participantId, sportsSeasonId, probabilities, scoringRules: defaultScoringRules, source: 'futures_odds', // Recalculated from remaining odds }); updated++; } catch (error) { errors.push(`Failed to recalculate participant ${participantId}: ${error}`); } } } } return { finishedParticipants: finishedMap.size, unfishedParticipants: existingEVs.length - finishedMap.size, updated, errors, }; } catch (error) { errors.push(`Failed to update probabilities: ${error}`); return { finishedParticipants: 0, unfishedParticipants: 0, updated, errors, }; } } /** * Get before/after comparison of probabilities for display * * Shows what will change when updateProbabilitiesAfterResult runs. * Useful for preview before committing changes. * * @param sportsSeasonId Sports season to preview * @returns Array of probability comparisons */ export async function previewProbabilityUpdate( sportsSeasonId: string ): Promise { const db = database(); // Get all results (finished participants) const results = await findParticipantResultsBySportsSeasonId(sportsSeasonId); // Get all existing EVs with participant names const existingEVs = await db.query.participantExpectedValues.findMany({ where: eq(schema.participantExpectedValues.sportsSeasonId, sportsSeasonId), with: { participant: true, }, }); // Create map of participantId -> finalPosition const finishedMap = new Map( results .filter(r => r.finalPosition !== null) .map(r => [r.participantId, r.finalPosition ?? 0]) ); const comparisons: ProbabilityComparison[] = []; for (const ev of existingEVs) { const before = evToProbabilityArray(ev); const finalPosition = finishedMap.get(ev.participantId); let after: number[]; let status: 'finished' | 'recalculated' | 'unchanged'; if (finalPosition !== undefined) { // Finished participant after = createFinishedProbabilities(finalPosition); status = 'finished'; } else { // For preview, we'd need to recalculate - for now just show unchanged // In a real implementation, we'd run the ICM calculation here too after = before; status = 'unchanged'; } comparisons.push({ participantId: ev.participantId, participantName: ev.participant?.name || 'Unknown', before, after, status, }); } return comparisons; }