/** * Expected Value (EV) Calculator * * Calculates fantasy points expected value based on probability distributions * and league-specific scoring rules. */ /** * Scoring rules for a fantasy league season */ export interface ScoringRules { pointsFor1st: number; pointsFor2nd: number; pointsFor3rd: number; pointsFor4th: number; pointsFor5th: number; pointsFor6th: number; pointsFor7th: number; pointsFor8th: number; } /** * Probability distribution for a participant's placements * All values should be decimals (0-1) and sum to ~1.0 */ export interface ProbabilityDistribution { probFirst: number; // e.g., 0.155 means 15.5% probSecond: number; probThird: number; probFourth: number; probFifth: number; probSixth: number; probSeventh: number; probEighth: number; } /** * Calculate Expected Value for a participant * * EV = Σ (probability × points) for each placement * * @param probabilities - Probability distribution (decimals 0-1) * @param scoringRules - League's point values for each placement * @returns Expected value (average projected points) * * @example * ```ts * const ev = calculateEV( * { probFirst: 0.20, probSecond: 0.15, probThird: 0.12, ..., probEighth: 0.05 }, * { pointsFor1st: 100, pointsFor2nd: 70, ..., pointsFor8th: 15 } * ); * // Returns: 45.5 (expected points) * ``` */ export function calculateEV( probabilities: ProbabilityDistribution, scoringRules: ScoringRules ): number { const ev = probabilities.probFirst * scoringRules.pointsFor1st + probabilities.probSecond * scoringRules.pointsFor2nd + probabilities.probThird * scoringRules.pointsFor3rd + probabilities.probFourth * scoringRules.pointsFor4th + probabilities.probFifth * scoringRules.pointsFor5th + probabilities.probSixth * scoringRules.pointsFor6th + probabilities.probSeventh * scoringRules.pointsFor7th + probabilities.probEighth * scoringRules.pointsFor8th; return ev; } /** * Validate that probability distribution sums to approximately 1.0 * (allows small rounding errors) * * @param probabilities - Probability distribution to validate * @param tolerance - Acceptable deviation from 1.0 (default 0.01) * @returns true if valid, false otherwise */ export function validateProbabilities( probabilities: ProbabilityDistribution, tolerance = 0.01 ): boolean { const sum = probabilities.probFirst + probabilities.probSecond + probabilities.probThird + probabilities.probFourth + probabilities.probFifth + probabilities.probSixth + probabilities.probSeventh + probabilities.probEighth; return Math.abs(sum - 1.0) <= tolerance; } /** * Normalize probabilities to sum to exactly 1.0 * Useful when importing odds or dealing with rounding errors * * @param probabilities - Probability distribution to normalize * @returns Normalized probabilities that sum to 1.0 */ export function normalizeProbabilities( probabilities: ProbabilityDistribution ): ProbabilityDistribution { const sum = probabilities.probFirst + probabilities.probSecond + probabilities.probThird + probabilities.probFourth + probabilities.probFifth + probabilities.probSixth + probabilities.probSeventh + probabilities.probEighth; if (sum === 0) { // Avoid division by zero - return equal probabilities (1/8 = 0.125) return { probFirst: 0.125, probSecond: 0.125, probThird: 0.125, probFourth: 0.125, probFifth: 0.125, probSixth: 0.125, probSeventh: 0.125, probEighth: 0.125, }; } const factor = 1.0 / sum; return { probFirst: Math.round(probabilities.probFirst * factor * 10000) / 10000, probSecond: Math.round(probabilities.probSecond * factor * 10000) / 10000, probThird: Math.round(probabilities.probThird * factor * 10000) / 10000, probFourth: Math.round(probabilities.probFourth * factor * 10000) / 10000, probFifth: Math.round(probabilities.probFifth * factor * 10000) / 10000, probSixth: Math.round(probabilities.probSixth * factor * 10000) / 10000, probSeventh: Math.round(probabilities.probSeventh * factor * 10000) / 10000, probEighth: Math.round(probabilities.probEighth * factor * 10000) / 10000, }; } /** * Calculate projected total points for a team * * @param actualPoints - Points from finished participants * @param remainingParticipantEVs - Array of EV values for unfinished participants * @returns Object with actual, projected, and remaining count */ export function calculateProjectedTotal( actualPoints: number, remainingParticipantEVs: number[] ): { actualPoints: number; projectedPoints: number; participantsFinished: number; participantsRemaining: number; } { const evSum = remainingParticipantEVs.reduce((sum, ev) => sum + ev, 0); const projectedPoints = actualPoints + evSum; return { actualPoints: Math.round(actualPoints * 100) / 100, projectedPoints: Math.round(projectedPoints * 100) / 100, participantsFinished: 0, // Caller should provide this participantsRemaining: remainingParticipantEVs.length, }; } // Replacement level is defined as the average EV of players ranked 12th–14th // (1-indexed). This range is a fixed product decision that applies regardless // of actual league size — it represents the typical last pick in a standard draft. const REPLACEMENT_LEVEL_START_IDX = 11; // 0-indexed (1-indexed position 12) const REPLACEMENT_LEVEL_END_IDX = 13; // 0-indexed (1-indexed position 14) /** * Calculate the replacement level EV for a sport's season. * * Replacement level = average EV of players ranked 12th–14th (1-indexed). * This is a fixed constant by product decision — it does not vary by league * size. It represents the baseline value any manager could expect from the * last drafter-worthy player in a sport. * * @param sortedEvs - All participant EVs for a sport season, sorted descending * @returns Replacement level EV */ export function calculateReplacementLevel(sortedEvs: number[]): number { if (sortedEvs.length === 0) return 0; const startIdx = Math.min(REPLACEMENT_LEVEL_START_IDX, sortedEvs.length - 1); const endIdx = Math.min(REPLACEMENT_LEVEL_END_IDX, sortedEvs.length - 1); const slice = sortedEvs.slice(startIdx, endIdx + 1); const avg = slice.reduce((sum, ev) => sum + ev, 0) / slice.length; return avg; } /** * Calculate Value Over Replacement Player (VORP). * * VORP = participant EV − replacement level EV * Positive = better than replacement; negative = worse than replacement. * * @param ev - Participant's expected value * @param replacementLevel - Replacement level EV from calculateReplacementLevel * @returns VORP value */ export function calculateVORP(ev: number, replacementLevel: number): number { return ev - replacementLevel; }