brackt/app/services/icm-calculator.ts
Chris Parsons 618bc57ec1
Replace console.* with structured logger, fix no-inferrable-types (closes #98) (#199)
- Add app/lib/logger.ts: dev passes through to console; prod routes errors
  to Sentry.captureException and warnings to Sentry.captureMessage, with
  extra context preserved. Uses captureMessage (not captureException) for
  string-only args to avoid fabricated stack traces.
- Add server/logger.ts: dev passes through; prod silences log/info but
  keeps warn/error on stderr (Sentry not initialized in that process).
- Replace all console.* calls across 44 app files and 4 server files.
- Upgrade no-console from warn → error in oxlint; exempt logger files and
  scripts/** via overrides.
- Add typescript/no-inferrable-types rule; fix violations in services and
  simulators. Exempt test files (intentional string widening for switch/if
  tests would break under literal type inference).

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 13:41:39 -07:00

242 lines
7.8 KiB
TypeScript

import { logger } from "~/lib/logger";
/**
* 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 = 8,
iterations = 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,
}));
logger.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++) {
const participantCounts = counts.get(placements[pos]);
if (participantCounts) participantCounts[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);
logger.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) ?? Array(scoringPlaces).fill(0);
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;
logger.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 = 8
): Map<string, ICMResult> {
// Convert odds to championship probabilities
const participants: ParticipantChips[] = futuresOdds.map(({ participantId, odds }) => ({
participantId,
championshipProbability: convertAmericanOddsToProbability(odds),
}));
return calculateICM(participants, scoringPlaces);
}