- 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>
242 lines
7.8 KiB
TypeScript
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);
|
|
}
|