brackt/app/services/__tests__/icm-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

319 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
calculateICM,
calculateICMFromOdds,
icmResultToArray,
type ParticipantChips,
} from '../icm-calculator';
describe('icm-calculator', () => {
describe('calculateICM', () => {
it('calculates probabilities for 8 equal participants', () => {
const participants: ParticipantChips[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
championshipProbability: 0.125, // Equal 12.5% each
}));
const results = calculateICM(participants);
expect(results.size).toBe(8);
// Each participant should have probabilities that sum to 1.0
results.forEach((result) => {
const probs = icmResultToArray(result);
const sum = probs.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
// With equal odds, probabilities vary by placement preference
// But should all be reasonable (not 0, not 1)
probs.forEach(p => {
expect(p).toBeGreaterThan(0);
expect(p).toBeLessThan(0.5);
});
});
});
it('gives stronger team higher probabilities for better placements', () => {
const participants: ParticipantChips[] = [
{ participantId: 'strong', championshipProbability: 0.5 }, // 50%
{ participantId: 'weak', championshipProbability: 0.01 }, // 1%
...Array.from({ length: 6 }, (_, i) => ({
participantId: String(i + 3),
championshipProbability: 0.0817, // ~8.17% each
})),
];
const results = calculateICM(participants);
const strongProbs = icmResultToArray(results.get('strong')!);
const weakProbs = icmResultToArray(results.get('weak')!);
// Strong team should have higher P(1st) than weak team
expect(strongProbs[0]).toBeGreaterThan(weakProbs[0]);
// Strong team should have higher P(2nd) than weak team
expect(strongProbs[1]).toBeGreaterThan(weakProbs[1]);
// Weak team should have higher probability of worse placements
expect(weakProbs[7]).toBeGreaterThan(strongProbs[7]);
});
it('handles 32 team NHL scenario', () => {
// Simulate realistic NHL championship odds distribution
const participants: ParticipantChips[] = [
{ participantId: 'COL', championshipProbability: 0.154 }, // 15.4% favorite
{ participantId: 'FLA', championshipProbability: 0.111 }, // 11.1%
{ participantId: 'VGK', championshipProbability: 0.111 }, // 11.1%
{ participantId: 'TBL', championshipProbability: 0.091 }, // 9.1%
{ participantId: 'NJD', championshipProbability: 0.067 }, // 6.7%
{ participantId: 'TOR', championshipProbability: 0.038 }, // 3.8%
{ participantId: 'NYR', championshipProbability: 0.024 }, // 2.4%
{ participantId: 'DET', championshipProbability: 0.013 }, // 1.3%
// 24 more teams with decreasing odds
...Array.from({ length: 24 }, (_, i) => ({
participantId: `TEAM${i + 9}`,
championshipProbability: 0.013 / (i + 2), // Decreasing odds
})),
];
const results = calculateICM(participants);
expect(results.size).toBe(32);
// Colorado (favorite) should have highest P(1st)
const colProbs = icmResultToArray(results.get('COL')!);
expect(colProbs[0]).toBeGreaterThan(0.05); // Should have >5% chance of 1st
// Even the worst team should have some probability for all placements
const worstProbs = icmResultToArray(results.get('TEAM32')!);
worstProbs.forEach(p => {
expect(p).toBeGreaterThan(0); // Not zero
expect(p).toBeLessThan(1); // Valid probability
});
// Column sums should equal 1.0 (each position distributed across all teams)
for (let place = 0; place < 8; place++) {
let colSum = 0;
results.forEach((result) => {
const probs = icmResultToArray(result);
colSum += probs[place];
});
expect(colSum).toBeCloseTo(1.0, 2);
}
});
it('handles edge case with single participant', () => {
const participants: ParticipantChips[] = [
{ participantId: '1', championshipProbability: 1.0 },
];
const results = calculateICM(participants);
expect(results.size).toBe(1);
const probs = icmResultToArray(results.get('1')!);
// With 1 team and 8 positions, each column gets 100%, so row sums to 800%
const sum = probs.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(8.0, 1); // 8 positions * 100% each
});
it('handles zero championship probabilities gracefully', () => {
const participants: ParticipantChips[] = [
{ participantId: '1', championshipProbability: 0 },
{ participantId: '2', championshipProbability: 0 },
{ participantId: '3', championshipProbability: 0 },
];
const results = calculateICM(participants);
expect(results.size).toBe(3);
// Should give equal probabilities when all have zero odds
// With 3 teams and 8 positions, each position has 33.33% per team
results.forEach((result) => {
const probs = icmResultToArray(result);
probs.forEach(p => {
expect(p).toBeCloseTo(1/3, 2); // Each team gets equal share of each position
});
});
});
it('normalizes championship probabilities that do not sum to 1.0', () => {
const participants: ParticipantChips[] = [
{ participantId: '1', championshipProbability: 0.6 }, // 60% (with vig)
{ participantId: '2', championshipProbability: 0.55 }, // 55% (with vig)
// Total > 1.0, should be normalized
];
const results = calculateICM(participants);
expect(results.size).toBe(2);
// Verify columns sum to 1.0
for (let place = 0; place < 8; place++) {
let colSum = 0;
results.forEach((result) => {
const probs = icmResultToArray(result);
colSum += probs[place];
});
expect(colSum).toBeCloseTo(1.0, 2);
}
});
it('returns empty map for empty input', () => {
const results = calculateICM([]);
expect(results.size).toBe(0);
});
it('maintains probability ordering for sorted championship odds', () => {
const participants: ParticipantChips[] = [
{ participantId: '1st', championshipProbability: 0.40 },
{ participantId: '2nd', championshipProbability: 0.30 },
{ participantId: '3rd', championshipProbability: 0.20 },
{ participantId: '4th', championshipProbability: 0.10 },
];
const results = calculateICM(participants);
const first = icmResultToArray(results.get('1st')!);
const second = icmResultToArray(results.get('2nd')!);
const third = icmResultToArray(results.get('3rd')!);
const fourth = icmResultToArray(results.get('4th')!);
// P(1st place) should be ordered
expect(first[0]).toBeGreaterThan(second[0]);
expect(second[0]).toBeGreaterThan(third[0]);
expect(third[0]).toBeGreaterThan(fourth[0]);
});
});
describe('calculateICMFromOdds', () => {
it('converts American odds to ICM probabilities', () => {
const odds = [
{ participantId: 'COL', odds: 550 }, // +550
{ participantId: 'FLA', odds: 800 }, // +800
{ participantId: 'ARI', odds: 100000 }, // +100000 (longshot)
];
const results = calculateICMFromOdds(odds);
expect(results.size).toBe(3);
// Colorado should have better odds than Arizona
const colProbs = icmResultToArray(results.get('COL')!);
const ariProbs = icmResultToArray(results.get('ARI')!);
expect(colProbs[0]).toBeGreaterThan(ariProbs[0]);
// Even Arizona should have some probability
expect(ariProbs[0]).toBeGreaterThan(0);
});
it('handles negative odds (favorites)', () => {
const odds = [
{ participantId: 'FAV', odds: -200 }, // Favorite
{ participantId: 'DOG', odds: 500 }, // Underdog
];
const results = calculateICMFromOdds(odds);
const favProbs = icmResultToArray(results.get('FAV')!);
const dogProbs = icmResultToArray(results.get('DOG')!);
// Favorite should have higher P(1st)
expect(favProbs[0]).toBeGreaterThan(dogProbs[0]);
});
it('uses custom scoring places', () => {
const odds = [
{ participantId: '1', odds: 200 },
{ participantId: '2', odds: 300 },
{ participantId: '3', odds: 400 },
];
const results = calculateICMFromOdds(odds, 5); // 5 scoring places
results.forEach((result) => {
const probs = icmResultToArray(result);
// Should still have 8 values but calculated for 5 places
expect(probs).toHaveLength(8);
});
});
});
describe('icmResultToArray', () => {
it('converts ICM result to array format', () => {
const icmResult = {
participantId: 'TEST',
probabilities: {
first: 0.25,
second: 0.20,
third: 0.15,
fourth: 0.12,
fifth: 0.10,
sixth: 0.08,
seventh: 0.06,
eighth: 0.04,
},
};
const array = icmResultToArray(icmResult);
expect(array).toEqual([0.25, 0.20, 0.15, 0.12, 0.10, 0.08, 0.06, 0.04]);
expect(array).toHaveLength(8);
});
});
describe('integration: realistic NHL 32-team scenario', () => {
it('calculates reasonable probabilities for full NHL league', () => {
// Full 32-team NHL with realistic odds distribution
const odds = [
{ participantId: 'COL', odds: 550 },
{ participantId: 'FLA', odds: 800 },
{ participantId: 'VGK', odds: 800 },
{ participantId: 'TBL', odds: 1000 },
{ participantId: 'NJD', odds: 1400 },
{ participantId: 'TOR', odds: 2500 },
{ participantId: 'NYR', odds: 4000 },
{ participantId: 'DET', odds: 7500 },
{ participantId: 'VAN', odds: 7500 },
{ participantId: 'NYI', odds: 15000 },
{ participantId: 'NSH', odds: 40000 },
// 21 more teams with increasing odds
...Array.from({ length: 21 }, (_, i) => ({
participantId: `TEAM${i + 12}`,
odds: 40000 + (i + 1) * 5000, // Increasing odds
})),
];
const results = calculateICMFromOdds(odds);
expect(results.size).toBe(32);
// Favorite (Colorado) should have reasonable championship probability
const colProbs = icmResultToArray(results.get('COL')!);
expect(colProbs[0]).toBeGreaterThan(0.05); // >5% for 1st
expect(colProbs[0]).toBeLessThan(0.35); // <35% for 1st (not guaranteed)
// Middle team (Detroit) should have middling probabilities
const detProbs = icmResultToArray(results.get('DET')!);
expect(detProbs[0]).toBeGreaterThan(0); // Some chance
expect(detProbs[0]).toBeLessThan(0.10); // But not high
// Longshot should have very small but non-zero probabilities
const longProbs = icmResultToArray(results.get('TEAM32')!);
expect(longProbs[0]).toBeGreaterThan(0); // Not impossible
expect(longProbs[0]).toBeLessThan(0.05); // But unlikely (with 32 teams, even worst has ~3% uniform)
// Column sums should equal 1.0 (each position distributed across all teams)
for (let place = 0; place < 8; place++) {
let colSum = 0;
results.forEach((result) => {
const probs = icmResultToArray(result);
colSum += probs[place];
});
expect(colSum).toBeCloseTo(1.0, 2);
}
});
});
});