brackt/app/services/ev-calculator.ts

205 lines
6.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 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,
};
}
/**
* Calculate the replacement level EV for a sport's season.
*
* Replacement level = average EV of players ranked 12th14th (1-indexed).
* These are the last picks in a standard 12-team draft, representing the
* baseline any manager could pick up off the waiver wire.
*
* @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;
// Target: average EVs at 1-indexed positions 12-14 (0-indexed: 11-13)
const startIdx = Math.min(11, sortedEvs.length - 1);
const endIdx = Math.min(13, 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;
}