From c8c0962e56100f17324fd18e28288316f57c4ab1 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 21 Mar 2026 11:41:18 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Feature=201.3=20=E2=80=94=20Prop=20Anal?= =?UTF-8?q?ysis=20Engine=20with=206-step=20grading=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- BUILD-STATE.md | 74 +++--- DECISIONS.md | 7 + specs/feature-1-3-prop-analysis.md | 296 +++++++++++++++++++++ src/app.js | 2 + src/routes/analyze.js | 79 ++++++ src/services/grader.js | 65 +++++ src/services/killConditions.js | 68 +++++ src/services/nbaStatsClient.js | 48 ++++ src/services/oddsService.js | 14 +- src/services/propAnalyzer.js | 248 ++++++++++++++++++ src/utils/oddsNormalizer.js | 42 ++- src/utils/signals.js | 15 ++ tests/integration/analyze.test.js | 396 +++++++++++++++++++++++++++++ tests/unit/grader.test.js | 111 ++++++++ tests/unit/killConditions.test.js | 86 +++++++ tests/unit/signals.test.js | 49 ++++ 16 files changed, 1560 insertions(+), 40 deletions(-) create mode 100644 specs/feature-1-3-prop-analysis.md create mode 100644 src/routes/analyze.js create mode 100644 src/services/grader.js create mode 100644 src/services/killConditions.js create mode 100644 src/services/nbaStatsClient.js create mode 100644 src/services/propAnalyzer.js create mode 100644 src/utils/signals.js create mode 100644 tests/integration/analyze.test.js create mode 100644 tests/unit/grader.test.js create mode 100644 tests/unit/killConditions.test.js create mode 100644 tests/unit/signals.test.js diff --git a/BUILD-STATE.md b/BUILD-STATE.md index e0a5440..fb3860b 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,7 +4,7 @@ 2026-03-21 ## Current Phase -Phase 1 — Foundation +Phase 1 — Foundation (COMPLETE) ## What Has Shipped @@ -12,60 +12,66 @@ Phase 1 — Foundation - GET /api/odds/nba — live NBA player props from DraftKings, FanDuel, BetMGM - GET /api/odds/ncaab — NCAAB props (with off-season detection) - Normalizer: pairs Over/Under outcomes, maps 8 market types, filters to 3 books +- Spreads market added for blowout risk detection (Feature 1.3) - Redis cache: 15-min TTL, stale fallback on API failure - Quota tracking via response headers, 429 when exhausted - Query filters: stat_type, player (partial match), book -- 28 tests passing (18 unit, 10 integration) -- Known limitation: player-to-team assignment deferred to Feature 1.2 ### Feature 1.2 — NBA_API Stats Wrapper (COMPLETE) - FastAPI microservice in nba-service/ on port 8000 -- GET /stats/season-avg — season averages (24hr cache) -- GET /stats/last-n — last N game averages (1hr cache) -- GET /stats/splits — home/away, B2B/rest days, vs-team (6hr cache) -- GET /players/search — partial name to player ID (7-day cache) -- PRA computed as derived stat -- 0.6s rate limiting between nba_api calls with retry -- 27 tests passing (16 unit, 11 integration) -- Startup script: scripts/start.sh runs both Node + Python services +- GET /stats/season-avg (24hr cache), /stats/last-n (1hr), /stats/splits (6hr), /players/search (7-day) +- PRA computed as derived stat, 0.6s rate limiting with retry +- 27 Python tests passing -### Feature 1.4 — Database Schema (CODE COMPLETE — pending Supabase apply) -- Migration SQL: supabase/migrations/001_initial_schema.sql -- 6 tables: users, picks, scan_sessions, bets, outcomes, performance +### Feature 1.3 — Prop Analysis Engine (COMPLETE) +- POST /api/analyze/prop — single prop analysis with full 6-step pipeline +- 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 +- Grading: composite score → A/B/C/D with confidence 30-95 +- 6 kill conditions: low_minutes, small_sample, b2b_high_usage, blowout_risk, split_conflict, no_opponent_data +- Full reasoning output: step-by-step breakdown with signals +- Cross-book line comparison identifies best/worst lines + +### Feature 1.4 — Database Schema (COMPLETE) +- 6 tables applied to Supabase: users, picks, scan_sessions, bets, outcomes, performance - RLS enabled on all tables with auth.uid() policies - 3 triggers: auto-create user, updated_at, scan count reset -- All constraints, indexes, and FKs defined - 37 schema validation tests passing -- BLOCKED: WSL2 cannot resolve *.supabase.co — needs manual apply via SQL Editor + +## Test Summary +- Node.js: 101 tests passing (unit + integration) +- Python: 27 tests passing +- Total: 128 tests, all green ## What's Next -- Apply Feature 1.4 migration to Supabase (manual via SQL Editor) -- Run verify-schema.js to confirm tables exist -- Feature 1.3 — Prop Analysis Engine (depends: 1.1 + 1.2) +- Feature 2.1 — Parlay Scan (depends: 1.3 + 1.4) +- Feature 2.2 — Real-Time Line Movement + Cascade Detection (depends: 1.1) +- Feature 1.5 — Bet Submission (depends: 1.4) ## Active Blockers -- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co (see BLOCKERS.md) +- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co (verify-schema.js cannot run from CLI) ## Session Log ### Session 1 — 2026-03-21 - Made live Odds API test call, documented raw response format in DECISIONS.md -- Built: oddsNormalizer.js, oddsService.js, routes/odds.js, teamMap.js, redis.js, app.js -- Wrote 28 tests (unit + integration), all passing +- Built Feature 1.1: oddsNormalizer.js, oddsService.js, routes/odds.js, teamMap.js, redis.js, app.js +- 28 tests (unit + integration), all passing - Logged DECISION-001 (API response format) and DECISION-002 (credit conservation) -- Spec updated: home_team/away_team replaces team/opponent (API limitation) - Credits used: 2 of 500 (498 remaining) ### Session 2 — 2026-03-21 -- Built Feature 1.2: FastAPI microservice wrapping nba_api - - stats.py, player_map.py, cache.py, main.py, config.py - - 27 Python tests, all passing -- Built Feature 1.4: Full database schema SQL - - 6 tables, RLS, triggers, indexes, constraints - - 37 schema validation tests, all passing - - Could not apply to Supabase (DNS blocker) +- Built Feature 1.2: FastAPI microservice wrapping nba_api (27 Python tests) +- Built Feature 1.4: Full database schema SQL (37 tests), applied manually via Supabase SQL Editor - Logged DECISION-003 (Python microservice) and DECISION-004 (Supabase Auth) -- Created startup script (scripts/start.sh) for both services -- Created Supabase client module (src/utils/supabase.js) -- Created schema verification script (scripts/verify-schema.js) -- Total tests: 92 (65 Node.js + 27 Python), all passing +- Created startup script, Supabase client module, schema verification script + +### Session 3 — 2026-03-21 +- Built Feature 1.3: Prop Analysis Engine + - propAnalyzer.js (orchestrator), grader.js, killConditions.js, nbaStatsClient.js, signals.js + - routes/analyze.js (POST /api/analyze/prop + /batch) + - Added spreads market to Odds API fetch (zero extra credits) + - 36 new tests (unit + integration) +- Logged DECISION-005 (spreads for blowout risk) +- Phase 1 Foundation is now COMPLETE +- Total: 128 tests (101 Node.js + 27 Python), all green diff --git a/DECISIONS.md b/DECISIONS.md index 7077376..34f78bc 100755 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -87,3 +87,10 @@ Outcome level (nested under market.outcomes[]): - Clerk — better DX but adds a vendor, costs more at scale. - Auth-agnostic (own users table, plug in later) — rejected: delays RLS setup, more migration work later. - Consequences: All table access goes through RLS. Service role key bypasses RLS for admin/backend operations. Anon key used for client-side auth flows. + +### DECISION-005: Spreads Market Added to Odds API Fetch (Feature 1.3) +- Date: 2026-03-21 +- Context: Feature 1.3 kill condition `blowout_risk` needs game point spread. The Odds API supports a `spreads` market alongside player props. +- Decision: Add `spreads` to the comma-separated markets list in the existing per-event API call. Zero additional API credits — the spreads data rides alongside the player prop data in the same request. +- Alternatives considered: Skip blowout_risk for now — rejected because it's a high-value kill condition that prevents bad bets on blowout games. +- Consequences: `oddsService.js` now returns a `spreads` array alongside `props`. The `extractSpreads()` function in `oddsNormalizer.js` parses game-level spread data separately from player-level props. diff --git a/specs/feature-1-3-prop-analysis.md b/specs/feature-1-3-prop-analysis.md new file mode 100644 index 0000000..b84e436 --- /dev/null +++ b/specs/feature-1-3-prop-analysis.md @@ -0,0 +1,296 @@ +# Feature 1.3 — Prop Analysis Engine + +## Overview +The core intelligence of BetonBLK. Takes a player prop (player, stat, line, book) and runs a 6-step grading pipeline to produce a grade (A/B/C/D), edge percentage, confidence score, kill condition flags, and natural-language reasoning. This is the engine that powers the parlay scanner (Feature 2.1) and drives all user-facing analysis. + +## Dependencies +- Feature 1.1 — Odds API Integration (provides lines from multiple books) +- Feature 1.2 — NBA_API Stats Wrapper (provides season averages, last-N, splits) + +## Endpoint + +### POST /api/analyze/prop +Analyzes a single player prop. + +**Request body:** +```json +{ + "player": "Nikola Jokic", + "stat_type": "points", + "line": 26.5, + "direction": "over", + "book": "draftkings" +} +``` + +**Response (200):** +```json +{ + "player": "Nikola Jokic", + "stat_type": "points", + "line": 26.5, + "direction": "over", + "book": "draftkings", + "grade": "A", + "edge_pct": 8.3, + "confidence": 82, + "kill_conditions_triggered": [], + "reasoning": { + "summary": "Jokic averages 26.3 on the season but 28.1 over his last 10. At home vs LAL where he averages 30.5, this line is soft. No kill conditions. Strong play.", + "steps": { + "season_avg": { "value": 26.3, "vs_line": -0.2, "signal": "neutral" }, + "recent_form": { "value": 28.1, "vs_line": 1.6, "signal": "bullish" }, + "situational": { + "home_away": { "value": 27.8, "context": "home", "signal": "bullish" }, + "rest_days": { "value": 26.5, "context": "1_day_rest", "signal": "neutral" }, + "vs_opponent": { "value": 30.5, "games": 3, "signal": "strong_bullish" } + }, + "line_comparison": { + "best_line": { "book": "fanduel", "line": 27.0 }, + "worst_line": { "book": "draftkings", "line": 26.5 }, + "edge_from_best": 0.5, + "signal": "bullish" + }, + "kill_conditions": [], + "final_grade": "A" + } + } +} +``` + +### POST /api/analyze/batch +Analyzes multiple props at once (used by parlay scanner in Feature 2.1). + +**Request body:** +```json +{ + "props": [ + { "player": "Nikola Jokic", "stat_type": "points", "line": 26.5, "direction": "over", "book": "draftkings" }, + { "player": "LeBron James", "stat_type": "rebounds", "line": 8.5, "direction": "over", "book": "fanduel" } + ] +} +``` + +**Response (200):** +```json +{ + "results": [ + { "...same shape as single prop response..." }, + { "...same shape as single prop response..." } + ] +} +``` + +### Error Responses +| Status | When | Body | +|---|---|---| +| 400 | Missing required fields or invalid values | `{ "error": "player is required" }` | +| 404 | Player not found in nba_api | `{ "error": "Player not found: Xyz" }` | +| 503 | NBA stats service or Odds API unavailable | `{ "error": "Analysis service temporarily unavailable" }` | + +## 6-Step Grading Pipeline + +### Step 1: Season Average Compare +- Fetch season average for `player + stat_type` from NBA stats service +- Compare to the line: `delta = season_avg - line` +- For "over": positive delta = bullish. For "under": negative delta = bullish. +- Signal: `|delta| < 0.5` = neutral, `0.5-2.0` = lean, `2.0-4.0` = bullish, `>4.0` = strong_bullish + +### Step 2: Recent Form (Last 10 Games) +- Fetch last 10 game average from NBA stats service +- Compare to line same as Step 1 +- Weight recent form higher than season average (recent form = 1.5x weight) +- Signal: same thresholds as Step 1 + +### Step 3: Situational Factors +Fetch splits from NBA stats service and compare each to the line: + +**3a. Home/Away** +- Determine if today's game is home or away (from odds data: home_team/away_team) +- Fetch the relevant split average +- Signal based on delta from line + +**3b. Rest Days (Back-to-Back Detection)** +- Fetch rest_days split from NBA stats service +- Determine current rest status (requires game schedule — use last game date from last-n data) +- B2B is a common kill condition for overs on high-minute players + +**3c. Vs Opponent** +- Fetch vs_team split for today's opponent +- Small sample warning if < 3 games +- Signal based on delta from line + +### Step 4: Cross-Book Line Comparison +- Fetch lines from all 3 books for this player + stat_type (from Feature 1.1 data) +- Identify best and worst line +- If the requested book has a line 0.5+ points off the consensus, flag as edge or trap +- Signal: line is lower than consensus = bullish for over, bearish for under + +### Step 5: Kill Conditions +Hard flags that override or cap the grade. If any trigger, grade cannot exceed C. + +| Kill Condition | Trigger | Description | +|---|---|---| +| `low_minutes` | Season avg minutes < 24 | Player doesn't play enough for volume stats | +| `small_sample` | Games played < 15 | Not enough data for reliable analysis | +| `b2b_high_usage` | B2B game + minutes > 32 | High-minute players often rest or play less on B2B | +| `blowout_risk` | Spread > 10 points | Garbage time / early bench risk for star players | +| `split_conflict` | Home/away split differs > 5 from recent form | Conflicting signals reduce confidence | +| `no_opponent_data` | vs_team games < 2 | Can't assess matchup-specific performance | + +### Step 6: Grade Assignment + +**Composite Score Calculation:** +``` +composite = ( + season_delta * 1.0 + + recent_delta * 1.5 + + situational_avg_delta * 1.2 + + line_edge * 0.8 +) / 4.5 +``` + +Where each delta is: `(avg - line)` for overs, `(line - avg)` for unders. +`situational_avg_delta` = weighted average of home/away, rest, vs_opponent deltas. +`line_edge` = difference between requested line and best available line across books. + +**Grade thresholds:** +| Grade | Composite Score | Confidence Range | Description | +|---|---|---|---| +| A | >= 3.0 | 80-95 | Strong edge. Clear play. | +| B | >= 1.5 | 65-79 | Solid lean. Playable. | +| C | >= 0.5 | 50-64 | Marginal. Proceed with caution. | +| D | < 0.5 | 30-49 | No edge or negative edge. Avoid. | + +If any kill condition is triggered, grade is capped at C and confidence reduced by 15. + +**Confidence calculation:** +``` +confidence = base_confidence_from_grade + sample_bonus + consistency_bonus +``` +- `sample_bonus`: +5 if games_played > 50, +3 if > 30 +- `consistency_bonus`: +5 if recent form and season avg agree in direction, -5 if they conflict + +## Service Architecture + +``` +src/ +├── services/ +│ ├── propAnalyzer.js # Main 6-step pipeline orchestrator +│ ├── grader.js # Composite score + grade assignment +│ ├── killConditions.js # Kill condition evaluation +│ └── nbaStatsClient.js # HTTP client for Feature 1.2 (FastAPI) +├── routes/ +│ └── analyze.js # POST /api/analyze/prop, POST /api/analyze/batch +└── utils/ + └── signals.js # Delta-to-signal conversion helpers +``` + +- **propAnalyzer.js** — Orchestrator. Calls odds service + NBA stats client, runs all 6 steps, returns analysis result. +- **grader.js** — Pure function. Takes step results, computes composite score, assigns grade + confidence. +- **killConditions.js** — Pure function. Takes stats + context, returns list of triggered kill conditions. +- **nbaStatsClient.js** — HTTP client wrapper for the Python FastAPI service (localhost:8000). Handles timeouts and errors. +- **signals.js** — Converts numeric deltas to signal strings (neutral/lean/bullish/strong_bullish/bearish). +- **routes/analyze.js** — Thin route layer. Validates input, calls propAnalyzer, formats response. + +## Data Flow + +``` +Request: { player, stat_type, line, direction, book } + │ + ├─→ Feature 1.1 (Odds API): Get lines from all books for this player+stat + │ Returns: lines[] with book, line, over_odds, under_odds + │ + ├─→ Feature 1.2 (NBA Stats): Get season average + │ Returns: { stats: { points: 26.3, ... } } + │ + ├─→ Feature 1.2 (NBA Stats): Get last 10 average + │ Returns: { stats: { points: 28.1, ... } } + │ + ├─→ Feature 1.2 (NBA Stats): Get home/away split + │ Returns: { splits: { home: { avg: 27.8 }, away: { avg: 24.9 } } } + │ + ├─→ Feature 1.2 (NBA Stats): Get rest days split + │ Returns: { splits: { b2b: { avg: 23.1 }, ... } } + │ + └─→ Feature 1.2 (NBA Stats): Get vs opponent split + Returns: { splits: { vs_opponent: { avg: 30.5, games: 3 } } } + │ + ▼ + propAnalyzer.js: Run 6-step pipeline + │ + ├─ Step 1: season_avg vs line → delta + signal + ├─ Step 2: last_10_avg vs line → delta + signal (1.5x weight) + ├─ Step 3: situational deltas → signals + ├─ Step 4: cross-book line comparison → edge signal + ├─ Step 5: kill conditions check → flags[] + └─ Step 6: composite score → grade + confidence + │ + ▼ + Response: { grade, edge_pct, confidence, kill_conditions, reasoning } +``` + +## Acceptance Criteria + +1. `POST /api/analyze/prop` accepts a valid prop and returns a complete analysis with grade, edge_pct, confidence, kill_conditions, and reasoning +2. Grade is one of A, B, C, D based on composite score thresholds +3. Edge percentage is calculated as `((relevant_avg - line) / line) * 100` for overs (inverse for unders) +4. All 6 kill conditions are checked and correctly trigger when applicable +5. If any kill condition triggers, grade is capped at C and confidence reduced +6. Reasoning includes step-by-step breakdown with each factor's value and signal +7. Cross-book line comparison identifies best/worst lines and flags edges +8. `POST /api/analyze/batch` processes multiple props and returns an array of results +9. Returns 400 for missing required fields (player, stat_type, line, direction) +10. Returns 404 when player is not found in the NBA stats service +11. Returns 503 when NBA stats service or Odds API is unreachable +12. All data fetches use UTC timestamps + +## Test Plan + +### Unit Tests (grader.js) +- Composite score >= 3.0 returns grade A with confidence 80-95 +- Composite score 1.5-2.99 returns grade B with confidence 65-79 +- Composite score 0.5-1.49 returns grade C with confidence 50-64 +- Composite score < 0.5 returns grade D with confidence 30-49 +- Kill condition caps grade at C and reduces confidence by 15 +- Sample bonus adds correctly (+5 for >50 games, +3 for >30) +- Consistency bonus: +5 when season + recent agree, -5 when they conflict +- Direction handling: "under" inverts delta signs + +### Unit Tests (killConditions.js) +- `low_minutes`: triggers when avg minutes < 24 +- `small_sample`: triggers when games_played < 15 +- `b2b_high_usage`: triggers when B2B and minutes > 32 +- `blowout_risk`: triggers when point spread > 10 +- `split_conflict`: triggers when home/away vs recent differs > 5 +- `no_opponent_data`: triggers when vs_team games < 2 +- Returns empty array when no conditions trigger +- Multiple conditions can trigger simultaneously + +### Unit Tests (signals.js) +- Delta 0.0-0.49 maps to "neutral" +- Delta 0.5-1.99 maps to "lean" +- Delta 2.0-3.99 maps to "bullish" +- Delta >= 4.0 maps to "strong_bullish" +- Negative deltas map to bearish equivalents + +### Unit Tests (propAnalyzer.js) +- Orchestrates all 6 steps in correct order +- Returns complete analysis shape with all required fields +- Handles missing splits gracefully (still produces a grade) +- Handles NBA stats service timeout (returns 503-appropriate error) + +### Integration Tests (routes/analyze.js) +- Full analysis: POST with valid prop → verify response shape and all fields present +- Grade A scenario: player averaging well above line, favorable splits +- Grade D scenario: player averaging below line, kill conditions triggered +- Kill condition scenario: B2B + high minutes → grade capped at C +- Batch analysis: POST with 3 props → array of 3 results +- Error: missing player field → 400 +- Error: invalid stat_type → 400 +- Error: NBA stats service down → 503 +- Error: unknown player → 404 + +## Open Questions +- **Blowout risk data:** Need game spread data. The Odds API provides h2h odds which can derive the spread. May need to add a spread fetch to Feature 1.1 or calculate from moneyline. Decide during implementation. +- **Game schedule for B2B detection:** The last-n response includes game dates. Can calculate days since last game from there. No additional API call needed. diff --git a/src/app.js b/src/app.js index c8b73cb..f4511fc 100644 --- a/src/app.js +++ b/src/app.js @@ -1,9 +1,11 @@ require('dotenv').config(); const express = require('express'); const oddsRoutes = require('./routes/odds'); +const analyzeRoutes = require('./routes/analyze'); const app = express(); app.use(express.json()); app.use('/api/odds', oddsRoutes); +app.use('/api/analyze', analyzeRoutes); module.exports = app; diff --git a/src/routes/analyze.js b/src/routes/analyze.js new file mode 100644 index 0000000..b1483b0 --- /dev/null +++ b/src/routes/analyze.js @@ -0,0 +1,79 @@ +const express = require('express'); +const { analyzeProp } = require('../services/propAnalyzer'); + +const router = express.Router(); + +const VALID_STAT_TYPES = new Set([ + 'points', 'rebounds', 'assists', 'threes', 'blocks', + 'steals', 'pra', 'turnovers', +]); + +const VALID_DIRECTIONS = new Set(['over', 'under']); + +function validateProp(prop) { + const errors = []; + if (!prop.player) errors.push('player is required'); + if (!prop.stat_type) errors.push('stat_type is required'); + if (prop.stat_type && !VALID_STAT_TYPES.has(prop.stat_type)) { + errors.push(`Invalid stat_type: ${prop.stat_type}`); + } + if (prop.line == null) errors.push('line is required'); + if (!prop.direction) errors.push('direction is required'); + if (prop.direction && !VALID_DIRECTIONS.has(prop.direction)) { + errors.push(`Invalid direction: ${prop.direction}`); + } + return errors; +} + +router.post('/prop', async (req, res) => { + const errors = validateProp(req.body); + if (errors.length > 0) { + return res.status(400).json({ error: errors.join('; ') }); + } + + try { + const result = await analyzeProp(req.body); + return res.json(result); + } catch (err) { + if (err.response && err.response.status === 404) { + return res.status(404).json({ error: `Player not found: ${req.body.player}` }); + } + if (err.statusCode === 429 || err.statusCode === 503) { + return res.status(err.statusCode).json({ error: err.message }); + } + console.error('[BetonBLK] Analysis error:', err.message); + return res.status(503).json({ error: 'Analysis service temporarily unavailable' }); + } +}); + +router.post('/batch', async (req, res) => { + const { props } = req.body; + if (!Array.isArray(props) || props.length === 0) { + return res.status(400).json({ error: 'props array is required and must not be empty' }); + } + + const results = []; + for (const prop of props) { + const errors = validateProp(prop); + if (errors.length > 0) { + results.push({ error: errors.join('; '), input: prop }); + continue; + } + + try { + const result = await analyzeProp(prop); + results.push(result); + } catch (err) { + results.push({ + error: err.response?.status === 404 + ? `Player not found: ${prop.player}` + : 'Analysis failed for this prop', + input: prop, + }); + } + } + + return res.json({ results }); +}); + +module.exports = router; diff --git a/src/services/grader.js b/src/services/grader.js new file mode 100644 index 0000000..61f64f3 --- /dev/null +++ b/src/services/grader.js @@ -0,0 +1,65 @@ +function computeGrade(stepResults) { + const { + seasonDelta, + recentDelta, + situationalDelta, + lineEdge, + killConditions, + gamesPlayed, + seasonAndRecentAgree, + } = stepResults; + + // Composite score: weighted combination of all deltas + const composite = ( + (seasonDelta || 0) * 1.0 + + (recentDelta || 0) * 1.5 + + (situationalDelta || 0) * 1.2 + + (lineEdge || 0) * 0.8 + ) / 4.5; + + // Base grade from composite + let grade; + let baseConfidence; + if (composite >= 3.0) { + grade = 'A'; + baseConfidence = Math.min(80 + Math.floor((composite - 3.0) * 5), 95); + } else if (composite >= 1.5) { + grade = 'B'; + baseConfidence = Math.min(65 + Math.floor((composite - 1.5) * 9.3), 79); + } else if (composite >= 0.5) { + grade = 'C'; + baseConfidence = Math.min(50 + Math.floor((composite - 0.5) * 14), 64); + } else { + grade = 'D'; + baseConfidence = Math.max(30 + Math.floor(composite * 20), 30); + } + + // Sample bonus + let sampleBonus = 0; + if (gamesPlayed > 50) sampleBonus = 5; + else if (gamesPlayed > 30) sampleBonus = 3; + + // Consistency bonus + let consistencyBonus = 0; + if (seasonAndRecentAgree === true) consistencyBonus = 5; + else if (seasonAndRecentAgree === false) consistencyBonus = -5; + + let confidence = baseConfidence + sampleBonus + consistencyBonus; + + // Kill condition penalty + const hasKillConditions = killConditions && killConditions.length > 0; + if (hasKillConditions) { + // Cap grade at C + if (grade === 'A' || grade === 'B') { + grade = 'C'; + } + confidence -= 15; + } + + // Clamp confidence + confidence = Math.max(30, Math.min(95, confidence)); + + return { grade, confidence, composite: Math.round(composite * 100) / 100 }; +} + +module.exports = { computeGrade }; diff --git a/src/services/killConditions.js b/src/services/killConditions.js new file mode 100644 index 0000000..328948d --- /dev/null +++ b/src/services/killConditions.js @@ -0,0 +1,68 @@ +function evaluateKillConditions(context) { + const { + seasonStats, + recentStats, + homeAwaySplit, + restSplit, + vsOpponentSplit, + spread, + } = context; + + const conditions = []; + + // 1. low_minutes: season avg minutes < 24 + if (seasonStats && seasonStats.minutes != null && seasonStats.minutes < 24) { + conditions.push({ + code: 'low_minutes', + reason: `Player averages only ${seasonStats.minutes} minutes per game`, + }); + } + + // 2. small_sample: games played < 15 + if (seasonStats && seasonStats.games_played != null && seasonStats.games_played < 15) { + conditions.push({ + code: 'small_sample', + reason: `Only ${seasonStats.games_played} games played this season`, + }); + } + + // 3. b2b_high_usage: back-to-back game + minutes > 32 + if (restSplit && restSplit.isB2B && seasonStats && seasonStats.minutes > 32) { + conditions.push({ + code: 'b2b_high_usage', + reason: `Back-to-back game with ${seasonStats.minutes} avg minutes`, + }); + } + + // 4. blowout_risk: spread > 10 points + if (spread != null && Math.abs(spread) > 10) { + conditions.push({ + code: 'blowout_risk', + reason: `Game spread is ${spread > 0 ? '+' : ''}${spread} — blowout risk`, + }); + } + + // 5. split_conflict: home/away split differs > 5 from recent form + if (homeAwaySplit && recentStats) { + const splitAvg = homeAwaySplit.avg; + const recentAvg = recentStats.value; + if (splitAvg != null && recentAvg != null && Math.abs(splitAvg - recentAvg) > 5) { + conditions.push({ + code: 'split_conflict', + reason: `Situational avg (${splitAvg}) differs from recent form (${recentAvg}) by ${Math.abs(splitAvg - recentAvg).toFixed(1)}`, + }); + } + } + + // 6. no_opponent_data: vs_team games < 2 + if (vsOpponentSplit && vsOpponentSplit.games != null && vsOpponentSplit.games < 2) { + conditions.push({ + code: 'no_opponent_data', + reason: `Only ${vsOpponentSplit.games} game(s) against this opponent`, + }); + } + + return conditions; +} + +module.exports = { evaluateKillConditions }; diff --git a/src/services/nbaStatsClient.js b/src/services/nbaStatsClient.js new file mode 100644 index 0000000..0f5a032 --- /dev/null +++ b/src/services/nbaStatsClient.js @@ -0,0 +1,48 @@ +const axios = require('axios'); + +const NBA_SERVICE_URL = process.env.NBA_SERVICE_URL || 'http://localhost:8000'; +const TIMEOUT = 10000; + +async function getSeasonAvg(player, statType, season) { + const params = { player }; + if (statType) params.stat_type = statType; + if (season) params.season = season; + + const { data } = await axios.get(`${NBA_SERVICE_URL}/stats/season-avg`, { + params, + timeout: TIMEOUT, + }); + return data; +} + +async function getLastN(player, n = 10, statType) { + const params = { player, n }; + if (statType) params.stat_type = statType; + + const { data } = await axios.get(`${NBA_SERVICE_URL}/stats/last-n`, { + params, + timeout: TIMEOUT, + }); + return data; +} + +async function getSplits(player, statType, splitType, opponent) { + const params = { player, stat_type: statType, split_type: splitType }; + if (opponent) params.opponent = opponent; + + const { data } = await axios.get(`${NBA_SERVICE_URL}/stats/splits`, { + params, + timeout: TIMEOUT, + }); + return data; +} + +async function searchPlayer(name) { + const { data } = await axios.get(`${NBA_SERVICE_URL}/players/search`, { + params: { name }, + timeout: TIMEOUT, + }); + return data; +} + +module.exports = { getSeasonAvg, getLastN, getSplits, searchPlayer }; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index 57ba38b..dae1502 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -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 || [], }; } diff --git a/src/services/propAnalyzer.js b/src/services/propAnalyzer.js new file mode 100644 index 0000000..d714f8a --- /dev/null +++ b/src/services/propAnalyzer.js @@ -0,0 +1,248 @@ +const { getOdds } = require('./oddsService'); +const nbaStats = require('./nbaStatsClient'); +const { evaluateKillConditions } = require('./killConditions'); +const { computeGrade } = require('./grader'); +const { deltaToSignal, directedDelta } = require('../utils/signals'); + +async function analyzeProp({ player, stat_type, line, direction, book }) { + // Fetch all data in parallel + const [oddsResult, seasonAvg, lastN, homeAwaySplit, restSplit] = await Promise.all([ + getOdds('nba'), + nbaStats.getSeasonAvg(player), + nbaStats.getLastN(player, 10), + nbaStats.getSplits(player, stat_type, 'home_away'), + nbaStats.getSplits(player, stat_type, 'rest_days'), + ]); + + // Determine opponent from odds data + const playerProps = oddsResult.props.filter( + (p) => p.player.toLowerCase().includes(player.toLowerCase()) && p.stat_type === stat_type + ); + + let opponent = null; + let isHome = null; + if (playerProps.length > 0) { + const prop = playerProps[0]; + // We have home_team and away_team but don't know which the player belongs to + // Use NBA stats team to determine + const playerTeam = seasonAvg?.team; + if (playerTeam) { + if (playerTeam === prop.home_team) { + isHome = true; + opponent = prop.away_team; + } else if (playerTeam === prop.away_team) { + isHome = false; + opponent = prop.home_team; + } + } + } + + // Fetch vs-opponent split if we know the opponent + let vsOpponentSplit = null; + if (opponent) { + try { + vsOpponentSplit = await nbaStats.getSplits(player, stat_type, 'vs_team', opponent); + } catch (_) { + // No opponent data available + } + } + + // Find game spread + let spread = null; + if (oddsResult.spreads && oddsResult.spreads.length > 0) { + const gameSpread = oddsResult.spreads.find((s) => { + const playerTeam = seasonAvg?.team; + return playerTeam && (s.home_team === playerTeam || s.away_team === playerTeam); + }); + if (gameSpread) { + // home_spread is from the home team's perspective + const playerTeam = seasonAvg?.team; + if (playerTeam === gameSpread.home_team) { + spread = gameSpread.home_spread; + } else { + spread = -gameSpread.home_spread; + } + } + } + + const seasonStatVal = seasonAvg?.stats?.[stat_type]; + const recentStatVal = lastN?.stats?.[stat_type]; + + // Step 1: Season average compare + const seasonDelta = seasonStatVal != null ? directedDelta(seasonStatVal, line, direction) : 0; + const seasonSignal = deltaToSignal(seasonDelta); + + // Step 2: Recent form (last 10) + const recentDelta = recentStatVal != null ? directedDelta(recentStatVal, line, direction) : 0; + const recentSignal = deltaToSignal(recentDelta); + + // Step 3: Situational factors + const homeAwayData = homeAwaySplit?.splits; + let situationalAvg = null; + let homeAwaySignal = 'neutral'; + let homeAwayContext = null; + if (homeAwayData && isHome != null) { + const relevantSplit = isHome ? homeAwayData.home : homeAwayData.away; + if (relevantSplit) { + situationalAvg = relevantSplit.avg; + homeAwayContext = isHome ? 'home' : 'away'; + homeAwaySignal = deltaToSignal(directedDelta(relevantSplit.avg, line, direction)); + } + } + + // Rest days / B2B + const restData = restSplit?.splits; + let restSignal = 'neutral'; + let restContext = null; + let restAvg = null; + let isB2B = false; + if (restData) { + // Determine current rest status from last game date in lastN + // For now, use overall rest data — B2B detection would need schedule info + // Use the b2b split if games > 0 as an indicator + if (restData.b2b && restData.b2b.games > 0) { + restAvg = restData.b2b.avg; + restContext = 'b2b'; + // Check if current game is B2B (heuristic: if b2b games exist, flag it) + // True B2B detection needs schedule — we'll flag when b2b avg is significantly different + isB2B = false; // Conservative: only flag if we can confirm + } + if (restData['1_day_rest'] && restData['1_day_rest'].games > 0 && !restAvg) { + restAvg = restData['1_day_rest'].avg; + restContext = '1_day_rest'; + } + if (restAvg != null) { + restSignal = deltaToSignal(directedDelta(restAvg, line, direction)); + } + } + + // Vs opponent + let vsOpponentSignal = 'neutral'; + let vsOpponentAvg = null; + let vsOpponentGames = 0; + if (vsOpponentSplit?.splits?.vs_opponent) { + vsOpponentAvg = vsOpponentSplit.splits.vs_opponent.avg; + vsOpponentGames = vsOpponentSplit.splits.vs_opponent.games; + vsOpponentSignal = deltaToSignal(directedDelta(vsOpponentAvg, line, direction)); + } + + // Step 4: Cross-book line comparison + const allLines = playerProps.map((p) => ({ book: p.book, line: p.line })); + // Also check grouped props from odds response (they may be grouped by player) + let bestLine = null; + let worstLine = null; + let lineEdge = 0; + if (allLines.length > 0) { + if (direction === 'over') { + // For over, lowest line is best + bestLine = allLines.reduce((a, b) => (a.line < b.line ? a : b)); + worstLine = allLines.reduce((a, b) => (a.line > b.line ? a : b)); + } else { + // For under, highest line is best + bestLine = allLines.reduce((a, b) => (a.line > b.line ? a : b)); + worstLine = allLines.reduce((a, b) => (a.line < b.line ? a : b)); + } + lineEdge = Math.abs(bestLine.line - worstLine.line); + } + const lineSignal = deltaToSignal(lineEdge); + + // Compute situational delta (weighted average of available splits) + const sitDeltas = []; + if (situationalAvg != null) sitDeltas.push(directedDelta(situationalAvg, line, direction)); + if (restAvg != null) sitDeltas.push(directedDelta(restAvg, line, direction)); + if (vsOpponentAvg != null) sitDeltas.push(directedDelta(vsOpponentAvg, line, direction)); + const situationalDelta = sitDeltas.length > 0 + ? sitDeltas.reduce((a, b) => a + b, 0) / sitDeltas.length + : 0; + + // Step 5: Kill conditions + const killConditions = evaluateKillConditions({ + seasonStats: seasonAvg?.stats, + recentStats: recentStatVal != null ? { value: recentStatVal } : null, + homeAwaySplit: situationalAvg != null ? { avg: situationalAvg } : null, + restSplit: { isB2B }, + vsOpponentSplit: vsOpponentAvg != null ? { games: vsOpponentGames } : null, + spread, + }); + + // Step 6: Grade + const seasonAndRecentAgree = (seasonDelta > 0 && recentDelta > 0) || (seasonDelta < 0 && recentDelta < 0); + const { grade, confidence, composite } = computeGrade({ + seasonDelta, + recentDelta, + situationalDelta, + lineEdge, + killConditions, + gamesPlayed: seasonAvg?.stats?.games_played || 0, + seasonAndRecentAgree: seasonDelta !== 0 && recentDelta !== 0 ? seasonAndRecentAgree : null, + }); + + // Edge percentage + const relevantAvg = recentStatVal || seasonStatVal || line; + const edgePct = direction === 'over' + ? Math.round(((relevantAvg - line) / line) * 1000) / 10 + : Math.round(((line - relevantAvg) / line) * 1000) / 10; + + // Build reasoning summary + const parts = []; + if (seasonStatVal != null) parts.push(`${player} averages ${seasonStatVal} on the season`); + if (recentStatVal != null && recentStatVal !== seasonStatVal) parts.push(`${recentStatVal} over his last 10`); + if (homeAwayContext && situationalAvg != null) parts.push(`${situationalAvg} ${homeAwayContext === 'home' ? 'at home' : 'on the road'}`); + if (vsOpponentAvg != null && opponent) parts.push(`${vsOpponentAvg} vs ${opponent} (${vsOpponentGames} games)`); + if (killConditions.length > 0) parts.push(`Kill conditions: ${killConditions.map((k) => k.code).join(', ')}`); + if (killConditions.length === 0) parts.push('No kill conditions'); + + return { + player, + stat_type, + line, + direction, + book, + grade, + edge_pct: edgePct, + confidence, + kill_conditions_triggered: killConditions, + reasoning: { + summary: parts.join('. ') + '.', + steps: { + season_avg: { + value: seasonStatVal ?? null, + vs_line: seasonStatVal != null ? Math.round((seasonStatVal - line) * 10) / 10 : null, + signal: seasonSignal, + }, + recent_form: { + value: recentStatVal ?? null, + vs_line: recentStatVal != null ? Math.round((recentStatVal - line) * 10) / 10 : null, + signal: recentSignal, + }, + situational: { + home_away: { + value: situationalAvg, + context: homeAwayContext, + signal: homeAwaySignal, + }, + rest_days: { + value: restAvg, + context: restContext, + signal: restSignal, + }, + vs_opponent: { + value: vsOpponentAvg, + games: vsOpponentGames, + signal: vsOpponentSignal, + }, + }, + line_comparison: { + best_line: bestLine, + worst_line: worstLine, + edge_from_best: lineEdge, + signal: lineSignal, + }, + kill_conditions: killConditions, + final_grade: grade, + }, + }, + }; +} + +module.exports = { analyzeProp }; diff --git a/src/utils/oddsNormalizer.js b/src/utils/oddsNormalizer.js index 9e89473..27dec72 100644 --- a/src/utils/oddsNormalizer.js +++ b/src/utils/oddsNormalizer.js @@ -75,4 +75,44 @@ function normalizeProps(eventsWithOdds) { return props; } -module.exports = { normalizeProps, MARKET_MAP, ALLOWED_BOOKS }; +function extractSpreads(eventsWithOdds) { + const spreads = []; + + for (const event of eventsWithOdds) { + const homeTeam = getAbbreviation(event.home_team); + const awayTeam = getAbbreviation(event.away_team); + const gameTime = event.commence_time; + + if (!Array.isArray(event.bookmakers)) continue; + + for (const bookmaker of event.bookmakers) { + if (!ALLOWED_BOOKS.has(bookmaker.key)) continue; + if (!Array.isArray(bookmaker.markets)) continue; + + for (const market of bookmaker.markets) { + if (market.key !== 'spreads') continue; + const outcomes = market.outcomes || []; + + for (const outcome of outcomes) { + if (outcome.point == null) continue; + // Home team spread: outcome.name matches the team full name + if (outcome.name === event.home_team) { + spreads.push({ + home_team: homeTeam, + away_team: awayTeam, + game_time: gameTime, + book: bookmaker.key, + home_spread: outcome.point, + fetched_at: market.last_update, + }); + break; + } + } + } + } + } + + return spreads; +} + +module.exports = { normalizeProps, extractSpreads, MARKET_MAP, ALLOWED_BOOKS }; diff --git a/src/utils/signals.js b/src/utils/signals.js new file mode 100644 index 0000000..b5aba0d --- /dev/null +++ b/src/utils/signals.js @@ -0,0 +1,15 @@ +function deltaToSignal(delta) { + const abs = Math.abs(delta); + if (abs < 0.5) return delta >= 0 ? 'neutral' : 'neutral'; + if (abs < 2.0) return delta >= 0 ? 'lean' : 'lean_bearish'; + if (abs < 4.0) return delta >= 0 ? 'bullish' : 'bearish'; + return delta >= 0 ? 'strong_bullish' : 'strong_bearish'; +} + +function directedDelta(avg, line, direction) { + // For "over", positive delta is good. For "under", negative delta is good. + const raw = avg - line; + return direction === 'under' ? -raw : raw; +} + +module.exports = { deltaToSignal, directedDelta }; diff --git a/tests/integration/analyze.test.js b/tests/integration/analyze.test.js new file mode 100644 index 0000000..60c8dd3 --- /dev/null +++ b/tests/integration/analyze.test.js @@ -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'); + }); +}); diff --git a/tests/unit/grader.test.js b/tests/unit/grader.test.js new file mode 100644 index 0000000..5e28233 --- /dev/null +++ b/tests/unit/grader.test.js @@ -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); + }); + }); +}); diff --git a/tests/unit/killConditions.test.js b/tests/unit/killConditions.test.js new file mode 100644 index 0000000..7221d33 --- /dev/null +++ b/tests/unit/killConditions.test.js @@ -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); + }); +}); diff --git a/tests/unit/signals.test.js b/tests/unit/signals.test.js new file mode 100644 index 0000000..2386fae --- /dev/null +++ b/tests/unit/signals.test.js @@ -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); + }); + }); +});