diff --git a/src/services/ledgerService.js b/src/services/ledgerService.js index 5d67316..adca9b6 100644 --- a/src/services/ledgerService.js +++ b/src/services/ledgerService.js @@ -315,17 +315,27 @@ async function attachClosingProb(sport, opts = {}) { if (error) return { updated: 0, error: error.message }; 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') - .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); // Latest usable capture per identity = the TRUE close. const best = new Map(); 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 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; diff --git a/tests/unit/projectionInstrument.test.js b/tests/unit/projectionInstrument.test.js index bf2bad8..11114af 100644 --- a/tests/unit/projectionInstrument.test.js +++ b/tests/unit/projectionInstrument.test.js @@ -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' }; - 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({ rows: [ROW], 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', 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: -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); - 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 () => { const sb = makeSb({ 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' }); expect(out.updated).toBe(0); @@ -124,6 +129,22 @@ describe('attachClosingProb — the market half', () => { 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'));