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
+296
View File
@@ -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.