2025-11-17 22:19:46 -08:00
|
|
|
|
import { describe, it, expect } from "vitest";
|
|
|
|
|
|
import {
|
|
|
|
|
|
calculateEV,
|
|
|
|
|
|
validateProbabilities,
|
|
|
|
|
|
normalizeProbabilities,
|
|
|
|
|
|
calculateProjectedTotal,
|
|
|
|
|
|
type ScoringRules,
|
|
|
|
|
|
type ProbabilityDistribution,
|
|
|
|
|
|
} from "../ev-calculator";
|
|
|
|
|
|
|
|
|
|
|
|
describe("calculateEV", () => {
|
|
|
|
|
|
const defaultScoring: ScoringRules = {
|
|
|
|
|
|
pointsFor1st: 100,
|
|
|
|
|
|
pointsFor2nd: 70,
|
|
|
|
|
|
pointsFor3rd: 50,
|
|
|
|
|
|
pointsFor4th: 40,
|
|
|
|
|
|
pointsFor5th: 25,
|
|
|
|
|
|
pointsFor6th: 25,
|
|
|
|
|
|
pointsFor7th: 15,
|
|
|
|
|
|
pointsFor8th: 15,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
it("should calculate EV for equal probabilities", () => {
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.125,
|
|
|
|
|
|
probSecond: 0.125,
|
|
|
|
|
|
probThird: 0.125,
|
|
|
|
|
|
probFourth: 0.125,
|
|
|
|
|
|
probFifth: 0.125,
|
|
|
|
|
|
probSixth: 0.125,
|
|
|
|
|
|
probSeventh: 0.125,
|
|
|
|
|
|
probEighth: 0.125,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// EV = 0.125 × (100+70+50+40+25+25+15+15) = 0.125 × 340 = 42.5
|
2025-11-17 22:19:46 -08:00
|
|
|
|
expect(ev).toBe(42.5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should calculate EV for favorite (high probability of 1st)", () => {
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.50,
|
|
|
|
|
|
probSecond: 0.30,
|
|
|
|
|
|
probThird: 0.10,
|
|
|
|
|
|
probFourth: 0.05,
|
|
|
|
|
|
probFifth: 0.03,
|
|
|
|
|
|
probSixth: 0.01,
|
|
|
|
|
|
probSeventh: 0.005,
|
|
|
|
|
|
probEighth: 0.005,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// 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
|
2025-11-17 22:19:46 -08:00
|
|
|
|
// = 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 = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.02,
|
|
|
|
|
|
probSecond: 0.05,
|
|
|
|
|
|
probThird: 0.08,
|
|
|
|
|
|
probFourth: 0.10,
|
|
|
|
|
|
probFifth: 0.15,
|
|
|
|
|
|
probSixth: 0.20,
|
|
|
|
|
|
probSeventh: 0.20,
|
|
|
|
|
|
probEighth: 0.20,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// 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
|
2025-11-17 22:19:46 -08:00
|
|
|
|
// = 2 + 3.5 + 4 + 4 + 3.75 + 5 + 3 + 3 = 28.25
|
|
|
|
|
|
expect(ev).toBe(28.25);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should calculate EV with custom scoring rules", () => {
|
|
|
|
|
|
const customScoring: ScoringRules = {
|
|
|
|
|
|
pointsFor1st: 200,
|
|
|
|
|
|
pointsFor2nd: 150,
|
|
|
|
|
|
pointsFor3rd: 100,
|
|
|
|
|
|
pointsFor4th: 80,
|
|
|
|
|
|
pointsFor5th: 50,
|
|
|
|
|
|
pointsFor6th: 50,
|
|
|
|
|
|
pointsFor7th: 30,
|
|
|
|
|
|
pointsFor8th: 30,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.20,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.15,
|
|
|
|
|
|
probFourth: 0.15,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.05,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, customScoring);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// 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
|
2025-11-17 22:19:46 -08:00
|
|
|
|
// = 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 = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 1.0,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
probSecond: 0,
|
|
|
|
|
|
probThird: 0,
|
|
|
|
|
|
probFourth: 0,
|
|
|
|
|
|
probFifth: 0,
|
|
|
|
|
|
probSixth: 0,
|
|
|
|
|
|
probSeventh: 0,
|
|
|
|
|
|
probEighth: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
2026-02-14 23:26:32 -08:00
|
|
|
|
expect(ev).toBe(100); // 1.0 × 100 = 100
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should handle probabilities with decimal places", () => {
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08: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,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// EV = 0.1575×100 + 0.1425×70 + ... = 46.29
|
2025-11-17 22:19:46 -08:00
|
|
|
|
expect(ev).toBeCloseTo(46.29, 2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should return 0 when all probabilities are 0", () => {
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
|
|
|
|
|
probFirst: 0,
|
|
|
|
|
|
probSecond: 0,
|
|
|
|
|
|
probThird: 0,
|
|
|
|
|
|
probFourth: 0,
|
|
|
|
|
|
probFifth: 0,
|
|
|
|
|
|
probSixth: 0,
|
|
|
|
|
|
probSeventh: 0,
|
|
|
|
|
|
probEighth: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const ev = calculateEV(probabilities, defaultScoring);
|
|
|
|
|
|
expect(ev).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("validateProbabilities", () => {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
it("should validate probabilities that sum to 1.0", () => {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const valid: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.20,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.15,
|
|
|
|
|
|
probFourth: 0.15,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.05,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expect(validateProbabilities(valid)).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
it("should accept probabilities within tolerance (default 0.01)", () => {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const nearlyValid: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.2005,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.15,
|
|
|
|
|
|
probFourth: 0.15,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.0495,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expect(validateProbabilities(nearlyValid)).toBe(true);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should reject probabilities that sum too high", () => {
|
|
|
|
|
|
const tooHigh: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.20,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.20,
|
|
|
|
|
|
probFourth: 0.20,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.05,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expect(validateProbabilities(tooHigh)).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should reject probabilities that sum too low", () => {
|
|
|
|
|
|
const tooLow: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.10,
|
|
|
|
|
|
probSecond: 0.10,
|
|
|
|
|
|
probThird: 0.10,
|
|
|
|
|
|
probFourth: 0.10,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.05,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
expect(validateProbabilities(tooLow)).toBe(false);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should allow custom tolerance", () => {
|
|
|
|
|
|
const probabilities: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.21,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.15,
|
|
|
|
|
|
probFourth: 0.15,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.04,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Sum is 1.0, within any tolerance
|
|
|
|
|
|
expect(validateProbabilities(probabilities, 0.01)).toBe(true);
|
|
|
|
|
|
expect(validateProbabilities(probabilities, 0.001)).toBe(true);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("normalizeProbabilities", () => {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
it("should normalize probabilities that sum to more than 1.0", () => {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const input: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.22,
|
|
|
|
|
|
probSecond: 0.22,
|
|
|
|
|
|
probThird: 0.17,
|
|
|
|
|
|
probFourth: 0.17,
|
|
|
|
|
|
probFifth: 0.11,
|
|
|
|
|
|
probSixth: 0.11,
|
|
|
|
|
|
probSeventh: 0.055,
|
|
|
|
|
|
probEighth: 0.055,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Sum = 1.11
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
|
|
const normalized = normalizeProbabilities(input);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Each should be scaled down by 1/1.11
|
|
|
|
|
|
expect(normalized.probFirst).toBeCloseTo(0.1982, 3);
|
|
|
|
|
|
expect(normalized.probSecond).toBeCloseTo(0.1982, 3);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Sum should be exactly 1.0 (within rounding)
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const sum = Object.values(normalized).reduce((a, b) => a + b, 0);
|
2026-02-14 23:26:32 -08:00
|
|
|
|
expect(sum).toBeCloseTo(1.0, 3);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
it("should normalize probabilities that sum to less than 1.0", () => {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const input: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.18,
|
|
|
|
|
|
probSecond: 0.18,
|
|
|
|
|
|
probThird: 0.135,
|
|
|
|
|
|
probFourth: 0.135,
|
|
|
|
|
|
probFifth: 0.09,
|
|
|
|
|
|
probSixth: 0.09,
|
|
|
|
|
|
probSeventh: 0.045,
|
|
|
|
|
|
probEighth: 0.045,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Sum = 0.90
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
|
|
const normalized = normalizeProbabilities(input);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// Each should be scaled up by 1/0.90
|
|
|
|
|
|
expect(normalized.probFirst).toBeCloseTo(0.20, 2);
|
|
|
|
|
|
expect(normalized.probSecond).toBeCloseTo(0.20, 2);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
|
|
|
|
|
|
const sum = Object.values(normalized).reduce((a, b) => a + b, 0);
|
2026-02-14 23:26:32 -08:00
|
|
|
|
expect(sum).toBeCloseTo(1.0, 3);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
it("should handle probabilities that already sum to 1.0", () => {
|
2025-11-17 22:19:46 -08:00
|
|
|
|
const input: ProbabilityDistribution = {
|
2026-02-14 23:26:32 -08:00
|
|
|
|
probFirst: 0.20,
|
|
|
|
|
|
probSecond: 0.20,
|
|
|
|
|
|
probThird: 0.15,
|
|
|
|
|
|
probFourth: 0.15,
|
|
|
|
|
|
probFifth: 0.10,
|
|
|
|
|
|
probSixth: 0.10,
|
|
|
|
|
|
probSeventh: 0.05,
|
|
|
|
|
|
probEighth: 0.05,
|
2025-11-17 22:19:46 -08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const normalized = normalizeProbabilities(input);
|
|
|
|
|
|
|
|
|
|
|
|
// Should remain essentially unchanged
|
2026-02-14 23:26:32 -08:00
|
|
|
|
expect(normalized.probFirst).toBeCloseTo(0.20, 3);
|
|
|
|
|
|
expect(normalized.probSecond).toBeCloseTo(0.20, 3);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should handle all zeros by returning equal probabilities", () => {
|
|
|
|
|
|
const input: ProbabilityDistribution = {
|
|
|
|
|
|
probFirst: 0,
|
|
|
|
|
|
probSecond: 0,
|
|
|
|
|
|
probThird: 0,
|
|
|
|
|
|
probFourth: 0,
|
|
|
|
|
|
probFifth: 0,
|
|
|
|
|
|
probSixth: 0,
|
|
|
|
|
|
probSeventh: 0,
|
|
|
|
|
|
probEighth: 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const normalized = normalizeProbabilities(input);
|
|
|
|
|
|
|
2026-02-14 23:26:32 -08:00
|
|
|
|
// 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);
|
2025-11-17 22:19:46 -08:00
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe("calculateProjectedTotal", () => {
|
|
|
|
|
|
it("should calculate projected total with multiple unfinished participants", () => {
|
|
|
|
|
|
const result = calculateProjectedTotal(
|
|
|
|
|
|
150, // actual points from finished
|
|
|
|
|
|
[45.5, 30.2, 25.8, 20.1] // EVs of unfinished participants
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.actualPoints).toBe(150);
|
|
|
|
|
|
expect(result.projectedPoints).toBe(271.6); // 150 + 121.6
|
|
|
|
|
|
expect(result.participantsRemaining).toBe(4);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should handle no remaining participants", () => {
|
|
|
|
|
|
const result = calculateProjectedTotal(250, []);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.actualPoints).toBe(250);
|
|
|
|
|
|
expect(result.projectedPoints).toBe(250); // Same as actual
|
|
|
|
|
|
expect(result.participantsRemaining).toBe(0);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should handle zero actual points", () => {
|
|
|
|
|
|
const result = calculateProjectedTotal(0, [50, 40, 30]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.actualPoints).toBe(0);
|
|
|
|
|
|
expect(result.projectedPoints).toBe(120);
|
|
|
|
|
|
expect(result.participantsRemaining).toBe(3);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("should round to 2 decimal places", () => {
|
|
|
|
|
|
const result = calculateProjectedTotal(100.123, [25.456, 30.789]);
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.actualPoints).toBe(100.12);
|
|
|
|
|
|
expect(result.projectedPoints).toBe(156.37); // Rounded
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|