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>
12 KiB
Feature 2.2 — Real-Time Line Movement + Cascade Detection
Overview
Two related systems that protect users from stale analysis:
-
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.
-
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.
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.
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.
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
gradeParlayFromLegson 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:
{
"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):
{
"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):
{
"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):
{ "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
- First odds fetch of the day captures baseline lines for all props
- Subsequent fetches compare current lines to baseline and detect movements >= 0.5
- Line movements include direction (up/down) and sharp_indicator
- Movements appear in the GET /api/odds/nba response and GET /api/movements
- GET /api/movements supports player, stat_type, and min_movement filters
- When a player's props disappear between fetches, cascade detection fires
- Cascade detection queries scan_sessions to find affected users
- Affected users receive cascade_alerts with re-graded parlay info
- GET /api/alerts returns unread alerts for the authenticated user (Analyst/Desk only)
- PATCH /api/alerts/:id/read marks an alert as read
- Free tier cannot access alerts (403)
- Zero additional Odds API credits consumed — all detection piggybacks on existing fetches
- Baseline and movement data stored in new database tables
- 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.