dcdad60896
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
133 lines
5.0 KiB
JavaScript
133 lines
5.0 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|