* 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>
240 lines
7.8 KiB
TypeScript
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);
|
|
}
|