Directional CLV — per-read signal, side-bound, with a real compute trigger

PER-READ ONLY. No aggregate CLV stat, no CLV marketing un-held.

PHASE 0 FINDING THAT SHAPED THE BUILD: the LOCK end must come from
model_snapshots, NOT ledger_entries. The ledger stores only the graded
side's locked_odds (694 rows, single-side) which CANNOT be de-vigged.
model_snapshots retains BOTH side prices on 520/520 graded rows AND an
already-de-vigged fair_prob on 520/520 — produced by the same
devig.devigTwoWay the close uses, so "same method both ends" holds by
construction rather than by convention.

THE COMPUTE TRIGGER is the SETTLE PASS (ledgerService.settleLedger). 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 and locked prices
are written hours earlier and the close at lock, so without this trigger a
correct CLV function would simply never populate.

JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date),
WITHOUT line — a close that moved off the graded line is the entire point.
Verified clean earlier: 164 identity groups, zero ambiguity. Rows whose
capture refused (missed/ambiguous/one-sided) are UNKNOWN for CLV, matching
the capture layer's own honesty.

SIGN IS SIDE-BOUND and proven by test before the logic existed — the
badge-inverting trap. Same market move:
  OVER-graded  -> positive  clv +0.0800  (fair .500 -> .580)
  UNDER-graded -> negative  clv -0.0800  (fair .500 -> .420)
exact mirrors. FLAT is a PROBABILITY-space threshold always (1.5pp): a
40-cent price move on a deep favourite reads flat, correctly, because
price space lies about magnitude.

UNKNOWN is a first-class state, never 0 — zero asserts "the market did not
move", which is a claim; a missing close asserts nothing. describe()
returns null for unknown so a badge can never render for it.

migration 030 adds dclv/dclv_state/dclv_fair_lock/dclv_fair_close/
dclv_computed_at as NEW columns rather than reusing the C4 clv fields —
conflating a verified per-read signal with a known-broken one would be the
worst kind of quiet lie.

Caught pre-deploy: the trigger call passed `deps`, which is not in scope in
settleLedger (it uses `opts`) — a ReferenceError at the call site, outside
the helper's try/catch, which broke two settlement suites.

Suite 286/3447 green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
Kev
2026-07-20 13:17:34 -04:00
parent b1ed675500
commit dcdad60896
3 changed files with 303 additions and 0 deletions
+111
View File
@@ -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 };
+60
View File
@@ -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,
+132
View File
@@ -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);
});
});