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
+9 -5
View File
@@ -1,11 +1,11 @@
const axios = require('axios');
const { getRedisClient } = require('../utils/redis');
const { normalizeProps, MARKET_MAP } = require('../utils/oddsNormalizer');
const { normalizeProps, extractSpreads, MARKET_MAP } = require('../utils/oddsNormalizer');
const ODDS_API_BASE = 'https://api.the-odds-api.com/v4/sports';
const CACHE_TTL = 900; // 15 minutes in seconds
const SPORT_KEYS = { nba: 'basketball_nba', ncaab: 'basketball_ncaab' };
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',');
const ALL_MARKETS = Object.keys(MARKET_MAP).join(',') + ',spreads';
const BOOKMAKERS = 'draftkings,fanduel,betmgm';
function getCacheKey(sport) {
@@ -91,11 +91,12 @@ async function fetchAllOdds(sport, apiKey) {
}
const props = normalizeProps(eventsWithOdds);
const spreads = extractSpreads(eventsWithOdds);
const quotaRemaining = lastHeaders['x-requests-remaining']
? parseInt(lastHeaders['x-requests-remaining'], 10)
: null;
return { props, quotaRemaining, headers: lastHeaders };
return { props, spreads, quotaRemaining, headers: lastHeaders };
}
function parseQuota(headers) {
@@ -119,6 +120,7 @@ async function getOdds(sport) {
source: 'cache',
quota_remaining: quota,
props: data.props,
spreads: data.spreads || [],
};
}
@@ -132,7 +134,7 @@ async function getOdds(sport) {
// Fetch live data
try {
const { props, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);
const { props, spreads, quotaRemaining, headers } = await fetchAllOdds(sport, apiKey);
// Update quota in Redis
if (headers) {
@@ -140,7 +142,7 @@ async function getOdds(sport) {
}
const now = new Date().toISOString();
const cacheData = { updated_at: now, props };
const cacheData = { updated_at: now, props, spreads };
await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL);
return {
@@ -149,6 +151,7 @@ async function getOdds(sport) {
source: 'live',
quota_remaining: quotaRemaining,
props,
spreads,
};
} catch (err) {
// If API fails, try stale cache (no TTL check — any cached data)
@@ -163,6 +166,7 @@ async function getOdds(sport) {
stale: true,
quota_remaining: quota,
props: data.props,
spreads: data.spreads || [],
};
}