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); }); }); });