brackt/app/services/icm-calculator.ts
Chris Parsons 4bffa40606
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations

Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.

no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).

consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.

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

* Fix no-non-null-assertion lint violations and promote to error

Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.

Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default

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

* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers

Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.

- prefer-add-event-listener: converted onchange/onclick/onload
  assignments to addEventListener in useDraftNotifications.ts and
  admin.data-sync.tsx; stored changeHandler ref for proper cleanup
  with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
  side-effect imports (*.css, @testing-library/jest-dom,
  @testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
  cypress/support/e2e.ts (file already has an import)

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

* Fix TypeScript errors from no-non-null-assertion fixes

Two fixes introduced by the non-null assertion cleanup produced type
errors:

- scoring-event.ts: `?? ""` was wrong type for a participant object map;
  restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
  truthy guarantee, causing TS18047 on the write-back block; added
  `participant &&` guard before accessing its properties

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

* Add npm run typecheck as Stop hook in Claude settings

Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00

240 lines
7.8 KiB
TypeScript

/**
* ICM (Independent Chip Model) Calculator - Monte Carlo Simulation
*
* Calculates probability distributions for tournament placements based on
* championship odds using Monte Carlo simulation (100,000+ iterations).
*
* Algorithm:
* 1. Pick 1st place: weighted random selection based on championship probabilities
* 2. Remove winner, pick 2nd place: weighted random from remaining
* 3. Continue for all 8 positions
* 4. Repeat 100,000 times
* 5. Calculate probabilities from simulation results
*
* This approach is much faster and more memory-efficient than exact recursive
* calculation for large fields (30+ participants). Results converge to true ICM
* probabilities with sufficient iterations.
*/
/**
* Participant with championship probability (chip stack)
*/
export interface ParticipantChips {
participantId: string;
championshipProbability: number; // 0-1 (e.g., 0.154 = 15.4%)
}
/**
* ICM result for a single participant
* Probabilities for finishing in each placement
*/
export interface ICMResult {
participantId: string;
probabilities: {
first: number;
second: number;
third: number;
fourth: number;
fifth: number;
sixth: number;
seventh: number;
eighth: number;
};
}
/**
* Simulate one tournament using weighted random selection
*
* @param participants Participants with championship probabilities (weights)
* @param scoringPlaces Number of positions to simulate
* @returns Array of participant IDs in finish order [1st, 2nd, ..., 8th]
*/
function simulateTournament(
participants: ParticipantChips[],
scoringPlaces: number
): string[] {
const placements: string[] = [];
let remaining = [...participants];
for (let pos = 0; pos < scoringPlaces && remaining.length > 0; pos++) {
// Calculate total weight of remaining participants
const totalWeight = remaining.reduce((sum, p) => sum + p.championshipProbability, 0);
// Weighted random selection
const rand = Math.random() * totalWeight;
let cumulative = 0;
let selected = remaining[0];
for (const p of remaining) {
cumulative += p.championshipProbability;
if (rand <= cumulative) {
selected = p;
break;
}
}
// Record placement and remove from remaining
placements.push(selected.participantId);
remaining = remaining.filter(p => p.participantId !== selected.participantId);
}
return placements;
}
/**
* Calculate ICM probability distribution for all participants using Monte Carlo simulation
*
* Simulates 100,000 tournaments using weighted random selection based on championship
* probabilities. Much faster and more memory-efficient than exact recursive calculation
* for large fields (30+ participants).
*
* @param participants Array of participants with championship probabilities
* @param scoringPlaces Number of places that score points (default 8)
* @param iterations Number of simulations to run (default 100,000)
* @returns Map of participantId to probability distribution
*
* @example
* const participants = [
* { participantId: 'OKC', championshipProbability: 0.364 }, // 36.4% (+175 odds)
* { participantId: 'BOS', championshipProbability: 0.300 },
* // ... 28 more NBA teams
* ];
* const results = calculateICM(participants);
* // Results for all 30 teams, each with P(1st) through P(8th) from simulation
*/
export function calculateICM(
participants: ParticipantChips[],
scoringPlaces: number = 8,
iterations: number = 100000
): Map<string, ICMResult> {
if (participants.length === 0) {
return new Map();
}
// Normalize championship probabilities to sum to 1.0 (remove bookmaker vig)
const totalProb = participants.reduce((sum, p) => sum + p.championshipProbability, 0);
const normalized = participants.map(p => ({
...p,
championshipProbability: totalProb > 0 ? p.championshipProbability / totalProb : 1 / participants.length,
}));
console.log(`[ICM Simulation] Starting ${iterations.toLocaleString()} simulations for ${normalized.length} participants`);
const startTime = Date.now();
// Initialize counters for each participant
const counts = new Map<string, number[]>();
for (const p of normalized) {
counts.set(p.participantId, Array(scoringPlaces).fill(0));
}
// Run simulations
for (let iter = 0; iter < iterations; iter++) {
const placements = simulateTournament(normalized, scoringPlaces);
// Record results
for (let pos = 0; pos < placements.length; pos++) {
const participantCounts = counts.get(placements[pos]);
if (participantCounts) participantCounts[pos]++;
}
// Progress logging every 10,000 iterations
if ((iter + 1) % 10000 === 0) {
const progress = ((iter + 1) / iterations * 100).toFixed(0);
const elapsed = Date.now() - startTime;
const rate = (iter + 1) / (elapsed / 1000);
console.log(`[ICM Simulation] ${progress}% complete (${(iter + 1).toLocaleString()}/${iterations.toLocaleString()}) - ${rate.toFixed(0)} sims/sec`);
}
}
// Convert counts to probabilities
const results = new Map<string, ICMResult>();
for (const p of normalized) {
const participantCounts = counts.get(p.participantId) ?? Array(scoringPlaces).fill(0);
const probabilities = participantCounts.map(count => count / iterations);
results.set(p.participantId, {
participantId: p.participantId,
probabilities: {
first: probabilities[0] || 0,
second: probabilities[1] || 0,
third: probabilities[2] || 0,
fourth: probabilities[3] || 0,
fifth: probabilities[4] || 0,
sixth: probabilities[5] || 0,
seventh: probabilities[6] || 0,
eighth: probabilities[7] || 0,
},
});
}
const elapsed = Date.now() - startTime;
console.log(`[ICM Simulation] Completed in ${(elapsed / 1000).toFixed(1)}s (${(iterations / (elapsed / 1000)).toFixed(0)} sims/sec)`);
return results;
}
/**
* Convert ICM result to array format for database storage
*
* @param icmResult ICM result object
* @returns Array of 8 probabilities [P(1st), P(2nd), ..., P(8th)]
*/
export function icmResultToArray(icmResult: ICMResult): number[] {
return [
icmResult.probabilities.first,
icmResult.probabilities.second,
icmResult.probabilities.third,
icmResult.probabilities.fourth,
icmResult.probabilities.fifth,
icmResult.probabilities.sixth,
icmResult.probabilities.seventh,
icmResult.probabilities.eighth,
];
}
/**
* Convert American odds to implied probability
*
* @param odds American odds (e.g., +175, -200)
* @returns Implied probability (0-1)
*/
function convertAmericanOddsToProbability(odds: number): number {
if (odds > 0) {
// Positive odds (underdog): probability = 100 / (odds + 100)
return 100 / (odds + 100);
} else {
// Negative odds (favorite): probability = -odds / (-odds + 100)
return -odds / (-odds + 100);
}
}
/**
* Convert futures odds to championship probabilities and calculate ICM
*
* Complete pipeline from odds to probability distributions.
*
* @param futuresOdds Array of {participantId, odds} with American odds
* @param scoringPlaces Number of places that score points (default 8)
* @returns Map of participantId to ICM result
*
* @example
* const odds = [
* { participantId: 'OKC', odds: 175 }, // +175 (36.4% implied)
* { participantId: 'BOS', odds: -150 }, // -150 (60% implied)
* // ... more teams
* ];
* const results = calculateICMFromOdds(odds);
*/
export function calculateICMFromOdds(
futuresOdds: Array<{ participantId: string; odds: number }>,
scoringPlaces: number = 8
): Map<string, ICMResult> {
// Convert odds to championship probabilities
const participants: ParticipantChips[] = futuresOdds.map(({ participantId, odds }) => ({
participantId,
championshipProbability: convertAmericanOddsToProbability(odds),
}));
return calculateICM(participants, scoringPlaces);
}