brackt/app/services/icm-calculator.ts
Chris Parsons 840212c4f8 feat: implement sports season expected value calculations and probability updates
- Added loader and action functions for managing expected values in sports seasons.
- Implemented UI for recalculating probabilities based on participant results.
- Created a service to update probabilities after results are finalized, including handling finished and unfinished participants.
- Developed tests for the probability updater service to ensure correct functionality.
- Introduced a preview feature to show potential changes before applying updates.
2025-11-21 22:05:50 -08:00

239 lines
7.7 KiB
TypeScript

/**
* ICM (Independent Chip Model) Calculator - Monte Carlo Simulation
*
* Calculates probability distributions for tournament placements based on
* championship odds using Monte Carlo simulation (100,000+ iterations).
*
* Algorithm:
* 1. Pick 1st place: weighted random selection based on championship probabilities
* 2. Remove winner, pick 2nd place: weighted random from remaining
* 3. Continue for all 8 positions
* 4. Repeat 100,000 times
* 5. Calculate probabilities from simulation results
*
* This approach is much faster and more memory-efficient than exact recursive
* calculation for large fields (30+ participants). Results converge to true ICM
* probabilities with sufficient iterations.
*/
/**
* Participant with championship probability (chip stack)
*/
export interface ParticipantChips {
participantId: string;
championshipProbability: number; // 0-1 (e.g., 0.154 = 15.4%)
}
/**
* ICM result for a single participant
* Probabilities for finishing in each placement
*/
export interface ICMResult {
participantId: string;
probabilities: {
first: number;
second: number;
third: number;
fourth: number;
fifth: number;
sixth: number;
seventh: number;
eighth: number;
};
}
/**
* Simulate one tournament using weighted random selection
*
* @param participants Participants with championship probabilities (weights)
* @param scoringPlaces Number of positions to simulate
* @returns Array of participant IDs in finish order [1st, 2nd, ..., 8th]
*/
function simulateTournament(
participants: ParticipantChips[],
scoringPlaces: number
): string[] {
const placements: string[] = [];
let remaining = [...participants];
for (let pos = 0; pos < scoringPlaces && remaining.length > 0; pos++) {
// Calculate total weight of remaining participants
const totalWeight = remaining.reduce((sum, p) => sum + p.championshipProbability, 0);
// Weighted random selection
const rand = Math.random() * totalWeight;
let cumulative = 0;
let selected = remaining[0];
for (const p of remaining) {
cumulative += p.championshipProbability;
if (rand <= cumulative) {
selected = p;
break;
}
}
// Record placement and remove from remaining
placements.push(selected.participantId);
remaining = remaining.filter(p => p.participantId !== selected.participantId);
}
return placements;
}
/**
* Calculate ICM probability distribution for all participants using Monte Carlo simulation
*
* Simulates 100,000 tournaments using weighted random selection based on championship
* probabilities. Much faster and more memory-efficient than exact recursive calculation
* for large fields (30+ participants).
*
* @param participants Array of participants with championship probabilities
* @param scoringPlaces Number of places that score points (default 8)
* @param iterations Number of simulations to run (default 100,000)
* @returns Map of participantId to probability distribution
*
* @example
* const participants = [
* { participantId: 'OKC', championshipProbability: 0.364 }, // 36.4% (+175 odds)
* { participantId: 'BOS', championshipProbability: 0.300 },
* // ... 28 more NBA teams
* ];
* const results = calculateICM(participants);
* // Results for all 30 teams, each with P(1st) through P(8th) from simulation
*/
export function calculateICM(
participants: ParticipantChips[],
scoringPlaces: number = 8,
iterations: number = 100000
): Map<string, ICMResult> {
if (participants.length === 0) {
return new Map();
}
// Normalize championship probabilities to sum to 1.0 (remove bookmaker vig)
const totalProb = participants.reduce((sum, p) => sum + p.championshipProbability, 0);
const normalized = participants.map(p => ({
...p,
championshipProbability: totalProb > 0 ? p.championshipProbability / totalProb : 1 / participants.length,
}));
console.log(`[ICM Simulation] Starting ${iterations.toLocaleString()} simulations for ${normalized.length} participants`);
const startTime = Date.now();
// Initialize counters for each participant
const counts = new Map<string, number[]>();
for (const p of normalized) {
counts.set(p.participantId, Array(scoringPlaces).fill(0));
}
// Run simulations
for (let iter = 0; iter < iterations; iter++) {
const placements = simulateTournament(normalized, scoringPlaces);
// Record results
for (let pos = 0; pos < placements.length; pos++) {
counts.get(placements[pos])![pos]++;
}
// Progress logging every 10,000 iterations
if ((iter + 1) % 10000 === 0) {
const progress = ((iter + 1) / iterations * 100).toFixed(0);
const elapsed = Date.now() - startTime;
const rate = (iter + 1) / (elapsed / 1000);
console.log(`[ICM Simulation] ${progress}% complete (${(iter + 1).toLocaleString()}/${iterations.toLocaleString()}) - ${rate.toFixed(0)} sims/sec`);
}
}
// Convert counts to probabilities
const results = new Map<string, ICMResult>();
for (const p of normalized) {
const participantCounts = counts.get(p.participantId)!;
const probabilities = participantCounts.map(count => count / iterations);
results.set(p.participantId, {
participantId: p.participantId,
probabilities: {
first: probabilities[0] || 0,
second: probabilities[1] || 0,
third: probabilities[2] || 0,
fourth: probabilities[3] || 0,
fifth: probabilities[4] || 0,
sixth: probabilities[5] || 0,
seventh: probabilities[6] || 0,
eighth: probabilities[7] || 0,
},
});
}
const elapsed = Date.now() - startTime;
console.log(`[ICM Simulation] Completed in ${(elapsed / 1000).toFixed(1)}s (${(iterations / (elapsed / 1000)).toFixed(0)} sims/sec)`);
return results;
}
/**
* Convert ICM result to array format for database storage
*
* @param icmResult ICM result object
* @returns Array of 8 probabilities [P(1st), P(2nd), ..., P(8th)]
*/
export function icmResultToArray(icmResult: ICMResult): number[] {
return [
icmResult.probabilities.first,
icmResult.probabilities.second,
icmResult.probabilities.third,
icmResult.probabilities.fourth,
icmResult.probabilities.fifth,
icmResult.probabilities.sixth,
icmResult.probabilities.seventh,
icmResult.probabilities.eighth,
];
}
/**
* Convert American odds to implied probability
*
* @param odds American odds (e.g., +175, -200)
* @returns Implied probability (0-1)
*/
function convertAmericanOddsToProbability(odds: number): number {
if (odds > 0) {
// Positive odds (underdog): probability = 100 / (odds + 100)
return 100 / (odds + 100);
} else {
// Negative odds (favorite): probability = -odds / (-odds + 100)
return -odds / (-odds + 100);
}
}
/**
* Convert futures odds to championship probabilities and calculate ICM
*
* Complete pipeline from odds to probability distributions.
*
* @param futuresOdds Array of {participantId, odds} with American odds
* @param scoringPlaces Number of places that score points (default 8)
* @returns Map of participantId to ICM result
*
* @example
* const odds = [
* { participantId: 'OKC', odds: 175 }, // +175 (36.4% implied)
* { participantId: 'BOS', odds: -150 }, // -150 (60% implied)
* // ... more teams
* ];
* const results = calculateICMFromOdds(odds);
*/
export function calculateICMFromOdds(
futuresOdds: Array<{ participantId: string; odds: number }>,
scoringPlaces: number = 8
): Map<string, ICMResult> {
// Convert odds to championship probabilities
const participants: ParticipantChips[] = futuresOdds.map(({ participantId, odds }) => ({
participantId,
championshipProbability: convertAmericanOddsToProbability(odds),
}));
return calculateICM(participants, scoringPlaces);
}