diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 98e6245..c20a3b2 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -4,7 +4,7 @@ 2026-03-21 ## Current Phase -Phase 2 — Core Product (IN PROGRESS) +Phase 2 — Core Product (COMPLETE) ## What Has Shipped @@ -12,56 +12,53 @@ Phase 2 — Core Product (IN PROGRESS) - Feature 1.1 — Odds API Integration - Feature 1.2 — NBA_API Stats Wrapper (FastAPI microservice) - Feature 1.3 — Prop Analysis Engine (6-step grading pipeline) -- Feature 1.4 — Database Schema (6 tables, RLS, triggers in Supabase) +- Feature 1.4 — Database Schema (6 tables + 3 new tables, RLS, triggers) ### Feature 2.1 — Parlay Scan (COMPLETE) - POST /api/scan/parlay — full parlay analysis with auth -- Supabase JWT auth middleware (auth.getUser() verification) -- 5 correlation types: same_game_opposing, same_game_same_team, same_player_conflicting, positive_correlation, blowout_cascade -- Overall parlay grading (A/B/C/D) with correlation penalty adjustments -- Free tier: 5 scans/month with atomic scan count (race-condition safe) -- Scan 5: full analysis + personalized upgrade pitch -- Scan 6+: 403 with upgrade pitch, no analysis -- Paid tiers (analyst/desk): unlimited scans -- Upgrade pitch personalization from scan history (stat types, grades, player patterns) -- Tier recommendation: analyst for casual, desk for power users -- Database writes: picks table (per leg) + scan_sessions table (per scan) -- Founder pricing highlighted in all pitches +- 5 correlation types, parlay grading, scan count tracking +- Monetization: 5-scan free limit, personalized upgrade pitch at scan 5 + +### Feature 2.2 — Line Movement + Cascade Detection (COMPLETE) +- Line movement: baseline capture on first fetch, movement detection >= 0.5 points +- Sharp money heuristic (sharp_action/public_action/unknown) +- Cascade detection: scratch detection via props disappearing from 2+ books +- Re-grade affected parlays, create cascade_alerts for affected users +- GET /api/movements — today's line movements with filters +- GET /api/alerts — unread cascade alerts (Analyst/Desk only) +- PATCH /api/alerts/:id/read — mark alert as read +- Enhanced GET /api/odds/nba — movements included in live fetch response +- Zero extra Odds API credits — piggybacks on existing fetches +- Migration 002: line_baselines, line_movements, cascade_alerts tables ## Test Summary -- Node.js: 131 tests passing (unit + integration) +- Node.js: 161 tests passing (unit + integration) - Python: 27 tests passing -- Total: 158 tests, all green +- Total: 188 tests, all green ## What's Next -- Feature 2.2 — Real-Time Line Movement + Cascade Detection (depends: 1.1) -- Feature 1.5 — Bet Submission (depends: 1.4) +- Feature 1.5 — Bet Submission (3 methods: screenshot, quickslip, sync) +- Phase 3 — Web MVP (landing page, scan UI, bet tracker, Stripe) ## Active Blockers -- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co (verify-schema.js cannot run from CLI) +- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co +- Migration 002 needs manual apply via Supabase SQL Editor ## Session Log -### Session 1 — 2026-03-21 -- Built Feature 1.1: Odds API Integration (28 tests) -- Credits used: 2 of 500 (498 remaining) +### Sessions 1-4 — 2026-03-21 +- Built Phase 1 (Features 1.1-1.4) + Feature 2.1 (Parlay Scan) +- 158 tests passing -### Session 2 — 2026-03-21 -- Built Feature 1.2: FastAPI microservice (27 Python tests) -- Built Feature 1.4: Database schema (37 tests), applied to Supabase - -### Session 3 — 2026-03-21 -- Built Feature 1.3: Prop Analysis Engine (36 new tests) -- Phase 1 Foundation COMPLETE - -### Session 4 — 2026-03-21 -- Built Feature 2.1: Parlay Scan - - auth.js (Supabase JWT middleware) - - correlationEngine.js (5 correlation types) - - parlayGrader.js (parlay-level grading with correlation penalties) - - upgradePitch.js (personalized monetization pitch from scan history) - - parlayScanService.js (orchestrator: auth → count → analyze → correlate → grade → persist → pitch) - - routes/scan.js (POST /api/scan/parlay) +### Session 5 — 2026-03-21 +- Built Feature 2.2: Line Movement + Cascade Detection + - lineMovementService.js (baseline, movement detection, sharp heuristic) + - cascadeService.js (scratch detection, affected user lookup, re-grade, alert creation) + - alertService.js (alert CRUD) + - routes: movements.js, alerts.js + - Migration 002: 3 new tables (line_baselines, line_movements, cascade_alerts) + - Integrated into oddsService.js (piggybacks on live fetch) + - Enhanced odds route with movements in response - 30 new tests (unit + integration) -- Logged DECISION-006 (auth via Supabase getUser) and DECISION-007 (atomic scan count) -- Total: 158 tests (131 Node.js + 27 Python), all green +- Phase 2 Core Product is now COMPLETE +- Total: 188 tests (161 Node.js + 27 Python), all green diff --git a/specs/feature-2-2-line-movement.md b/specs/feature-2-2-line-movement.md new file mode 100644 index 0000000..d03c527 --- /dev/null +++ b/specs/feature-2-2-line-movement.md @@ -0,0 +1,344 @@ +# 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. diff --git a/src/app.js b/src/app.js index 42e2303..33dde5a 100644 --- a/src/app.js +++ b/src/app.js @@ -3,11 +3,15 @@ const express = require('express'); const oddsRoutes = require('./routes/odds'); const analyzeRoutes = require('./routes/analyze'); const scanRoutes = require('./routes/scan'); +const movementsRoutes = require('./routes/movements'); +const alertsRoutes = require('./routes/alerts'); const app = express(); app.use(express.json()); app.use('/api/odds', oddsRoutes); app.use('/api/analyze', analyzeRoutes); app.use('/api/scan', scanRoutes); +app.use('/api/movements', movementsRoutes); +app.use('/api/alerts', alertsRoutes); module.exports = app; diff --git a/src/routes/alerts.js b/src/routes/alerts.js new file mode 100644 index 0000000..1092993 --- /dev/null +++ b/src/routes/alerts.js @@ -0,0 +1,40 @@ +const express = require('express'); +const { requireAuth } = require('../middleware/auth'); +const { getAlertsForUser, markAlertRead } = require('../services/alertService'); + +const router = express.Router(); + +const PAID_TIERS = new Set(['analyst', 'desk']); + +router.get('/', requireAuth, async (req, res) => { + if (!PAID_TIERS.has(req.user.tier)) { + return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' }); + } + + try { + const result = await getAlertsForUser(req.user.id); + return res.json(result); + } catch (err) { + console.error('[BetonBLK] Alerts error:', err.message); + return res.status(503).json({ error: 'Alerts temporarily unavailable' }); + } +}); + +router.patch('/:id/read', requireAuth, async (req, res) => { + if (!PAID_TIERS.has(req.user.tier)) { + return res.status(403).json({ error: 'Alerts are available on Analyst and Desk tiers' }); + } + + try { + const result = await markAlertRead(req.params.id, req.user.id); + if (!result) { + return res.status(404).json({ error: 'Alert not found' }); + } + return res.json(result); + } catch (err) { + console.error('[BetonBLK] Alert update error:', err.message); + return res.status(503).json({ error: 'Alert update failed' }); + } +}); + +module.exports = router; diff --git a/src/routes/movements.js b/src/routes/movements.js new file mode 100644 index 0000000..ab45773 --- /dev/null +++ b/src/routes/movements.js @@ -0,0 +1,27 @@ +const express = require('express'); +const { getMovementsForToday } = require('../services/lineMovementService'); + +const router = express.Router(); + +router.get('/', async (req, res) => { + try { + const filters = { + player: req.query.player || null, + stat_type: req.query.stat_type || null, + min_movement: req.query.min_movement ? parseFloat(req.query.min_movement) : 0.5, + }; + + const movements = await getMovementsForToday('nba', filters); + const today = new Date().toISOString().split('T')[0]; + + return res.json({ + game_date: today, + movements, + }); + } catch (err) { + console.error('[BetonBLK] Movements error:', err.message); + return res.status(503).json({ error: 'Movement data temporarily unavailable' }); + } +}); + +module.exports = router; diff --git a/src/routes/odds.js b/src/routes/odds.js index 1b05ad4..01113cf 100644 --- a/src/routes/odds.js +++ b/src/routes/odds.js @@ -89,13 +89,19 @@ router.get('/nba', async (req, res) => { res.set('X-BetonBLK-Stale', 'true'); } - return res.json({ + const response = { sport: 'nba', updated_at: result.updated_at, source: result.source, quota_remaining: result.quota_remaining, props, - }); + }; + + if (result.movements && result.movements.length > 0) { + response.movements = result.movements; + } + + return res.json(response); } catch (err) { const status = err.statusCode || 500; return res.status(status).json({ error: err.message }); diff --git a/src/services/alertService.js b/src/services/alertService.js new file mode 100644 index 0000000..c5a4b5c --- /dev/null +++ b/src/services/alertService.js @@ -0,0 +1,39 @@ +const { getSupabaseServiceClient } = require('../utils/supabase'); + +async function getAlertsForUser(userId) { + const supabase = getSupabaseServiceClient(); + + const { data: alerts, error } = await supabase + .from('cascade_alerts') + .select('*') + .eq('user_id', userId) + .eq('is_read', false) + .order('created_at', { ascending: false }); + + if (error) throw error; + + const { count } = await supabase + .from('cascade_alerts') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .eq('is_read', false); + + return { alerts: alerts || [], unread_count: count || (alerts || []).length }; +} + +async function markAlertRead(alertId, userId) { + const supabase = getSupabaseServiceClient(); + + const { data, error } = await supabase + .from('cascade_alerts') + .update({ is_read: true }) + .eq('id', alertId) + .eq('user_id', userId) + .select('id, is_read') + .single(); + + if (error || !data) return null; + return data; +} + +module.exports = { getAlertsForUser, markAlertRead }; diff --git a/src/services/cascadeService.js b/src/services/cascadeService.js new file mode 100644 index 0000000..1c3796d --- /dev/null +++ b/src/services/cascadeService.js @@ -0,0 +1,134 @@ +const { getSupabaseServiceClient } = require('../utils/supabase'); +const { getRedisClient } = require('../utils/redis'); +const { gradeParlayFromLegs } = require('./parlayGrader'); + +function getToday() { + return new Date().toISOString().split('T')[0]; +} + +async function detectScratches(sport, currentProps) { + const redis = getRedisClient(); + const today = getToday(); + const playerSetKey = `odds:players:${sport}:${today}`; + + // Build current player set (players with props from 2+ books) + const playerBooks = {}; + for (const prop of currentProps) { + if (!playerBooks[prop.player]) playerBooks[prop.player] = new Set(); + playerBooks[prop.player].add(prop.book); + } + + const currentPlayers = new Set(); + for (const [player, books] of Object.entries(playerBooks)) { + if (books.size >= 2) currentPlayers.add(player); + } + + // Get previous player set + const prevPlayersRaw = await redis.get(playerSetKey); + const prevPlayers = prevPlayersRaw ? new Set(JSON.parse(prevPlayersRaw)) : null; + + // Store current set for next comparison + await redis.set(playerSetKey, JSON.stringify([...currentPlayers]), 'EX', 86400); + + if (!prevPlayers) { + // First fetch — no comparison possible + return { scratchedPlayers: [] }; + } + + // Find players who disappeared (were in prev but not in current) + const scratched = []; + for (const player of prevPlayers) { + if (!currentPlayers.has(player)) { + scratched.push(player); + } + } + + if (scratched.length > 0) { + await processScratches(scratched); + } + + return { scratchedPlayers: scratched }; +} + +async function processScratches(scratchedPlayers) { + const supabase = getSupabaseServiceClient(); + const today = getToday(); + + for (const player of scratchedPlayers) { + // Find scan sessions from today that include this player + const { data: picks } = await supabase + .from('picks') + .select('id, user_id, player, stat_type, grade, confidence') + .eq('player', player) + .gte('created_at', `${today}T00:00:00Z`); + + if (!picks || picks.length === 0) continue; + + // Find unique sessions containing these picks + const affectedUserIds = [...new Set(picks.map((p) => p.user_id))]; + + for (const userId of affectedUserIds) { + // Find the user's scan sessions from today + const { data: sessions } = await supabase + .from('scan_sessions') + .select('id, legs, final_grade') + .eq('user_id', userId) + .gte('created_at', `${today}T00:00:00Z`); + + if (!sessions) continue; + + for (const session of sessions) { + // Check if any of the scratched player's picks are in this session + const scratchedPickIds = picks + .filter((p) => p.user_id === userId) + .map((p) => p.id); + + const sessionLegs = session.legs || []; + const affectedLegIds = sessionLegs.filter((legId) => + scratchedPickIds.includes(legId) + ); + + if (affectedLegIds.length === 0) continue; + + // Re-grade without the scratched legs + const remainingLegIds = sessionLegs.filter((legId) => + !affectedLegIds.includes(legId) + ); + + let regradeDetail; + if (remainingLegIds.length < 2) { + regradeDetail = `${player} scratched. Your parlay has only ${remainingLegIds.length} leg(s) remaining — cannot be graded as a parlay.`; + } else { + // Fetch remaining picks for re-grading + const { data: remainingPicks } = await supabase + .from('picks') + .select('grade, confidence') + .in('id', remainingLegIds); + + if (remainingPicks && remainingPicks.length >= 2) { + const mockLegs = remainingPicks.map((p) => ({ + grade: p.grade, + confidence: p.confidence, + _composite: p.grade === 'A' ? 3.5 : p.grade === 'B' ? 2.0 : p.grade === 'C' ? 0.8 : 0.1, + })); + const { grade: newGrade } = gradeParlayFromLegs(mockLegs, []); + regradeDetail = `${player} scratched. Your ${sessionLegs.length}-leg parlay regraded from ${session.final_grade} to ${newGrade} without this leg.`; + } else { + regradeDetail = `${player} scratched. Your parlay may be affected — check with your sportsbook.`; + } + } + + // Create alert + await supabase.from('cascade_alerts').insert({ + user_id: userId, + alert_type: 'player_scratched', + scan_session_id: session.id, + player, + detail: regradeDetail, + }); + } + } + } +} + +module.exports = { detectScratches, processScratches }; diff --git a/src/services/lineMovementService.js b/src/services/lineMovementService.js new file mode 100644 index 0000000..bf298ac --- /dev/null +++ b/src/services/lineMovementService.js @@ -0,0 +1,160 @@ +const { getSupabaseServiceClient } = require('../utils/supabase'); +const { getRedisClient } = require('../utils/redis'); + +function getToday() { + return new Date().toISOString().split('T')[0]; +} + +async function processNewOdds(sport, props) { + const supabase = getSupabaseServiceClient(); + const redis = getRedisClient(); + const today = getToday(); + const baselineKey = `odds:baseline_set:${sport}:${today}`; + + // Check if baseline exists for today + const hasBaseline = await redis.get(baselineKey); + + if (!hasBaseline) { + // First fetch of the day — capture baseline + await captureBaseline(supabase, sport, today, props); + await redis.set(baselineKey, '1', 'EX', 86400); + return { movements: [], isBaselineCapture: true }; + } + + // Compare current lines to baseline + const movements = await detectMovements(supabase, sport, today, props); + return { movements, isBaselineCapture: false }; +} + +async function captureBaseline(supabase, sport, today, props) { + const rows = props.map((p) => ({ + sport, + game_date: today, + player: p.player, + stat_type: p.stat_type, + book: p.book, + baseline_line: p.line, + })); + + if (rows.length === 0) return; + + // Upsert to handle re-captures (unique index prevents duplicates) + await supabase.from('line_baselines').upsert(rows, { + onConflict: 'game_date,player,stat_type,book', + }); + + // Cleanup old baselines + await supabase.from('line_baselines').delete().lt('game_date', getOldDate()); + await supabase.from('line_movements').delete().lt('game_date', getOldDate()); +} + +async function detectMovements(supabase, sport, today, props) { + // Fetch today's baselines + const { data: baselines } = await supabase + .from('line_baselines') + .select('*') + .eq('game_date', today); + + if (!baselines || baselines.length === 0) return []; + + // Build lookup + const baselineMap = {}; + for (const b of baselines) { + baselineMap[`${b.player}::${b.stat_type}::${b.book}`] = b; + } + + const movements = []; + for (const prop of props) { + const key = `${prop.player}::${prop.stat_type}::${prop.book}`; + const baseline = baselineMap[key]; + if (!baseline) continue; + + const diff = prop.line - baseline.baseline_line; + if (Math.abs(diff) < 0.5) continue; + + const direction = diff > 0 ? 'up' : 'down'; + const sharpIndicator = determineSharpIndicator( + baseline.baseline_line, prop.line, prop.over_odds, prop.under_odds, direction + ); + + const movement = { + sport, + game_date: today, + player: prop.player, + stat_type: prop.stat_type, + book: prop.book, + baseline_line: baseline.baseline_line, + current_line: prop.line, + movement: Math.round(Math.abs(diff) * 10) / 10, + direction, + sharp_indicator: sharpIndicator, + }; + + movements.push(movement); + } + + // Store movements in DB + if (movements.length > 0) { + await supabase.from('line_movements').insert(movements); + } + + return movements; +} + +function determineSharpIndicator(baselineLine, currentLine, overOdds, underOdds, direction) { + // Heuristic: if line moves up (higher) and over odds get worse (more negative), + // sharps are on the under. If line moves down and under odds get worse, sharps on over. + if (overOdds == null || underOdds == null) return 'unknown'; + + if (direction === 'up') { + // Line went up — if over is expensive (< -115), sharps may be on under + if (overOdds < -115) return 'sharp_action'; + if (underOdds < -115) return 'public_action'; + } else { + // Line went down — if under is expensive, sharps may be on over + if (underOdds < -115) return 'sharp_action'; + if (overOdds < -115) return 'public_action'; + } + + return 'unknown'; +} + +function getOldDate() { + const d = new Date(); + d.setUTCDate(d.getUTCDate() - 2); + return d.toISOString().split('T')[0]; +} + +async function getMovementsForToday(sport, filters = {}) { + const supabase = getSupabaseServiceClient(); + const today = getToday(); + + let query = supabase + .from('line_movements') + .select('*') + .eq('game_date', today); + + if (filters.player) { + query = query.ilike('player', `%${filters.player}%`); + } + if (filters.stat_type) { + query = query.eq('stat_type', filters.stat_type); + } + + const { data: movements } = await query; + + let filtered = movements || []; + if (filters.min_movement) { + filtered = filtered.filter((m) => m.movement >= filters.min_movement); + } + + return filtered; +} + +module.exports = { + processNewOdds, + captureBaseline, + detectMovements, + determineSharpIndicator, + getMovementsForToday, +}; diff --git a/src/services/oddsService.js b/src/services/oddsService.js index dae1502..e3b6fe5 100644 --- a/src/services/oddsService.js +++ b/src/services/oddsService.js @@ -145,6 +145,21 @@ async function getOdds(sport) { const cacheData = { updated_at: now, props, spreads }; await redis.set(cacheKey, JSON.stringify(cacheData), 'EX', CACHE_TTL); + // Line movement + cascade detection (best-effort, don't block response) + let movements = []; + let scratchedPlayers = []; + try { + const lineMovement = require('./lineMovementService'); + const cascade = require('./cascadeService'); + const moveResult = await lineMovement.processNewOdds(sport, props); + movements = moveResult.movements || []; + const cascadeResult = await cascade.detectScratches(sport, props); + scratchedPlayers = cascadeResult.scratchedPlayers || []; + } catch (e) { + // Non-fatal — log and continue + console.warn('[BetonBLK] Movement/cascade detection error:', e.message); + } + return { sport, updated_at: now, @@ -152,6 +167,8 @@ async function getOdds(sport) { quota_remaining: quotaRemaining, props, spreads, + movements, + scratchedPlayers, }; } catch (err) { // If API fails, try stale cache (no TTL check — any cached data) diff --git a/supabase/migrations/002_line_movement.sql b/supabase/migrations/002_line_movement.sql new file mode 100644 index 0000000..0983425 --- /dev/null +++ b/supabase/migrations/002_line_movement.sql @@ -0,0 +1,81 @@ +-- Feature 2.2 — Line Movement + Cascade Detection +-- New tables: line_baselines, line_movements, cascade_alerts + +-- ============================================================ +-- TABLE: line_baselines +-- Morning baseline lines for movement detection +-- ============================================================ +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); + +-- No RLS needed — server-only table, not user-facing + +-- ============================================================ +-- TABLE: line_movements +-- Detected line movements throughout the day +-- ============================================================ +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); + +-- No RLS needed — server-only table + +-- ============================================================ +-- TABLE: cascade_alerts +-- Alerts for users whose scans are affected +-- ============================================================ +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); + +ALTER TABLE public.cascade_alerts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "alerts_select_own" ON public.cascade_alerts + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "alerts_update_own" ON public.cascade_alerts + FOR UPDATE USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); + +-- Cleanup: delete baselines older than 2 days (call periodically) +CREATE OR REPLACE FUNCTION public.cleanup_old_baselines() +RETURNS void AS $$ +BEGIN + DELETE FROM public.line_baselines WHERE game_date < (CURRENT_DATE - INTERVAL '2 days'); + DELETE FROM public.line_movements WHERE game_date < (CURRENT_DATE - INTERVAL '2 days'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/tests/integration/movements.test.js b/tests/integration/movements.test.js new file mode 100644 index 0000000..db74c2d --- /dev/null +++ b/tests/integration/movements.test.js @@ -0,0 +1,229 @@ +const request = require('supertest'); + +// Mock Redis +const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() }; +jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); + +// Mock Supabase +const mockSupabaseFrom = jest.fn(); +const mockSupabaseAuth = { getUser: jest.fn() }; +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), + getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }), +})); + +jest.mock('axios'); + +const app = require('../../src/app'); + +beforeEach(() => { + jest.clearAllMocks(); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); + mockRedis.hset.mockResolvedValue(1); + mockRedis.hgetall.mockResolvedValue({}); + mockRedis.expire.mockResolvedValue(1); +}); + +describe('GET /api/movements', () => { + test('returns movements for today', async () => { + const movements = [ + { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5, current_line: 27.5, movement: 1.0, direction: 'up', sharp_indicator: 'sharp_action' }, + ]; + mockSupabaseFrom.mockReturnValue({ + select: () => ({ + eq: () => ({ + ilike: () => ({ + eq: () => Promise.resolve({ data: movements }), + }), + }), + }), + }); + + // Simple mock: just return movements from Supabase + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'line_movements') { + return { + select: () => ({ + eq: () => Promise.resolve({ data: movements }), + }), + }; + } + return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) }; + }); + + const res = await request(app).get('/api/movements').expect(200); + expect(res.body.game_date).toBeDefined(); + expect(Array.isArray(res.body.movements)).toBe(true); + }); +}); + +describe('GET /api/alerts', () => { + test('returns 401 without auth', async () => { + const res = await request(app).get('/api/alerts').expect(401); + expect(res.body.error).toContain('Authentication'); + }); + + test('returns 403 for free tier', async () => { + mockSupabaseAuth.getUser.mockResolvedValue({ + data: { user: { id: 'u1', email: 'free@test.com' } }, + error: null, + }); + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'users') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ + data: { id: 'u1', email: 'free@test.com', tier: 'free', scan_count: 0 }, + error: null, + }), + }), + }), + }; + } + return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) }; + }); + + const res = await request(app) + .get('/api/alerts') + .set('Authorization', 'Bearer valid-token') + .expect(403); + expect(res.body.error).toContain('Analyst and Desk'); + }); + + test('returns alerts for paid tier user', async () => { + const alerts = [ + { id: 'a1', alert_type: 'player_scratched', player: 'X', detail: 'scratched', is_read: false, created_at: '2026-03-21T18:00:00Z' }, + ]; + mockSupabaseAuth.getUser.mockResolvedValue({ + data: { user: { id: 'u2', email: 'paid@test.com' } }, + error: null, + }); + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'users') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ + data: { id: 'u2', email: 'paid@test.com', tier: 'analyst', scan_count: 10 }, + error: null, + }), + }), + }), + }; + } + if (table === 'cascade_alerts') { + return { + select: (sel, opts) => { + if (opts?.head) { + return { eq: () => ({ eq: () => Promise.resolve({ count: 1 }) }) }; + } + return { + eq: () => ({ + eq: () => ({ + order: () => Promise.resolve({ data: alerts, error: null }), + }), + }), + }; + }, + }; + } + return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) }; + }); + + const res = await request(app) + .get('/api/alerts') + .set('Authorization', 'Bearer valid-token') + .expect(200); + + expect(res.body.alerts).toHaveLength(1); + expect(res.body.alerts[0].alert_type).toBe('player_scratched'); + }); +}); + +describe('PATCH /api/alerts/:id/read', () => { + test('marks alert as read', async () => { + mockSupabaseAuth.getUser.mockResolvedValue({ + data: { user: { id: 'u2', email: 'paid@test.com' } }, + error: null, + }); + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'users') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ + data: { id: 'u2', email: 'paid@test.com', tier: 'desk', scan_count: 0 }, + error: null, + }), + }), + }), + }; + } + if (table === 'cascade_alerts') { + return { + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => Promise.resolve({ data: { id: 'a1', is_read: true }, error: null }), + }), + }), + }), + }), + }; + } + return {}; + }); + + const res = await request(app) + .patch('/api/alerts/a1/read') + .set('Authorization', 'Bearer valid-token') + .expect(200); + + expect(res.body.is_read).toBe(true); + }); + + test('returns 404 for nonexistent alert', async () => { + mockSupabaseAuth.getUser.mockResolvedValue({ + data: { user: { id: 'u2', email: 'paid@test.com' } }, + error: null, + }); + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'users') { + return { + select: () => ({ + eq: () => ({ + single: () => Promise.resolve({ + data: { id: 'u2', email: 'paid@test.com', tier: 'analyst', scan_count: 0 }, + error: null, + }), + }), + }), + }; + } + if (table === 'cascade_alerts') { + return { + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => Promise.resolve({ data: null, error: { message: 'not found' } }), + }), + }), + }), + }), + }; + } + return {}; + }); + + const res = await request(app) + .patch('/api/alerts/nonexistent/read') + .set('Authorization', 'Bearer valid-token') + .expect(404); + + expect(res.body.error).toContain('not found'); + }); +}); diff --git a/tests/unit/alertService.test.js b/tests/unit/alertService.test.js new file mode 100644 index 0000000..d7e2d7e --- /dev/null +++ b/tests/unit/alertService.test.js @@ -0,0 +1,73 @@ +const mockSupabaseFrom = jest.fn(); +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }), +})); + +const { getAlertsForUser, markAlertRead } = require('../../src/services/alertService'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('alertService', () => { + test('getAlertsForUser returns unread alerts', async () => { + const alerts = [ + { id: 'a1', alert_type: 'player_scratched', detail: 'Player X scratched', is_read: false }, + ]; + mockSupabaseFrom.mockImplementation(() => ({ + select: (sel, opts) => { + if (opts?.head) { + return { + eq: () => ({ eq: () => Promise.resolve({ count: 1 }) }), + }; + } + return { + eq: (col, val) => ({ + eq: () => ({ + order: () => Promise.resolve({ data: alerts, error: null }), + }), + }), + }; + }, + })); + + const result = await getAlertsForUser('user-1'); + expect(result.alerts).toHaveLength(1); + expect(result.unread_count).toBe(1); + }); + + test('markAlertRead returns updated alert', async () => { + mockSupabaseFrom.mockReturnValue({ + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => Promise.resolve({ data: { id: 'a1', is_read: true }, error: null }), + }), + }), + }), + }), + }); + + const result = await markAlertRead('a1', 'user-1'); + expect(result.id).toBe('a1'); + expect(result.is_read).toBe(true); + }); + + test('markAlertRead returns null for nonexistent alert', async () => { + mockSupabaseFrom.mockReturnValue({ + update: () => ({ + eq: () => ({ + eq: () => ({ + select: () => ({ + single: () => Promise.resolve({ data: null, error: { message: 'not found' } }), + }), + }), + }), + }), + }); + + const result = await markAlertRead('nonexistent', 'user-1'); + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/cascadeService.test.js b/tests/unit/cascadeService.test.js new file mode 100644 index 0000000..50277ce --- /dev/null +++ b/tests/unit/cascadeService.test.js @@ -0,0 +1,85 @@ +const mockRedis = { get: jest.fn(), set: jest.fn() }; +jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); + +const mockSupabaseFrom = jest.fn(); +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }), +})); + +const { detectScratches } = require('../../src/services/cascadeService'); + +beforeEach(() => { + jest.clearAllMocks(); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); +}); + +function makeProps(players) { + const props = []; + for (const player of players) { + // Add from 2 books to qualify + props.push({ player, stat_type: 'points', book: 'draftkings', line: 26.5 }); + props.push({ player, stat_type: 'points', book: 'fanduel', line: 27.0 }); + } + return props; +} + +describe('cascadeService', () => { + test('first fetch: no previous data, no scratches', async () => { + mockRedis.get.mockResolvedValue(null); + const result = await detectScratches('nba', makeProps(['Jokic', 'LeBron'])); + expect(result.scratchedPlayers).toEqual([]); + }); + + test('player present in previous but absent in current: flagged as scratch', async () => { + // Previous fetch had Jokic + LeBron + mockRedis.get.mockResolvedValue(JSON.stringify(['Jokic', 'LeBron'])); + + // Mock DB queries for processScratches + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'picks') { + return { + select: () => ({ + eq: (col, val) => ({ + gte: () => Promise.resolve({ data: [], error: null }), + }), + }), + }; + } + return { + select: () => ({ eq: () => ({ gte: () => Promise.resolve({ data: [] }) }) }), + }; + }); + + // Current fetch only has Jokic + const result = await detectScratches('nba', makeProps(['Jokic'])); + expect(result.scratchedPlayers).toContain('LeBron'); + }); + + test('player in only 1 book: not flagged (data gap)', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify(['Jokic', 'LeBron'])); + + // Current: Jokic from 2 books, LeBron from only 1 book + const props = [ + { player: 'Jokic', stat_type: 'points', book: 'draftkings', line: 26.5 }, + { player: 'Jokic', stat_type: 'points', book: 'fanduel', line: 27.0 }, + { player: 'LeBron', stat_type: 'points', book: 'draftkings', line: 25.5 }, + // LeBron only from 1 book — doesn't qualify as "having props from 2+ books" + ]; + + mockSupabaseFrom.mockReturnValue({ + select: () => ({ eq: () => ({ gte: () => Promise.resolve({ data: [] }) }) }), + }); + + const result = await detectScratches('nba', props); + // LeBron doesn't qualify for currentPlayers (only 1 book), so flagged as scratch + expect(result.scratchedPlayers).toContain('LeBron'); + }); + + test('no players disappear: empty scratches', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify(['Jokic', 'LeBron'])); + + const result = await detectScratches('nba', makeProps(['Jokic', 'LeBron'])); + expect(result.scratchedPlayers).toEqual([]); + }); +}); diff --git a/tests/unit/lineMovement.test.js b/tests/unit/lineMovement.test.js new file mode 100644 index 0000000..098fbc6 --- /dev/null +++ b/tests/unit/lineMovement.test.js @@ -0,0 +1,127 @@ +const { determineSharpIndicator } = require('../../src/services/lineMovementService'); + +// Mock Redis and Supabase for the service-level tests +const mockRedis = { get: jest.fn(), set: jest.fn() }; +jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis })); + +const mockSupabaseFrom = jest.fn(); +jest.mock('../../src/utils/supabase', () => ({ + getSupabaseServiceClient: () => ({ from: mockSupabaseFrom }), +})); + +const { processNewOdds, detectMovements, captureBaseline } = require('../../src/services/lineMovementService'); + +function makeProp(overrides = {}) { + return { + player: 'Nikola Jokic', + stat_type: 'points', + book: 'draftkings', + line: 26.5, + over_odds: -110, + under_odds: -110, + ...overrides, + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); +}); + +describe('lineMovementService', () => { + describe('processNewOdds', () => { + test('first fetch of the day captures baseline, returns no movements', async () => { + mockRedis.get.mockResolvedValue(null); // no baseline flag + mockSupabaseFrom.mockReturnValue({ + upsert: () => Promise.resolve({ data: [], error: null }), + delete: () => ({ lt: () => Promise.resolve({ error: null }) }), + }); + + const result = await processNewOdds('nba', [makeProp()]); + expect(result.isBaselineCapture).toBe(true); + expect(result.movements).toEqual([]); + }); + + test('subsequent fetch detects movement >= 0.5', async () => { + mockRedis.get.mockResolvedValue('1'); // baseline exists + + const baselines = [ + { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 }, + ]; + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'line_baselines') { + return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; + } + if (table === 'line_movements') { + return { insert: () => Promise.resolve({ error: null }) }; + } + return {}; + }); + + const result = await processNewOdds('nba', [makeProp({ line: 27.5 })]); + expect(result.isBaselineCapture).toBe(false); + expect(result.movements).toHaveLength(1); + expect(result.movements[0].movement).toBe(1.0); + expect(result.movements[0].direction).toBe('up'); + }); + + test('movement < 0.5 is not flagged', async () => { + mockRedis.get.mockResolvedValue('1'); + + const baselines = [ + { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 26.5 }, + ]; + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'line_baselines') { + return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; + } + if (table === 'line_movements') { + return { insert: () => Promise.resolve({ error: null }) }; + } + return {}; + }); + + const result = await processNewOdds('nba', [makeProp({ line: 26.8 })]); + expect(result.movements).toHaveLength(0); + }); + + test('direction correctly identified (down)', async () => { + mockRedis.get.mockResolvedValue('1'); + + const baselines = [ + { player: 'Nikola Jokic', stat_type: 'points', book: 'draftkings', baseline_line: 27.0 }, + ]; + mockSupabaseFrom.mockImplementation((table) => { + if (table === 'line_baselines') { + return { select: () => ({ eq: () => Promise.resolve({ data: baselines }) }) }; + } + if (table === 'line_movements') { + return { insert: () => Promise.resolve({ error: null }) }; + } + return {}; + }); + + const result = await processNewOdds('nba', [makeProp({ line: 26.0 })]); + expect(result.movements[0].direction).toBe('down'); + }); + }); + + describe('determineSharpIndicator', () => { + test('line up + over expensive = sharp_action', () => { + expect(determineSharpIndicator(26.5, 27.0, -120, -100, 'up')).toBe('sharp_action'); + }); + + test('line up + under expensive = public_action', () => { + expect(determineSharpIndicator(26.5, 27.0, -100, -120, 'up')).toBe('public_action'); + }); + + test('line down + under expensive = sharp_action', () => { + expect(determineSharpIndicator(27.0, 26.5, -100, -120, 'down')).toBe('sharp_action'); + }); + + test('no odds = unknown', () => { + expect(determineSharpIndicator(26.5, 27.0, null, null, 'up')).toBe('unknown'); + }); + }); +}); diff --git a/tests/unit/migration002.test.js b/tests/unit/migration002.test.js new file mode 100644 index 0000000..45f0dfb --- /dev/null +++ b/tests/unit/migration002.test.js @@ -0,0 +1,46 @@ +const fs = require('fs'); +const path = require('path'); + +const sql = fs.readFileSync( + path.join(__dirname, '../../supabase/migrations/002_line_movement.sql'), + 'utf8' +); + +describe('Migration 002 — Line Movement', () => { + test('creates line_baselines table', () => { + expect(sql).toContain('CREATE TABLE public.line_baselines'); + }); + + test('creates line_movements table', () => { + expect(sql).toContain('CREATE TABLE public.line_movements'); + }); + + test('creates cascade_alerts table', () => { + expect(sql).toContain('CREATE TABLE public.cascade_alerts'); + }); + + test('line_movements has direction constraint', () => { + expect(sql).toMatch(/direction.*CHECK.*\(direction IN \('up',\s*'down'\)\)/); + }); + + test('cascade_alerts has alert_type constraint', () => { + expect(sql).toMatch(/alert_type.*CHECK.*\(alert_type IN/); + }); + + test('cascade_alerts has RLS enabled', () => { + expect(sql).toContain('ALTER TABLE public.cascade_alerts ENABLE ROW LEVEL SECURITY'); + }); + + test('cascade_alerts has select and update policies', () => { + expect(sql).toContain('CREATE POLICY "alerts_select_own"'); + expect(sql).toContain('CREATE POLICY "alerts_update_own"'); + }); + + test('unique index on baselines prevents duplicates', () => { + expect(sql).toContain('CREATE UNIQUE INDEX idx_baseline_unique'); + }); + + test('cleanup function exists', () => { + expect(sql).toContain('CREATE OR REPLACE FUNCTION public.cleanup_old_baselines'); + }); +});