Fix the close-attach: de-vig raw prices, not a column that does not exist

Caught by inducing on real rows. The first attach ran and marked 642 rows
market-unavailable while attaching ZERO closes — because it selected a
`fair_prob` column from closing_captures, which has none. That table stores
over_odds and under_odds deliberately (Session 64) so the de-vig can run later
against the same engine the grade-time fair price uses; asking it for a
probability returns nothing and makes every row look closeless.

The de-vig now runs here, via devigTwoWay, which is what makes lock and close
comparable at all. A one-sided capture yields no fair probability and is
correctly not a close.

Repair checked rather than assumed: the 642 markings turn out to be CORRECT —
every one is a game from before closing capture existed on 2026-07-20, so those
rows genuinely have no close and the absence is true. Zero capture-era rows were
wrongly marked. The bug would have mis-marked every future row, which is what
the fix prevents.

Two tests added: the de-vig path with real prices, and a source assertion that
the query never again asks closing_captures for a column it does not have.

Tests 3616 passed / 294 suites.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
Kev
2026-07-20 23:11:52 -04:00
parent c5580f333e
commit 474ebc5d3a
2 changed files with 39 additions and 8 deletions
+13 -3
View File
@@ -315,17 +315,27 @@ async function attachClosingProb(sport, opts = {}) {
if (error) return { updated: 0, error: error.message }; if (error) return { updated: 0, error: error.message };
if (!rows || !rows.length) return { updated: 0, absent: 0 }; if (!rows || !rows.length) return { updated: 0, absent: 0 };
// closing_captures deliberately stores BOTH RAW SIDE PRICES rather than a
// probability (Session 64), so the de-vig runs here — the same devigTwoWay
// the grade-time fair price uses, which is what makes lock and close
// comparable at all. Asking this table for a `fair_prob` column is a bug: it
// has none, and every row then looks closeless.
const { devigTwoWay } = require('../utils/devig');
const { data: caps } = await sb.from('closing_captures') const { data: caps } = await sb.from('closing_captures')
.select('player_key, stat, side, game_date, fair_prob, captured_at, missed_reason') .select('player_key, stat, side, game_date, over_odds, under_odds, captured_at, missed_reason')
.eq('sport', sp).limit(50000); .eq('sport', sp).limit(50000);
// Latest usable capture per identity = the TRUE close. // Latest usable capture per identity = the TRUE close.
const best = new Map(); const best = new Map();
for (const c of caps || []) { for (const c of caps || []) {
if (c.missed_reason || c.fair_prob == null) continue; if (c.missed_reason) continue;
const dv = devigTwoWay(c.over_odds, c.under_odds);
const leg = dv && dv[String(c.side).toLowerCase() === 'under' ? 'under' : 'over'];
const fp = leg && Number.isFinite(leg.fair_prob) ? leg.fair_prob : null;
if (fp == null) continue; // one-sided or unusable → not a close
const k = `${c.player_key}|${c.stat}|${c.side}|${c.game_date}`; const k = `${c.player_key}|${c.stat}|${c.side}|${c.game_date}`;
const prev = best.get(k); const prev = best.get(k);
if (!prev || String(c.captured_at) > String(prev.captured_at)) best.set(k, c); if (!prev || String(c.captured_at) > String(prev.captured_at)) best.set(k, { ...c, fair_prob: fp });
} }
let updated = 0; let absent = 0; let updated = 0; let absent = 0;
+26 -5
View File
@@ -86,23 +86,28 @@ describe('attachClosingProb — the market half', () => {
}; };
const ROW = { id: 'r1', player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20' }; const ROW = { id: 'r1', player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20' };
it('writes the LATEST usable capture as the true close', async () => { 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({ const sb = makeSb({
rows: [ROW], rows: [ROW],
caps: [ caps: [
{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', fair_prob: 0.60, captured_at: '2026-07-20T20:00:00Z' }, { 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', fair_prob: 0.64, captured_at: '2026-07-20T22:50: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' }); const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
expect(out.updated).toBe(1); expect(out.updated).toBe(1);
expect(sb.updates[0].patch.closing_prob).toBe(0.64); // the later one // 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 () => { it('IGNORES refused captures — a missed_reason is not a close', async () => {
const sb = makeSb({ const sb = makeSb({
rows: [ROW], rows: [ROW],
caps: [{ player_key: 'josh bell', stat: 'hits', side: 'over', game_date: '2026-07-20', fair_prob: 0.7, captured_at: 'x', missed_reason: 'one_sided_price' }], 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' }); const out = await ledger.attachClosingProb('mlb', { sb, beforeDate: '2026-07-21' });
expect(out.updated).toBe(0); expect(out.updated).toBe(0);
@@ -124,6 +129,22 @@ describe('attachClosingProb — the market half', () => {
expect(out.updated).toBe(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)', () => { it('only considers rows that have no close yet (write-once)', () => {
const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8'); const src = require('fs').readFileSync(require.resolve('../../src/services/ledgerService'), 'utf8');
const fn = src.slice(src.indexOf('async function attachClosingProb')); const fn = src.slice(src.indexOf('async function attachClosingProb'));