brackt/app/services/ev-calculator.ts
Chris Parsons c6ba59b0e6
User/chris/ev f1 framework (#93)
* feat: EV simulation framework with F1 Monte Carlo simulator

- Add EV snapshot tables (participant_ev_snapshots, team_ev_snapshots) and simulation_status column on sports seasons
- Add ev-snapshot model with upsert and history query functions
- Add simulator framework: types, bracket/F1/golf simulators, registry
- F1 simulator: vig-removed ICM weighted draw (pre-season) + race-by-race Monte Carlo from current standings (in-season); per-position column normalization to prevent floating-point EV drift
- Add admin simulate route and Run Simulation button on sports season page
- Rework futures-odds admin page to save odds then run simulation in one action
- Remove recalculate-probabilities route (superseded by simulate route)
- Remove EV trend chart panel and associated DB queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: map simulators to sports via simulatorType field

Adds a `simulator_type` enum column to the `sports` table so each sport
can be assigned a specific simulation algorithm rather than deriving it
from the sports season's scoring pattern.

- Add `simulatorTypeEnum` (f1_standings, indycar_standings,
  golf_qualifying_points, playoff_bracket) + `simulatorType` nullable
  column on `sports` table; migration 0037
- Rewrite simulator registry to key off `SimulatorType` instead of
  `ScoringPattern`; indycar_standings shares F1Simulator for now
- `findSportsSeasonById` now returns `SportsSeasonWithSport` so callers
  have typed access to `sport.simulatorType`
- Simulate and futures-odds actions read `sport.simulatorType`; guard
  fires before setting `simulationStatus: running`
- Admin sport edit page gains a Simulator Type dropdown

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 15:34:31 -07:00

170 lines
5 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 decimals (0-1) and sum to ~1.0
*/
export interface ProbabilityDistribution {
probFirst: number; // e.g., 0.155 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 (decimals 0-1)
* @param scoringRules - League's point values for each placement
* @returns Expected value (average projected points)
*
* @example
* ```ts
* const ev = calculateEV(
* { probFirst: 0.20, probSecond: 0.15, probThird: 0.12, ..., probEighth: 0.05 },
* { pointsFor1st: 100, pointsFor2nd: 70, ..., pointsFor8th: 15 }
* );
* // Returns: 45.5 (expected points)
* ```
*/
export function calculateEV(
probabilities: ProbabilityDistribution,
scoringRules: ScoringRules
): number {
const ev =
probabilities.probFirst * scoringRules.pointsFor1st +
probabilities.probSecond * scoringRules.pointsFor2nd +
probabilities.probThird * scoringRules.pointsFor3rd +
probabilities.probFourth * scoringRules.pointsFor4th +
probabilities.probFifth * scoringRules.pointsFor5th +
probabilities.probSixth * scoringRules.pointsFor6th +
probabilities.probSeventh * scoringRules.pointsFor7th +
probabilities.probEighth * scoringRules.pointsFor8th;
return ev;
}
/**
* Validate that probability distribution sums to approximately 1.0
* (allows small rounding errors)
*
* @param probabilities - Probability distribution to validate
* @param tolerance - Acceptable deviation from 1.0 (default 0.01)
* @returns true if valid, false otherwise
*/
export function validateProbabilities(
probabilities: ProbabilityDistribution,
tolerance: number = 0.01
): boolean {
const sum =
probabilities.probFirst +
probabilities.probSecond +
probabilities.probThird +
probabilities.probFourth +
probabilities.probFifth +
probabilities.probSixth +
probabilities.probSeventh +
probabilities.probEighth;
return Math.abs(sum - 1.0) <= tolerance;
}
/**
* Normalize probabilities to sum to exactly 1.0
* Useful when importing odds or dealing with rounding errors
*
* @param probabilities - Probability distribution to normalize
* @returns Normalized probabilities that sum to 1.0
*/
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 (1/8 = 0.125)
return {
probFirst: 0.125,
probSecond: 0.125,
probThird: 0.125,
probFourth: 0.125,
probFifth: 0.125,
probSixth: 0.125,
probSeventh: 0.125,
probEighth: 0.125,
};
}
const factor = 1.0 / sum;
return {
probFirst: Math.round(probabilities.probFirst * factor * 10000) / 10000,
probSecond: Math.round(probabilities.probSecond * factor * 10000) / 10000,
probThird: Math.round(probabilities.probThird * factor * 10000) / 10000,
probFourth: Math.round(probabilities.probFourth * factor * 10000) / 10000,
probFifth: Math.round(probabilities.probFifth * factor * 10000) / 10000,
probSixth: Math.round(probabilities.probSixth * factor * 10000) / 10000,
probSeventh: Math.round(probabilities.probSeventh * factor * 10000) / 10000,
probEighth: Math.round(probabilities.probEighth * factor * 10000) / 10000,
};
}
/**
* 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,
};
}