Implements Phase 5.2 of the EV system with Harville-Malmuth Independent Chip Model for calculating participant placement probabilities from futures odds. ## Key Features ### ICM Probability Calculator - Implements Harville-Malmuth method for distributing probabilities - Converts American odds to championship probabilities - Generates P(1st) through P(8th) for all participants - Column-normalized: each placement sums to 100% across all teams - Works with any number of participants (not limited to 8) ### Admin UI - Futures Odds Entry - Enter American odds (e.g., +550, -200) for championship futures - Live preview of ICM-calculated probability distributions - Displays all 8 placement probabilities - Persists odds for editing on subsequent visits - Automatic probability normalization (removes bookmaker vig) ### Database Schema Updates - Renamed participant_expected_values.season_id → sports_season_id - Updated foreign key to reference sports_seasons instead of seasons - Added source_odds field to store original futures odds - Migration 0025: Column rename and FK update - Migration 0026: Add source_odds field ### Model Layer - participant-expected-value: CRUD operations for probability distributions - Supports multiple probability sources (manual, futures_odds, elo_simulation) - Automatic EV calculation based on league scoring rules - Probability validation and normalization ### Service Layer - icm-calculator: Harville-Malmuth probability distribution - probability-engine: Odds conversion and Elo utilities (for future use) - bracket-simulator: Monte Carlo simulation (for future hybrid approach) - ev-calculator: Expected value computation from probabilities ## Technical Details - Uses exponential decay favoring top positions for strong teams - Preserves championship probability ordering in final distributions - Row sums vary (strong teams ~100%, weak teams lower) - All probabilities between 0-1, mathematically valid - Comprehensive test suite: 97 tests passing ## Future Enhancements - Hybrid approach: ICM pre-playoffs, bracket simulation during playoffs - Integration with league-specific scoring rules - Historical probability tracking for accuracy analysis 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
295 lines
8.9 KiB
TypeScript
295 lines
8.9 KiB
TypeScript
/**
|
|
* 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<EloCalibrationParams, 'eloMin' | 'eloMax'> = 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<string, number> {
|
|
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<string, number>();
|
|
|
|
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 };
|
|
}
|