brackt/app/services/ev-calculator.ts
Chris Parsons 840212c4f8 feat: implement sports season expected value calculations and probability updates
- Added loader and action functions for managing expected values in sports seasons.
- Implemented UI for recalculating probabilities based on participant results.
- Created a service to update probabilities after results are finalized, including handling finished and unfinished participants.
- Developed tests for the probability updater service to ensure correct functionality.
- Introduced a preview feature to show potential changes before applying updates.
2025-11-21 22:05:50 -08:00

173 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 percentages (0-100) and sum to ~100
*/
export interface ProbabilityDistribution {
probFirst: number; // e.g., 15.5 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 (percentages 0-100)
* @param scoringRules - League's point values for each placement
* @returns Expected value (average projected points)
*
* @example
* ```ts
* const ev = calculateEV(
* { probFirst: 20, probSecond: 15, probThird: 12, ..., probEighth: 5 },
* { pointsFor1st: 100, pointsFor2nd: 70, ..., pointsFor8th: 15 }
* );
* // Returns: 45.5 (expected points)
* ```
*/
export function calculateEV(
probabilities: ProbabilityDistribution,
scoringRules: ScoringRules
): number {
// Probabilities are already decimals (0-1), no need to divide by 100
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 Math.round(ev * 100) / 100; // Round to 2 decimal places
}
/**
* Validate that probability distribution sums to approximately 100%
* (allows small rounding errors)
*
* @param probabilities - Probability distribution to validate
* @param tolerance - Acceptable deviation from 100% (default 0.1%)
* @returns true if valid, false otherwise
*/
export function validateProbabilities(
probabilities: ProbabilityDistribution,
tolerance: number = 0.01
): boolean {
const sum =
probabilities.probFirst +
probabilities.probSecond +
probabilities.probThird +
probabilities.probFourth +
probabilities.probFifth +
probabilities.probSixth +
probabilities.probSeventh +
probabilities.probEighth;
// Validate sum is close to 1.0 (decimals, not percentages)
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 (decimals, not percentages)
*/
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 = 12.5%)
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,
};
}
// Normalize to sum = 1.0 (decimals, not percentages)
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,
};
}