'use strict'; /** * Line-snapshot service (Session 28). * * Lightweight, Redis-only line-movement tracking that complements the * Supabase-backed `lineMovementService` (which captures opening baselines * + sharp indicators for grading). This layer records a rolling history * of a prop's line through the day so the UI can draw a sparkline and a * "biggest movers" board — with ZERO odds-api credits (it only stores * what an odds fetch already returned). * * Key shape: * linehistory:{sport}:{gameId}:{player}:{stat} → Redis list of * JSON { time, line, book } snapshots (cap 100, 48h TTL). * * Everything is defensive: Redis down → no-op / empty, never a throw. */ const { getRedisClient, isDegraded } = require('../utils/redis'); const MAX_SNAPSHOTS = 100; const TTL_SECONDS = 48 * 3600; const SCAN_COUNT = 200; const MAX_KEYS = 1000; const SHARP_THRESHOLD = 1.5; // points of movement that flags sharp money const STABLE_THRESHOLD = 0.5; // < this = "stable" function snapshotKey(sport, gameId, player, stat) { return `linehistory:${sport}:${gameId}:${player}:${stat}`; } function safeNum(v) { const n = Number(v); return Number.isFinite(n) ? n : null; } /** * Append a line snapshot for each prop. `now` is injectable for tests. * Props need { gameId|game_id, player, stat|stat_type, line, book }. */ async function recordSnapshots(sport, props, now = Date.now()) { if (isDegraded && isDegraded()) return 0; if (!Array.isArray(props) || props.length === 0) return 0; const redis = getRedisClient(); if (!redis || typeof redis.rpush !== 'function') return 0; let written = 0; for (const p of props) { const gameId = p.gameId || p.game_id; const player = p.player; const stat = p.stat || p.stat_type; const line = safeNum(p.line); if (!gameId || !player || !stat || line === null) continue; const key = snapshotKey(sport, gameId, player, stat); const snap = JSON.stringify({ time: now, line, book: p.book || null }); try { await redis.rpush(key, snap); await redis.ltrim(key, -MAX_SNAPSHOTS, -1); await redis.expire(key, TTL_SECONDS); written += 1; } catch { /* swallow — snapshot recording must never break an odds fetch */ } } return written; } async function getLineHistory(sport, gameId, player, stat) { if (isDegraded && isDegraded()) return []; const redis = getRedisClient(); if (!redis || typeof redis.lrange !== 'function') return []; try { const raw = await redis.lrange(snapshotKey(sport, gameId, player, stat), 0, -1); return (raw || []) .map((s) => { try { return JSON.parse(s); } catch { return null; } }) .filter(Boolean); } catch { return []; } } /** * Classify a list of snapshots (oldest → newest) into a movement summary. * Empty / single-snapshot → stable, never an error. */ function classifyMovement(snapshots) { if (!Array.isArray(snapshots) || snapshots.length < 2) { const only = snapshots && snapshots[0] ? safeNum(snapshots[0].line) : null; return { opening: only, current: only, delta: 0, movement: 'stable', sharpSignal: false, snapshots: snapshots || [] }; } const opening = safeNum(snapshots[0].line) ?? 0; const current = safeNum(snapshots[snapshots.length - 1].line) ?? 0; const delta = Math.round((current - opening) * 100) / 100; const abs = Math.abs(delta); return { opening, current, delta, movement: abs < STABLE_THRESHOLD ? 'stable' : delta > 0 ? 'rising' : 'dropping', sharpSignal: abs >= SHARP_THRESHOLD, snapshots, }; } function parseKey(key) { // linehistory:{sport}:{gameId}:{player}:{stat} const parts = String(key).split(':'); if (parts.length < 5 || parts[0] !== 'linehistory') return null; // player names may contain no colons in practice; stat is the last part. const [, sport, gameId] = parts; const stat = parts[parts.length - 1]; const player = parts.slice(3, parts.length - 1).join(':'); return { sport, gameId, player, stat }; } async function scanKeys(match) { if (isDegraded && isDegraded()) return []; const redis = getRedisClient(); if (!redis || typeof redis.scan !== 'function') return []; const keys = []; let cursor = '0'; try { do { const [next, batch] = await redis.scan(cursor, 'MATCH', match, 'COUNT', SCAN_COUNT); cursor = next; for (const k of batch) { if (!keys.includes(k)) keys.push(k); if (keys.length >= MAX_KEYS) return keys; } } while (cursor !== '0'); } catch { return keys; } return keys; } /** * Biggest movers for a sport — every tracked prop classified, filtered to * meaningful moves, sorted by absolute delta desc. `limit` caps the list. */ async function getBiggestMovers(sport, { limit = 20, minDelta = STABLE_THRESHOLD } = {}) { const keys = await scanKeys(`linehistory:${sport}:*`); const movers = []; for (const key of keys) { const meta = parseKey(key); if (!meta) continue; const history = await getLineHistory(sport, meta.gameId, meta.player, meta.stat); const cls = classifyMovement(history); if (Math.abs(cls.delta) < minDelta) continue; movers.push({ sport, gameId: meta.gameId, player: meta.player, stat: meta.stat, opening: cls.opening, current: cls.current, delta: cls.delta, movement: cls.movement, sharpSignal: cls.sharpSignal, snapshots: cls.snapshots, }); } movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); return limit > 0 ? movers.slice(0, limit) : movers; } module.exports = { recordSnapshots, getLineHistory, classifyMovement, getBiggestMovers, __internals: { snapshotKey, parseKey, scanKeys, SHARP_THRESHOLD, STABLE_THRESHOLD }, };