brackt/app/services/ev-calculator.ts

172 lines
5.1 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
/**
* Expected Value (EV) Calculator
*
* Calculates fantasy points expected value based on probability distributions
* and league-specific scoring rules.
*/
/**
* Scoring rules for a fantasy league season
*/
export interface ScoringRules {
pointsFor1st: number;
pointsFor2nd: number;
pointsFor3rd: number;
pointsFor4th: number;
pointsFor5th: number;
pointsFor6th: number;
pointsFor7th: number;
pointsFor8th: number;
}
/**
* Probability distribution for a participant's placements
* All values should be percentages (0-100) and sum to ~100
*/
export interface ProbabilityDistribution {
probFirst: number; // e.g., 15.5 means 15.5%
probSecond: number;
probThird: number;
probFourth: number;
probFifth: number;
probSixth: number;
probSeventh: number;
probEighth: number;
}
/**
* Calculate Expected Value for a participant
*
* EV = Σ (probability × points) for each placement
*
* @param probabilities - Probability distribution (percentages 0-100)
* @param scoringRules - League's point values for each placement
* @returns Expected value (average projected points)
*
* @example
* ```ts
* const ev = calculateEV(
* { probFirst: 20, probSecond: 15, probThird: 12, ..., probEighth: 5 },
* { pointsFor1st: 100, pointsFor2nd: 70, ..., pointsFor8th: 15 }
* );
* // Returns: 45.5 (expected points)
* ```
*/
export function calculateEV(
probabilities: ProbabilityDistribution,
scoringRules: ScoringRules
): number {
// Convert percentages to decimals (divide by 100)
const ev =
(probabilities.probFirst / 100) * scoringRules.pointsFor1st +
(probabilities.probSecond / 100) * scoringRules.pointsFor2nd +
(probabilities.probThird / 100) * scoringRules.pointsFor3rd +
(probabilities.probFourth / 100) * scoringRules.pointsFor4th +
(probabilities.probFifth / 100) * scoringRules.pointsFor5th +
(probabilities.probSixth / 100) * scoringRules.pointsFor6th +
(probabilities.probSeventh / 100) * scoringRules.pointsFor7th +
(probabilities.probEighth / 100) * scoringRules.pointsFor8th;
return Math.round(ev * 100) / 100; // Round to 2 decimal places
}
/**
* Validate that probability distribution sums to approximately 100%
* (allows small rounding errors)
*
* @param probabilities - Probability distribution to validate
* @param tolerance - Acceptable deviation from 100% (default 0.1%)
* @returns true if valid, false otherwise
*/
export function validateProbabilities(
probabilities: ProbabilityDistribution,
tolerance: number = 0.1
): boolean {
const sum =
probabilities.probFirst +
probabilities.probSecond +
probabilities.probThird +
probabilities.probFourth +
probabilities.probFifth +
probabilities.probSixth +
probabilities.probSeventh +
probabilities.probEighth;
return Math.abs(sum - 100) <= tolerance;
}
/**
* Normalize probabilities to sum to exactly 100%
* Useful when importing odds or dealing with rounding errors
*
* @param probabilities - Probability distribution to normalize
* @returns Normalized probabilities that sum to 100%
*/
export function normalizeProbabilities(
probabilities: ProbabilityDistribution
): ProbabilityDistribution {
const sum =
probabilities.probFirst +
probabilities.probSecond +
probabilities.probThird +
probabilities.probFourth +
probabilities.probFifth +
probabilities.probSixth +
probabilities.probSeventh +
probabilities.probEighth;
if (sum === 0) {
// Avoid division by zero - return equal probabilities
return {
probFirst: 12.5,
probSecond: 12.5,
probThird: 12.5,
probFourth: 12.5,
probFifth: 12.5,
probSixth: 12.5,
probSeventh: 12.5,
probEighth: 12.5,
};
}
const factor = 100 / sum;
return {
probFirst: Math.round(probabilities.probFirst * factor * 100) / 100,
probSecond: Math.round(probabilities.probSecond * factor * 100) / 100,
probThird: Math.round(probabilities.probThird * factor * 100) / 100,
probFourth: Math.round(probabilities.probFourth * factor * 100) / 100,
probFifth: Math.round(probabilities.probFifth * factor * 100) / 100,
probSixth: Math.round(probabilities.probSixth * factor * 100) / 100,
probSeventh: Math.round(probabilities.probSeventh * factor * 100) / 100,
probEighth: Math.round(probabilities.probEighth * factor * 100) / 100,
};
}
/**
* Calculate projected total points for a team
*
* @param actualPoints - Points from finished participants
* @param remainingParticipantEVs - Array of EV values for unfinished participants
* @returns Object with actual, projected, and remaining count
*/
export function calculateProjectedTotal(
actualPoints: number,
remainingParticipantEVs: number[]
): {
actualPoints: number;
projectedPoints: number;
participantsFinished: number;
participantsRemaining: number;
} {
const evSum = remainingParticipantEVs.reduce((sum, ev) => sum + ev, 0);
const projectedPoints = actualPoints + evSum;
return {
actualPoints: Math.round(actualPoints * 100) / 100,
projectedPoints: Math.round(projectedPoints * 100) / 100,
participantsFinished: 0, // Caller should provide this
participantsRemaining: remainingParticipantEVs.length,
};
}