2366660f5e
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>
345 lines
12 KiB
Markdown
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.
|