brackt/app/services/probability-engine.ts

495 lines
16 KiB
TypeScript
Raw 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);
}
Fix auto racing sims ignoring championship standings The IndyCar simulator gave a near-clinched championship leader ~60% to win the title. It was reporting the futures odds and nothing else. `event_type` has no race value, so a season_standings calendar (F1, IndyCar) is stored as `schedule_event` rows — the admin default for that scoring pattern. The simulator skipped exactly those rows when counting races, so it saw zero remaining races, took the branch labelled "pre-season", and never looked at `participant_season_results`. Since `sourceOdds` is an admin input that is never auto-refreshed, the output was whatever the market said months ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races left, stale futures at -300) the old path returns ~55%; counting the races returns 100.0%. - Add `countSeasonRaces` in a new leaf model. A race is every event except `final_standings`, and completion is inferred from the event date, since nobody marks rows labelled "Non-Scoring" complete. Its own module because `scoring-event.ts` reaches back into `simulator.ts` through `scoring-calculator`. - Split "season over" from "pre-season". Both had `remainingRaces === 0`, so a finished season reverted to an odds draw instead of reporting the final standings. - Warn when a season has championship points but no calendar, in the simulator and as a non-blocking readiness warning on the setup page. - Replace proportional vig removal with a power devig. Dividing every runner by the same book sum guts the favourite in a 27-driver market: a 75% implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the market instead of being handed 1/N. - Move the race points tables to their own module so tests can read them without tripping the manifest/registry import cycle. The existing tests missed all of this because their event fixtures used `eventType: "race"`, which is not a value the enum has. Rebuilt on real enum values, plus a regression test for the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
2026-08-17 00:36:20 +00:00
/**
* Remove vig with a power transform instead of proportional division.
*
* `normalizeProbabilities` divides every runner by the same book sum, which
* assumes the overround is spread evenly across the field. In a large futures
* market it is not the juice is concentrated in the longshots, so dividing
* proportionally guts the favourite. In a 27-driver championship market with a
* book sum of 1.36, a 75.0% implied favourite comes out at 55.3%; with a book
* sum of 1.05, a -20000 near-lock comes out at 95.0%.
*
* The power method instead solves for the exponent `k` where `Σ pᵢ^k = 1`. Since
* `p^k` shrinks small probabilities much harder than large ones, the favourite
* keeps its shape: the same two markets give 69.5% and 99.3%.
*
* Solved by bisection `Σ pᵢ^k` is monotonically decreasing in `k` for
* `pᵢ ∈ (0, 1)`, so 60 halvings of `[0.01, 10]` converge well past float
* precision.
*
* @param impliedProbs Raw implied probabilities (as decimals 0-1), vig included
* @returns Vig-free probabilities summing to 1.0
*
* @example
* devigPower([0.75, 0.18, 0.12, 0.09]) // favourite stays ~0.70, not ~0.65
*/
export function devigPower(impliedProbs: number[]): number[] {
if (impliedProbs.length === 0) return [];
// Clamp into the open interval: p^k is only monotonic in k for 0 < p < 1, and
// an exact 0 or 1 pins the bisection regardless of the rest of the field.
// Clamping also means an all-zero market cannot divide by zero: every runner
// ends up at the floor and the field comes back uniform.
const clamped = impliedProbs.map((p) =>
Math.min(1 - 1e-9, Math.max(1e-9, p))
);
const sum = clamped.reduce((acc, p) => acc + p, 0);
// A single runner, or a book with no overround to strip, has no exponent to
// find — fall through to proportional scaling.
if (clamped.length === 1 || sum <= 1) {
return normalizeProbabilities(clamped);
}
let low = 0.01;
let high = 10;
for (let i = 0; i < 60; i++) {
const mid = (low + high) / 2;
const total = clamped.reduce((acc, p) => acc + Math.pow(p, mid), 0);
if (total > 1) {
low = mid;
} else {
high = mid;
}
}
const k = (low + high) / 2;
// Renormalize: bisection lands within float noise of 1.0, not exactly on it.
return normalizeProbabilities(clamped.map((p) => Math.pow(p, k)));
}
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
/**
* 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;
}