2366660f5e
Line movement system: - Baseline capture on first odds fetch of the day - Movement detection >= 0.5 points with direction (up/down) - Sharp money heuristic (sharp_action/public_action/unknown) - GET /api/movements with player, stat_type, min_movement filters - Movements included in GET /api/odds/nba live responses Cascade detection system: - Scratch detection: player props disappear from 2+ books - Affected user lookup via scan_sessions + picks - Parlay re-grade without scratched legs - cascade_alerts created for affected users - GET /api/alerts (Analyst/Desk only), PATCH /api/alerts/:id/read Zero extra Odds API credits — all detection piggybacks on existing fetches. Migration 002: line_baselines, line_movements, cascade_alerts tables. 30 new tests, 188 total (161 Node.js + 27 Python), all passing. Phase 2 Core Product COMPLETE. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
128 lines
4.4 KiB
JavaScript
128 lines
4.4 KiB
JavaScript
const { determineSharpIndicator } = require('../../src/services/lineMovementService');
|
|
|
|
// Mock Redis and Supabase for the service-level tests
|
|
const mockRedis = { get: jest.fn(), set: jest.fn() };
|
|
jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis }));
|
|
|
|
const mockSupabaseFrom = jest.fn();
|
|
jest.mock('../../src/utils/supabase', () => ({
|
|
getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }),
|
|
}));
|
|
|
|
const { processNewOdds, detectMovements, captureBaseline } = require('../../src/services/lineMovementService');
|
|
|
|
function makeProp(overrides = {}) {
|
|
return {
|
|
player: 'Nikola Jokic',
|
|
stat_type: 'points',
|
|
book: 'draftkings',
|
|
line: 26.5,
|
|
over_odds: -110,
|
|
under_odds: -110,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
});
|
|
|
|
describe('lineMovementService', () => {
|
|
describe('processNewOdds', () => {
|
|
test('first fetch of the day captures baseline, returns no movements', async () => {
|
|
mockRedis.get.mockResolvedValue(null); // no baseline flag
|
|
mockSupabaseFrom.mockReturnValue({
|
|
upsert: () => Promise.resolve({ data: [], error: null }),
|
|
delete: () => ({ lt: () => Promise.resolve({ error: null }) }),
|
|
});
|
|
|
|
const result = await processNewOdds('nba', [makeProp()]);
|
|
expect(result.isBaselineCapture).toBe(true);
|
|
expect(result.movements).toEqual([]);
|
|
});
|
|
|
|
test('subsequent fetch detects movement >= 0.5', async () => {
|
|
mockRedis.get.mockResolvedValue('1'); // baseline exists
|
|
|
|
const baselines = [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 },
|
|
];
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'line_baselines') {
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) };
|
|
}
|
|
if (table === 'line_movements') {
|
|
return { insert: () => Promise.resolve({ error: null }) };
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const result = await processNewOdds('nba', [makeProp({ line: 27.5 })]);
|
|
expect(result.isBaselineCapture).toBe(false);
|
|
expect(result.movements).toHaveLength(1);
|
|
expect(result.movements[0].movement).toBe(1.0);
|
|
expect(result.movements[0].direction).toBe('up');
|
|
});
|
|
|
|
test('movement < 0.5 is not flagged', async () => {
|
|
mockRedis.get.mockResolvedValue('1');
|
|
|
|
const baselines = [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 },
|
|
];
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'line_baselines') {
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) };
|
|
}
|
|
if (table === 'line_movements') {
|
|
return { insert: () => Promise.resolve({ error: null }) };
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const result = await processNewOdds('nba', [makeProp({ line: 26.8 })]);
|
|
expect(result.movements).toHaveLength(0);
|
|
});
|
|
|
|
test('direction correctly identified (down)', async () => {
|
|
mockRedis.get.mockResolvedValue('1');
|
|
|
|
const baselines = [
|
|
{ player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 27.0 },
|
|
];
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'line_baselines') {
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) };
|
|
}
|
|
if (table === 'line_movements') {
|
|
return { insert: () => Promise.resolve({ error: null }) };
|
|
}
|
|
return {};
|
|
});
|
|
|
|
const result = await processNewOdds('nba', [makeProp({ line: 26.0 })]);
|
|
expect(result.movements[0].direction).toBe('down');
|
|
});
|
|
});
|
|
|
|
describe('determineSharpIndicator', () => {
|
|
test('line up + over expensive = sharp_action', () => {
|
|
expect(determineSharpIndicator(26.5, 27.0, -120, -100, 'up')).toBe('sharp_action');
|
|
});
|
|
|
|
test('line up + under expensive = public_action', () => {
|
|
expect(determineSharpIndicator(26.5, 27.0, -100, -120, 'up')).toBe('public_action');
|
|
});
|
|
|
|
test('line down + under expensive = sharp_action', () => {
|
|
expect(determineSharpIndicator(27.0, 26.5, -100, -120, 'down')).toBe('sharp_action');
|
|
});
|
|
|
|
test('no odds = unknown', () => {
|
|
expect(determineSharpIndicator(26.5, 27.0, null, null, 'up')).toBe('unknown');
|
|
});
|
|
});
|
|
});
|