/** * ICM (Independent Chip Model) Calculator * * Calculates probability distributions for tournament placements based on * championship odds (futures). Works for any number of participants. * * Key Concepts: * - Championship probability = "chip stack" in poker ICM terms * - Distributes probabilities across all scoring placements (1st-8th) * - Every participant gets probabilities, even with tiny championship odds * - More accurate than bracket simulation for pre-playoff scenarios * * Based on poker tournament ICM algorithms: * - https://en.wikipedia.org/wiki/Independent_Chip_Model * - https://www.holdemresources.net/blog/high-accuracy-mtt-icm/ */ /** * Participant with championship probability */ 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; }; } /** * Calculate probability that participant A beats participant B in a head-to-head * * Uses relative chip stacks (championship probabilities) * * @param chipA Championship probability of participant A * @param chipB Championship probability of participant B * @returns Probability that A beats B (0-1) */ function headToHeadProbability(chipA: number, chipB: number): number { if (chipA + chipB === 0) return 0.5; return chipA / (chipA + chipB); } /** * Calculate ICM probabilities using a power-law distribution * * This approach uses the championship probability as a "strength" indicator * and distributes probabilities across placements using a weighted model. * * Key insight: Stronger teams (higher championship odds) should have: * - Much higher probability of top placements * - Lower probability of bottom placements * * @param myChip Championship probability of this participant * @param allChips Array of all championship probabilities (sorted desc) * @param myIndex Index of this participant in sorted array * @param place Target placement (1 = first, 2 = second, etc.) * @param totalPlaces Total number of scoring places * @returns Probability of finishing in that place */ function calculatePlaceProbability( myChip: number, allChips: number[], myIndex: number, place: number, totalPlaces: number ): number { const n = allChips.length; const totalChips = allChips.reduce((sum, c) => sum + c, 0); if (totalChips === 0) { return 1 / totalPlaces; } // Normalize chip to relative strength (0-1) const myStrength = myChip / totalChips; // For each place, calculate probability using a weighted distribution // Stronger teams have exponentially higher probability of better placements // Base probability for this place based on team's overall strength // Uses exponential decay: stronger teams heavily favor top places const strengthFactor = Math.pow(myStrength * n, 1.2); // Place preference: higher places weighted more for strong teams // Place 1 = weight 1.0, Place 8 = weight closer to 0 const placeWeight = Math.pow((totalPlaces - place + 1) / totalPlaces, 2.5); // Anti-place preference: lower places weighted more for weak teams const antiPlaceWeight = Math.pow(place / totalPlaces, 2.5); // Combine: strong teams get placeWeight, weak teams get antiPlaceWeight const combinedWeight = myStrength * placeWeight + (1 - myStrength) * antiPlaceWeight; return strengthFactor * combinedWeight; } /** * Calculate ICM probability distribution for all participants * * Uses simplified ICM algorithm optimized for fantasy sports scoring. * Produces a doubly-stochastic matrix where: * - Each row (participant) sums to 1.0 * - Each column (placement) sums to 1.0 * * @param participants Array of participants with championship probabilities * @param scoringPlaces Number of places that score points (default 8) * @returns Map of participantId to probability distribution * * @example * const participants = [ * { participantId: 'COL', championshipProbability: 0.154 }, // 15.4% * { participantId: 'FLA', championshipProbability: 0.111 }, // 11.1% * // ... 30 more NHL teams * ]; * const results = calculateICM(participants); * // Results for all 32 teams, each with P(1st) through P(8th) */ export function calculateICM( participants: ParticipantChips[], scoringPlaces: number = 8 ): Map { if (participants.length === 0) { return new Map(); } // Normalize championship probabilities to sum to 1.0 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, })); const n = normalized.length; // Create initial probability matrix with strong but convergent differentiation const probMatrix: number[][] = []; for (let i = 0; i < n; i++) { probMatrix[i] = []; const strength = normalized[i].championshipProbability; for (let place = 0; place < scoringPlaces; place++) { // Use moderate power to maintain ordering while allowing convergence // Square root of strength scaled by n to create differentiation const strengthFactor = Math.pow(strength * n, 1.5); // Exponential decay based on strength // Strong teams → sharp decay (probability concentrated on top positions) // Weak teams → gradual decay (probability spread across positions) const decayRate = 0.5 + strength * 3; const decayFactor = Math.exp(-place * decayRate); probMatrix[i][place] = strengthFactor * decayFactor; } } // Normalize ONLY columns (each placement position sums to 1.0) // This ensures exactly 100% probability is distributed for each position // Row sums will be < 100% for teams unlikely to finish in top 8 for (let place = 0; place < scoringPlaces; place++) { let colSum = 0; for (let i = 0; i < n; i++) { colSum += probMatrix[i][place]; } if (colSum > 0) { for (let i = 0; i < n; i++) { probMatrix[i][place] /= colSum; } } else { // Equal distribution if column sum is zero for (let i = 0; i < n; i++) { probMatrix[i][place] = 1 / n; } } } // Convert matrix to result map const results = new Map(); for (let i = 0; i < n; i++) { results.set(normalized[i].participantId, { participantId: normalized[i].participantId, probabilities: { first: probMatrix[i][0], second: probMatrix[i][1], third: probMatrix[i][2], fourth: probMatrix[i][3], fifth: probMatrix[i][4], sixth: probMatrix[i][5], seventh: probMatrix[i][6], eighth: probMatrix[i][7], }, }); } 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 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: 'COL', odds: 550 }, // +550 * { participantId: 'ARI', odds: 100000 }, // +100000 (longshot) * // ... all 32 NHL teams * ]; * const results = calculateICMFromOdds(odds); * // Every team gets probabilities, even Arizona with +100000 odds */ export function calculateICMFromOdds( futuresOdds: Array<{ participantId: string; odds: number }>, scoringPlaces: number = 8 ): Map { // Convert odds to probabilities const participants: ParticipantChips[] = futuresOdds.map(({ participantId, odds }) => { // Convert American odds to probability let probability: number; if (odds > 0) { probability = 100 / (odds + 100); } else { probability = Math.abs(odds) / (Math.abs(odds) + 100); } return { participantId, championshipProbability: probability, }; }); return calculateICM(participants, scoringPlaces); }