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