/** * Probability Engine * * Converts betting odds to probabilities and transforms them into Elo ratings * for Monte Carlo simulation of playoff brackets. * * Key concepts: * 1. Futures odds (e.g., +550 to win championship) are "compressed" probabilities * 2. We "decompress" them using power transformation to get single-game strength * 3. Map decompressed strength to Elo scale * 4. Use Elo to calculate head-to-head win probabilities */ /** * Odds format types supported by the system */ export type OddsFormat = 'american' | 'decimal'; /** * Calibration parameters for Elo conversion * These are sport-specific and empirically tuned */ export interface EloCalibrationParams { /** Power transformation exponent (typically 0.25-0.5) */ exponent: number; /** Minimum Elo rating in the system */ eloMin: number; /** Maximum Elo rating in the system */ eloMax: number; } /** * Default calibration parameters (starting point before calibration) */ export const DEFAULT_CALIBRATION: EloCalibrationParams = { exponent: 0.33, // Cube root eloMin: 1250, eloMax: 1750, }; /** * Convert American odds to implied probability * * American odds work as follows: * - Positive (+500): Underdog. Probability = 100 / (odds + 100) * - Negative (-200): Favorite. Probability = |odds| / (|odds| + 100) * * @param odds American odds (e.g., +500, -200) * @returns Implied probability as decimal (0-1) * * @example * convertAmericanOddsToProbability(500) // 0.1667 (16.67%) * convertAmericanOddsToProbability(-200) // 0.6667 (66.67%) */ export function convertAmericanOddsToProbability(odds: number): number { if (odds === 0) { throw new Error('American odds cannot be zero'); } if (odds > 0) { // Underdog: probability = 100 / (odds + 100) return 100 / (odds + 100); } else { // Favorite: probability = |odds| / (|odds| + 100) return Math.abs(odds) / (Math.abs(odds) + 100); } } /** * Convert decimal odds to implied probability * * Decimal odds (European format) are simpler: * Probability = 1 / decimal_odds * * @param odds Decimal odds (e.g., 6.0, 1.5) * @returns Implied probability as decimal (0-1) * * @example * convertDecimalOddsToProbability(6.0) // 0.1667 (16.67%) * convertDecimalOddsToProbability(1.5) // 0.6667 (66.67%) */ export function convertDecimalOddsToProbability(odds: number): number { if (odds <= 1) { throw new Error('Decimal odds must be greater than 1'); } return 1 / odds; } /** * Normalize probabilities to sum to exactly 100% * * Bookmaker odds include "vig" (vigorish/juice), so raw probabilities * typically sum to >100%. This function removes the vig proportionally. * * @param probabilities Array of probabilities (as decimals 0-1) * @returns Normalized probabilities that sum to 1.0 * * @example * normalizeProbabilities([0.55, 0.50]) // [0.5238, 0.4762] (was 105%, now 100%) */ export function normalizeProbabilities(probabilities: number[]): number[] { const sum = probabilities.reduce((acc, p) => acc + p, 0); if (sum === 0) { throw new Error('Cannot normalize probabilities that sum to zero'); } return probabilities.map(p => p / sum); } /** * Decompress championship probability to single-game strength * * Championship futures are "compressed" because they represent winning * multiple games. We apply a power transformation to decompress them * back to relative single-game strength. * * The exponent is empirically calibrated to match actual game betting lines. * * @param championshipProb Championship win probability (0-1) * @param exponent Power transformation exponent (typically 0.25-0.5) * @returns Decompressed strength value * * @example * decompressProbability(0.154, 0.33) // 2.49 (Colorado at 15.4%) * decompressProbability(0.0066, 0.33) // 0.87 (NY Islanders at 0.66%) */ export function decompressProbability( championshipProb: number, exponent: number = DEFAULT_CALIBRATION.exponent ): number { if (championshipProb < 0 || championshipProb > 1) { throw new Error('Championship probability must be between 0 and 1'); } if (exponent <= 0) { throw new Error('Exponent must be positive'); } // Convert to percentage (0-100) for more intuitive scaling const percentage = championshipProb * 100; // Apply power transformation return Math.pow(percentage, exponent); } /** * Map decompressed strength value to Elo rating scale * * Takes the decompressed strength values and normalizes them to * the Elo scale (typically 1250-1750 for NHL/NFL). * * @param strength Decompressed strength value * @param minStrength Minimum strength across all teams * @param maxStrength Maximum strength across all teams * @param params Calibration parameters (Elo min/max) * @returns Elo rating * * @example * mapToElo(2.49, 0.5, 3.0, {eloMin: 1250, eloMax: 1750}) // 1648 */ export function mapToElo( strength: number, minStrength: number, maxStrength: number, params: Pick = DEFAULT_CALIBRATION ): number { if (maxStrength <= minStrength) { throw new Error('Max strength must be greater than min strength'); } if (strength < minStrength || strength > maxStrength) { // Allow slight rounding errors if (Math.abs(strength - minStrength) < 0.001) { strength = minStrength; } else if (Math.abs(strength - maxStrength) < 0.001) { strength = maxStrength; } else { throw new Error('Strength must be between min and max strength'); } } // Normalize to 0-1 range const normalized = (strength - minStrength) / (maxStrength - minStrength); // Map to Elo scale return params.eloMin + (normalized * (params.eloMax - params.eloMin)); } /** * Calculate win probability from Elo ratings * * Uses standard Elo formula with 400-point scaling factor. * A 400-point difference means the stronger team has ~90.9% win probability. * * @param eloA Elo rating of team A * @param eloB Elo rating of team B * @returns Probability that team A wins (0-1) * * @example * eloWinProbability(1648, 1324) // 0.828 (82.8%) * eloWinProbability(1500, 1500) // 0.5 (50%) */ export function eloWinProbability(eloA: number, eloB: number): number { return 1 / (1 + Math.pow(10, (eloB - eloA) / 400)); } /** * Convert futures odds to Elo ratings for all participants * * Complete pipeline: * 1. Convert odds to probabilities * 2. Normalize probabilities (remove vig) * 3. Decompress to strength values * 4. Map to Elo scale * * @param futuresOdds Array of {participantId, odds} objects * @param oddsFormat Format of the odds ('american' or 'decimal') * @param params Calibration parameters * @returns Map of participantId to Elo rating * * @example * const odds = [ * { participantId: '1', odds: 550 }, // Colorado +550 * { participantId: '2', odds: 15000 } // NY Islanders +15000 * ]; * const elos = convertFuturesToElo(odds, 'american'); * // Map { '1' => 1648, '2' => 1324 } */ export function convertFuturesToElo( futuresOdds: Array<{ participantId: string; odds: number }>, oddsFormat: OddsFormat = 'american', params: EloCalibrationParams = DEFAULT_CALIBRATION ): Map { if (futuresOdds.length === 0) { return new Map(); } // Step 1: Convert odds to probabilities const converter = oddsFormat === 'american' ? convertAmericanOddsToProbability : convertDecimalOddsToProbability; const probabilities = futuresOdds.map(({ odds }) => converter(odds)); // Step 2: Normalize (remove vig) const normalized = normalizeProbabilities(probabilities); // Step 3: Decompress to strength values const strengths = normalized.map(p => decompressProbability(p, params.exponent)); // Step 4: Find min/max for normalization const minStrength = Math.min(...strengths); const maxStrength = Math.max(...strengths); // Step 5: Map to Elo scale const eloRatings = new Map(); futuresOdds.forEach(({ participantId }, index) => { const elo = mapToElo(strengths[index], minStrength, maxStrength, params); eloRatings.set(participantId, Math.round(elo)); // Round to integer }); return eloRatings; } /** * Calculate prediction error for calibration * * Compares predicted win probabilities against actual betting lines * to measure calibration accuracy. * * @param predicted Predicted probabilities * @param actual Actual betting line probabilities * @returns Object with mean absolute error and max error * * @example * calculatePredictionError([0.828, 0.565], [0.730, 0.630]) * // { meanError: 0.081, maxError: 0.098 } */ export function calculatePredictionError( predicted: number[], actual: number[] ): { meanError: number; maxError: number } { if (predicted.length !== actual.length) { throw new Error('Predicted and actual arrays must have same length'); } const errors = predicted.map((p, i) => Math.abs(p - actual[i])); const meanError = errors.reduce((sum, e) => sum + e, 0) / errors.length; const maxError = Math.max(...errors); return { meanError, maxError }; }