Files
vyndr/specs/feature-2-2-line-movement.md
T
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

12 KiB

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.

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 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:

{
  "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

  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.