'use strict'; /** * DIRECTIONAL CLV — PER-READ signal (Session 64). * * "Did the market move TOWARD the side we graded, between our lock and the * close?" Toward = positive (a badge: we were early). Away = negative * (caution). This is a per-read signal ONLY — there is deliberately no * aggregate "our CLV is +X%" anywhere, and that claim stays held until the * model is backtest-proven. * * WHY IT IS COMPUTABLE NOW: grade-lock was verified live (0 true overwrites * across 698 identity+line groups), so the lock is a fixed reference rather * than something later cycles rewrite. * * BOTH ENDS ARE DE-VIGGED WITH THE SAME METHOD. Comparing a raw price to a * de-vigged one would manufacture movement out of vig, so both ends go through * `devig.devigTwoWay` (multiplicative). The lock end comes from * `model_snapshots`, which retains BOTH side prices and an already-de-vigged * `fair_prob` from that same function — `ledger_entries.locked_odds` is * single-side and CANNOT be de-vigged, so it is not the source. * * SIGN IS SIDE-BOUND. A move that helps an over HURTS an under on the same * prop, so the sign is computed from the fair probability OF OUR GRADED SIDE — * never from the raw direction of the line. This is the badge-inverting trap * and it is guarded by a test that asserts the two sides are exact mirrors. * * FLAT IS A PROBABILITY-SPACE THRESHOLD, ALWAYS. Price space lies: a 40-cent * move on a deep favourite is a tiny probability move, while 10 cents near * even money is large. * * MISSING/AMBIGUOUS CLOSE IS `unknown`, NEVER 0. Zero means "the market did not * move", which is a claim; absence of a close is not. */ const { devigTwoWay } = require('../utils/devig'); // Fair-probability points below which we call it flat. 1.5pp — smaller than // this is inside the noise of two books' vig assumptions. const FLAT_THRESHOLD = Number(process.env.DCLV_FLAT_THRESHOLD || 0.015); const sideOf = (s) => (String(s || 'over').toLowerCase() === 'under' ? 'under' : 'over'); function num(v) { if (v == null || v === '') return null; const n = Number(v); return Number.isFinite(n) ? n : null; } /** Fair probability of ONE side, de-vigged from both raw prices. */ function fairProbOfSide(overOdds, underOdds, side) { const o = num(overOdds); const u = num(underOdds); if (o == null || u == null) return null; // one-sided → not de-viggable const dv = devigTwoWay(o, u); if (!dv) return null; return sideOf(side) === 'under' ? dv.under.fair_prob : dv.over.fair_prob; } const UNKNOWN = (reason) => ({ state: 'unknown', clv: null, fair_lock: null, fair_close: null, reason, }); /** * Compute the per-read directional CLV. * * @param {Object} a * - side our graded side ('over' | 'under') * - lockOverOdds/lockUnderOdds both raw prices at grade time, OR * - lockFairProb the already-de-vigged fair prob of our side * - closeOverOdds/closeUnderOdds both raw prices at the close * - missedReason capture refusal (missed_window, doubleheader_…) * - flatThreshold probability-space flat band */ function computeDirectionalClv(a = {}) { const side = sideOf(a.side); const threshold = Number.isFinite(a.flatThreshold) ? a.flatThreshold : FLAT_THRESHOLD; // A capture that refused is not a close. Consistency with the capture layer: // what was unknowable then stays unknowable now. if (a.missedReason) return UNKNOWN(a.missedReason); const fairLock = num(a.lockFairProb) != null ? num(a.lockFairProb) : fairProbOfSide(a.lockOverOdds, a.lockUnderOdds, side); if (fairLock == null) return UNKNOWN('lock_not_devigable'); const fairClose = fairProbOfSide(a.closeOverOdds, a.closeUnderOdds, side); if (fairClose == null) return UNKNOWN('no_usable_close'); // SIDE-BOUND by construction: both probabilities are already "our side". const clv = Math.round((fairClose - fairLock) * 1e6) / 1e6; let state; if (Math.abs(clv) < threshold) state = 'flat'; else state = clv > 0 ? 'positive' : 'negative'; return { state, clv, fair_lock: fairLock, fair_close: fairClose, reason: null }; } /** Copy for the badge. Never renders anything for `unknown`. */ function describe(result) { if (!result) return null; switch (result.state) { case 'positive': return { label: 'MOVED TOWARD US', tone: 'confirm' }; case 'negative': return { label: 'MOVED AWAY', tone: 'caution' }; case 'flat': return { label: 'LINE HELD', tone: 'neutral' }; default: return null; // unknown → render NOTHING } } module.exports = { computeDirectionalClv, fairProbOfSide, describe, FLAT_THRESHOLD };