feat: Feature 1.3 — Prop Analysis Engine with 6-step grading pipeline

Core intelligence for BetonBLK prop analysis:
- POST /api/analyze/prop — single prop analysis
- POST /api/analyze/batch — multi-prop analysis for parlay scanner
- 6-step pipeline: season avg → recent form → situational splits →
  cross-book lines → kill conditions → grade (A/B/C/D)
- 6 kill conditions: low_minutes, small_sample, b2b_high_usage,
  blowout_risk, split_conflict, no_opponent_data
- Composite scoring with confidence (30-95), bonuses, penalties
- Added spreads market to Odds API fetch (zero extra credits)
- Full reasoning output with step-by-step breakdown

36 new tests (unit + integration), 128 total across all features

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-21 11:41:18 -04:00
parent 3da1b4242c
commit c8c0962e56
16 changed files with 1560 additions and 40 deletions
+396
View File
@@ -0,0 +1,396 @@
const request = require('supertest');
// Mock Redis
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
hset: jest.fn(),
hgetall: jest.fn(),
expire: jest.fn(),
};
jest.mock('../../src/utils/redis', () => ({
getRedisClient: () => mockRedis,
}));
// Mock axios (used by both oddsService and nbaStatsClient)
jest.mock('axios');
const axios = require('axios');
process.env.ODDS_API_KEY = 'test-key';
process.env.NBA_SERVICE_URL = 'http://localhost:8000';
const app = require('../../src/app');
// Mock data
const MOCK_ODDS_EVENTS = [
{
id: 'game-1',
sport_key: 'basketball_nba',
home_team: 'Denver Nuggets',
away_team: 'Los Angeles Lakers',
commence_time: '2026-03-21T19:00:00Z',
},
];
const MOCK_ODDS_WITH_SPREADS = {
...MOCK_ODDS_EVENTS[0],
bookmakers: [
{
key: 'draftkings',
title: 'DraftKings',
markets: [
{
key: 'player_points',
last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Over', description: 'Nikola Jokic', price: -110, point: 26.5 },
{ name: 'Under', description: 'Nikola Jokic', price: -110, point: 26.5 },
],
},
{
key: 'spreads',
last_update: '2026-03-21T14:28:00Z',
outcomes: [
{ name: 'Denver Nuggets', price: -110, point: -5.5 },
{ name: 'Los Angeles Lakers', price: -110, point: 5.5 },
],
},
],
},
{
key: 'fanduel',
title: 'FanDuel',
markets: [
{
key: 'player_points',
last_update: '2026-03-21T14:30:00Z',
outcomes: [
{ name: 'Over', description: 'Nikola Jokic', price: -105, point: 27.0 },
{ name: 'Under', description: 'Nikola Jokic', price: -115, point: 27.0 },
],
},
],
},
],
};
const MOCK_SEASON_AVG = {
player: 'Nikola Jokic',
player_id: 203999,
team: 'DEN',
season: '2025-26',
source: 'cache',
stats: {
points: 26.3, rebounds: 12.4, assists: 9.1, threes: 1.1,
blocks: 0.7, steals: 1.4, pra: 47.8, turnovers: 3.2,
games_played: 65, minutes: 34.2,
},
};
const MOCK_LAST_N = {
player: 'Nikola Jokic',
player_id: 203999,
team: 'DEN',
last_n: 10,
source: 'cache',
stats: {
points: 28.1, rebounds: 13.0, assists: 10.2, threes: 1.3,
blocks: 0.8, steals: 1.5, pra: 51.3, turnovers: 2.9,
games_played: 10, minutes: 35.1,
},
};
const MOCK_HOME_AWAY = {
player: 'Nikola Jokic',
stat_type: 'points',
split_type: 'home_away',
source: 'cache',
splits: {
home: { avg: 27.8, games: 33 },
away: { avg: 24.9, games: 32 },
},
};
const MOCK_REST_DAYS = {
player: 'Nikola Jokic',
stat_type: 'points',
split_type: 'rest_days',
source: 'cache',
splits: {
b2b: { avg: 23.1, games: 8 },
'1_day_rest': { avg: 26.5, games: 40 },
'2_plus_days_rest': { avg: 28.2, games: 17 },
},
};
const MOCK_VS_TEAM = {
player: 'Nikola Jokic',
stat_type: 'points',
split_type: 'vs_team',
opponent: 'LAL',
source: 'cache',
splits: {
vs_opponent: { avg: 30.5, games: 3 },
vs_all_others: { avg: 25.8, games: 62 },
},
};
const API_HEADERS = {
'x-requests-remaining': '488',
'x-requests-used': '12',
};
function setupMocks() {
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockRedis.hset.mockResolvedValue(1);
mockRedis.hgetall.mockResolvedValue({});
mockRedis.expire.mockResolvedValue(1);
// Odds API: events then event odds
axios.get.mockImplementation((url) => {
if (url.includes('/events') && !url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
}
if (url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
}
// NBA stats service calls
if (url.includes('/stats/season-avg')) {
return Promise.resolve({ data: MOCK_SEASON_AVG });
}
if (url.includes('/stats/last-n')) {
return Promise.resolve({ data: MOCK_LAST_N });
}
if (url.includes('/stats/splits')) {
if (url.includes('split_type=home_away') || (arguments[1]?.params?.split_type === 'home_away')) {
return Promise.resolve({ data: MOCK_HOME_AWAY });
}
if (url.includes('split_type=rest_days') || (arguments[1]?.params?.split_type === 'rest_days')) {
return Promise.resolve({ data: MOCK_REST_DAYS });
}
if (url.includes('split_type=vs_team') || (arguments[1]?.params?.split_type === 'vs_team')) {
return Promise.resolve({ data: MOCK_VS_TEAM });
}
return Promise.resolve({ data: MOCK_HOME_AWAY });
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
});
}
// Better mock that checks params
function setupDetailedMocks() {
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockRedis.hset.mockResolvedValue(1);
mockRedis.hgetall.mockResolvedValue({});
mockRedis.expire.mockResolvedValue(1);
axios.get.mockImplementation((url, config) => {
// Odds API
if (url.includes('the-odds-api.com') && url.includes('/events') && !url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
}
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_WITH_SPREADS, headers: API_HEADERS });
}
// NBA stats service
if (url.includes('localhost:8000/stats/season-avg')) {
return Promise.resolve({ data: MOCK_SEASON_AVG });
}
if (url.includes('localhost:8000/stats/last-n')) {
return Promise.resolve({ data: MOCK_LAST_N });
}
if (url.includes('localhost:8000/stats/splits')) {
const splitType = config?.params?.split_type;
if (splitType === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
if (splitType === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
if (splitType === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
return Promise.resolve({ data: MOCK_HOME_AWAY });
}
if (url.includes('localhost:8000/players/search')) {
return Promise.resolve({ data: { results: [{ player_id: 203999, full_name: 'Nikola Jokic' }] } });
}
return Promise.reject(new Error(`Unmocked URL: ${url}`));
});
}
beforeEach(() => {
jest.clearAllMocks();
});
describe('POST /api/analyze/prop', () => {
it('returns complete analysis with all fields', async () => {
setupDetailedMocks();
const res = await request(app)
.post('/api/analyze/prop')
.send({
player: 'Nikola Jokic',
stat_type: 'points',
line: 26.5,
direction: 'over',
book: 'draftkings',
})
.expect(200);
expect(res.body.player).toBe('Nikola Jokic');
expect(res.body.stat_type).toBe('points');
expect(res.body.grade).toMatch(/^[ABCD]$/);
expect(typeof res.body.edge_pct).toBe('number');
expect(typeof res.body.confidence).toBe('number');
expect(res.body.confidence).toBeGreaterThanOrEqual(30);
expect(res.body.confidence).toBeLessThanOrEqual(95);
expect(Array.isArray(res.body.kill_conditions_triggered)).toBe(true);
expect(res.body.reasoning).toBeDefined();
expect(res.body.reasoning.summary).toBeDefined();
expect(res.body.reasoning.steps).toBeDefined();
expect(res.body.reasoning.steps.season_avg).toBeDefined();
expect(res.body.reasoning.steps.recent_form).toBeDefined();
expect(res.body.reasoning.steps.situational).toBeDefined();
expect(res.body.reasoning.steps.line_comparison).toBeDefined();
expect(res.body.reasoning.steps.kill_conditions).toBeDefined();
expect(res.body.reasoning.steps.final_grade).toBeDefined();
});
it('returns grade A/B for player averaging above line with good recent form', async () => {
setupDetailedMocks();
const res = await request(app)
.post('/api/analyze/prop')
.send({
player: 'Nikola Jokic',
stat_type: 'points',
line: 24.5,
direction: 'over',
book: 'draftkings',
})
.expect(200);
expect(['A', 'B']).toContain(res.body.grade);
expect(res.body.edge_pct).toBeGreaterThan(0);
});
it('returns grade D for player averaging below line', async () => {
setupDetailedMocks();
const res = await request(app)
.post('/api/analyze/prop')
.send({
player: 'Nikola Jokic',
stat_type: 'points',
line: 35.5,
direction: 'over',
book: 'draftkings',
})
.expect(200);
expect(res.body.grade).toBe('D');
});
it('caps grade when kill conditions trigger (blowout spread)', async () => {
// Override spread to be a blowout
const bigSpreadOdds = JSON.parse(JSON.stringify(MOCK_ODDS_WITH_SPREADS));
bigSpreadOdds.bookmakers[0].markets[1].outcomes[0].point = -15;
bigSpreadOdds.bookmakers[0].markets[1].outcomes[1].point = 15;
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockRedis.hset.mockResolvedValue(1);
mockRedis.hgetall.mockResolvedValue({});
mockRedis.expire.mockResolvedValue(1);
axios.get.mockImplementation((url, config) => {
if (url.includes('the-odds-api.com') && !url.includes('/odds')) {
return Promise.resolve({ data: MOCK_ODDS_EVENTS, headers: API_HEADERS });
}
if (url.includes('the-odds-api.com') && url.includes('/odds')) {
return Promise.resolve({ data: bigSpreadOdds, headers: API_HEADERS });
}
if (url.includes('localhost:8000/stats/season-avg')) {
return Promise.resolve({ data: MOCK_SEASON_AVG });
}
if (url.includes('localhost:8000/stats/last-n')) {
return Promise.resolve({ data: MOCK_LAST_N });
}
if (url.includes('localhost:8000/stats/splits')) {
const st = config?.params?.split_type;
if (st === 'home_away') return Promise.resolve({ data: MOCK_HOME_AWAY });
if (st === 'rest_days') return Promise.resolve({ data: MOCK_REST_DAYS });
if (st === 'vs_team') return Promise.resolve({ data: MOCK_VS_TEAM });
return Promise.resolve({ data: MOCK_HOME_AWAY });
}
return Promise.reject(new Error(`Unmocked: ${url}`));
});
const res = await request(app)
.post('/api/analyze/prop')
.send({
player: 'Nikola Jokic',
stat_type: 'points',
line: 24.5,
direction: 'over',
book: 'draftkings',
})
.expect(200);
const codes = res.body.kill_conditions_triggered.map((k) => k.code);
expect(codes).toContain('blowout_risk');
expect(['C', 'D']).toContain(res.body.grade);
});
it('returns 400 for missing player field', async () => {
const res = await request(app)
.post('/api/analyze/prop')
.send({ stat_type: 'points', line: 26.5, direction: 'over' })
.expect(400);
expect(res.body.error).toContain('player is required');
});
it('returns 400 for invalid stat_type', async () => {
const res = await request(app)
.post('/api/analyze/prop')
.send({ player: 'Jokic', stat_type: 'invalid', line: 26.5, direction: 'over' })
.expect(400);
expect(res.body.error).toContain('Invalid stat_type');
});
it('returns 400 for missing direction', async () => {
const res = await request(app)
.post('/api/analyze/prop')
.send({ player: 'Jokic', stat_type: 'points', line: 26.5 })
.expect(400);
expect(res.body.error).toContain('direction is required');
});
});
describe('POST /api/analyze/batch', () => {
it('processes multiple props and returns array', async () => {
setupDetailedMocks();
const res = await request(app)
.post('/api/analyze/batch')
.send({
props: [
{ player: 'Nikola Jokic', stat_type: 'points', line: 26.5, direction: 'over', book: 'draftkings' },
{ player: 'Nikola Jokic', stat_type: 'rebounds', line: 12.5, direction: 'over', book: 'fanduel' },
],
})
.expect(200);
expect(Array.isArray(res.body.results)).toBe(true);
expect(res.body.results.length).toBe(2);
});
it('returns 400 for empty props array', async () => {
const res = await request(app)
.post('/api/analyze/batch')
.send({ props: [] })
.expect(400);
expect(res.body.error).toContain('props array is required');
});
});
+111
View File
@@ -0,0 +1,111 @@
const { computeGrade } = require('../../src/services/grader');
function makeStepResults(overrides = {}) {
return {
seasonDelta: 0,
recentDelta: 0,
situationalDelta: 0,
lineEdge: 0,
killConditions: [],
gamesPlayed: 65,
seasonAndRecentAgree: null,
...overrides,
};
}
describe('grader', () => {
describe('grade assignment', () => {
test('composite >= 3.0 returns grade A with confidence 80-95', () => {
// composite = (4*1 + 4*1.5 + 4*1.2 + 4*0.8) / 4.5 = 18/4.5 = 4.0
const result = computeGrade(makeStepResults({
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
}));
expect(result.grade).toBe('A');
expect(result.confidence).toBeGreaterThanOrEqual(80);
expect(result.confidence).toBeLessThanOrEqual(95);
});
test('composite 1.5-2.99 returns grade B with confidence 65-79', () => {
// composite = (2*1 + 2*1.5 + 2*1.2 + 2*0.8) / 4.5 = 9/4.5 = 2.0
const result = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
}));
expect(result.grade).toBe('B');
expect(result.confidence).toBeGreaterThanOrEqual(65);
expect(result.confidence).toBeLessThanOrEqual(79);
});
test('composite 0.5-1.49 returns grade C with confidence 50-64', () => {
// composite = (1*1 + 1*1.5 + 1*1.2 + 0*0.8) / 4.5 = 3.7/4.5 ≈ 0.82
const result = computeGrade(makeStepResults({
seasonDelta: 1, recentDelta: 1, situationalDelta: 1, lineEdge: 0,
}));
expect(result.grade).toBe('C');
expect(result.confidence).toBeGreaterThanOrEqual(50);
expect(result.confidence).toBeLessThanOrEqual(64);
});
test('composite < 0.5 returns grade D with confidence 30-49', () => {
const result = computeGrade(makeStepResults({
seasonDelta: -1, recentDelta: -1, situationalDelta: -1, lineEdge: 0,
}));
expect(result.grade).toBe('D');
expect(result.confidence).toBeGreaterThanOrEqual(30);
expect(result.confidence).toBeLessThanOrEqual(49);
});
});
describe('kill condition penalty', () => {
test('caps grade at C and reduces confidence by 15', () => {
const result = computeGrade(makeStepResults({
seasonDelta: 4, recentDelta: 4, situationalDelta: 4, lineEdge: 4,
killConditions: [{ code: 'blowout_risk' }],
}));
expect(result.grade).toBe('C');
// Original would be A (80+), minus 15 = 65+
expect(result.confidence).toBeLessThan(85);
});
test('grade B with kill condition becomes C', () => {
const result = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, situationalDelta: 2, lineEdge: 2,
killConditions: [{ code: 'low_minutes' }],
}));
expect(result.grade).toBe('C');
});
});
describe('bonuses', () => {
test('sample bonus +5 for > 50 games', () => {
const with50 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 55 }));
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
expect(with50.confidence).toBe(without.confidence + 5);
});
test('sample bonus +3 for > 30 games', () => {
const with30 = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 35 }));
const without = computeGrade(makeStepResults({ seasonDelta: 1, recentDelta: 1, gamesPlayed: 20 }));
expect(with30.confidence).toBe(without.confidence + 3);
});
test('consistency bonus +5 when season and recent agree', () => {
const agree = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: true,
}));
const noInfo = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
}));
expect(agree.confidence).toBe(noInfo.confidence + 5);
});
test('consistency penalty -5 when season and recent conflict', () => {
const conflict = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: false,
}));
const noInfo = computeGrade(makeStepResults({
seasonDelta: 2, recentDelta: 2, seasonAndRecentAgree: null,
}));
expect(conflict.confidence).toBe(noInfo.confidence - 5);
});
});
});
+86
View File
@@ -0,0 +1,86 @@
const { evaluateKillConditions } = require('../../src/services/killConditions');
function makeContext(overrides = {}) {
return {
seasonStats: { minutes: 34, games_played: 65, points: 26 },
recentStats: { value: 28 },
homeAwaySplit: { avg: 27 },
restSplit: { isB2B: false },
vsOpponentSplit: { games: 3 },
spread: -3,
...overrides,
};
}
describe('killConditions', () => {
test('returns empty array when no conditions trigger', () => {
const result = evaluateKillConditions(makeContext());
expect(result).toEqual([]);
});
test('low_minutes: triggers when avg minutes < 24', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 22, games_played: 65 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('low_minutes');
});
test('small_sample: triggers when games_played < 15', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 34, games_played: 10 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('small_sample');
});
test('b2b_high_usage: triggers when B2B and minutes > 32', () => {
const result = evaluateKillConditions(makeContext({
restSplit: { isB2B: true },
seasonStats: { minutes: 35, games_played: 65 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('b2b_high_usage');
});
test('blowout_risk: triggers when spread > 10', () => {
const result = evaluateKillConditions(makeContext({ spread: -12 }));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('blowout_risk');
});
test('blowout_risk: triggers when spread > +10 too', () => {
const result = evaluateKillConditions(makeContext({ spread: 11 }));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('blowout_risk');
});
test('split_conflict: triggers when home/away vs recent differs > 5', () => {
const result = evaluateKillConditions(makeContext({
homeAwaySplit: { avg: 20 },
recentStats: { value: 28 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('split_conflict');
});
test('no_opponent_data: triggers when vs_team games < 2', () => {
const result = evaluateKillConditions(makeContext({
vsOpponentSplit: { games: 1 },
}));
expect(result).toHaveLength(1);
expect(result[0].code).toBe('no_opponent_data');
});
test('multiple conditions can trigger simultaneously', () => {
const result = evaluateKillConditions(makeContext({
seasonStats: { minutes: 20, games_played: 10 },
spread: -15,
}));
const codes = result.map((r) => r.code);
expect(codes).toContain('low_minutes');
expect(codes).toContain('small_sample');
expect(codes).toContain('blowout_risk');
expect(result.length).toBe(3);
});
});
+49
View File
@@ -0,0 +1,49 @@
const { deltaToSignal, directedDelta } = require('../../src/utils/signals');
describe('signals', () => {
describe('deltaToSignal', () => {
test('0.0-0.49 maps to neutral', () => {
expect(deltaToSignal(0)).toBe('neutral');
expect(deltaToSignal(0.3)).toBe('neutral');
expect(deltaToSignal(0.49)).toBe('neutral');
});
test('0.5-1.99 maps to lean', () => {
expect(deltaToSignal(0.5)).toBe('lean');
expect(deltaToSignal(1.5)).toBe('lean');
expect(deltaToSignal(1.99)).toBe('lean');
});
test('2.0-3.99 maps to bullish', () => {
expect(deltaToSignal(2.0)).toBe('bullish');
expect(deltaToSignal(3.5)).toBe('bullish');
});
test('>= 4.0 maps to strong_bullish', () => {
expect(deltaToSignal(4.0)).toBe('strong_bullish');
expect(deltaToSignal(7.0)).toBe('strong_bullish');
});
test('negative deltas map to bearish equivalents', () => {
expect(deltaToSignal(-0.3)).toBe('neutral');
expect(deltaToSignal(-1.0)).toBe('lean_bearish');
expect(deltaToSignal(-2.5)).toBe('bearish');
expect(deltaToSignal(-5.0)).toBe('strong_bearish');
});
});
describe('directedDelta', () => {
test('over: positive when avg > line', () => {
expect(directedDelta(28, 26, 'over')).toBe(2);
});
test('over: negative when avg < line', () => {
expect(directedDelta(24, 26, 'over')).toBe(-2);
});
test('under: inverts delta', () => {
expect(directedDelta(28, 26, 'under')).toBe(-2);
expect(directedDelta(24, 26, 'under')).toBe(2);
});
});
});