brackt/app/services/bracket-simulator.ts

345 lines
10 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
/**
* Bracket Simulator
*
* Monte Carlo simulation of single-elimination playoff brackets.
* Supports NHL (8 teams) and NFL (14 teams) formats.
*
* The simulator:
* 1. Takes teams with Elo ratings
* 2. Simulates the bracket many times (typically 100,000)
* 3. Tracks placement frequencies (1st, 2nd, 3-4, 5-8, etc.)
* 4. Returns probability distribution for each team
*/
import { eloWinProbability } from './probability-engine';
/**
* Team with Elo rating for simulation
*/
export interface TeamForSimulation {
participantId: string;
elo: number;
}
/**
* Bracket format configurations
*/
export type BracketFormat = 'nhl-8' | 'nfl-14';
/**
* Result of a single simulation
* Maps participantId to their placement (1 = champion, 2 = runner-up, etc.)
*/
export type SimulationResult = Map<string, number>;
/**
* Aggregated results across all simulations
* For each participant, tracks how many times they finished in each placement
*/
export interface PlacementCounts {
[participantId: string]: {
1: number; // Champion
2: number; // Runner-up
3: number; // Semifinal loser (tied 3-4)
4: number; // Semifinal loser (tied 3-4)
5: number; // Quarterfinal loser (tied 5-8)
6: number; // Quarterfinal loser (tied 5-8)
7: number; // Quarterfinal loser (tied 5-8)
8: number; // Quarterfinal loser (tied 5-8)
};
}
/**
* Probability distribution for a single participant
* Array of 8 probabilities [P(1st), P(2nd), P(3rd), P(4th), P(5th), P(6th), P(7th), P(8th)]
*/
export type ProbabilityDistribution = [number, number, number, number, number, number, number, number];
/**
* Final simulation results
* Maps participantId to their probability distribution
*/
export type SimulationResults = Map<string, ProbabilityDistribution>;
/**
* Simulate a single head-to-head matchup
*
* @param team1 First team
* @param team2 Second team
* @returns Winner of the matchup
*/
function simulateMatchup(team1: TeamForSimulation, team2: TeamForSimulation): TeamForSimulation {
const team1WinProb = eloWinProbability(team1.elo, team2.elo);
const random = Math.random();
return random < team1WinProb ? team1 : team2;
}
/**
* Simulate a single round of the bracket
*
* @param teams Teams in this round (must be even number)
* @returns Winners advancing to next round
*/
function simulateRound(teams: TeamForSimulation[]): TeamForSimulation[] {
if (teams.length % 2 !== 0) {
throw new Error('Number of teams must be even for bracket round');
}
const winners: TeamForSimulation[] = [];
// Simulate each matchup
for (let i = 0; i < teams.length; i += 2) {
const winner = simulateMatchup(teams[i], teams[i + 1]);
winners.push(winner);
}
return winners;
}
/**
* Simulate an entire 8-team bracket
*
* Placement determination:
* - 1st: Finals winner
* - 2nd: Finals loser
* - 3-4: Semifinal losers (tied)
* - 5-8: Quarterfinal losers (tied)
*
* @param teams 8 teams in bracket order
* @returns Map of participantId to placement
*/
function simulate8TeamBracket(teams: TeamForSimulation[]): SimulationResult {
if (teams.length !== 8) {
throw new Error('8-team bracket requires exactly 8 teams');
}
const placements = new Map<string, number>();
// Quarterfinals: 8 → 4
const semifinalists = simulateRound(teams);
const quarterfinalsLosers = teams.filter(t => !semifinalists.some(s => s.participantId === t.participantId));
// Quarterfinal losers tie for 5-8
quarterfinalsLosers.forEach(team => placements.set(team.participantId, 5));
// Semifinals: 4 → 2
const finalists = simulateRound(semifinalists);
const semifinalsLosers = semifinalists.filter(t => !finalists.some(f => f.participantId === t.participantId));
// Semifinal losers tie for 3-4
semifinalsLosers.forEach(team => placements.set(team.participantId, 3));
// Finals: 2 → 1
const champion = simulateMatchup(finalists[0], finalists[1]);
const runnerUp = finalists.find(t => t.participantId !== champion.participantId)!;
placements.set(champion.participantId, 1);
placements.set(runnerUp.participantId, 2);
return placements;
}
/**
* Initialize placement counts for participants
*
* @param participantIds List of participant IDs
* @returns Placement counts initialized to 0
*/
function initializePlacementCounts(participantIds: string[]): PlacementCounts {
const counts: PlacementCounts = {};
participantIds.forEach(id => {
counts[id] = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0 };
});
return counts;
}
/**
* Convert placement counts to probability distributions
*
* @param counts Placement counts from simulations
* @param totalSimulations Total number of simulations run
* @returns Probability distribution for each participant
*/
function countsToDistributions(
counts: PlacementCounts,
totalSimulations: number
): SimulationResults {
const distributions = new Map<string, ProbabilityDistribution>();
Object.entries(counts).forEach(([participantId, placementCounts]) => {
const distribution: ProbabilityDistribution = [
placementCounts[1] / totalSimulations,
placementCounts[2] / totalSimulations,
placementCounts[3] / totalSimulations,
placementCounts[4] / totalSimulations,
placementCounts[5] / totalSimulations,
placementCounts[6] / totalSimulations,
placementCounts[7] / totalSimulations,
placementCounts[8] / totalSimulations,
];
distributions.set(participantId, distribution);
});
return distributions;
}
/**
* Run Monte Carlo simulation of playoff bracket
*
* @param teams Teams with Elo ratings
* @param format Bracket format ('nhl-8' or 'nfl-14')
* @param simulations Number of simulations to run (default: 100,000)
* @param onProgress Optional progress callback (called every 10,000 simulations)
* @returns Probability distribution for each team
*
* @example
* const teams = [
* { participantId: '1', elo: 1650 },
* { participantId: '2', elo: 1600 },
* // ... 6 more teams
* ];
* const results = await simulateBracket(teams, 'nhl-8', 100000);
* // Map { '1' => [0.25, 0.18, 0.15, ...], '2' => [0.18, 0.20, ...], ... }
*/
export async function simulateBracket(
teams: TeamForSimulation[],
format: BracketFormat = 'nhl-8',
simulations: number = 100000,
onProgress?: (current: number, total: number) => void
): Promise<SimulationResults> {
// Validate input
if (format === 'nhl-8' && teams.length !== 8) {
throw new Error('NHL format requires exactly 8 teams');
}
if (simulations <= 0) {
throw new Error('Number of simulations must be positive');
}
// Initialize placement counters
const participantIds = teams.map(t => t.participantId);
const placementCounts = initializePlacementCounts(participantIds);
// Run simulations
for (let i = 0; i < simulations; i++) {
// Simulate bracket (currently only 8-team supported)
const result = simulate8TeamBracket(teams);
// Record placements
result.forEach((placement, participantId) => {
// Handle ties: placement 3 or 4 both count as tied-3rd
// placement 5-8 all count as tied-5th
if (placement >= 3 && placement <= 4) {
placementCounts[participantId][3] += 0.5; // Split tied placements
placementCounts[participantId][4] += 0.5;
} else if (placement >= 5 && placement <= 8) {
// Split across all 5-8 placements
placementCounts[participantId][5] += 0.25;
placementCounts[participantId][6] += 0.25;
placementCounts[participantId][7] += 0.25;
placementCounts[participantId][8] += 0.25;
} else {
// 1st or 2nd place - no ties
placementCounts[participantId][placement as 1 | 2] += 1;
}
});
// Report progress every 10,000 simulations
if (onProgress && (i + 1) % 10000 === 0) {
onProgress(i + 1, simulations);
}
}
// Convert counts to probabilities
const results = countsToDistributions(placementCounts, simulations);
// Final progress callback
if (onProgress) {
onProgress(simulations, simulations);
}
return results;
}
/**
* Simulate bracket synchronously (blocking)
*
* Use this for smaller simulation counts or when you don't need progress updates.
* For large simulations (100k+), prefer the async version with progress callbacks.
*
* @param teams Teams with Elo ratings
* @param format Bracket format
* @param simulations Number of simulations to run
* @returns Probability distribution for each team
*/
export function simulateBracketSync(
teams: TeamForSimulation[],
format: BracketFormat = 'nhl-8',
simulations: number = 100000
): SimulationResults {
// Validate input
if (format === 'nhl-8' && teams.length !== 8) {
throw new Error('NHL format requires exactly 8 teams');
}
if (simulations <= 0) {
throw new Error('Number of simulations must be positive');
}
// Initialize placement counters
const participantIds = teams.map(t => t.participantId);
const placementCounts = initializePlacementCounts(participantIds);
// Run simulations
for (let i = 0; i < simulations; i++) {
const result = simulate8TeamBracket(teams);
// Record placements
result.forEach((placement, participantId) => {
if (placement >= 3 && placement <= 4) {
placementCounts[participantId][3] += 0.5;
placementCounts[participantId][4] += 0.5;
} else if (placement >= 5 && placement <= 8) {
placementCounts[participantId][5] += 0.25;
placementCounts[participantId][6] += 0.25;
placementCounts[participantId][7] += 0.25;
placementCounts[participantId][8] += 0.25;
} else {
placementCounts[participantId][placement as 1 | 2] += 1;
}
});
}
// Convert counts to probabilities
return countsToDistributions(placementCounts, simulations);
}
/**
* Get expected placement counts from probability distribution
*
* Useful for debugging/validation
*
* @param distribution Probability distribution
* @param simulations Number of simulations that produced this distribution
* @returns Expected count for each placement
*/
export function getExpectedCounts(
distribution: ProbabilityDistribution,
simulations: number
): Record<number, number> {
return {
1: Math.round(distribution[0] * simulations),
2: Math.round(distribution[1] * simulations),
3: Math.round(distribution[2] * simulations),
4: Math.round(distribution[3] * simulations),
5: Math.round(distribution[4] * simulations),
6: Math.round(distribution[5] * simulations),
7: Math.round(distribution[6] * simulations),
8: Math.round(distribution[7] * simulations),
};
}