From 22ec6381b83fd5465410568c29c43a54c39ac711 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Thu, 9 Apr 2026 01:59:46 +0000 Subject: [PATCH] feat: add calculateReplacementLevel and calculateVORP to ev-calculator --- app/services/__tests__/ev-calculator.test.ts | 47 ++++++++++++++++++++ app/services/ev-calculator.ts | 35 +++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/app/services/__tests__/ev-calculator.test.ts b/app/services/__tests__/ev-calculator.test.ts index 0436257..8425665 100644 --- a/app/services/__tests__/ev-calculator.test.ts +++ b/app/services/__tests__/ev-calculator.test.ts @@ -4,6 +4,8 @@ import { validateProbabilities, normalizeProbabilities, calculateProjectedTotal, + calculateReplacementLevel, + calculateVORP, type ScoringRules, type ProbabilityDistribution, } from "../ev-calculator"; @@ -359,3 +361,48 @@ describe("calculateProjectedTotal", () => { expect(result.projectedPoints).toBe(156.37); // Rounded }); }); + +describe("calculateReplacementLevel", () => { + it("returns average EV of 12th-14th players (indices 11-13) when 14+ players exist", () => { + const evs = Array.from({ length: 16 }, (_, i) => 100 - i * 10); + // evs = [100, 90, 80, 70, 60, 50, 40, 30, 20, 10, 0, -10, -20, -30, -40, -50] + // indices 11-13 = -10, -20, -30 → avg = -20 + expect(calculateReplacementLevel(evs)).toBeCloseTo(-20); + }); + + it("returns average EV of 12th-14th when exactly 14 players", () => { + const evs = Array.from({ length: 14 }, (_, i) => 100 - i * 5); + // evs[11] = 45, evs[12] = 40, evs[13] = 35 → avg = 40 + expect(calculateReplacementLevel(evs)).toBeCloseTo(40); + }); + + it("uses available players starting at index 11 when fewer than 14", () => { + const evs = [50, 40, 30, 20, 15, 12, 10, 8, 6, 4, 2, 1]; // 12 players + // Only index 11: evs[11] = 1 → avg = 1 + expect(calculateReplacementLevel(evs)).toBeCloseTo(1); + }); + + it("uses last player when fewer than 12 players", () => { + const evs = [50, 40, 30, 20, 10]; // 5 players + // Use last player: evs[4] = 10 + expect(calculateReplacementLevel(evs)).toBeCloseTo(10); + }); + + it("returns 0 for empty array", () => { + expect(calculateReplacementLevel([])).toBe(0); + }); +}); + +describe("calculateVORP", () => { + it("returns ev minus replacement level", () => { + expect(calculateVORP(75, 40)).toBeCloseTo(35); + }); + + it("returns negative value when ev is below replacement level", () => { + expect(calculateVORP(20, 40)).toBeCloseTo(-20); + }); + + it("returns 0 when ev equals replacement level", () => { + expect(calculateVORP(40, 40)).toBe(0); + }); +}); diff --git a/app/services/ev-calculator.ts b/app/services/ev-calculator.ts index 1f0aafe..781ae07 100644 --- a/app/services/ev-calculator.ts +++ b/app/services/ev-calculator.ts @@ -168,3 +168,38 @@ export function calculateProjectedTotal( participantsRemaining: remainingParticipantEVs.length, }; } + +/** + * Calculate the replacement level EV for a sport's season. + * + * Replacement level = average EV of players ranked 12th–14th (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; +}