175 lines
5.7 KiB
JavaScript
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 },
|
|
};
|