brackt/app/services/ev-calculator.ts
Chris Parsons d5aa8a3de4
fix: address VORP code review issues (#283)
* 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>
2026-04-08 23:20:02 -04:00

211 lines
6.7 KiB
TypeScript
Raw Permalink 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,
};
}
// Replacement level is defined as the average EV of players ranked 12th14th
// (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 12th14th (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;
}