Files
vyndr/src/services/directionalClv.js
T
builtbykev dcdad60896 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
2026-07-20 13:17:34 -04:00

112 lines
4.6 KiB
JavaScript

'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 };