brackt/app/services/probability-engine.ts

436 lines
13 KiB
TypeScript
Raw Permalink Normal View History

feat: implement Expected Value System with ICM probability calculator 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>
2025-11-17 22:19:46 -08:00
/**
* 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));
}
feat: implement Expected Value System with ICM probability calculator 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>
2025-11-17 22:19:46 -08:00
export function eloWinProbability(eloA: number, eloB: number): number {
return eloWinProbabilityWithParity(eloA, eloB, 400);
feat: implement Expected Value System with ICM probability calculator 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>
2025-11-17 22:19:46 -08:00
}
/**
* 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>();
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
// 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;
}
feat: implement Expected Value System with ICM probability calculator 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>
2025-11-17 22:19:46 -08:00
futuresOdds.forEach(({ participantId }, index) => {
const elo = mapToElo(strengths[index], minStrength, maxStrength, params);
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
eloRatings.set(participantId, elo);
feat: implement Expected Value System with ICM probability calculator 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>
2025-11-17 22:19:46 -08:00
});
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;
}