brackt/app/services/probability-engine.ts
Chris Parsons af64a29cfa
Fix NCAAW futures odds simulation and admin import UX (#420)
- Revert ncaaw-simulator to Barthag win probability formula; set
  realistic rating bounds (ratingMin: 0.70, ratingMax: 0.97) so derived
  ratings stay in the range where the formula behaves well
- Add batchSaveFuturesOddsForSimulator which clears all ratings (manual
  and generated) before upserting sourceOdds, so futures odds always
  drive the simulation rather than being silently overridden by existing
  Barthag ratings from Simulator Setup
- Add clearSourceOddsForParticipants to zero out both tables for
  participants excluded from a bulk import
- Add "Clear existing odds" checkbox to the bulk import card; applies
  client-side on match and server-side on submit
- Fix missing sportsSeasonId filter in batchSaveFuturesOddsForSimulator
  pre-clear UPDATE (could have wiped ratings across other seasons)
- Fix race condition: run batchSaveSourceOdds then
  batchSaveFuturesOddsForSimulator sequentially so the simulator inputs
  table always ends in the correct cleared state
- Fix Math.round in convertFuturesToElo collapsing Barthag-scale ratings
  to 0 or 1; Elo callers already round after clamping
- Handle all-identical-odds edge case in convertFuturesToElo (assign
  midpoint instead of throwing)
- Add missingRatingStrategy: worstKnownMinus to ncaaw_bracket manifest
  so fallbackRatingDelta is live config, not dead
- Log warning in resolveRatings when only 1 participant has odds
- Reset clearExisting checkbox after applyMatches

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 01:06:16 -07:00

435 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 eloWinProbabilityWithParity(
eloA: number,
eloB: number,
parityFactor = 400
): number {
return 1 / (1 + Math.pow(10, (eloB - eloA) / parityFactor));
}
export function eloWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, 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>();
// All participants have identical odds — assign the midpoint rating to everyone.
if (maxStrength === minStrength) {
const midpoint = (params.eloMin + params.eloMax) / 2;
futuresOdds.forEach(({ participantId }) => eloRatings.set(participantId, midpoint));
return eloRatings;
}
futuresOdds.forEach(({ participantId }, index) => {
const elo = mapToElo(strengths[index], minStrength, maxStrength, params);
eloRatings.set(participantId, elo);
});
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 };
}
/**
* Convert projected season win totals to an Elo rating.
*
* Given a team's projected total wins (including games already played),
* derives the Elo rating that would produce that win rate against an
* average opponent over the full season.
*
* Inverse of the Elo win probability formula:
* elo = avgElo - parityFactor × log₁₀((1 winRate) / winRate)
* where winRate = projectedWins / totalGames
*
* @param projectedWins Total projected wins for the season (e.g. 15.6)
* @param totalGames Total regular season games (e.g. 23 for AFL)
* @param parityFactor Elo parity factor for the sport (e.g. 450 for AFL)
* @param averageElo Elo of an average opponent (typically 1500)
* @returns Elo rating (rounded to integer)
*
* @example
* projectedWinsToElo(15.6, 23, 450) // 1646 (Western Bulldogs 2026)
* projectedWinsToElo(11.5, 23, 450) // 1500 (average team)
* projectedWinsToElo(7.1, 23, 450) // 1342 (Essendon 2026)
*/
export function projectedWinsToElo(
projectedWins: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500
): number {
if (totalGames <= 0) {
throw new Error('Total games must be positive');
}
if (parityFactor <= 0) {
throw new Error('Parity factor must be positive');
}
if (projectedWins < 0 || projectedWins > totalGames) {
throw new Error(
`Projected wins (${projectedWins}) must be between 0 and total games (${totalGames})`
);
}
const winRate = projectedWins / totalGames;
// Edge cases: clamp to avoid log(0) or division by zero
if (winRate >= 1.0) return averageElo + parityFactor * 3; // ~dominant cap
if (winRate <= 0.0) return averageElo - parityFactor * 3; // ~terrible floor
const elo = averageElo - parityFactor * Math.log10((1 - winRate) / winRate);
return Math.round(elo);
}
/**
* Convert an Elo rating back to projected season wins.
*
* This is the forward direction of projectedWinsToElo:
* winRate = 1 / (1 + 10^((averageElo - elo) / parityFactor))
* projectedWins = winRate × totalGames
*
* Useful for displaying the equivalent projected wins for a given Elo rating.
*
* @param elo Elo rating
* @param totalGames Total regular season games
* @param parityFactor Elo parity factor
* @param averageElo Elo of an average opponent
* @returns Projected wins (decimal, e.g. 15.6)
*/
export function eloToProjectedWins(
elo: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500
): number {
const winProb = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
return winProb * totalGames;
}
/**
* Convert projected league table points to an Elo rating.
*
* For soccer-style standings where each match offers up to `maxPointsPerGame`
* table points (EPL: 3), this maps projected points-per-game onto the same
* inverse-logit Elo scale used by projectedWinsToElo.
*/
export function projectedTablePointsToElo(
projectedPoints: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500,
maxPointsPerGame = 3
): number {
if (totalGames <= 0) {
throw new Error('Total games must be positive');
}
if (parityFactor <= 0) {
throw new Error('Parity factor must be positive');
}
if (maxPointsPerGame <= 0) {
throw new Error('Max points per game must be positive');
}
const maxPoints = totalGames * maxPointsPerGame;
if (projectedPoints < 0 || projectedPoints > maxPoints) {
throw new Error(
`Projected points (${projectedPoints}) must be between 0 and maximum points (${maxPoints})`
);
}
const pointsShare = projectedPoints / maxPoints;
if (pointsShare >= 1.0) return averageElo + parityFactor * 3;
if (pointsShare <= 0.0) return averageElo - parityFactor * 3;
const elo = averageElo - parityFactor * Math.log10((1 - pointsShare) / pointsShare);
return Math.round(elo);
}
/** Convert Elo back to projected soccer-style table points. */
export function eloToProjectedTablePoints(
elo: number,
totalGames: number,
parityFactor = 400,
averageElo = 1500,
maxPointsPerGame = 3
): number {
const pointsShare = 1 / (1 + Math.pow(10, (averageElo - elo) / parityFactor));
return pointsShare * totalGames * maxPointsPerGame;
}