/** * Steam detection — flags lines that move 1+ points in <2 hours. * * Inputs: a stream of { prop_key, book, line, odds, recorded_at } samples. * The orchestrator persists samples to `line_history` and calls check() with * the rolling window for tonight's slate. */ const TWO_HOURS_MS = 2 * 60 * 60_000; const STEAM_THRESHOLD = 1; /** * @param {Array<{prop_key:string, book:string, line:number, odds:number|null, recorded_at:string|number}>} samples * @returns {Array<{prop_key:string, book:string, from_line:number, to_line:number, delta:number, duration_ms:number, started_at:string, ended_at:string}>} */ function check(samples) { if (!Array.isArray(samples) || samples.length === 0) return []; // Group samples by prop_key + book and sort chronologically. const buckets = new Map(); for (const s of samples) { const k = `${s.prop_key}|${s.book}`; if (!buckets.has(k)) buckets.set(k, []); buckets.get(k).push({ ...s, t: new Date(s.recorded_at).getTime() }); } const flags = []; for (const [key, rows] of buckets.entries()) { rows.sort((a, b) => a.t - b.t); for (let i = 0; i < rows.length; i++) { // Walk forward in time and stop as soon as the gap > window. const start = rows[i]; for (let j = i + 1; j < rows.length; j++) { const end = rows[j]; if (end.t - start.t > TWO_HOURS_MS) break; const delta = end.line - start.line; if (Math.abs(delta) >= STEAM_THRESHOLD) { const [propKey, book] = key.split('|'); flags.push({ prop_key: propKey, book, from_line: start.line, to_line: end.line, delta, duration_ms: end.t - start.t, started_at: new Date(start.t).toISOString(), ended_at: new Date(end.t).toISOString(), }); break; // one flag per starting sample } } } } return flags; } module.exports = { check, TWO_HOURS_MS, STEAM_THRESHOLD };