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