diff --git a/BUILD-STATE.md b/BUILD-STATE.md index 2d6cd99..db41752 100755 --- a/BUILD-STATE.md +++ b/BUILD-STATE.md @@ -3,6 +3,34 @@ ## Last Updated 2026-08-12 +## Fix B1 (2026-08-12) — dCLV close-leg filter ✅ +4,780 tests / 372 suites, web build exit 0. **Grade untouched (0 grade-related +diff lines); no rows deleted or archived.** +- Added `.is('missed_reason', null)` + both-odds-not-null to the close leg + (`ledgerService:625`), matching `attachClosingProb:455`. Filter applied BEFORE + the limit, so it is "latest PRICED" not "latest, hopefully priced". +- **dCLV: 100% `unknown` → flat 45.8% / negative 23.0% / positive 22.8% / + unknown 8.3%** on 600 real settled rows. ClvBadge (analyst/desk) will render + **MOVED TOWARD US 114 · MOVED AWAY 117** per 600 rows — ~38% of rows gain a + badge that showed nothing before. +- 8 regression tests, incl. one proving the fixture actually exercises the old + bug (an unfiltered read returns `unknown` on the same data). +- **`missed_window` is now genuinely unread** — the precondition the RECLAIM + order correctly stopped on. + +### RE-STAMP DECISION (described, NOT run) +14,083 settled rows carry a persisted `dclv_state='unknown'`. `settleLedger` is +first-computation-wins, so they will NOT recompute. To correct them: +`UPDATE ledger_entries SET dclv_computed_at = NULL WHERE dclv_state = 'unknown'` +then re-run the settle pass — it would recompute from the (unchanged, immutable) +`closing_captures` and `model_snapshots` history. Arguments both ways: +FOR — those rows are wrong, and the history to fix them exists. +AGAINST — first-computation-wins exists so a badge a user saw never flips; +these rows currently show NO badge, so a re-stamp only ADDS badges (it cannot +flip a shown one), which weakens the objection considerably. +Recommend running it, as its own order, AFTER the fix has been live a full slate +and the new-row distribution is confirmed against the simulation. + ## DEPLOY (2026-08-12 03:10 UTC) — A1–A7 LIVE, grade unchanged ✅ Commit **f61ec6b** pushed to gitea, auto-deployed by Coolify, first slot wrote 2026-08-12T03:10:52Z. Pre-deploy gate: 4,772 tests / 371 suites, web build exit diff --git a/CLAUDE.md b/CLAUDE.md index cce4673..2d3e4e5 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2085,6 +2085,37 @@ phased plan in the Session-57 conversation / BUILD-STATE Next section). on the cron and had last drained through 08-08. Accrual counts must be read off SETTLED rows, never graded ones. +## B1 — the dCLV close leg (non-obvious) +- **`computeDirectionalForRow` was the UNFIXED TWIN of the A1 CLV bug.** Its + sibling `attachClosingProb` (`ledgerService:455`) got `.is('missed_reason', + null)` during the CLV instrument repair; the close leg at `:625` never did, and + **nothing tested it** — which is exactly why it survived a fix made a few lines + above it. Both reads now filter, and a test asserts EVERY + `from('closing_captures')` read carries the filter. +- **The mechanism is a clock artifact, not a data gap.** `buildCaptureRows` + records a refusal for every prop × book × **both sides** on every ~20-minute + cycle AFTER first pitch, so a `missed_window` row ALWAYS has a later + `captured_at` than the last real price. `latest-first` without a filter + returned a refusal on **26,448 of 26,448** identity groups; `computeDirectionalClv` + refuses on a `missedReason`; hence `dclv_state = 'unknown'` on 100% of rows + since Session 64. **Filter BEFORE the limit** — "latest of the priced rows", + never "the latest row, hopefully priced". +- **MEASURED before/after on 600 real settled rows:** 100% `unknown` → + **flat 275 (45.8%) · negative 138 (23.0%) · positive 137 (22.8%) · unknown 50 + (8.3%)**. Badge render: **MOVED TOWARD US 114 · MOVED AWAY 117 · no badge 369**. + The near-symmetry (114 vs 117) is the honest shape for a market that mostly + does not move — a lopsided result would have been the warning sign. +- **`positive` ≠ badge.** 137 positive but 114 rendered: `clvBadge` suppresses a + bare label when `locked_odds`/`closing_odds` can't form the receipt. Flat and + unknown render nothing by design. +- **EXISTING ROWS DO NOT RECOMPUTE.** `settleLedger:792` is + first-computation-wins (`row.dclv_computed_at ? null : compute…`) so a badge a + user saw can never flip. The 14,083 rows already stamped `unknown` stay + `unknown`; only rows settled from here carry real values. A re-stamp is a + separate, deliberate decision — described in BUILD-STATE, not run. +- **This makes `missed_window` genuinely unread**, which is the precondition the + reclaim order stopped on. The reclaim is still downstream and still needs R2. + ## Active Skills - vyndr-voice (all user-facing output) - prop-analysis (grading methodology) diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 01e40d9..2deddb7 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -605,10 +605,25 @@ async function computeDirectionalForRow(sb, sport, row, deps = {}) { .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), + // ── FIX B1 — READ ONLY PRICED CAPTURES ────────────────────────── + // This select had no `missed_reason` filter while its sibling + // `attachClosingProb` (above) has had one since the CLV instrument repair. + // `closing_captures` records a refusal for every prop x book x side on + // every ~20-minute cycle AFTER first pitch, so those rows always carry a + // LATER `captured_at` than the last real price — and `latest-first` + // therefore returned a refusal on 100% of identity groups (measured: + // 26,448 of 26,448 over five days). `computeDirectionalClv` refuses on a + // `missedReason`, which is why `dclv_state` has been 'unknown' on every + // row since Session 64. + // + // The filter belongs BEFORE the limit: latest of the PRICED rows, not + // "the latest row, hopefully priced". 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) + .is('missed_reason', null) + .not('over_odds', 'is', null).not('under_odds', 'is', null) .order('captured_at', { ascending: false }).limit(1), ]); const lock = snaps && snaps[0]; @@ -1085,5 +1100,9 @@ module.exports = { rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor, dateET, sideOf, oddsForSide, isConfigured, CONFLICT, teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS, + // B1 — exported so the close leg is directly testable. It was untestable, + // and that is why the missing missed_reason filter survived while its + // sibling was fixed a few lines above it. + computeDirectionalForRow, }, }; diff --git a/tests/unit/dclvCloseLeg.test.js b/tests/unit/dclvCloseLeg.test.js new file mode 100644 index 0000000..b6b854a --- /dev/null +++ b/tests/unit/dclvCloseLeg.test.js @@ -0,0 +1,144 @@ +'use strict'; + +/** + * B1 — the dCLV close leg reads PRICED captures only. + * + * THE TEST THE TWIN NEVER HAD. `attachClosingProb` got a `missed_reason` filter + * during the CLV instrument repair and a comment explaining why; + * `computeDirectionalForRow` did not, and nothing tested it, so the defect + * survived while its sibling was fixed a foot away. + * + * The mechanism: `closing_captures` records a refusal for every prop x book x + * side on every ~20-minute cycle AFTER first pitch, so a refusal ALWAYS carries + * a later `captured_at` than the last real price. `latest-first` without a + * filter therefore returns a refusal every time — measured 26,448 of 26,448 + * identity groups — and `computeDirectionalClv` refuses on a `missedReason`. + */ + +const ledgerService = require('../../src/services/ledgerService'); + +/** + * A fake client that records the filters applied to `closing_captures` and + * serves rows honouring them, so an unapplied filter fails the test. + */ +function fakeSb({ snaps, closes }) { + const seen = { closing: null }; + return { + seen, + from(table) { + const st = { table, is: {}, notNull: [], order: null, asc: true }; + const q = { + select() { return q; }, + eq() { return q; }, + is(col, val) { st.is[col] = val; return q; }, + not(col, op, val) { if (op === 'is' && val === null) st.notNull.push(col); return q; }, + order(col, o) { st.order = col; st.asc = !o || o.ascending !== false; return q; }, + limit(n) { + if (table === 'model_snapshots') { + const rows = [...snaps].sort((a, b) => (st.asc ? 1 : -1) * String(a.captured_at).localeCompare(String(b.captured_at))); + return Promise.resolve({ data: rows.slice(0, n) }); + } + seen.closing = { ...st }; + let rows = [...closes]; + // Honour the filters the production select applies. + if ('missed_reason' in st.is && st.is.missed_reason === null) rows = rows.filter((r) => r.missed_reason == null); + for (const c of st.notNull) rows = rows.filter((r) => r[c] != null); + rows.sort((a, b) => (st.asc ? 1 : -1) * String(a.captured_at).localeCompare(String(b.captured_at))); + return Promise.resolve({ data: rows.slice(0, n) }); + }, + }; + return q; + }, + }; +} + +const ROW = { player_key: 'luis arraez', stat: 'total_bases', side: 'over', game_date: '2026-08-10' }; + +// The lock: both side prices, so it is de-viggable. +const SNAPS = [{ fair_prob: 0.52, over_odds: -110, under_odds: -110, captured_at: '2026-08-10T14:00:00Z' }]; + +// The production shape: a real price, then refusals stamped after first pitch. +const CLOSES = [ + { over_odds: -140, under_odds: 120, missed_reason: null, captured_at: '2026-08-10T23:41:20Z' }, + { over_odds: null, under_odds: null, missed_reason: 'missed_window', captured_at: '2026-08-11T01:21:09Z' }, + { over_odds: null, under_odds: null, missed_reason: 'missed_window', captured_at: '2026-08-11T03:21:09Z' }, +]; + +const compute = ledgerService.__internals + ? ledgerService.__internals.computeDirectionalForRow + : null; + +describe('the close leg selects the latest PRICED capture', () => { + it('applies the missed_reason filter (the fix)', async () => { + const sb = fakeSb({ snaps: SNAPS, closes: CLOSES }); + await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW); + expect(sb.seen.closing.is).toHaveProperty('missed_reason', null); + }); + + it('requires BOTH side prices, matching its sibling', async () => { + const sb = fakeSb({ snaps: SNAPS, closes: CLOSES }); + await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW); + expect(sb.seen.closing.notNull).toEqual(expect.arrayContaining(['over_odds', 'under_odds'])); + }); + + it('still takes the LATEST of the priced rows, not the earliest', async () => { + const twoPriced = [ + { over_odds: -200, under_odds: 170, missed_reason: null, captured_at: '2026-08-10T18:00:00Z' }, + ...CLOSES, + ]; + const sb = fakeSb({ snaps: SNAPS, closes: twoPriced }); + const out = await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW); + // -140/120 (23:41) not -200/170 (18:00): the later price wins. + expect(sb.seen.closing.asc).toBe(false); + expect(out.state).not.toBe('unknown'); + }); + + it('RETURNS A REAL STATE where the unfiltered read returned unknown', async () => { + const sb = fakeSb({ snaps: SNAPS, closes: CLOSES }); + const out = await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW); + expect(['positive', 'negative', 'flat']).toContain(out.state); + expect(out.clv).not.toBeNull(); + expect(out.fair_close).not.toBeNull(); + }); + + it('THE REGRESSION: an unfiltered read would have returned unknown on this data', async () => { + // Proves the fixture actually exercises the bug — a test that passes on data + // where the old code also worked proves nothing. + const dclv = require('../../src/services/directionalClv'); + const latestUnfiltered = [...CLOSES].sort((a, b) => String(b.captured_at).localeCompare(String(a.captured_at)))[0]; + const old = dclv.computeDirectionalClv({ + side: ROW.side, lockFairProb: 0.52, + closeOverOdds: latestUnfiltered.over_odds, closeUnderOdds: latestUnfiltered.under_odds, + missedReason: latestUnfiltered.missed_reason, + }); + expect(old.state).toBe('unknown'); + expect(old.reason).toBe('missed_window'); + }); + + it('a genuinely closeless identity is STILL unknown — absence is preserved', async () => { + const onlyRefusals = CLOSES.filter((c) => c.missed_reason); + const sb = fakeSb({ snaps: SNAPS, closes: onlyRefusals }); + const out = await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW); + expect(out.state).toBe('unknown'); + expect(out.reason).toBe('no_usable_close'); + }); + + it('no retained lock => null, never a fabricated comparison', async () => { + const sb = fakeSb({ snaps: [], closes: CLOSES }); + expect(await ledgerService.__internals.computeDirectionalForRow(sb, 'mlb', ROW)).toBeNull(); + }); +}); + +describe('the two selects agree (the twin is no longer a twin)', () => { + const src = require('fs').readFileSync( + require('path').join(__dirname, '..', '..', 'src/services/ledgerService.js'), 'utf8'); + + it('BOTH closing_captures reads filter missed_reason', () => { + const reads = src.split("from('closing_captures')").slice(1); + expect(reads.length).toBeGreaterThanOrEqual(2); + for (const r of reads) { + const head = r.slice(0, 500); + expect(head).toMatch(/\.is\('missed_reason', null\)/); + } + }); +});