Files
vyndr/specs/feature-2-2-line-movement.md
builtbykev 2366660f5e feat: Feature 2.2 — Line Movement + Cascade Detection
Line movement system:
- Baseline capture on first odds fetch of the day
- Movement detection >= 0.5 points with direction (up/down)
- Sharp money heuristic (sharp_action/public_action/unknown)
- GET /api/movements with player, stat_type, min_movement filters
- Movements included in GET /api/odds/nba live responses

Cascade detection system:
- Scratch detection: player props disappear from 2+ books
- Affected user lookup via scan_sessions + picks
- Parlay re-grade without scratched legs
- cascade_alerts created for affected users
- GET /api/alerts (Analyst/Desk only), PATCH /api/alerts/:id/read

Zero extra Odds API credits — all detection piggybacks on existing fetches.
Migration 002: line_baselines, line_movements, cascade_alerts tables.

30 new tests, 188 total (161 Node.js + 27 Python), all passing.
Phase 2 Core Product COMPLETE.

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

345 lines
12 KiB
Markdown

# Feature 2.2 — Real-Time Line Movement + Cascade Detection
## Overview
Two related systems that protect users from stale analysis:
1. **Line Movement** — Track how lines move throughout the day. When a line moves 0.5+ points from the morning baseline, flag it with direction (sharp money indicator). Surface this on the next odds or scan request.
2. **Cascade Detection** — When a star player is scratched (their props disappear from the odds feed), identify all user scans containing that player, re-grade the affected parlays, and create alerts for those users.
Both systems operate within the 500 credit/month budget by piggybacking on existing odds fetches rather than polling independently.
## Dependencies
- Feature 1.1 — Odds API Integration (odds data + cache)
- Feature 1.3 — Prop Analysis Engine (re-grading for cascade)
- Feature 1.4 — Database Schema (scan_sessions, picks for cascade lookups)
## Design Constraint: 500 Credits/Month
No dedicated polling. All line movement and cascade detection happens as a side effect of normal odds fetches:
- When `getOdds('nba')` fetches fresh data (cache miss), compare new lines against baseline
- When props disappear between fetches, trigger cascade detection
- Baseline captured on the first fetch of each day
## New Database Tables
### line_baselines
Stores the morning baseline for each prop.
```sql
CREATE TABLE public.line_baselines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sport TEXT NOT NULL,
game_date DATE NOT NULL,
player TEXT NOT NULL,
stat_type TEXT NOT NULL,
book TEXT NOT NULL,
baseline_line NUMERIC(5,1) NOT NULL,
captured_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_baseline_unique ON public.line_baselines(game_date, player, stat_type, book);
CREATE INDEX idx_baseline_date ON public.line_baselines(game_date);
```
### line_movements
Stores detected line movements.
```sql
CREATE TABLE public.line_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sport TEXT NOT NULL,
game_date DATE NOT NULL,
player TEXT NOT NULL,
stat_type TEXT NOT NULL,
book TEXT NOT NULL,
baseline_line NUMERIC(5,1) NOT NULL,
current_line NUMERIC(5,1) NOT NULL,
movement NUMERIC(4,1) NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('up', 'down')),
sharp_indicator TEXT CHECK (sharp_indicator IN ('sharp_action', 'public_action', 'unknown')),
detected_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_movements_date ON public.line_movements(game_date);
CREATE INDEX idx_movements_player ON public.line_movements(player);
```
### cascade_alerts
Stores alerts for users whose scans are affected by scratches or major line movements.
```sql
CREATE TABLE public.cascade_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
alert_type TEXT NOT NULL CHECK (alert_type IN ('player_scratched', 'line_moved', 'parlay_regraded')),
scan_session_id UUID REFERENCES public.scan_sessions(id),
player TEXT,
detail TEXT NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_alerts_user ON public.cascade_alerts(user_id, is_read);
```
RLS policies (same pattern as other tables — user can read/update own alerts only).
## Migration File
```
supabase/migrations/002_line_movement.sql
```
## Line Movement System
### How It Works
```
1. First odds fetch of the day → no baseline exists
→ Store all current lines as baseline in line_baselines
→ Return odds normally
2. Subsequent odds fetches (cache miss, fresh API call)
→ Compare each prop line to its baseline
→ If |current - baseline| >= 0.5:
→ Insert into line_movements
→ Determine direction: up (line increased) or down (line decreased)
→ Determine sharp_indicator (heuristic)
→ Attach movements to the odds response
3. Client reads movements via endpoint or as part of odds response
```
### Sharp Money Heuristic
```
If line moves AGAINST the public side:
- Over odds get worse (more negative) while line drops → sharp_action on under
- Under odds get worse while line rises → sharp_action on over
If line moves WITH the public side:
- public_action (books adjusting to balance)
Default: unknown
```
This is a rough heuristic. True sharp detection requires bet volume data we don't have. Label accordingly.
### Baseline Capture Timing
- Baseline is captured on the **first fresh odds fetch** of each UTC day
- If no fetch happens before games start, baseline is captured whenever the first fetch occurs
- Baselines are keyed by `(game_date, player, stat_type, book)` — unique constraint prevents duplicates
- Baselines expire: delete rows older than 2 days (cleanup job or on next day's first fetch)
## Cascade Detection System
### How It Works
```
1. On each fresh odds fetch, compare player list to previous fetch
→ Previous player set stored in Redis: `odds:players:{sport}:{date}`
2. If a player had props in the previous fetch but NOT in the current fetch:
→ Player may be scratched
→ Only flag if the player had props from at least 2 books previously (rules out data gaps)
3. When scratch detected:
a. Query scan_sessions + picks for any user who scanned a parlay with this player today
b. For each affected session:
→ Create cascade_alert with alert_type='player_scratched'
→ Optionally re-grade the parlay without the scratched leg
c. Store the re-graded result in the alert detail
4. Cascade alerts are read by clients via GET /api/alerts
```
### Re-Grading Logic
When a player is scratched from a parlay:
- The scratched leg is removed
- If only 1 leg remains: parlay cannot be graded (mark as "affected — single leg remaining")
- If 2+ legs remain: re-run `gradeParlayFromLegs` on remaining legs
- New grade is stored in the cascade_alert detail
## Endpoints
### GET /api/odds/nba (enhanced — existing endpoint)
The existing odds response now includes a `movements` array when line movements are detected.
**Enhanced response shape:**
```json
{
"sport": "nba",
"updated_at": "...",
"source": "live",
"quota_remaining": 488,
"props": ["..."],
"spreads": ["..."],
"movements": [
{
"player": "Nikola Jokic",
"stat_type": "points",
"book": "draftkings",
"baseline_line": 26.5,
"current_line": 27.0,
"movement": 0.5,
"direction": "up",
"sharp_indicator": "sharp_action",
"detected_at": "2026-03-21T16:30:00Z"
}
]
}
```
### GET /api/movements
Returns all line movements for today. Available to all tiers.
**Query params:**
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
| player | string | no | all | Filter by player name |
| stat_type | string | no | all | Filter by stat type |
| min_movement | number | no | 0.5 | Minimum movement threshold |
**Response (200):**
```json
{
"game_date": "2026-03-21",
"movements": [
{
"player": "Nikola Jokic",
"stat_type": "points",
"book": "draftkings",
"baseline_line": 26.5,
"current_line": 27.0,
"movement": 0.5,
"direction": "up",
"sharp_indicator": "sharp_action",
"detected_at": "2026-03-21T16:30:00Z"
}
],
"scratched_players": ["Player X"]
}
```
### GET /api/alerts (auth required)
Returns unread cascade alerts for the authenticated user. Analyst and Desk tiers only.
**Response (200):**
```json
{
"alerts": [
{
"id": "uuid",
"alert_type": "player_scratched",
"player": "Player X",
"scan_session_id": "uuid",
"detail": "Player X scratched. Your 3-leg parlay regraded from B to C without this leg.",
"is_read": false,
"created_at": "2026-03-21T18:00:00Z"
}
],
"unread_count": 1
}
```
### PATCH /api/alerts/:id/read (auth required)
Marks an alert as read.
**Response (200):**
```json
{ "id": "uuid", "is_read": true }
```
### Error Responses
| Status | When | Body |
|---|---|---|
| 401 | No auth token (alerts endpoints) | `{ "error": "Authentication required" }` |
| 403 | Free tier accessing alerts | `{ "error": "Alerts are available on Analyst and Desk tiers" }` |
| 404 | Alert not found or not owned | `{ "error": "Alert not found" }` |
## Service Architecture
```
src/
├── services/
│ ├── lineMovementService.js # Baseline capture, movement detection, sharp heuristic
│ ├── cascadeService.js # Scratch detection, affected user lookup, re-grade, alert creation
│ └── alertService.js # Alert CRUD (read, mark read)
├── routes/
│ ├── odds.js # Enhanced: movements included in response
│ ├── movements.js # GET /api/movements
│ └── alerts.js # GET /api/alerts, PATCH /api/alerts/:id/read
```
- **lineMovementService.js** — On each fresh fetch: capture baseline if first of day, compare lines, detect movements, store in DB.
- **cascadeService.js** — On each fresh fetch: compare player sets, detect scratches, query affected scans, create alerts, re-grade.
- **alertService.js** — Simple CRUD for cascade_alerts table.
## Integration with Existing oddsService.js
The `getOdds()` function in `oddsService.js` is the natural hook. After a successful live fetch:
```
1. Call lineMovementService.processNewOdds(props)
→ Captures baseline or detects movements
→ Returns movements[]
2. Call cascadeService.detectScratches(props)
→ Compares to previous player set
→ Creates alerts if scratches found
→ Returns scratched_players[]
3. Attach movements and scratched_players to the response
```
This adds zero extra API calls — it piggybacks on the existing fetch.
## Acceptance Criteria
1. First odds fetch of the day captures baseline lines for all props
2. Subsequent fetches compare current lines to baseline and detect movements >= 0.5
3. Line movements include direction (up/down) and sharp_indicator
4. Movements appear in the GET /api/odds/nba response and GET /api/movements
5. GET /api/movements supports player, stat_type, and min_movement filters
6. When a player's props disappear between fetches, cascade detection fires
7. Cascade detection queries scan_sessions to find affected users
8. Affected users receive cascade_alerts with re-graded parlay info
9. GET /api/alerts returns unread alerts for the authenticated user (Analyst/Desk only)
10. PATCH /api/alerts/:id/read marks an alert as read
11. Free tier cannot access alerts (403)
12. Zero additional Odds API credits consumed — all detection piggybacks on existing fetches
13. Baseline and movement data stored in new database tables
14. Old baselines (>2 days) are cleaned up
## Test Plan
### Unit Tests (lineMovementService.js)
- First fetch of the day: stores baseline, returns no movements
- Subsequent fetch with 0.5+ movement: detects and returns movement
- Movement < 0.5: not flagged
- Direction correctly identified (up vs down)
- Sharp heuristic: line moves against public side → sharp_action
- Baseline not duplicated on second fetch of the same day
- Old baselines cleaned up
### Unit Tests (cascadeService.js)
- Player present in previous fetch but absent in current: flagged as scratch
- Player absent from only 1 book (not 2+): not flagged (data gap)
- Scratch triggers scan_sessions query for affected users
- Re-grade produces updated parlay grade without scratched leg
- Alert created with correct type and detail
### Unit Tests (alertService.js)
- Returns unread alerts for user
- Marks alert as read
- Returns 404 for nonexistent alert
### Integration Tests
- Full flow: baseline capture → line moves → movement appears in odds response
- Full cascade: player props disappear → alert created for affected user
- GET /api/movements with filters returns correct subset
- GET /api/alerts returns user's alerts only
- PATCH /api/alerts/:id/read updates alert
- Free tier blocked from alerts (403)
- Zero extra API calls made (verify axios call count)
## Open Questions
- **Injury/scratch data source:** The Odds API removing a player's props is our primary signal. This has a delay — props may remain briefly after a scratch announcement. For Phase 2, this detection level is acceptable. A dedicated injury feed (e.g., Rotowire API) could be added later for faster detection.
- **Alert delivery:** This spec uses pull-based alerts (client fetches GET /api/alerts). Push-based (WebSocket or SSE) would be better UX but adds infrastructure. Acceptable for MVP; push can be layered on in Phase 3 UI work.