brackt/app/services/__tests__/ev-calculator.test.ts
Chris Parsons 79ec477a98 feat: implement Expected Value System with ICM probability calculator
Implements Phase 5.2 of the EV system with Harville-Malmuth Independent Chip Model
for calculating participant placement probabilities from futures odds.

## Key Features

### ICM Probability Calculator
- Implements Harville-Malmuth method for distributing probabilities
- Converts American odds to championship probabilities
- Generates P(1st) through P(8th) for all participants
- Column-normalized: each placement sums to 100% across all teams
- Works with any number of participants (not limited to 8)

### Admin UI - Futures Odds Entry
- Enter American odds (e.g., +550, -200) for championship futures
- Live preview of ICM-calculated probability distributions
- Displays all 8 placement probabilities
- Persists odds for editing on subsequent visits
- Automatic probability normalization (removes bookmaker vig)

### Database Schema Updates
- Renamed participant_expected_values.season_id → sports_season_id
- Updated foreign key to reference sports_seasons instead of seasons
- Added source_odds field to store original futures odds
- Migration 0025: Column rename and FK update
- Migration 0026: Add source_odds field

### Model Layer
- participant-expected-value: CRUD operations for probability distributions
- Supports multiple probability sources (manual, futures_odds, elo_simulation)
- Automatic EV calculation based on league scoring rules
- Probability validation and normalization

### Service Layer
- icm-calculator: Harville-Malmuth probability distribution
- probability-engine: Odds conversion and Elo utilities (for future use)
- bracket-simulator: Monte Carlo simulation (for future hybrid approach)
- ev-calculator: Expected value computation from probabilities

## Technical Details

- Uses exponential decay favoring top positions for strong teams
- Preserves championship probability ordering in final distributions
- Row sums vary (strong teams ~100%, weak teams lower)
- All probabilities between 0-1, mathematically valid
- Comprehensive test suite: 97 tests passing

## Future Enhancements

- Hybrid approach: ICM pre-playoffs, bracket simulation during playoffs
- Integration with league-specific scoring rules
- Historical probability tracking for accuracy analysis

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 22:19:46 -08:00

362 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = {
probFirst: 12.5,
probSecond: 12.5,
probThird: 12.5,
probFourth: 12.5,
probFifth: 12.5,
probSixth: 12.5,
probSeventh: 12.5,
probEighth: 12.5,
};
const ev = calculateEV(probabilities, defaultScoring);
// EV = 12.5% × (100+70+50+40+25+25+15+15) = 12.5% × 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,
};
const ev = calculateEV(probabilities, defaultScoring);
// EV = 50% × 100 + 30% × 70 + 10% × 50 + 5% × 40 + 3% × 25 + 1% × 25 + 0.5% × 15 + 0.5% × 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,
};
const ev = calculateEV(probabilities, defaultScoring);
// EV = 2% × 100 + 5% × 70 + 8% × 50 + 10% × 40 + 15% × 25 + 20% × 25 + 20% × 15 + 20% × 15
// = 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 = {
probFirst: 20,
probSecond: 20,
probThird: 15,
probFourth: 15,
probFifth: 10,
probSixth: 10,
probSeventh: 5,
probEighth: 5,
};
const ev = calculateEV(probabilities, customScoring);
// EV = 20% × 200 + 20% × 150 + 15% × 100 + 15% × 80 + 10% × 50 + 10% × 50 + 5% × 30 + 5% × 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,
probSecond: 0,
probThird: 0,
probFourth: 0,
probFifth: 0,
probSixth: 0,
probSeventh: 0,
probEighth: 0,
};
const ev = calculateEV(probabilities, defaultScoring);
expect(ev).toBe(100); // 100% × 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,
};
const ev = calculateEV(probabilities, defaultScoring);
// EV = 15.75% × 100 + 14.25% × 70 + ... = 46.29
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", () => {
it("should validate probabilities that sum to 100", () => {
const valid: ProbabilityDistribution = {
probFirst: 20,
probSecond: 20,
probThird: 15,
probFourth: 15,
probFifth: 10,
probSixth: 10,
probSeventh: 5,
probEighth: 5,
};
expect(validateProbabilities(valid)).toBe(true);
});
it("should accept probabilities within tolerance (default 0.1%)", () => {
const nearlyValid: ProbabilityDistribution = {
probFirst: 20.05,
probSecond: 20,
probThird: 15,
probFourth: 15,
probFifth: 10,
probSixth: 10,
probSeventh: 5,
probEighth: 4.95,
};
expect(validateProbabilities(nearlyValid)).toBe(true);
});
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,
};
expect(validateProbabilities(tooHigh)).toBe(false);
});
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,
};
expect(validateProbabilities(tooLow)).toBe(false);
});
it("should allow custom tolerance", () => {
const probabilities: ProbabilityDistribution = {
probFirst: 21,
probSecond: 20,
probThird: 15,
probFourth: 15,
probFifth: 10,
probSixth: 10,
probSeventh: 5,
probEighth: 4,
};
// 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);
});
});
describe("normalizeProbabilities", () => {
it("should normalize probabilities that sum to more than 100", () => {
const input: ProbabilityDistribution = {
probFirst: 22,
probSecond: 22,
probThird: 17,
probFourth: 17,
probFifth: 11,
probSixth: 11,
probSeventh: 5.5,
probEighth: 5.5,
};
// Sum = 111
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);
// Sum should be exactly 100 (within rounding)
const sum = Object.values(normalized).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(100, 1);
});
it("should normalize probabilities that sum to less than 100", () => {
const input: ProbabilityDistribution = {
probFirst: 18,
probSecond: 18,
probThird: 13.5,
probFourth: 13.5,
probFifth: 9,
probSixth: 9,
probSeventh: 4.5,
probEighth: 4.5,
};
// Sum = 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);
const sum = Object.values(normalized).reduce((a, b) => a + b, 0);
expect(sum).toBeCloseTo(100, 1);
});
it("should handle probabilities that already sum to 100", () => {
const input: ProbabilityDistribution = {
probFirst: 20,
probSecond: 20,
probThird: 15,
probFourth: 15,
probFifth: 10,
probSixth: 10,
probSeventh: 5,
probEighth: 5,
};
const normalized = normalizeProbabilities(input);
// Should remain essentially unchanged
expect(normalized.probFirst).toBeCloseTo(20, 1);
expect(normalized.probSecond).toBeCloseTo(20, 1);
});
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);
// 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);
});
});
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
});
});