Files
vyndr/specs/feature-2-1-parlay-scan.md
T
builtbykev 411cb6f196 feat: Feature 2.1 — Parlay Scan with correlation detection + monetization
POST /api/scan/parlay — authenticated parlay analysis:
- Supabase JWT auth middleware (auth.getUser verification)
- 5 correlation types detected between legs (same_game, same_team,
  same_player_conflicting, positive_correlation, blowout_cascade)
- Overall parlay grading (A/B/C/D) with correlation penalty adjustments
- Free tier: 5 scans/month, atomic scan count increment
- Scan 5: full analysis + personalized upgrade pitch
- Scan 6+: 403 block with upgrade pitch
- Pitch personalization from scan history (top stats, grades, tier rec)
- DB writes: picks + scan_sessions per scan

30 new tests, 158 total (131 Node.js + 27 Python), all passing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 12:45:15 -04:00

325 lines
13 KiB
Markdown

# Feature 2.1 — Parlay Scan
## Overview
The flagship user-facing feature. A user submits a parlay (array of prop legs), BetonBLK grades each leg individually, checks for correlations and conflicts between legs, produces an overall parlay grade, and writes the scan to the database. Free users get 5 scans per month. At scan 5, the system fires a personalized upgrade pitch based on what it learned from scans 1-4.
## Dependencies
- Feature 1.3 — Prop Analysis Engine (`POST /api/analyze/batch`)
- Feature 1.4 — Database Schema (`scan_sessions`, `picks`, `users` tables)
## Endpoint
### POST /api/scan/parlay
Scans a parlay. Requires authentication (Supabase JWT).
**Request headers:**
```
Authorization: Bearer <supabase_jwt>
```
**Request body:**
```json
{
"legs": [
{ "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" },
{ "player": "Anthony Davis", "stat_type": "blocks", "line": 1.5, "direction": "over", "book": "draftkings" }
]
}
```
**Response (200) — scan allowed:**
```json
{
"scan_id": "uuid",
"parlay_grade": "B",
"parlay_confidence": 68,
"correlation_flags": [
{
"type": "same_game_same_team",
"legs": [1, 2],
"detail": "LeBron James and Anthony Davis are both Lakers — usage overlap possible",
"impact": "minor_negative"
}
],
"legs": [
{
"index": 0,
"player": "Nikola Jokic",
"stat_type": "points",
"line": 26.5,
"direction": "over",
"grade": "A",
"confidence": 85,
"edge_pct": 6.0,
"kill_conditions": [],
"reasoning_summary": "Jokic averages 26.3 season, 28.1 last 10. At home vs LAL where he averages 30.5. Strong play."
},
{
"index": 1,
"...": "..."
}
],
"scan_count": 3,
"scans_remaining": 2,
"upgrade_pitch": null
}
```
**Response (200) — scan 5 (free tier limit):**
```json
{
"scan_id": "uuid",
"parlay_grade": "B",
"parlay_confidence": 68,
"correlation_flags": [],
"legs": ["...full analysis..."],
"scan_count": 5,
"scans_remaining": 0,
"upgrade_pitch": {
"hook": "You've scanned 5 parlays this month. 3 of them graded B or higher — you've got a good eye.",
"insight": "Your best edge has been player points overs. Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number.",
"cta": "Unlock unlimited scans for $14.99/mo (founder rate)",
"tier_recommended": "analyst",
"founder_price": "$14.99/mo",
"standard_price": "$19.99/mo"
}
}
```
**Response (403) — free tier exhausted (scan 6+):**
```json
{
"error": "scan_limit_reached",
"scan_count": 5,
"scans_remaining": 0,
"upgrade_pitch": {
"hook": "You've used all 5 free scans this month.",
"insight": "Upgrade to keep scanning. Your recent parlays suggest you'd benefit from the Analyst tier.",
"cta": "Unlock unlimited scans for $14.99/mo (founder rate)",
"tier_recommended": "analyst",
"founder_price": "$14.99/mo",
"standard_price": "$19.99/mo"
}
}
```
### Error Responses
| Status | When | Body |
|---|---|---|
| 400 | Missing legs, empty array, or invalid leg | `{ "error": "legs array is required with at least 2 props" }` |
| 401 | No auth token or invalid JWT | `{ "error": "Authentication required" }` |
| 403 | Free tier scan limit reached (6+) | See above — includes upgrade_pitch |
| 422 | Leg count > 12 | `{ "error": "Maximum 12 legs per parlay" }` |
| 503 | Analysis service unavailable | `{ "error": "Scan service temporarily unavailable" }` |
## Scan Count Logic
```
1. Read user.scan_count and user.tier from database
2. If tier is 'analyst' or 'desk': skip count check, proceed
3. If tier is 'free':
a. If scan_count >= 5: return 403 with upgrade_pitch (no analysis)
b. If scan_count == 4: process scan, then return result WITH upgrade_pitch (this is scan 5)
c. If scan_count < 4: process scan normally
4. After successful scan: increment user.scan_count
```
The `scan_reset_date` trigger on the users table (Feature 1.4) handles monthly reset automatically.
## Correlation Detection
The correlation engine flags conflicts between legs that reduce parlay viability. Each flag has an `impact` level: `minor_negative`, `major_negative`, or `positive`.
### Correlation Types
**1. same_game_opposing_players**
Two legs from the same game on opposing teams with the same stat type, both "over."
- Detection: same `game_time` + different teams + same `stat_type` + both `direction: "over"`
- Impact: `minor_negative` — one team likely dominates, both can't boom
- Example: Jokic over points + LeBron over points (same game)
**2. same_game_same_team**
Two legs from the same game, same team. Usage overlap risk.
- Detection: same `game_time` + same team + both player props
- Impact: `minor_negative` — minutes/touches compete
- Example: LeBron over points + AD over points (both Lakers)
**3. same_player_conflicting**
Same player, conflicting directions or stats that contradict.
- Detection: same `player` + conflicting legs (e.g., over points + under PRA)
- Impact: `major_negative` — mathematically contradictory or near-contradictory
- Example: Jokic over points + Jokic under PRA
**4. positive_correlation**
Legs that support each other.
- Detection: same player, complementary stats in same direction (e.g., over points + over PRA)
- Impact: `positive` — correlated outcomes, but note this reduces true parlay independence
- Example: Jokic over points + Jokic over PRA (points is a component of PRA)
**5. blowout_cascade**
Multiple legs from a game with a large spread — all are at risk.
- Detection: 2+ legs from a game where `|spread| > 8`
- Impact: `major_negative` — blowout risk compounds across multiple legs
### Correlation Impact on Parlay Grade
- Each `minor_negative` correlation: -0.3 from parlay composite score
- Each `major_negative` correlation: -1.0 from parlay composite score
- Each `positive` correlation: noted but no score change (informational)
- If any `major_negative` exists: parlay grade capped at B
## Overall Parlay Grading
```
1. Grade each leg via POST /api/analyze/batch
2. Run correlation detection across all legs
3. Compute parlay composite:
leg_avg = average of all leg composite scores
correlation_penalty = sum of correlation impacts
parlay_composite = leg_avg + correlation_penalty
4. Apply parlay-specific thresholds:
A: parlay_composite >= 2.5 AND no leg graded D AND no major_negative correlations
B: parlay_composite >= 1.5 AND at most 1 leg graded D
C: parlay_composite >= 0.5
D: parlay_composite < 0.5 OR 2+ legs graded D
5. Parlay confidence = average of leg confidences, adjusted:
- -5 per minor_negative correlation
- -15 per major_negative correlation
- Clamped to 30-95
```
## Upgrade Pitch Personalization
At scan 5, the system analyzes scans 1-4 to build a personalized pitch.
**Data gathered from prior scans:**
- Count of scans by parlay grade (how many A/B/C/D parlays)
- Most common stat_type scanned
- Most common players scanned
- Average leg count per parlay
- Best grade achieved
**Pitch template logic:**
```
hook: "You've scanned {total} parlays this month. {good_count} graded B or higher — {compliment}."
compliment options:
- "you've got a good eye" (if good_count >= 3)
- "you're getting sharper" (if good_count >= 2)
- "BetonBLK is helping you filter" (if good_count >= 1)
- "let's find better edges together" (if good_count == 0)
insight: "Your best edge has been {top_stat_type} {top_direction}s. {tier_benefit}."
tier_benefit:
- analyst: "Analyst tier gives you unlimited scans plus line movement alerts so you never miss a soft number."
- desk: "Desk tier adds full bet tracking, ROI analytics, and priority cascade alerts."
cta: "Unlock unlimited scans for {founder_price} (founder rate)"
tier_recommended:
- "analyst" if avg legs <= 4 (casual bettor)
- "desk" if avg legs > 4 OR user has scanned 5+ different players (power user)
```
## Database Writes
### On successful scan:
1. Insert one row per leg into `picks` table:
```sql
INSERT INTO picks (user_id, player, stat_type, line, book, direction, grade, edge_pct, reasoning, kill_conditions, confidence)
```
2. Insert one row into `scan_sessions`:
```sql
INSERT INTO scan_sessions (user_id, legs, final_grade, kill_conditions, correlation_notes)
```
Where `legs` = array of pick UUIDs from step 1, `correlation_notes` = JSON string of correlation_flags.
3. Increment `users.scan_count`:
```sql
UPDATE users SET scan_count = scan_count + 1 WHERE id = :user_id
```
## Service Architecture
```
src/
├── services/
│ ├── parlayScanService.js # Orchestrator: scan count check → analyze → correlate → grade → persist
│ ├── correlationEngine.js # Detects correlations between legs
│ ├── parlayGrader.js # Overall parlay grade from leg grades + correlations
│ └── upgradePitch.js # Personalized pitch generator from scan history
├── routes/
│ └── scan.js # POST /api/scan/parlay
└── middleware/
└── auth.js # Supabase JWT verification middleware
```
- **parlayScanService.js** — Main orchestrator. Checks scan count, calls analyze/batch, runs correlation + grading, writes to DB, generates pitch if applicable.
- **correlationEngine.js** — Pure function. Takes array of analyzed legs + odds data, returns correlation_flags[].
- **parlayGrader.js** — Pure function. Takes leg grades + correlation flags, returns parlay grade + confidence.
- **upgradePitch.js** — Reads scan history from DB, generates personalized pitch.
- **auth.js** — Middleware that verifies Supabase JWT and attaches `req.user` with `{ id, email, tier }`.
## Acceptance Criteria
1. `POST /api/scan/parlay` accepts 2-12 legs and returns complete parlay analysis
2. Each leg includes individual grade, confidence, edge_pct, kill conditions, and reasoning summary
3. Correlation detection correctly identifies all 5 correlation types
4. Correlation impacts adjust parlay composite score (minor: -0.3, major: -1.0)
5. Overall parlay grade follows the grading rules (A/B/C/D with correlation constraints)
6. Free tier users get exactly 5 scans per month; scan 6+ returns 403
7. Paid tier users (analyst, desk) have unlimited scans
8. Scan 5 returns full analysis WITH a personalized upgrade pitch
9. Upgrade pitch references user's actual scan history (stat types, grades, player patterns)
10. Each successful scan writes picks to `picks` table and session to `scan_sessions` table
11. `users.scan_count` increments after each successful scan
12. Auth middleware rejects requests without valid Supabase JWT (401)
13. Returns 400 for fewer than 2 legs or missing required fields
14. Returns 422 for more than 12 legs
## Test Plan
### Unit Tests (correlationEngine.js)
- Detects same_game_opposing_players (same game, diff teams, same stat, both over)
- Detects same_game_same_team (same game, same team)
- Detects same_player_conflicting (same player, contradictory legs)
- Detects positive_correlation (same player, complementary stats)
- Detects blowout_cascade (2+ legs from high-spread game)
- Returns empty array when no correlations exist
- Handles single-leg edge case (no correlations possible with 1 leg)
### Unit Tests (parlayGrader.js)
- Grade A: high composite, no D legs, no major_negative correlations
- Grade B: moderate composite with at most 1 D leg
- Grade C: low but positive composite
- Grade D: negative composite or 2+ D legs
- Major_negative caps grade at B
- Correlation penalties reduce composite correctly
- Confidence averages legs and adjusts for correlations
### Unit Tests (upgradePitch.js)
- Generates pitch with correct scan count summary
- Identifies most common stat type from scan history
- Recommends analyst for casual bettors (avg <= 4 legs)
- Recommends desk for power users (avg > 4 legs)
- Handles edge case: no prior scans (scan 1 as free tier limit would be unusual)
### Integration Tests (routes/scan.js)
- Full scan: 3-leg parlay → complete response with grades, correlations, scan_count
- Scan count increments after each successful scan
- Scan 5: returns full analysis plus upgrade_pitch
- Scan 6+: returns 403 with upgrade_pitch (no analysis)
- Paid user: unlimited scans, no pitch
- Auth required: no token → 401
- Invalid legs: empty array → 400, >12 legs → 422
- Correlation: same-game same-team legs → flagged in response
- Database: scan_sessions row created with correct legs array
- Database: picks rows created for each leg
## Open Questions
- **Auth middleware implementation:** Supabase JWT verification needs the project's JWT secret. We have the service key in .env. The anon key signs JWTs. Need to verify JWT using `@supabase/supabase-js` auth helpers or the JWT secret directly. Decide during implementation.
- **Scan count race condition:** Two concurrent scans from the same user could both read scan_count=4 and both proceed. Use a Postgres advisory lock or `UPDATE ... WHERE scan_count < 5 RETURNING scan_count` for atomic check-and-increment.