brackt/app/services/__tests__/bracket-simulator.test.ts

281 lines
9.5 KiB
TypeScript
Raw Normal View History

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
import { describe, it, expect } from 'vitest';
import {
simulateBracket,
simulateBracketSync,
getExpectedCounts,
type TeamForSimulation,
type ProbabilityDistribution,
} from '../bracket-simulator';
describe('bracket-simulator', () => {
describe('simulateBracketSync', () => {
it('simulates 8-team bracket with equal ratings', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1500 },
{ participantId: '2', elo: 1500 },
{ participantId: '3', elo: 1500 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1500 },
{ participantId: '6', elo: 1500 },
{ participantId: '7', elo: 1500 },
{ participantId: '8', elo: 1500 },
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
expect(results.size).toBe(8);
// With equal ratings, each team should have roughly equal probability
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
results.forEach((distribution, _participantId) => {
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
// Sum of probabilities should be 1.0
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
// Each team should win championship ~12.5% of the time (1/8)
expect(distribution[0]).toBeGreaterThan(0.08);
expect(distribution[0]).toBeLessThan(0.17);
});
});
it('gives higher win probability to stronger team', () => {
const teams: TeamForSimulation[] = [
{ participantId: 'strong', elo: 1700 }, // Much stronger
{ participantId: '2', elo: 1500 },
{ participantId: '3', elo: 1500 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1500 },
{ participantId: '6', elo: 1500 },
{ participantId: '7', elo: 1500 },
{ participantId: 'weak', elo: 1300 }, // Much weaker
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
const strongDist = results.get('strong')!;
const weakDist = results.get('weak')!;
// Strong team should have higher championship probability
expect(strongDist[0]).toBeGreaterThan(weakDist[0]);
expect(strongDist[0]).toBeGreaterThan(0.2); // Should win >20% of the time
// Weak team should have lower championship probability
expect(weakDist[0]).toBeLessThan(0.1); // Should win <10% of the time
});
it('handles extreme Elo differences', () => {
const teams: TeamForSimulation[] = [
{ participantId: 'champion', elo: 1900 }, // Dominant
{ participantId: '2', elo: 1400 },
{ participantId: '3', elo: 1400 },
{ participantId: '4', elo: 1400 },
{ participantId: '5', elo: 1400 },
{ participantId: '6', elo: 1400 },
{ participantId: '7', elo: 1400 },
{ participantId: '8', elo: 1400 },
];
const results = simulateBracketSync(teams, 'nhl-8', 10000);
const championDist = results.get('champion')!;
// Dominant team should win very often
expect(championDist[0]).toBeGreaterThan(0.6); // >60% championship probability
});
it('ensures probabilities sum to 1.0 for each team', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1650 },
{ participantId: '2', elo: 1600 },
{ participantId: '3', elo: 1550 },
{ participantId: '4', elo: 1500 },
{ participantId: '5', elo: 1450 },
{ participantId: '6', elo: 1400 },
{ participantId: '7', elo: 1350 },
{ participantId: '8', elo: 1300 },
];
const results = simulateBracketSync(teams, 'nhl-8', 5000);
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
results.forEach((distribution, _participantId) => {
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
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
});
});
it('throws error for wrong number of teams', () => {
const teams: TeamForSimulation[] = [
{ participantId: '1', elo: 1500 },
{ participantId: '2', elo: 1500 },
];
expect(() => {
simulateBracketSync(teams, 'nhl-8', 1000);
}).toThrow('requires exactly 8 teams');
});
it('throws error for invalid simulation count', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
expect(() => {
simulateBracketSync(teams, 'nhl-8', 0);
}).toThrow('must be positive');
expect(() => {
simulateBracketSync(teams, 'nhl-8', -100);
}).toThrow('must be positive');
});
it('produces consistent results with same random seed', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500 + i * 50,
}));
// Run with small sample size for speed
const results1 = simulateBracketSync(teams, 'nhl-8', 1000);
const results2 = simulateBracketSync(teams, 'nhl-8', 1000);
// Results won't be identical due to randomness, but should be in same ballpark
const dist1 = results1.get('1')!;
const dist2 = results2.get('1')!;
// Championship probabilities should be within 10% of each other
expect(Math.abs(dist1[0] - dist2[0])).toBeLessThan(0.1);
});
it('returns valid probability distribution structure', () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const results = simulateBracketSync(teams, 'nhl-8', 1000);
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
results.forEach((distribution, _participantId) => {
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
// Should be array of 8 numbers
expect(distribution).toHaveLength(8);
// All probabilities should be between 0 and 1
distribution.forEach(prob => {
expect(prob).toBeGreaterThanOrEqual(0);
expect(prob).toBeLessThanOrEqual(1);
});
});
});
});
describe('simulateBracket (async)', () => {
it('simulates bracket asynchronously', async () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const results = await simulateBracket(teams, 'nhl-8', 5000);
expect(results.size).toBe(8);
results.forEach((distribution) => {
const sum = distribution.reduce((acc, p) => acc + p, 0);
expect(sum).toBeCloseTo(1.0, 1);
});
});
it('calls progress callback', async () => {
const teams: TeamForSimulation[] = Array.from({ length: 8 }, (_, i) => ({
participantId: String(i + 1),
elo: 1500,
}));
const progressCalls: Array<{ current: number; total: number }> = [];
await simulateBracket(teams, 'nhl-8', 25000, (current, total) => {
progressCalls.push({ current, total });
});
// Should have progress updates at 10k, 20k, and 25k
expect(progressCalls.length).toBeGreaterThan(0);
expect(progressCalls[progressCalls.length - 1].current).toBe(25000);
expect(progressCalls[progressCalls.length - 1].total).toBe(25000);
});
});
describe('getExpectedCounts', () => {
it('converts probabilities to expected counts', () => {
const distribution: ProbabilityDistribution = [
0.25, // 25% 1st
0.20, // 20% 2nd
0.15, // 15% 3rd
0.10, // 10% 4th
0.10, // 10% 5th
0.10, // 10% 6th
0.05, // 5% 7th
0.05, // 5% 8th
];
const counts = getExpectedCounts(distribution, 10000);
expect(counts[1]).toBe(2500);
expect(counts[2]).toBe(2000);
expect(counts[3]).toBe(1500);
expect(counts[4]).toBe(1000);
expect(counts[5]).toBe(1000);
expect(counts[6]).toBe(1000);
expect(counts[7]).toBe(500);
expect(counts[8]).toBe(500);
});
it('rounds to nearest integer', () => {
const distribution: ProbabilityDistribution = [
0.123, 0.123, 0.123, 0.123, 0.123, 0.123, 0.131, 0.131,
];
const counts = getExpectedCounts(distribution, 1000);
// Should round 123 and 131
expect(counts[1]).toBe(123);
expect(counts[7]).toBe(131);
});
});
describe('integration: realistic NHL scenario', () => {
it('simulates NHL playoff bracket with realistic Elo ratings', async () => {
// Based on plan: Colorado (1648), NY Islanders (1324), etc.
const teams: TeamForSimulation[] = [
{ participantId: 'COL', elo: 1648 }, // Colorado (strongest)
{ participantId: 'FLA', elo: 1620 }, // Florida
{ participantId: 'VGK', elo: 1615 }, // Vegas
{ participantId: 'TBL', elo: 1590 }, // Tampa Bay
{ participantId: 'NJD', elo: 1565 }, // New Jersey
{ participantId: 'TOR', elo: 1510 }, // Toronto
{ participantId: 'NYR', elo: 1470 }, // NY Rangers
{ participantId: 'NYI', elo: 1324 }, // NY Islanders (weakest)
];
const results = await simulateBracket(teams, 'nhl-8', 10000);
const colDist = results.get('COL')!;
const nyiDist = results.get('NYI')!;
// Colorado should have highest championship probability
expect(colDist[0]).toBeGreaterThan(0.15); // >15%
// NY Islanders should have lowest championship probability
expect(nyiDist[0]).toBeLessThan(0.08); // <8%
// Colorado should be more likely to win than NYI
expect(colDist[0]).toBeGreaterThan(nyiDist[0]);
// Probabilities should sum to 1
const colSum = colDist.reduce((acc, p) => acc + p, 0);
const nyiSum = nyiDist.reduce((acc, p) => acc + p, 0);
expect(colSum).toBeCloseTo(1.0, 1);
expect(nyiSum).toBeCloseTo(1.0, 1);
});
});
});