# 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.