The IndyCar simulator gave a near-clinched championship leader ~60% to win the title. It was reporting the futures odds and nothing else. `event_type` has no race value, so a season_standings calendar (F1, IndyCar) is stored as `schedule_event` rows — the admin default for that scoring pattern. The simulator skipped exactly those rows when counting races, so it saw zero remaining races, took the branch labelled "pre-season", and never looked at `participant_season_results`. Since `sourceOdds` is an admin input that is never auto-refreshed, the output was whatever the market said months ago. With a realistic late-season field (leader on 601 pts vs 480, 2 races left, stale futures at -300) the old path returns ~55%; counting the races returns 100.0%. - Add `countSeasonRaces` in a new leaf model. A race is every event except `final_standings`, and completion is inferred from the event date, since nobody marks rows labelled "Non-Scoring" complete. Its own module because `scoring-event.ts` reaches back into `simulator.ts` through `scoring-calculator`. - Split "season over" from "pre-season". Both had `remainingRaces === 0`, so a finished season reverted to an odds draw instead of reporting the final standings. - Warn when a season has championship points but no calendar, in the simulator and as a non-blocking readiness warning on the setup page. - Replace proportional vig removal with a power devig. Dividing every runner by the same book sum guts the favourite in a 27-driver market: a 75% implied favourite came out at 55%, a -20000 near-lock at 95%. Power devig gives 69.5% and 99.3%. Unpriced drivers now floor at the bottom of the market instead of being handed 1/N. - Move the race points tables to their own module so tests can read them without tripping the manifest/registry import cycle. The existing tests missed all of this because their event fixtures used `eventType: "race"`, which is not a value the enum has. Rebuilt on real enum values, plus a regression test for the reported case. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TUcV7KenckF893zQ46EDXt
457 lines
16 KiB
TypeScript
457 lines
16 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
convertAmericanOddsToProbability,
|
|
convertDecimalOddsToProbability,
|
|
normalizeProbabilities,
|
|
devigPower,
|
|
decompressProbability,
|
|
mapToElo,
|
|
eloWinProbability,
|
|
convertFuturesToElo,
|
|
calculatePredictionError,
|
|
projectedWinsToElo,
|
|
eloToProjectedWins,
|
|
} 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('devigPower', () => {
|
|
/** 27-driver championship market: one -300 favourite and a long tail. */
|
|
const CHAMPIONSHIP_MARKET = [
|
|
-300, 450, 700, 1200, 1800, 2500, 4000, 5000, 6000, 8000, 10000, 12000,
|
|
15000, 20000, 25000, 30000, 40000, 50000, 50000, 50000, 50000, 50000,
|
|
50000, 50000, 50000, 50000, 50000,
|
|
].map(convertAmericanOddsToProbability);
|
|
|
|
it('sums to exactly 1.0', () => {
|
|
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
|
expect(devigged.reduce((sum, p) => sum + p, 0)).toBeCloseTo(1.0, 10);
|
|
});
|
|
|
|
it('preserves a heavy favourite that proportional devig would gut', () => {
|
|
const proportional = normalizeProbabilities(CHAMPIONSHIP_MARKET);
|
|
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
|
|
|
// -300 is 75.0% implied. The book sums to ~1.36, so dividing everyone by
|
|
// the same overround drops the favourite to ~55%.
|
|
expect(CHAMPIONSHIP_MARKET[0]).toBeCloseTo(0.75, 4);
|
|
expect(proportional[0]).toBeCloseTo(0.553, 2);
|
|
expect(devigged[0]).toBeCloseTo(0.695, 2);
|
|
expect(devigged[0]).toBeGreaterThan(proportional[0]);
|
|
});
|
|
|
|
it('keeps a near-lock near-certain', () => {
|
|
const market = [-20000, ...Array(26).fill(50000)].map(convertAmericanOddsToProbability);
|
|
expect(normalizeProbabilities(market)[0]).toBeCloseTo(0.950, 2);
|
|
expect(devigPower(market)[0]).toBeCloseTo(0.993, 2);
|
|
});
|
|
|
|
it('preserves the ordering of the field', () => {
|
|
const devigged = devigPower(CHAMPIONSHIP_MARKET);
|
|
for (let i = 1; i < devigged.length; i++) {
|
|
expect(devigged[i]).toBeLessThanOrEqual(devigged[i - 1]);
|
|
}
|
|
});
|
|
|
|
it('normalizes a book that is already vig-free', () => {
|
|
const devigged = devigPower([0.5, 0.3, 0.2]);
|
|
expect(devigged[0]).toBeCloseTo(0.5, 6);
|
|
expect(devigged[1]).toBeCloseTo(0.3, 6);
|
|
expect(devigged[2]).toBeCloseTo(0.2, 6);
|
|
});
|
|
|
|
it('scales a single runner to certainty', () => {
|
|
expect(devigPower([0.8])).toEqual([1]);
|
|
});
|
|
|
|
it('returns an empty array for an empty market', () => {
|
|
expect(devigPower([])).toEqual([]);
|
|
});
|
|
|
|
it('returns a uniform field for an all-zero market', () => {
|
|
devigPower([0, 0, 0]).forEach(p => expect(p).toBeCloseTo(1 / 3, 6));
|
|
});
|
|
});
|
|
|
|
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') ?? 0);
|
|
expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0);
|
|
|
|
// 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') ?? 0);
|
|
expect(eloRatings.get('2')).toBeGreaterThan(eloRatings.get('3') ?? 0);
|
|
});
|
|
|
|
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') ?? 1500;
|
|
const nyiElo = eloRatings.get('nyi') ?? 1500;
|
|
|
|
// 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
|
|
});
|
|
});
|
|
|
|
describe('projectedWinsToElo', () => {
|
|
it('converts average projected wins to 1500 Elo', () => {
|
|
// 11.5 wins out of 23 = 0.5 win rate = 1500 Elo
|
|
expect(projectedWinsToElo(11.5, 23, 450)).toBe(1500);
|
|
});
|
|
|
|
it('converts AFL 2026 Bulldogs (15.6 wins / 23) to ~1646 Elo', () => {
|
|
expect(projectedWinsToElo(15.6, 23, 450)).toBe(1646);
|
|
});
|
|
|
|
it('converts AFL 2026 Essendon (7.1 wins / 23) to ~1342 Elo', () => {
|
|
expect(projectedWinsToElo(7.1, 23, 450)).toBe(1342);
|
|
});
|
|
|
|
it('higher projected wins produce higher Elo', () => {
|
|
const elo10 = projectedWinsToElo(10, 23, 450);
|
|
const elo15 = projectedWinsToElo(15, 23, 450);
|
|
expect(elo15).toBeGreaterThan(elo10);
|
|
});
|
|
|
|
it('uses parity factor 400 as default', () => {
|
|
// NFL-like: 8.5/17 = 0.5 → 1500
|
|
expect(projectedWinsToElo(8.5, 17)).toBe(1500);
|
|
});
|
|
|
|
it('handles 0 projected wins (floor)', () => {
|
|
const elo = projectedWinsToElo(0, 23, 450);
|
|
expect(elo).toBeLessThan(1000);
|
|
});
|
|
|
|
it('handles max projected wins (cap)', () => {
|
|
const elo = projectedWinsToElo(23, 23, 450);
|
|
expect(elo).toBeGreaterThan(2500);
|
|
});
|
|
|
|
it('is inverse of eloToProjectedWins (round-trip)', () => {
|
|
const wins = 14.3;
|
|
const elo = projectedWinsToElo(wins, 23, 450);
|
|
const roundTrip = eloToProjectedWins(elo, 23, 450);
|
|
expect(roundTrip).toBeCloseTo(wins, 0);
|
|
});
|
|
|
|
it('throws for negative projected wins', () => {
|
|
expect(() => projectedWinsToElo(-1, 23, 450)).toThrow('between 0 and');
|
|
});
|
|
|
|
it('throws for projected wins exceeding total games', () => {
|
|
expect(() => projectedWinsToElo(25, 23, 450)).toThrow('between 0 and');
|
|
});
|
|
|
|
it('throws for zero total games', () => {
|
|
expect(() => projectedWinsToElo(5, 0, 450)).toThrow('must be positive');
|
|
});
|
|
});
|
|
|
|
describe('eloToProjectedWins', () => {
|
|
it('returns half the season for 1500 Elo (average)', () => {
|
|
expect(eloToProjectedWins(1500, 23, 450)).toBeCloseTo(11.5, 1);
|
|
});
|
|
|
|
it('returns more wins for higher Elo', () => {
|
|
const winsHigh = eloToProjectedWins(1700, 23, 450);
|
|
const winsLow = eloToProjectedWins(1300, 23, 450);
|
|
expect(winsHigh).toBeGreaterThan(winsLow);
|
|
});
|
|
|
|
it('round-trips with projectedWinsToElo', () => {
|
|
const elo = 1600;
|
|
const wins = eloToProjectedWins(elo, 23, 450);
|
|
const roundTrip = projectedWinsToElo(wins, 23, 450);
|
|
expect(roundTrip).toBe(elo);
|
|
});
|
|
});
|
|
});
|