300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
const { SIMILARITY_WEIGHTS, calculateSimilarityScore, findSimilarGames, getPosteriorDistribution } = require('../../src/services/similarityEngine');
|
|
const { detectChangepoints, checkMultiSignalEvolution } = require('../../src/services/evolutionEngine');
|
|
const { detectDiscrepancy, detectSteamMove, getReliabilityScore } = require('../../src/services/lineDiscrepancyDetector');
|
|
const { scanAltLines, calculateModelProbability, compareToBookImplied, normalCDF: altNormalCDF } = require('../../src/services/altLineScanner');
|
|
const { DISTRIBUTION_SHAPES, getDistributionShape, calculateProbability, normalCDF, poissonCDF, negativeBinomialCDF } = require('../../src/services/bayesianEngine');
|
|
const { walkForwardValidate, calculateCLV, checkDrift, applyLearningRateCap } = require('../../src/services/modelTrainer');
|
|
const { phiCoefficient, hasMinimumObservations, calculateJuiceAdjustedEV } = require('../../src/services/correlationMath');
|
|
|
|
// Mock axios for evolution engine tests
|
|
jest.mock('axios', () => ({
|
|
post: jest.fn(),
|
|
}));
|
|
const axios = require('axios');
|
|
|
|
describe('Intelligence Engine', () => {
|
|
|
|
// --- Similarity Engine ---
|
|
|
|
describe('Similarity Engine', () => {
|
|
test('similarity score returns value between 0 and 1', () => {
|
|
const gameA = { functional_role_match: 0.8, pace: 100, rest_days: 2 };
|
|
const gameB = { functional_role_match: 0.7, pace: 98, rest_days: 1 };
|
|
const score = calculateSimilarityScore(gameA, gameB);
|
|
expect(score).toBeGreaterThanOrEqual(0);
|
|
expect(score).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('similarity weights sum to 1.0', () => {
|
|
const sum = Object.values(SIMILARITY_WEIGHTS).reduce((s, v) => s + v, 0);
|
|
expect(Math.round(sum * 100) / 100).toBe(1.0);
|
|
});
|
|
|
|
test('identical games return score of 1', () => {
|
|
const game = { functional_role_match: 0.8, pace: 100, rest_days: 2, opponent_defensive_rating: 110 };
|
|
const score = calculateSimilarityScore(game, game);
|
|
expect(score).toBe(1);
|
|
});
|
|
|
|
test('findSimilarGames returns LOW confidence when below minInstances', () => {
|
|
const target = { pace: 100 };
|
|
const historical = Array.from({ length: 5 }, (_, i) => ({ pace: 95 + i, statValue: 20 + i }));
|
|
const result = findSimilarGames(target, historical, 15);
|
|
expect(result.confidence).toBe('LOW');
|
|
expect(result.usedSeasonAvg).toBe(true);
|
|
});
|
|
|
|
test('findSimilarGames returns HIGH confidence with enough instances', () => {
|
|
const target = { pace: 100 };
|
|
const historical = Array.from({ length: 30 }, (_, i) => ({ pace: 95 + (i % 10), statValue: 20 + i }));
|
|
const result = findSimilarGames(target, historical, 15);
|
|
expect(result.confidence).toBe('HIGH');
|
|
expect(result.usedSeasonAvg).toBe(false);
|
|
});
|
|
|
|
test('getPosteriorDistribution returns correct structure', () => {
|
|
const games = Array.from({ length: 20 }, (_, i) => ({ statValue: 20 + Math.random() * 10 }));
|
|
const dist = getPosteriorDistribution(games);
|
|
expect(dist).toHaveProperty('mean');
|
|
expect(dist).toHaveProperty('stddev');
|
|
expect(dist).toHaveProperty('ci_low');
|
|
expect(dist).toHaveProperty('ci_high');
|
|
expect(dist).toHaveProperty('n');
|
|
expect(dist.n).toBe(20);
|
|
expect(dist.ci_low).toBeLessThan(dist.ci_high);
|
|
});
|
|
});
|
|
|
|
// --- Evolution Engine ---
|
|
|
|
describe('Evolution Engine', () => {
|
|
test('graceful degradation on HTTP failure', async () => {
|
|
axios.post.mockRejectedValue(new Error('Connection refused'));
|
|
const result = await detectChangepoints('player1', 'usage_rate', [0.2, 0.3], ['2026-01-01', '2026-01-02']);
|
|
expect(result.evolution_detected).toBe(false);
|
|
expect(result.error).toBe('Connection refused');
|
|
});
|
|
|
|
test('graceful degradation on timeout', async () => {
|
|
const timeoutError = new Error('timeout');
|
|
timeoutError.code = 'ECONNABORTED';
|
|
axios.post.mockRejectedValue(timeoutError);
|
|
const result = await detectChangepoints('player1', 'usage_rate', [0.2, 0.3], ['2026-01-01', '2026-01-02']);
|
|
expect(result.evolution_detected).toBe(false);
|
|
expect(result.error).toBe('timeout');
|
|
});
|
|
});
|
|
|
|
// --- Line Discrepancy Detector ---
|
|
|
|
describe('Line Discrepancy Detector', () => {
|
|
test('detects discrepancy when gap > 0.5', () => {
|
|
const lines = [
|
|
{ book: 'pinnacle', line: 24.5 },
|
|
{ book: 'circa', line: 24.5 },
|
|
{ book: 'draftkings', line: 25.5 },
|
|
{ book: 'fanduel', line: 25.5 },
|
|
{ book: 'betmgm', line: 25.5 },
|
|
];
|
|
const result = detectDiscrepancy(lines);
|
|
expect(result.discrepancy).toBe(true);
|
|
expect(result.gap).toBe(1);
|
|
});
|
|
|
|
test('no discrepancy when gap <= 0.5', () => {
|
|
const lines = [
|
|
{ book: 'pinnacle', line: 25.0 },
|
|
{ book: 'draftkings', line: 25.5 },
|
|
{ book: 'fanduel', line: 25.0 },
|
|
];
|
|
const result = detectDiscrepancy(lines);
|
|
expect(result.discrepancy).toBe(false);
|
|
});
|
|
|
|
test('detects steam move with 3+ books moving 0.5+ in 10 min', () => {
|
|
const now = new Date();
|
|
const movements = [
|
|
{ book: 'draftkings', line: 0.5, timestamp: now.toISOString() },
|
|
{ book: 'fanduel', line: 0.5, timestamp: new Date(now.getTime() + 60000).toISOString() },
|
|
{ book: 'betmgm', line: 0.5, timestamp: new Date(now.getTime() + 120000).toISOString() },
|
|
{ book: 'caesars', line: 0.5, timestamp: new Date(now.getTime() + 180000).toISOString() },
|
|
];
|
|
const result = detectSteamMove(movements);
|
|
expect(result.steam_move).toBe(true);
|
|
expect(result.books_moved).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
test('no steam move with insufficient books', () => {
|
|
const now = new Date();
|
|
const movements = [
|
|
{ book: 'draftkings', line: 0.5, timestamp: now.toISOString() },
|
|
{ book: 'fanduel', line: 0.5, timestamp: new Date(now.getTime() + 60000).toISOString() },
|
|
];
|
|
const result = detectSteamMove(movements);
|
|
expect(result.steam_move).toBe(false);
|
|
});
|
|
|
|
test('reliability score returns valid range', () => {
|
|
const score = getReliabilityScore('points', 'nba');
|
|
expect(score).toBeGreaterThan(0);
|
|
expect(score).toBeLessThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// --- Alt Line Scanner ---
|
|
|
|
describe('Alt Line Scanner', () => {
|
|
test('calculates model probability correctly', () => {
|
|
// Mean 25, stddev 5, line 25 => P(over) should be ~0.5
|
|
const prob = calculateModelProbability(25, 5, 25, 'over');
|
|
expect(prob).toBeCloseTo(0.5, 1);
|
|
});
|
|
|
|
test('compareToBookImplied detects value', () => {
|
|
const result = compareToBookImplied(0.60, -110);
|
|
expect(result.model_prob).toBe(0.6);
|
|
expect(result.book_implied).toBeCloseTo(0.524, 2);
|
|
expect(result.value_detected).toBe(true);
|
|
expect(result.edge).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('scanAltLines returns optimal line with edge', () => {
|
|
const prop = { projected_mean: 25, projected_stddev: 5, direction: 'over' };
|
|
const odds = [
|
|
{ line: 22.5, odds: -130, book: 'draftkings' },
|
|
{ line: 24.5, odds: -110, book: 'draftkings' },
|
|
{ line: 27.5, odds: +120, book: 'draftkings' },
|
|
];
|
|
const result = scanAltLines(prop, odds);
|
|
expect(result).not.toBeNull();
|
|
expect(result.optimal_line).toBeDefined();
|
|
expect(result.edge).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// --- Bayesian Engine ---
|
|
|
|
describe('Bayesian Engine', () => {
|
|
test('normalCDF returns ~0.5 at the mean', () => {
|
|
const result = normalCDF(10, 10, 3);
|
|
expect(result).toBeCloseTo(0.5, 2);
|
|
});
|
|
|
|
test('normalCDF returns known values', () => {
|
|
// P(X <= 1) for N(0,1) should be ~0.8413
|
|
const result = normalCDF(1, 0, 1);
|
|
expect(result).toBeCloseTo(0.8413, 2);
|
|
});
|
|
|
|
test('poissonCDF correctness', () => {
|
|
// P(X <= 2) for Poisson(1) = e^-1 * (1 + 1 + 0.5) = 0.9197
|
|
const result = poissonCDF(2, 1);
|
|
expect(result).toBeCloseTo(0.9197, 2);
|
|
});
|
|
|
|
test('distribution shape mapping returns correct shapes', () => {
|
|
expect(getDistributionShape('points')).toBe('normal');
|
|
expect(getDistributionShape('walks')).toBe('poisson');
|
|
expect(getDistributionShape('home_runs')).toBe('negative_binomial');
|
|
expect(getDistributionShape('pitcher_strikeouts')).toBe('bimodal_mixture');
|
|
expect(getDistributionShape('unknown_stat')).toBe('normal');
|
|
});
|
|
|
|
test('calculateProbability works for poisson over', () => {
|
|
const prob = calculateProbability('poisson', { lambda: 5 }, 4, 'over');
|
|
// P(X > 4) for Poisson(5)
|
|
expect(prob).toBeGreaterThan(0.4);
|
|
expect(prob).toBeLessThan(0.8);
|
|
});
|
|
});
|
|
|
|
// --- Model Trainer ---
|
|
|
|
describe('Model Trainer', () => {
|
|
test('walkForwardValidate returns accuracy metrics', () => {
|
|
const predictions = [
|
|
{ predicted: 25, timestamp: '2026-01-01' },
|
|
{ predicted: 22, timestamp: '2026-01-02' },
|
|
{ predicted: 30, timestamp: '2026-01-03' },
|
|
];
|
|
const actuals = [
|
|
{ actual: 24, timestamp: '2026-01-01' },
|
|
{ actual: 23, timestamp: '2026-01-02' },
|
|
{ actual: 28, timestamp: '2026-01-03' },
|
|
];
|
|
const result = walkForwardValidate(predictions, actuals);
|
|
expect(result).toHaveProperty('accuracy');
|
|
expect(result).toHaveProperty('mae');
|
|
expect(result).toHaveProperty('rmse');
|
|
expect(result.n).toBe(3);
|
|
});
|
|
|
|
test('CLV calculation returns correct structure', () => {
|
|
const clv = calculateCLV(24.5, 25.0, 25.5);
|
|
expect(clv.clv_at_prediction).toBe(1.0);
|
|
expect(clv.clv_at_24hr).toBe(0.5);
|
|
expect(clv.clv_at_tip).toBe(0);
|
|
});
|
|
|
|
test('drift detection after 10 consecutive negative CLV', () => {
|
|
const history = [1, 2, 0.5, -1, -2, -0.5, -1, -0.3, -2, -1.5, -0.8, -0.2, -1];
|
|
const result = checkDrift(history);
|
|
expect(result.drift_detected).toBe(true);
|
|
expect(result.consecutive_negative).toBe(10);
|
|
expect(result.alert).toBe(true);
|
|
});
|
|
|
|
test('no drift with mixed CLV history', () => {
|
|
const history = [1, -1, 2, -2, 0.5, -0.5, 1, -1, 0.3];
|
|
const result = checkDrift(history);
|
|
expect(result.drift_detected).toBe(false);
|
|
});
|
|
|
|
test('learning rate cap enforcement (max 0.05 delta)', () => {
|
|
expect(applyLearningRateCap(0.20, 0.30)).toBe(0.25);
|
|
expect(applyLearningRateCap(0.20, 0.10)).toBe(0.15);
|
|
expect(applyLearningRateCap(0.20, 0.22)).toBe(0.22);
|
|
expect(applyLearningRateCap(0.20, 0.20)).toBe(0.20);
|
|
});
|
|
});
|
|
|
|
// --- Correlation Math ---
|
|
|
|
describe('Correlation Math', () => {
|
|
test('phi coefficient calculation for joint outcomes', () => {
|
|
// Perfect positive correlation
|
|
const phi = phiCoefficient(50, 0, 0, 50);
|
|
expect(phi).toBe(1);
|
|
});
|
|
|
|
test('phi coefficient returns 0 for no correlation', () => {
|
|
const phi = phiCoefficient(25, 25, 25, 25);
|
|
expect(phi).toBe(0);
|
|
});
|
|
|
|
test('phi coefficient handles zero denominator', () => {
|
|
const phi = phiCoefficient(0, 0, 0, 0);
|
|
expect(phi).toBe(0);
|
|
});
|
|
|
|
test('hasMinimumObservations checks threshold', () => {
|
|
expect(hasMinimumObservations(100)).toBe(true);
|
|
expect(hasMinimumObservations(99)).toBe(false);
|
|
expect(hasMinimumObservations(50, 50)).toBe(true);
|
|
});
|
|
|
|
test('calculateJuiceAdjustedEV returns correct EV', () => {
|
|
// 60% win prob, -110 stake: 0.6*100 - 0.4*110 = 60 - 44 = 16
|
|
const ev = calculateJuiceAdjustedEV(0.6, 110);
|
|
expect(ev).toBeCloseTo(16, 0);
|
|
});
|
|
|
|
test('calculateJuiceAdjustedEV negative EV on bad bet', () => {
|
|
// 40% win prob: 0.4*100 - 0.6*110 = 40 - 66 = -26
|
|
const ev = calculateJuiceAdjustedEV(0.4, 110);
|
|
expect(ev).toBeLessThan(0);
|
|
});
|
|
});
|
|
|
|
});
|