* fix: address VORP code review issues - Switch admin EV form to batchUpsertParticipantEVs to avoid N×N concurrent UPDATE storm (was calling upsertParticipantEV per participant, each triggering a full syncVorpForSeason) - deleteParticipantEV now resets vorpValue to "0" and calls syncVorpForSeason so remaining participants' ranks stay correct - syncVorpForSeason issues a single bulk CASE UPDATE instead of N individual UPDATE statements - Add doc warning on recalculateEV that callers must sync VORP manually - Extract REPLACEMENT_LEVEL_START/END_IDX constants; clarify comment that 12-14 is a fixed product decision, not derived from league size - Include vorpValue in draft room participant select projection - Update drizzle-orm mock to support sql.join; update test assertions to reflect single bulk-update call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve lint errors (toSorted, unused var) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
211 lines
6.7 KiB
TypeScript
211 lines
6.7 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|