brackt/app/services/ev-calculator.ts
Chris Parsons 79ec477a98 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

171 lines
5.1 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.

/**
* 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,
};
}