Files
vyndr/src/services/lineSnapshotService.js
T

175 lines
5.7 KiB
JavaScript

'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 },
};