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>
295 lines
8.1 KiB
TypeScript
295 lines
8.1 KiB
TypeScript
/**
|
|
* Model for Participant Expected Values
|
|
*
|
|
* Manages probability distributions and calculated EVs for participants
|
|
* in sports seasons.
|
|
*/
|
|
|
|
import { database } from "~/database/context";
|
|
import { participantExpectedValues } from "~/database/schema";
|
|
import { eq, and } from "drizzle-orm";
|
|
import type { ProbabilityDistribution, ScoringRules } from "~/services/ev-calculator";
|
|
import { calculateEV, validateProbabilities, normalizeProbabilities } from "~/services/ev-calculator";
|
|
|
|
export type ProbabilitySource = "manual" | "futures_odds" | "elo_simulation" | "performance_model";
|
|
|
|
export interface ParticipantEV {
|
|
id: string;
|
|
participantId: string;
|
|
sportsSeasonId: string;
|
|
probFirst: string;
|
|
probSecond: string;
|
|
probThird: string;
|
|
probFourth: string;
|
|
probFifth: string;
|
|
probSixth: string;
|
|
probSeventh: string;
|
|
probEighth: string;
|
|
expectedValue: string;
|
|
source: ProbabilitySource | null;
|
|
sourceOdds: number | null;
|
|
calculatedAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
export interface CreateProbabilityInput {
|
|
participantId: string;
|
|
sportsSeasonId: string;
|
|
probabilities: ProbabilityDistribution;
|
|
scoringRules: ScoringRules;
|
|
source?: ProbabilitySource;
|
|
sourceOdds?: number; // American odds if source is futures_odds
|
|
}
|
|
|
|
export interface UpdateProbabilityInput {
|
|
probabilities: ProbabilityDistribution;
|
|
scoringRules: ScoringRules;
|
|
source?: ProbabilitySource;
|
|
}
|
|
|
|
/**
|
|
* Create or update participant probabilities and calculate EV
|
|
*
|
|
* @param input - Probabilities, scoring rules, and metadata
|
|
* @returns Created/updated participant EV record
|
|
* @throws Error if probabilities don't sum to 100% (within tolerance)
|
|
*/
|
|
export async function upsertParticipantEV(
|
|
input: CreateProbabilityInput
|
|
): Promise<ParticipantEV> {
|
|
const { participantId, sportsSeasonId, probabilities, scoringRules, source = "manual", sourceOdds } = input;
|
|
|
|
// Validate probabilities sum to 100%
|
|
if (!validateProbabilities(probabilities)) {
|
|
throw new Error(
|
|
`Probabilities must sum to 100% (±0.1%). Current sum: ${
|
|
Object.values(probabilities).reduce((a, b) => a + b, 0)
|
|
}%`
|
|
);
|
|
}
|
|
|
|
// Calculate EV
|
|
const expectedValue = calculateEV(probabilities, scoringRules);
|
|
|
|
const db = database();
|
|
|
|
// Check if record exists
|
|
const existing = await db
|
|
.select()
|
|
.from(participantExpectedValues)
|
|
.where(
|
|
and(
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
const now = new Date();
|
|
|
|
if (existing.length > 0) {
|
|
// Update existing
|
|
const updated = await db
|
|
.update(participantExpectedValues)
|
|
.set({
|
|
probFirst: probabilities.probFirst.toString(),
|
|
probSecond: probabilities.probSecond.toString(),
|
|
probThird: probabilities.probThird.toString(),
|
|
probFourth: probabilities.probFourth.toString(),
|
|
probFifth: probabilities.probFifth.toString(),
|
|
probSixth: probabilities.probSixth.toString(),
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
probEighth: probabilities.probEighth.toString(),
|
|
expectedValue: expectedValue.toString(),
|
|
source,
|
|
sourceOdds: sourceOdds ?? null,
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.where(eq(participantExpectedValues.id, existing[0].id))
|
|
.returning();
|
|
|
|
return updated[0];
|
|
} else {
|
|
// Create new
|
|
const created = await db
|
|
.insert(participantExpectedValues)
|
|
.values({
|
|
participantId,
|
|
sportsSeasonId,
|
|
probFirst: probabilities.probFirst.toString(),
|
|
probSecond: probabilities.probSecond.toString(),
|
|
probThird: probabilities.probThird.toString(),
|
|
probFourth: probabilities.probFourth.toString(),
|
|
probFifth: probabilities.probFifth.toString(),
|
|
probSixth: probabilities.probSixth.toString(),
|
|
probSeventh: probabilities.probSeventh.toString(),
|
|
probEighth: probabilities.probEighth.toString(),
|
|
expectedValue: expectedValue.toString(),
|
|
source,
|
|
sourceOdds: sourceOdds ?? null,
|
|
calculatedAt: now,
|
|
updatedAt: now,
|
|
})
|
|
.returning();
|
|
|
|
return created[0];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create or update with auto-normalization
|
|
* Automatically normalizes probabilities if they don't sum to 100%
|
|
*/
|
|
export async function upsertParticipantEVWithNormalization(
|
|
input: CreateProbabilityInput
|
|
): Promise<ParticipantEV> {
|
|
const normalized = normalizeProbabilities(input.probabilities);
|
|
|
|
return upsertParticipantEV({
|
|
...input,
|
|
probabilities: normalized,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get participant EV for a specific sports season
|
|
*/
|
|
export async function getParticipantEV(
|
|
participantId: string,
|
|
sportsSeasonId: string
|
|
): Promise<ParticipantEV | null> {
|
|
const db = database();
|
|
const result = await db
|
|
.select()
|
|
.from(participantExpectedValues)
|
|
.where(
|
|
and(
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
)
|
|
)
|
|
.limit(1);
|
|
|
|
return result[0] || null;
|
|
}
|
|
|
|
/**
|
|
* Get all participant EVs for a sports season
|
|
*/
|
|
export async function getAllParticipantEVsForSeason(
|
|
sportsSeasonId: string
|
|
): Promise<ParticipantEV[]> {
|
|
const db = database();
|
|
return db
|
|
.select()
|
|
.from(participantExpectedValues)
|
|
.where(eq(participantExpectedValues.sportsSeasonId, sportsSeasonId));
|
|
}
|
|
|
|
/**
|
|
* Delete participant EV
|
|
*/
|
|
export async function deleteParticipantEV(
|
|
participantId: string,
|
|
sportsSeasonId: string
|
|
): Promise<void> {
|
|
const db = database();
|
|
await db
|
|
.delete(participantExpectedValues)
|
|
.where(
|
|
and(
|
|
eq(participantExpectedValues.participantId, participantId),
|
|
eq(participantExpectedValues.sportsSeasonId, sportsSeasonId)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Batch upsert multiple participant EVs
|
|
* Useful for updating all participants after generating probabilities
|
|
*/
|
|
export async function batchUpsertParticipantEVs(
|
|
inputs: CreateProbabilityInput[]
|
|
): Promise<ParticipantEV[]> {
|
|
const results: ParticipantEV[] = [];
|
|
|
|
// Process in batches to avoid overwhelming the database
|
|
const batchSize = 50;
|
|
for (let i = 0; i < inputs.length; i += batchSize) {
|
|
const batch = inputs.slice(i, i + batchSize);
|
|
const batchResults = await Promise.all(
|
|
batch.map((input) => upsertParticipantEV(input))
|
|
);
|
|
results.push(...batchResults);
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Convert database record to ProbabilityDistribution
|
|
*/
|
|
export function toProbabilityDistribution(ev: ParticipantEV): ProbabilityDistribution {
|
|
return {
|
|
probFirst: parseFloat(ev.probFirst),
|
|
probSecond: parseFloat(ev.probSecond),
|
|
probThird: parseFloat(ev.probThird),
|
|
probFourth: parseFloat(ev.probFourth),
|
|
probFifth: parseFloat(ev.probFifth),
|
|
probSixth: parseFloat(ev.probSixth),
|
|
probSeventh: parseFloat(ev.probSeventh),
|
|
probEighth: parseFloat(ev.probEighth),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Recalculate EV for a participant with new scoring rules
|
|
* Keeps probabilities the same, only updates EV based on new scoring
|
|
*/
|
|
export async function recalculateEV(
|
|
participantId: string,
|
|
sportsSeasonId: string,
|
|
newScoringRules: ScoringRules
|
|
): Promise<ParticipantEV | null> {
|
|
const existing = await getParticipantEV(participantId, sportsSeasonId);
|
|
|
|
if (!existing) {
|
|
return null;
|
|
}
|
|
|
|
const probabilities = toProbabilityDistribution(existing);
|
|
const newEV = calculateEV(probabilities, newScoringRules);
|
|
|
|
const db = database();
|
|
const updated = await db
|
|
.update(participantExpectedValues)
|
|
.set({
|
|
expectedValue: newEV.toString(),
|
|
calculatedAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(participantExpectedValues.id, existing.id))
|
|
.returning();
|
|
|
|
return updated[0];
|
|
}
|
|
|
|
/**
|
|
* Recalculate EVs for all participants in a sports season
|
|
* Used when scoring rules change
|
|
*/
|
|
export async function recalculateAllEVsForSeason(
|
|
sportsSeasonId: string,
|
|
newScoringRules: ScoringRules
|
|
): Promise<number> {
|
|
const allEVs = await getAllParticipantEVsForSeason(sportsSeasonId);
|
|
|
|
await Promise.all(
|
|
allEVs.map((ev) =>
|
|
recalculateEV(ev.participantId, sportsSeasonId, newScoringRules)
|
|
)
|
|
);
|
|
|
|
return allEVs.length;
|
|
}
|