6552281661
The closing_prob funnel collapsed 100k priced captures -> 59 usable. Root cause (VERIFIED against prod, join key is PERFECT with 0 mismatches): - attachClosingProb read closing_captures with .limit(50000) and NO ORDER BY on a 730k-row table that is 86% refusal rows -> saw ~7% for MLB, missed most priced closes and declared 200+ rows closeless that HAD a capture. - market_unavailable_reason was write-once/terminal, so a row wrongly declared (truncated read / premature declaration before the capture was visible) could never recover even once its genuine capture existed. 298 rows (204 MLB + 94 WNBA) were stuck this way. Fix (CLV computation only — no grade/locked_odds/outcome touched): - Read ONLY priced captures (missed_reason IS NULL, both odds NOT NULL), scoped to the candidate rows' game_dates -> small AND complete, no arbitrary truncation. - Drop the market_unavailable exclusion from candidates; make it a re-checkable absence: a genuine close now UPGRADES the row (writes closing_prob, clears the verdict). closing_prob stays write-once (first true close wins). No capture + past game -> still declared absent (honest). No churn on already-absent rows. - New internal trigger POST /api/internal/ledger/attach-closing[/:sport] for backfill + verification (scheduler already runs attach per tick). Recovers ~312 usable closes (59 -> ~371), MLB included. Capture itself was healthy all along (94.9% MLB / 95.8% WNBA per-prop coverage). Full suite 3835 green (17/17 instrument tests incl. 2 new recovery cases), web build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VsztNChZ7vEvSR61AuMhD1
197 lines
9.8 KiB
JavaScript
197 lines
9.8 KiB
JavaScript
/* ============================================================
|
|
Session 70 — THE MEASUREMENT INSTRUMENT.
|
|
|
|
Calibration needs p_win beside the outcome. Market-comparison needs p_win
|
|
beside the close. Both need them ON THE SAME ROW. Before this, p_win lived
|
|
only in a table that never settles and the close lived only in a table with
|
|
no probability — so neither question was answerable.
|
|
============================================================ */
|
|
|
|
const ledger = require('../../src/services/ledgerService');
|
|
|
|
const GRADE = {
|
|
player: 'Josh Bell', stat_type: 'hits', direction: 'over', grade: 'B',
|
|
gradedAt: { line: 0.5, odds: -210, timestamp: '2026-07-21T03:00:00.000Z' },
|
|
p_win: 0.757, fair_prob: 0.633, edge_pct: 12, confidence: 63,
|
|
archetype_axes: { blend: [{ label: 'BOMBER', axis: 'POWER', tier: 'elite' }] },
|
|
};
|
|
const PROP = { player: 'Josh Bell', stat_type: 'hits', game_time: '2026-07-21T23:05:00Z', book: 'betmgm', over_odds: -210, under_odds: 170 };
|
|
|
|
/** rowsFromSnapshot is internal; exercise it through the public writer. */
|
|
async function rowFor(grade, prop = PROP) {
|
|
let captured = null;
|
|
const sb = { from: () => ({ upsert: async (rows) => { captured = rows; return { error: null }; } }) };
|
|
await ledger.recordPipelineGrades('mlb', [grade], [prop], { sb, now: () => '2026-07-21T03:00:00.000Z' });
|
|
return captured && captured[0];
|
|
}
|
|
|
|
describe('lock-time capture — the projection we ACTUALLY made', () => {
|
|
it('writes p_win, the market at lock, and the archetype VECTOR onto the row', async () => {
|
|
const r = await rowFor(GRADE);
|
|
expect(r.p_win).toBe(0.757);
|
|
expect(r.fair_prob_lock).toBe(0.633);
|
|
expect(r.archetype_vector).toEqual(GRADE.archetype_axes);
|
|
expect(r.projection_locked_at).toBe('2026-07-21T03:00:00.000Z');
|
|
});
|
|
|
|
it('lands them on the SAME record as the outcome — the join is the point', async () => {
|
|
const r = await rowFor(GRADE);
|
|
// One row carries identity + p_win + the settle target. Calibration and
|
|
// market-comparison are then plain SQL, not a lossy in-memory join.
|
|
for (const f of ['player_key', 'stat', 'line', 'side', 'game_date', 'p_win']) {
|
|
expect(r[f]).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('stores the VECTOR, not a label — a label cannot attribute anything', async () => {
|
|
const withBlend = await rowFor({ ...GRADE, archetype_axes: undefined, archetype_blend: [{ archetype: 'BOMBER', weight: 1 }], archetype: 'BOMBER' });
|
|
expect(withBlend.archetype_vector.blend).toHaveLength(1);
|
|
});
|
|
|
|
it('a grade with no archetype stores NULL, not an empty object', async () => {
|
|
const r = await rowFor({ ...GRADE, archetype_axes: undefined, archetype_blend: undefined, archetype: undefined });
|
|
expect(r.archetype_vector).toBeNull();
|
|
});
|
|
|
|
it('a grade with no p_win stores NULL, never 0', async () => {
|
|
const r = await rowFor({ ...GRADE, p_win: undefined, fair_prob: undefined });
|
|
expect(r.p_win).toBeNull();
|
|
expect(r.fair_prob_lock).toBeNull();
|
|
});
|
|
|
|
it('is IMMUTABLE — the writer never overwrites an existing lock', async () => {
|
|
// recordPipelineGrades upserts with ignoreDuplicates, so a re-run cannot
|
|
// rewrite p_win. Re-deriving at settle would measure a projection we never
|
|
// made.
|
|
let opts = null;
|
|
const sb = { from: () => ({ upsert: async (_r, o) => { opts = o; return { error: null }; } }) };
|
|
await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb });
|
|
expect(opts.ignoreDuplicates).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('attachClosingProb — the market half', () => {
|
|
const makeSb = ({ rows, caps }) => {
|
|
const updates = [];
|
|
return {
|
|
updates,
|
|
from: (t) => ({
|
|
select: () => ({
|
|
is: function () { return this; }, eq: function () { return this; },
|
|
not: function () { return this; }, in: function () { return this; },
|
|
limit: async () => ({ data: t === 'ledger_entries' ? rows : caps, error: null }),
|
|
}),
|
|
update: (patch) => ({ eq: async (_c, id) => { updates.push({ id, patch }); return { error: null }; } }),
|
|
}),
|
|
};
|
|
};
|
|
const ROW = { id: 'r1', player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20' };
|
|
|
|
it('DE-VIGS the raw both-side close prices (the table stores no probability)', async () => {
|
|
// closing_captures deliberately stores over_odds/under_odds, NOT a
|
|
// probability. Selecting a `fair_prob` column from it returns nothing and
|
|
// makes every row look closeless — that shipped once and marked rows
|
|
// market-unavailable without ever reading a capture.
|
|
const sb = makeSb({
|
|
rows: [ROW],
|
|
caps: [
|
|
{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', over_odds: -150, under_odds: 120, captured_at: '2026-07-20T20:00:00Z' },
|
|
{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', over_odds: -210, under_odds: 170, captured_at: '2026-07-20T22:50:00Z' },
|
|
],
|
|
});
|
|
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(out.updated).toBe(1);
|
|
// The LATER capture is the true close, de-vigged from -210/+170.
|
|
expect(sb.updates[0].patch.closing_prob).toBe(0.647);
|
|
});
|
|
|
|
it('IGNORES refused captures — a missed_reason is not a close', async () => {
|
|
const sb = makeSb({
|
|
rows: [ROW],
|
|
caps: [{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', over_odds: -210, under_odds: 170, captured_at: 'x', missed_reason: 'one_sided_price' }],
|
|
});
|
|
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(out.updated).toBe(0);
|
|
expect(sb.updates[0].patch.market_unavailable_reason).toBe('no_usable_close');
|
|
});
|
|
|
|
it('NEVER imputes a closing line — absent is marked, not filled', async () => {
|
|
const sb = makeSb({ rows: [ROW], caps: [] });
|
|
await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(sb.updates[0].patch.closing_prob).toBeUndefined();
|
|
expect(sb.updates[0].patch.market_unavailable_reason).toBe('no_usable_close');
|
|
});
|
|
|
|
it('does NOT declare a future game closeless — a close can still arrive', async () => {
|
|
// Premature absence is as dishonest as imputation, in the other direction.
|
|
const sb = makeSb({ rows: [{ ...ROW, game_date: '2026-07-25' }], caps: [] });
|
|
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(sb.updates).toHaveLength(0);
|
|
expect(out.updated).toBe(0);
|
|
});
|
|
|
|
it('a one-sided capture is NOT a close (de-vig needs both sides)', async () => {
|
|
const sb = makeSb({
|
|
rows: [ROW],
|
|
caps: [{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', over_odds: -210, under_odds: null, captured_at: 'x' }],
|
|
});
|
|
await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(sb.updates[0].patch.market_unavailable_reason).toBe('no_usable_close');
|
|
});
|
|
|
|
it('never asks closing_captures for a column it does not have', () => {
|
|
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
|
const fn = src.slice(src.indexOf('async function attachClosingProb'));
|
|
expect(fn).toMatch(/from\('closing_captures'\)[\s\S]{0,200}over_odds, under_odds/);
|
|
expect(fn).not.toMatch(/closing_captures'\)[\s\S]{0,200}fair_prob,/);
|
|
});
|
|
|
|
it('only considers rows that have no close yet (write-once)', () => {
|
|
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
|
const fn = src.slice(src.indexOf('async function attachClosingProb'));
|
|
expect(fn).toMatch(/\.is\('closing_prob', null\)/);
|
|
});
|
|
|
|
// CLV instrument repair — the write-once verdict on market_unavailable_reason
|
|
// was permanent, so a row wrongly declared closeless (truncated read /
|
|
// premature declaration) could never recover even though its capture existed.
|
|
it('RECOVERS a row previously declared market_unavailable when a real close now exists', async () => {
|
|
const sb = makeSb({
|
|
rows: [{ ...ROW, market_unavailable_reason: 'no_usable_close' }],
|
|
caps: [{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', over_odds: -210, under_odds: 170, captured_at: '2026-07-20T22:50:00Z' }],
|
|
});
|
|
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(out.updated).toBe(1);
|
|
expect(out.recovered).toBe(1);
|
|
expect(sb.updates[0].patch.closing_prob).toBe(0.647);
|
|
// the buggy verdict is CLEARED, not left stale beside a real close.
|
|
expect(sb.updates[0].patch.market_unavailable_reason).toBeNull();
|
|
});
|
|
|
|
it('leaves an already-declared row untouched when there is STILL no capture (no churn, no re-write)', async () => {
|
|
const sb = makeSb({ rows: [{ ...ROW, market_unavailable_reason: 'no_usable_close' }], caps: [] });
|
|
const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
|
|
expect(out.updated).toBe(0);
|
|
expect(out.absent).toBe(0);
|
|
expect(sb.updates).toHaveLength(0); // no write at all
|
|
});
|
|
});
|
|
|
|
describe('what stays measurable when the market is absent', () => {
|
|
it('calibration needs only p_win + outcome; market-comparison needs the close', () => {
|
|
// Encoded as a contract check: the three fields are independent columns, so
|
|
// a row missing the close still scores for calibration.
|
|
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
|
|
expect(src).toMatch(/market_unavailable_reason/);
|
|
expect(src).toMatch(/p_win: numOrNull\(g\.p_win\)/);
|
|
});
|
|
});
|
|
|
|
describe('scheduler wiring', () => {
|
|
const src = require('fs').readFileSync(require.resolve('../../src/snapshotScheduler'), 'utf8');
|
|
it('runs the close attach on every configured sport', () => {
|
|
expect(src).toContain('attachClosingProb');
|
|
expect(src).toMatch(/for \(const sp of cadence\.ALL_SPORTS\)/);
|
|
});
|
|
});
|