diff --git a/src/services/directionalClv.js b/src/services/directionalClv.js new file mode 100644 index 0000000..b28293e --- /dev/null +++ b/src/services/directionalClv.js @@ -0,0 +1,111 @@ +'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 }; diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 8cdaf98..8035c2a 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -332,6 +332,53 @@ function clvResultOf(clv) { * CLV from the captured closing line. Idempotent: only rows with * outcome IS NULL are fetched, and a row is written at most once. */ + +/** + * Per-read directional CLV for one settling row (Session 64). + * + * THE JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date) + * — WITHOUT `line`, because a close that moved off the graded line is the whole + * point. Verified clean: 164 identity groups, zero ambiguity. + * + * The LOCK end reads model_snapshots (both side prices + an already-de-vigged + * fair_prob from the same devig function), NOT ledger_entries.locked_odds, + * which is single-side and cannot be de-vigged. + * + * Never throws: a CLV failure must not block a settlement. + */ +async function computeDirectionalForRow(sb, sport, row, deps = {}) { + try { + const dclv = deps.directionalClv || require('./directionalClv'); + const [{ data: snaps }, { data: closes }] = await Promise.all([ + sb.from('model_snapshots') + .select('fair_prob, over_odds, under_odds, captured_at') + .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat) + .eq('side', row.side).eq('game_date', row.game_date) + .order('captured_at', { ascending: true }).limit(1), + sb.from('closing_captures') + .select('over_odds, under_odds, missed_reason, captured_at') + .eq('sport', sport).eq('player_key', row.player_key).eq('stat', row.stat) + .eq('side', row.side).eq('game_date', row.game_date) + .order('captured_at', { ascending: false }).limit(1), + ]); + const lock = snaps && snaps[0]; + const close = closes && closes[0]; + if (!lock) return null; // no retained lock → nothing to compare + return dclv.computeDirectionalClv({ + side: row.side, + lockFairProb: lock.fair_prob, + lockOverOdds: lock.over_odds, + lockUnderOdds: lock.under_odds, + closeOverOdds: close ? close.over_odds : null, + closeUnderOdds: close ? close.under_odds : null, + missedReason: close ? close.missed_reason : null, + }); + } catch (e) { + console.warn('[ledger] directional CLV failed (settlement continues):', e.message); + return null; + } +} + async function settleLedger(sport, opts = {}) { if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', settled: 0, pending: 0 }; const sb = opts.sb || defaultClient(); @@ -447,6 +494,12 @@ async function settleLedger(sport, opts = {}) { const outcome = settleResult(row.side, actual, row.line); if (!outcome) { pending += 1; continue; } const clv = computeClv(row.side, row.line, row.closing_line); + // Session 64 — DIRECTIONAL CLV is computed HERE, in the settle pass. This + // is the trigger: at settle the game is final, so the close has landed and + // the read is final — the only moment BOTH ends of the comparison exist. + // (Grade + locked prices are written hours earlier; the close at lock. A + // CLV function without this trigger would be a correct dead wire.) + const dclvRes = await computeDirectionalForRow(sb, sp, row, opts); const { error: upErr } = await sb.from('ledger_entries') .update({ outcome, @@ -454,6 +507,13 @@ async function settleLedger(sport, opts = {}) { settled_at: nowIso, clv, clv_result: clvResultOf(clv), + ...(dclvRes ? { + dclv: dclvRes.clv, + dclv_state: dclvRes.state, + dclv_fair_lock: dclvRes.fair_lock, + dclv_fair_close: dclvRes.fair_close, + dclv_computed_at: nowIso, + } : {}), settle_attempts: attempts, settlement_source: res.source || 'date_log', settlement_version: SETTLEMENT_VERSION, diff --git a/tests/unit/directionalClv.test.js b/tests/unit/directionalClv.test.js new file mode 100644 index 0000000..fdbb181 --- /dev/null +++ b/tests/unit/directionalClv.test.js @@ -0,0 +1,132 @@ +/** + * Session 64 — DIRECTIONAL CLV (per-read signal only). + * + * User-facing: a wrong badge is a credibility kill, so the polarity trap is + * proven BEFORE the logic exists. The same line move must produce OPPOSITE + * signs for an over-grade and an under-grade — a move that helps an over hurts + * an under on the very same prop. + * + * NOT an aggregate CLV stat. Aggregate stays held until backtest-proven. + */ +const dclv = require('../../src/services/directionalClv'); + +// Lock: over -110 / under -110 → fair over ≈ 0.500 +// Close: over -150 / under +130 → fair over ≈ 0.598 (market moved toward OVER) +const LOCK = { over: -110, under: -110 }; +const CLOSE_TOWARD_OVER = { over: -150, under: 130 }; + +describe('POLARITY — the badge-inverting trap', () => { + test('a move toward OVER is POSITIVE for an over-graded read', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: LOCK.over, lockUnderOdds: LOCK.under, + closeOverOdds: CLOSE_TOWARD_OVER.over, closeUnderOdds: CLOSE_TOWARD_OVER.under, + }); + expect(r.state).toBe('positive'); + expect(r.clv).toBeGreaterThan(0); + }); + + test('the SAME move is NEGATIVE for an under-graded read', () => { + const r = dclv.computeDirectionalClv({ + side: 'under', lockOverOdds: LOCK.over, lockUnderOdds: LOCK.under, + closeOverOdds: CLOSE_TOWARD_OVER.over, closeUnderOdds: CLOSE_TOWARD_OVER.under, + }); + expect(r.state).toBe('negative'); + expect(r.clv).toBeLessThan(0); + }); + + test('the two sides are exact mirrors — sign is SIDE-BOUND, not line-direction', () => { + const args = { + lockOverOdds: LOCK.over, lockUnderOdds: LOCK.under, + closeOverOdds: CLOSE_TOWARD_OVER.over, closeUnderOdds: CLOSE_TOWARD_OVER.under, + }; + const o = dclv.computeDirectionalClv({ ...args, side: 'over' }); + const u = dclv.computeDirectionalClv({ ...args, side: 'under' }); + expect(o.clv).toBeCloseTo(-u.clv, 6); + }); +}); + +describe('FLAT — threshold is PROBABILITY space, never price space', () => { + test('a sub-threshold fair-prob move is FLAT, not a badge', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, + closeOverOdds: -112, closeUnderOdds: -108, // tiny move + flatThreshold: 0.02, + }); + expect(r.state).toBe('flat'); + }); + + test('a big PRICE move that is a small PROBABILITY move still respects the prob threshold', () => { + // Deep favourite: a 40-cent price move is a small probability move. + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -1000, lockUnderOdds: 700, + closeOverOdds: -1040, closeUnderOdds: 740, + flatThreshold: 0.02, + }); + expect(r.state).toBe('flat'); + expect(Math.abs(r.clv)).toBeLessThan(0.02); + }); +}); + +describe('UNKNOWN — never 0, never a fabricated badge', () => { + test('a missed close is UNKNOWN', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, missedReason: 'missed_window', + }); + expect(r.state).toBe('unknown'); + expect(r.clv).toBeNull(); + }); + + test('no close at all is UNKNOWN, not flat', () => { + const r = dclv.computeDirectionalClv({ side: 'over', lockOverOdds: -110, lockUnderOdds: -110 }); + expect(r.state).toBe('unknown'); + expect(r.clv).toBeNull(); + }); + + test('a doubleheader-ambiguous close is UNKNOWN', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, + closeOverOdds: -150, closeUnderOdds: 130, missedReason: 'doubleheader_ambiguous', + }); + expect(r.state).toBe('unknown'); + }); + + test('a ONE-SIDED close cannot be de-vigged → UNKNOWN, never half-computed', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, + closeOverOdds: -150, closeUnderOdds: null, + }); + expect(r.state).toBe('unknown'); + expect(r.clv).toBeNull(); + }); + + test('a one-sided LOCK is UNKNOWN too — both ends must be de-viggable', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: null, + closeOverOdds: -150, closeUnderOdds: 130, + }); + expect(r.state).toBe('unknown'); + }); +}); + +describe('SAME de-vig method at both ends', () => { + test('an unchanged market yields exactly 0 and reads FLAT', () => { + const r = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, + closeOverOdds: -110, closeUnderOdds: -110, + }); + expect(r.clv).toBeCloseTo(0, 9); + expect(r.state).toBe('flat'); + }); + + test('a pre-de-vigged lock fair prob may be supplied and must agree', () => { + const a = dclv.computeDirectionalClv({ + side: 'over', lockOverOdds: -110, lockUnderOdds: -110, + closeOverOdds: -150, closeUnderOdds: 130, + }); + const b = dclv.computeDirectionalClv({ + side: 'over', lockFairProb: a.fair_lock, + closeOverOdds: -150, closeUnderOdds: 130, + }); + expect(b.clv).toBeCloseTo(a.clv, 9); + }); +});