import { describe, it, expect } from 'vitest'; import { convertAmericanOddsToProbability, convertDecimalOddsToProbability, normalizeProbabilities, decompressProbability, mapToElo, eloWinProbability, convertFuturesToElo, calculatePredictionError, } from '../probability-engine'; describe('probability-engine', () => { describe('convertAmericanOddsToProbability', () => { it('converts positive odds (underdog) correctly', () => { expect(convertAmericanOddsToProbability(500)).toBeCloseTo(0.1667, 4); expect(convertAmericanOddsToProbability(100)).toBeCloseTo(0.5, 4); expect(convertAmericanOddsToProbability(200)).toBeCloseTo(0.3333, 4); }); it('converts negative odds (favorite) correctly', () => { expect(convertAmericanOddsToProbability(-200)).toBeCloseTo(0.6667, 4); expect(convertAmericanOddsToProbability(-150)).toBeCloseTo(0.6, 4); expect(convertAmericanOddsToProbability(-100)).toBeCloseTo(0.5, 4); }); it('handles extreme odds', () => { expect(convertAmericanOddsToProbability(15000)).toBeCloseTo(0.0066, 4); expect(convertAmericanOddsToProbability(-500)).toBeCloseTo(0.8333, 4); }); it('throws error for zero odds', () => { expect(() => convertAmericanOddsToProbability(0)).toThrow('cannot be zero'); }); }); describe('convertDecimalOddsToProbability', () => { it('converts decimal odds correctly', () => { expect(convertDecimalOddsToProbability(6.0)).toBeCloseTo(0.1667, 4); expect(convertDecimalOddsToProbability(2.0)).toBeCloseTo(0.5, 4); expect(convertDecimalOddsToProbability(1.5)).toBeCloseTo(0.6667, 4); }); it('handles extreme decimal odds', () => { expect(convertDecimalOddsToProbability(151.0)).toBeCloseTo(0.0066, 4); expect(convertDecimalOddsToProbability(1.2)).toBeCloseTo(0.8333, 4); }); it('throws error for odds <= 1', () => { expect(() => convertDecimalOddsToProbability(1.0)).toThrow('must be greater than 1'); expect(() => convertDecimalOddsToProbability(0.5)).toThrow('must be greater than 1'); }); }); describe('normalizeProbabilities', () => { it('normalizes probabilities that sum to >100%', () => { const probs = [0.55, 0.50]; // 105% const normalized = normalizeProbabilities(probs); expect(normalized[0]).toBeCloseTo(0.5238, 4); expect(normalized[1]).toBeCloseTo(0.4762, 4); expect(normalized.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10); }); it('normalizes probabilities that sum to <100%', () => { const probs = [0.45, 0.40]; // 85% const normalized = normalizeProbabilities(probs); expect(normalized[0]).toBeCloseTo(0.5294, 4); expect(normalized[1]).toBeCloseTo(0.4706, 4); expect(normalized.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10); }); it('handles already normalized probabilities', () => { const probs = [0.6, 0.4]; // 100% const normalized = normalizeProbabilities(probs); expect(normalized[0]).toBeCloseTo(0.6, 4); expect(normalized[1]).toBeCloseTo(0.4, 4); }); it('works with many probabilities', () => { const probs = [0.2, 0.2, 0.2, 0.2, 0.2]; // 100% const normalized = normalizeProbabilities(probs); normalized.forEach(p => expect(p).toBeCloseTo(0.2, 4)); expect(normalized.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10); }); it('throws error for zero sum', () => { expect(() => normalizeProbabilities([0, 0, 0])).toThrow('sum to zero'); }); }); describe('decompressProbability', () => { it('decompresses championship probabilities with default exponent', () => { expect(decompressProbability(0.154)).toBeCloseTo(2.465, 2); // Colorado 15.4% expect(decompressProbability(0.0066)).toBeCloseTo(0.872, 2); // NY Islanders 0.66% }); it('decompresses with custom exponent', () => { expect(decompressProbability(0.154, 0.5)).toBeCloseTo(3.924, 2); // Square root expect(decompressProbability(0.154, 0.25)).toBeCloseTo(1.981, 2); // Fourth root }); it('handles edge cases', () => { expect(decompressProbability(1.0, 0.33)).toBeCloseTo(4.571, 2); // 100% probability expect(decompressProbability(0.01, 0.33)).toBeCloseTo(1.0, 2); // 1% probability -> 1^0.33 = 1 }); it('throws error for invalid probability', () => { expect(() => decompressProbability(-0.1)).toThrow('must be between 0 and 1'); expect(() => decompressProbability(1.5)).toThrow('must be between 0 and 1'); }); it('throws error for invalid exponent', () => { expect(() => decompressProbability(0.5, 0)).toThrow('must be positive'); expect(() => decompressProbability(0.5, -1)).toThrow('must be positive'); }); }); describe('mapToElo', () => { it('maps strength to Elo scale correctly', () => { const elo1 = mapToElo(2.49, 0.5, 3.0, { eloMin: 1250, eloMax: 1750 }); expect(elo1).toBeCloseTo(1648, 0); const elo2 = mapToElo(0.87, 0.5, 3.0, { eloMin: 1250, eloMax: 1750 }); expect(elo2).toBeCloseTo(1324, 0); }); it('maps min strength to min Elo', () => { const elo = mapToElo(0.5, 0.5, 3.0, { eloMin: 1250, eloMax: 1750 }); expect(elo).toBe(1250); }); it('maps max strength to max Elo', () => { const elo = mapToElo(3.0, 0.5, 3.0, { eloMin: 1250, eloMax: 1750 }); expect(elo).toBe(1750); }); it('maps mid strength to mid Elo', () => { const elo = mapToElo(1.75, 0.5, 3.0, { eloMin: 1250, eloMax: 1750 }); expect(elo).toBeCloseTo(1500, 0); }); it('allows slight rounding errors', () => { // Slightly outside range due to floating point errors expect(() => mapToElo(0.4999, 0.5, 3.0)).not.toThrow(); expect(() => mapToElo(3.0001, 0.5, 3.0)).not.toThrow(); }); it('throws error for strength outside range', () => { expect(() => mapToElo(0.1, 0.5, 3.0)).toThrow('must be between min and max'); expect(() => mapToElo(5.0, 0.5, 3.0)).toThrow('must be between min and max'); }); it('throws error for invalid strength range', () => { expect(() => mapToElo(1.0, 2.0, 1.0)).toThrow('must be greater than min'); }); }); describe('eloWinProbability', () => { it('calculates 50% for equal ratings', () => { expect(eloWinProbability(1500, 1500)).toBeCloseTo(0.5, 4); expect(eloWinProbability(1600, 1600)).toBeCloseTo(0.5, 4); }); it('calculates correct probability for rating differences', () => { // ~91% for 400-point favorite expect(eloWinProbability(1700, 1300)).toBeCloseTo(0.909, 3); // ~76% for 200-point favorite expect(eloWinProbability(1600, 1400)).toBeCloseTo(0.760, 3); // ~64% for 100-point favorite expect(eloWinProbability(1550, 1450)).toBeCloseTo(0.640, 3); }); it('is symmetric (inverse for reversed teams)', () => { const prob1 = eloWinProbability(1600, 1400); const prob2 = eloWinProbability(1400, 1600); expect(prob1 + prob2).toBeCloseTo(1.0, 10); }); it('handles extreme differences', () => { expect(eloWinProbability(2000, 1000)).toBeGreaterThan(0.99); expect(eloWinProbability(1000, 2000)).toBeLessThan(0.01); }); }); describe('convertFuturesToElo', () => { it('converts American odds to Elo ratings', () => { const odds = [ { participantId: '1', odds: 550 }, // Colorado +550 (15.4%) { participantId: '2', odds: 800 }, // Florida +800 (11.1%) { participantId: '3', odds: 15000 }, // NY Islanders +15000 (0.66%) ]; const eloRatings = convertFuturesToElo(odds, 'american'); expect(eloRatings.size).toBe(3); expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); // Strongest team should be near max Elo expect(eloRatings.get('1')).toBeGreaterThan(1600); // Weakest team should be near min Elo expect(eloRatings.get('3')).toBeLessThan(1400); }); it('converts decimal odds to Elo ratings', () => { const odds = [ { participantId: '1', odds: 6.5 }, { participantId: '2', odds: 9.0 }, { participantId: '3', odds: 151.0 }, ]; const eloRatings = convertFuturesToElo(odds, 'decimal'); expect(eloRatings.size).toBe(3); expect(eloRatings.get('1')).toBeGreaterThan(eloRatings.get('2')!); expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3')!); }); it('uses custom calibration parameters', () => { const odds = [ { participantId: '1', odds: 550 }, { participantId: '2', odds: 15000 }, ]; const params = { exponent: 0.5, eloMin: 1000, eloMax: 2000 }; const eloRatings = convertFuturesToElo(odds, 'american', params); expect(eloRatings.get('1')).toBeGreaterThanOrEqual(1000); expect(eloRatings.get('1')).toBeLessThanOrEqual(2000); expect(eloRatings.get('2')).toBeGreaterThanOrEqual(1000); expect(eloRatings.get('2')).toBeLessThanOrEqual(2000); }); it('returns empty map for empty input', () => { const eloRatings = convertFuturesToElo([]); expect(eloRatings.size).toBe(0); }); it('returns integer Elo values', () => { const odds = [ { participantId: '1', odds: 550 }, { participantId: '2', odds: 800 }, ]; const eloRatings = convertFuturesToElo(odds, 'american'); eloRatings.forEach((elo) => { expect(elo).toBe(Math.round(elo)); }); }); }); describe('calculatePredictionError', () => { it('calculates mean and max error correctly', () => { const predicted = [0.828, 0.565]; const actual = [0.730, 0.630]; const error = calculatePredictionError(predicted, actual); expect(error.meanError).toBeCloseTo(0.0815, 2); expect(error.maxError).toBeCloseTo(0.098, 2); }); it('returns zero error for perfect predictions', () => { const predicted = [0.5, 0.6, 0.7]; const actual = [0.5, 0.6, 0.7]; const error = calculatePredictionError(predicted, actual); expect(error.meanError).toBe(0); expect(error.maxError).toBe(0); }); it('handles single value', () => { const error = calculatePredictionError([0.75], [0.80]); expect(error.meanError).toBeCloseTo(0.05, 4); expect(error.maxError).toBeCloseTo(0.05, 4); }); it('throws error for mismatched array lengths', () => { expect(() => { calculatePredictionError([0.5, 0.6], [0.5]); }).toThrow('must have same length'); }); }); describe('integration: NHL example from plan', () => { it('converts NHL futures to Elo and predicts game line within reasonable error', () => { // From plan: Colorado +550, NY Islanders +15000 const odds = [ { participantId: 'col', odds: 550 }, // 15.4% { participantId: 'nyi', odds: 15000 }, // 0.66% ]; const eloRatings = convertFuturesToElo(odds, 'american'); const colElo = eloRatings.get('col')!; const nyiElo = eloRatings.get('nyi')!; // Calculate predicted game line const predicted = eloWinProbability(colElo, nyiElo); // Actual line from plan: Colorado -270 (73.0%) const actual = convertAmericanOddsToProbability(-270); // Error should be reasonable (target: <10% before calibration) const error = Math.abs(predicted - actual); // This may not pass exactly without calibration, but should be in ballpark // Once calibration is done in UI, this should be < 0.05 expect(error).toBeLessThan(0.25); // 25% tolerance before calibration }); }); });