feat: add calculateReplacementLevel and calculateVORP to ev-calculator
This commit is contained in:
parent
30903bec19
commit
22ec6381b8
2 changed files with 82 additions and 0 deletions
|
|
@ -4,6 +4,8 @@ import {
|
||||||
validateProbabilities,
|
validateProbabilities,
|
||||||
normalizeProbabilities,
|
normalizeProbabilities,
|
||||||
calculateProjectedTotal,
|
calculateProjectedTotal,
|
||||||
|
calculateReplacementLevel,
|
||||||
|
calculateVORP,
|
||||||
type ScoringRules,
|
type ScoringRules,
|
||||||
type ProbabilityDistribution,
|
type ProbabilityDistribution,
|
||||||
} from "../ev-calculator";
|
} from "../ev-calculator";
|
||||||
|
|
@ -359,3 +361,48 @@ describe("calculateProjectedTotal", () => {
|
||||||
expect(result.projectedPoints).toBe(156.37); // Rounded
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -168,3 +168,38 @@ export function calculateProjectedTotal(
|
||||||
participantsRemaining: remainingParticipantEVs.length,
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue