feat: add calculateReplacementLevel and calculateVORP to ev-calculator

This commit is contained in:
Chris Parsons 2026-04-09 01:59:46 +00:00
parent 30903bec19
commit 22ec6381b8
2 changed files with 82 additions and 0 deletions

View file

@ -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);
});
});

View file

@ -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 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;
}