From 96b4c5a35008b85f73a2461ddfa336a116c9aef7 Mon Sep 17 00:00:00 2001 From: Chris Parsons Date: Sat, 14 Feb 2026 23:26:32 -0800 Subject: [PATCH] fix: align ev-calculator tests with decimal (0-1) probability format The implementation and rest of the system (ICM calculator, probability updater) use decimals (0-1) for probabilities, but the tests were using percentages (0-100). Updated tests and docs to match. Co-Authored-By: Claude Opus 4.6 --- app/services/__tests__/ev-calculator.test.ts | 273 +++++++++---------- app/services/ev-calculator.ts | 19 +- 2 files changed, 144 insertions(+), 148 deletions(-) diff --git a/app/services/__tests__/ev-calculator.test.ts b/app/services/__tests__/ev-calculator.test.ts index e6a22bb..0436257 100644 --- a/app/services/__tests__/ev-calculator.test.ts +++ b/app/services/__tests__/ev-calculator.test.ts @@ -22,56 +22,56 @@ describe("calculateEV", () => { it("should calculate EV for equal probabilities", () => { const probabilities: ProbabilityDistribution = { - probFirst: 12.5, - probSecond: 12.5, - probThird: 12.5, - probFourth: 12.5, - probFifth: 12.5, - probSixth: 12.5, - probSeventh: 12.5, - probEighth: 12.5, + 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 ev = calculateEV(probabilities, defaultScoring); - // EV = 12.5% × (100+70+50+40+25+25+15+15) = 12.5% × 340 = 42.5 + // EV = 0.125 × (100+70+50+40+25+25+15+15) = 0.125 × 340 = 42.5 expect(ev).toBe(42.5); }); it("should calculate EV for favorite (high probability of 1st)", () => { const probabilities: ProbabilityDistribution = { - probFirst: 50, - probSecond: 30, - probThird: 10, - probFourth: 5, - probFifth: 3, - probSixth: 1, - probSeventh: 0.5, - probEighth: 0.5, + probFirst: 0.50, + probSecond: 0.30, + probThird: 0.10, + probFourth: 0.05, + probFifth: 0.03, + probSixth: 0.01, + probSeventh: 0.005, + probEighth: 0.005, }; const ev = calculateEV(probabilities, defaultScoring); - // EV = 50% × 100 + 30% × 70 + 10% × 50 + 5% × 40 + 3% × 25 + 1% × 25 + 0.5% × 15 + 0.5% × 15 + // EV = 0.50×100 + 0.30×70 + 0.10×50 + 0.05×40 + 0.03×25 + 0.01×25 + 0.005×15 + 0.005×15 // = 50 + 21 + 5 + 2 + 0.75 + 0.25 + 0.075 + 0.075 = 79.15 expect(ev).toBe(79.15); }); it("should calculate EV for underdog (low probability of 1st)", () => { const probabilities: ProbabilityDistribution = { - probFirst: 2, - probSecond: 5, - probThird: 8, - probFourth: 10, - probFifth: 15, - probSixth: 20, - probSeventh: 20, - probEighth: 20, + probFirst: 0.02, + probSecond: 0.05, + probThird: 0.08, + probFourth: 0.10, + probFifth: 0.15, + probSixth: 0.20, + probSeventh: 0.20, + probEighth: 0.20, }; const ev = calculateEV(probabilities, defaultScoring); - // EV = 2% × 100 + 5% × 70 + 8% × 50 + 10% × 40 + 15% × 25 + 20% × 25 + 20% × 15 + 20% × 15 + // EV = 0.02×100 + 0.05×70 + 0.08×50 + 0.10×40 + 0.15×25 + 0.20×25 + 0.20×15 + 0.20×15 // = 2 + 3.5 + 4 + 4 + 3.75 + 5 + 3 + 3 = 28.25 expect(ev).toBe(28.25); }); @@ -89,26 +89,26 @@ describe("calculateEV", () => { }; const probabilities: ProbabilityDistribution = { - probFirst: 20, - probSecond: 20, - probThird: 15, - probFourth: 15, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 5, + probFirst: 0.20, + probSecond: 0.20, + probThird: 0.15, + probFourth: 0.15, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.05, }; const ev = calculateEV(probabilities, customScoring); - // EV = 20% × 200 + 20% × 150 + 15% × 100 + 15% × 80 + 10% × 50 + 10% × 50 + 5% × 30 + 5% × 30 + // EV = 0.20×200 + 0.20×150 + 0.15×100 + 0.15×80 + 0.10×50 + 0.10×50 + 0.05×30 + 0.05×30 // = 40 + 30 + 15 + 12 + 5 + 5 + 1.5 + 1.5 = 110 expect(ev).toBe(110); }); it("should handle 100% probability of one placement (finished participant)", () => { const probabilities: ProbabilityDistribution = { - probFirst: 100, + probFirst: 1.0, probSecond: 0, probThird: 0, probFourth: 0, @@ -119,24 +119,24 @@ describe("calculateEV", () => { }; const ev = calculateEV(probabilities, defaultScoring); - expect(ev).toBe(100); // 100% × 100 = 100 + expect(ev).toBe(100); // 1.0 × 100 = 100 }); it("should handle probabilities with decimal places", () => { const probabilities: ProbabilityDistribution = { - probFirst: 15.75, - probSecond: 14.25, - probThird: 13.50, - probFourth: 12.75, - probFifth: 11.00, - probSixth: 10.50, - probSeventh: 11.25, - probEighth: 11.00, + probFirst: 0.1575, + probSecond: 0.1425, + probThird: 0.1350, + probFourth: 0.1275, + probFifth: 0.1100, + probSixth: 0.1050, + probSeventh: 0.1125, + probEighth: 0.1100, }; const ev = calculateEV(probabilities, defaultScoring); - // EV = 15.75% × 100 + 14.25% × 70 + ... = 46.29 + // EV = 0.1575×100 + 0.1425×70 + ... = 46.29 expect(ev).toBeCloseTo(46.29, 2); }); @@ -158,31 +158,31 @@ describe("calculateEV", () => { }); describe("validateProbabilities", () => { - it("should validate probabilities that sum to 100", () => { + it("should validate probabilities that sum to 1.0", () => { const valid: ProbabilityDistribution = { - probFirst: 20, - probSecond: 20, - probThird: 15, - probFourth: 15, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 5, + probFirst: 0.20, + probSecond: 0.20, + probThird: 0.15, + probFourth: 0.15, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.05, }; expect(validateProbabilities(valid)).toBe(true); }); - it("should accept probabilities within tolerance (default 0.1%)", () => { + it("should accept probabilities within tolerance (default 0.01)", () => { const nearlyValid: ProbabilityDistribution = { - probFirst: 20.05, - probSecond: 20, - probThird: 15, - probFourth: 15, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 4.95, + probFirst: 0.2005, + probSecond: 0.20, + probThird: 0.15, + probFourth: 0.15, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.0495, }; expect(validateProbabilities(nearlyValid)).toBe(true); @@ -190,14 +190,14 @@ describe("validateProbabilities", () => { it("should reject probabilities that sum too high", () => { const tooHigh: ProbabilityDistribution = { - probFirst: 20, - probSecond: 20, - probThird: 20, - probFourth: 20, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 5, + probFirst: 0.20, + probSecond: 0.20, + probThird: 0.20, + probFourth: 0.20, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.05, }; expect(validateProbabilities(tooHigh)).toBe(false); @@ -205,14 +205,14 @@ describe("validateProbabilities", () => { it("should reject probabilities that sum too low", () => { const tooLow: ProbabilityDistribution = { - probFirst: 10, - probSecond: 10, - probThird: 10, - probFourth: 10, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 5, + probFirst: 0.10, + probSecond: 0.10, + probThird: 0.10, + probFourth: 0.10, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.05, }; expect(validateProbabilities(tooLow)).toBe(false); @@ -220,88 +220,87 @@ describe("validateProbabilities", () => { it("should allow custom tolerance", () => { const probabilities: ProbabilityDistribution = { - probFirst: 21, - probSecond: 20, - probThird: 15, - probFourth: 15, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 4, + probFirst: 0.21, + probSecond: 0.20, + probThird: 0.15, + probFourth: 0.15, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.04, }; - // Sum is 100, but with 1% tolerance - expect(validateProbabilities(probabilities, 1)).toBe(true); - // With stricter 0.1% tolerance - expect(validateProbabilities(probabilities, 0.1)).toBe(true); + // Sum is 1.0, within any tolerance + expect(validateProbabilities(probabilities, 0.01)).toBe(true); + expect(validateProbabilities(probabilities, 0.001)).toBe(true); }); }); describe("normalizeProbabilities", () => { - it("should normalize probabilities that sum to more than 100", () => { + it("should normalize probabilities that sum to more than 1.0", () => { const input: ProbabilityDistribution = { - probFirst: 22, - probSecond: 22, - probThird: 17, - probFourth: 17, - probFifth: 11, - probSixth: 11, - probSeventh: 5.5, - probEighth: 5.5, + probFirst: 0.22, + probSecond: 0.22, + probThird: 0.17, + probFourth: 0.17, + probFifth: 0.11, + probSixth: 0.11, + probSeventh: 0.055, + probEighth: 0.055, }; - // Sum = 111 + // Sum = 1.11 const normalized = normalizeProbabilities(input); - // Each should be scaled down by 100/111 - expect(normalized.probFirst).toBeCloseTo(19.82, 2); - expect(normalized.probSecond).toBeCloseTo(19.82, 2); + // Each should be scaled down by 1/1.11 + expect(normalized.probFirst).toBeCloseTo(0.1982, 3); + expect(normalized.probSecond).toBeCloseTo(0.1982, 3); - // Sum should be exactly 100 (within rounding) + // Sum should be exactly 1.0 (within rounding) const sum = Object.values(normalized).reduce((a, b) => a + b, 0); - expect(sum).toBeCloseTo(100, 1); + expect(sum).toBeCloseTo(1.0, 3); }); - it("should normalize probabilities that sum to less than 100", () => { + it("should normalize probabilities that sum to less than 1.0", () => { const input: ProbabilityDistribution = { - probFirst: 18, - probSecond: 18, - probThird: 13.5, - probFourth: 13.5, - probFifth: 9, - probSixth: 9, - probSeventh: 4.5, - probEighth: 4.5, + probFirst: 0.18, + probSecond: 0.18, + probThird: 0.135, + probFourth: 0.135, + probFifth: 0.09, + probSixth: 0.09, + probSeventh: 0.045, + probEighth: 0.045, }; - // Sum = 90 + // Sum = 0.90 const normalized = normalizeProbabilities(input); - // Each should be scaled up by 100/90 - expect(normalized.probFirst).toBeCloseTo(20, 1); - expect(normalized.probSecond).toBeCloseTo(20, 1); + // Each should be scaled up by 1/0.90 + expect(normalized.probFirst).toBeCloseTo(0.20, 2); + expect(normalized.probSecond).toBeCloseTo(0.20, 2); const sum = Object.values(normalized).reduce((a, b) => a + b, 0); - expect(sum).toBeCloseTo(100, 1); + expect(sum).toBeCloseTo(1.0, 3); }); - it("should handle probabilities that already sum to 100", () => { + it("should handle probabilities that already sum to 1.0", () => { const input: ProbabilityDistribution = { - probFirst: 20, - probSecond: 20, - probThird: 15, - probFourth: 15, - probFifth: 10, - probSixth: 10, - probSeventh: 5, - probEighth: 5, + probFirst: 0.20, + probSecond: 0.20, + probThird: 0.15, + probFourth: 0.15, + probFifth: 0.10, + probSixth: 0.10, + probSeventh: 0.05, + probEighth: 0.05, }; const normalized = normalizeProbabilities(input); // Should remain essentially unchanged - expect(normalized.probFirst).toBeCloseTo(20, 1); - expect(normalized.probSecond).toBeCloseTo(20, 1); + expect(normalized.probFirst).toBeCloseTo(0.20, 3); + expect(normalized.probSecond).toBeCloseTo(0.20, 3); }); it("should handle all zeros by returning equal probabilities", () => { @@ -318,10 +317,10 @@ describe("normalizeProbabilities", () => { const normalized = normalizeProbabilities(input); - // Should return 12.5% for each (equal distribution) - expect(normalized.probFirst).toBe(12.5); - expect(normalized.probSecond).toBe(12.5); - expect(normalized.probEighth).toBe(12.5); + // Should return 0.125 for each (equal distribution) + expect(normalized.probFirst).toBe(0.125); + expect(normalized.probSecond).toBe(0.125); + expect(normalized.probEighth).toBe(0.125); }); }); diff --git a/app/services/ev-calculator.ts b/app/services/ev-calculator.ts index 11f485c..83a3ed1 100644 --- a/app/services/ev-calculator.ts +++ b/app/services/ev-calculator.ts @@ -21,10 +21,10 @@ export interface ScoringRules { /** * Probability distribution for a participant's placements - * All values should be percentages (0-100) and sum to ~100 + * All values should be decimals (0-1) and sum to ~1.0 */ export interface ProbabilityDistribution { - probFirst: number; // e.g., 15.5 means 15.5% + probFirst: number; // e.g., 0.155 means 15.5% probSecond: number; probThird: number; probFourth: number; @@ -39,14 +39,14 @@ export interface ProbabilityDistribution { * * EV = Σ (probability × points) for each placement * - * @param probabilities - Probability distribution (percentages 0-100) + * @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: 20, probSecond: 15, probThird: 12, ..., probEighth: 5 }, + * { probFirst: 0.20, probSecond: 0.15, probThird: 0.12, ..., probEighth: 0.05 }, * { pointsFor1st: 100, pointsFor2nd: 70, ..., pointsFor8th: 15 } * ); * // Returns: 45.5 (expected points) @@ -56,7 +56,6 @@ export function calculateEV( probabilities: ProbabilityDistribution, scoringRules: ScoringRules ): number { - // Probabilities are already decimals (0-1), no need to divide by 100 const ev = probabilities.probFirst * scoringRules.pointsFor1st + probabilities.probSecond * scoringRules.pointsFor2nd + @@ -71,11 +70,11 @@ export function calculateEV( } /** - * Validate that probability distribution sums to approximately 100% + * Validate that probability distribution sums to approximately 1.0 * (allows small rounding errors) * * @param probabilities - Probability distribution to validate - * @param tolerance - Acceptable deviation from 100% (default 0.1%) + * @param tolerance - Acceptable deviation from 1.0 (default 0.01) * @returns true if valid, false otherwise */ export function validateProbabilities( @@ -92,7 +91,6 @@ export function validateProbabilities( probabilities.probSeventh + probabilities.probEighth; - // Validate sum is close to 1.0 (decimals, not percentages) return Math.abs(sum - 1.0) <= tolerance; } @@ -101,7 +99,7 @@ export function validateProbabilities( * Useful when importing odds or dealing with rounding errors * * @param probabilities - Probability distribution to normalize - * @returns Normalized probabilities that sum to 1.0 (decimals, not percentages) + * @returns Normalized probabilities that sum to 1.0 */ export function normalizeProbabilities( probabilities: ProbabilityDistribution @@ -117,7 +115,7 @@ export function normalizeProbabilities( probabilities.probEighth; if (sum === 0) { - // Avoid division by zero - return equal probabilities (1/8 = 0.125 = 12.5%) + // Avoid division by zero - return equal probabilities (1/8 = 0.125) return { probFirst: 0.125, probSecond: 0.125, @@ -130,7 +128,6 @@ export function normalizeProbabilities( }; } - // Normalize to sum = 1.0 (decimals, not percentages) const factor = 1.0 / sum; return {