Files
vyndr/tests/unit/ledgerService.test.js
T
builtbykev 270c4db47a STOP voiding on player-absence — it destroyed real results
Correctness fix to code I shipped minutes ago. The induced live settle
pass voided 64 rows as 'player_dnp' and a large share of them are WRONG:
the Jul 18 set is everyday starters (Freeman, Bellinger, Tucker, Chisholm,
Conforto). They played.

ROOT CAUSE — and my Phase 0 diagnosis was wrong. It is not DNP. The
ledger row's game_date is WRONG. ledgerService derives game_date from the
GRADE timestamp when the feed carries no game_time, and a 01:00/03:00 UTC
snapshot is 21:00/23:00 ET the PREVIOUS day, so rows get labelled with the
previous ET date. Verified against fresh season logs (cache disabled, so
not staleness; found:true, so not name resolution):
  Freddie Freeman  played Jul 17 and Jul 19 (x2, doubleheader) — NOT Jul 18
  Steven Kwan      played Jul 18 (x2) and Jul 19               — NOT Jul 17
Settlement was correct to find no game on the labelled date. My void logic
then converted a data-labelling bug into destroyed results.

FIX: never void on player-absence alone. Voiding now requires POSITIVE
evidence — the games themselves postponed/cancelled. Absence returns
'unknown' (reason player_absent_unconfirmed), so the row retries and ages
out to 'unrecoverable' at the cap. We cannot distinguish "did not play"
from "mislabelled date", so we must not claim DNP. Both terminal states
are excluded from the record denominator either way.

Window-decay remains genuinely fixed (full season log vs a rolling
window), and terminal states still prevent immortal rows.

NOT DONE HERE: the 64 wrong voids are still in the table, and the
game_date derivation is still wrong at the source. Both are reported for
the table — no healing in this order.

Suite green, build exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
2026-07-20 03:10:34 -04:00

418 lines
19 KiB
JavaScript

// Session 58 (Phase 1) — ledgerService: the truth infrastructure.
// Everything runs against a fake Supabase client — zero network, zero env.
const ledger = require('../../src/services/ledgerService');
const { rowsFromSnapshot, computeClv, clvResultOf } = ledger.__internals;
// ---- fake Supabase client ------------------------------------------------
// Minimal chainable stub for the exact query shapes ledgerService uses.
function fakeSb() {
const calls = { upserts: [], updates: [] };
const state = { selectResults: [], selectCursor: 0, countResult: 0 };
function builder() {
const b = {
_update: null,
upsert(rows, opts) {
calls.upserts.push({ rows, opts });
return Promise.resolve({ error: null });
},
update(values) { b._update = values; return b; },
select() { return b; },
eq() { return b; },
is() { return b; },
not() { return b; },
lt() { return b; },
gte() { return b; },
gt() { return b; },
in(col, ids) {
if (b._update) {
calls.updates.push({ values: b._update, ids });
return Promise.resolve({ error: null });
}
return terminal();
},
order() { return b; },
limit() { return terminal(); },
then(resolve, reject) { return terminal().then(resolve, reject); },
};
function terminal() {
if (b._update) {
calls.updates.push({ values: b._update });
return Promise.resolve({ error: null });
}
const data = state.selectResults[state.selectCursor] ?? [];
state.selectCursor += 1;
return Promise.resolve({ data, error: null, count: state.countResult });
}
return b;
}
return { from: () => builder(), _calls: calls, _state: state };
}
// ---- fixtures --------------------------------------------------------------
const NOW = '2026-07-10T18:00:00.000Z';
const GRADE = {
player: 'Aaron Judge', player_name: 'Aaron Judge', stat_type: 'home_runs',
line: 0.5, direction: 'over', grade: 'A', confidence: 78, edge_pct: 12.4,
projection: 0.9,
gradedAt: { line: 0.5, odds: -115, timestamp: NOW },
};
const PROP = {
player: 'Aaron Judge', stat_type: 'home_runs', line: 0.5,
home_team: 'NYY', away_team: 'BOS', game_time: '2026-07-10T23:05:00Z',
book: 'draftkings', over_odds: -115, under_odds: -105,
};
describe('rowsFromSnapshot — the write shape', () => {
test('builds a public row with REAL captured market values', () => {
const rows = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(rows).toHaveLength(1);
const r = rows[0];
expect(r.user_id).toBeNull();
expect(r.player_key).toBe('aaron judge');
expect(r.line).toBe(0.5); // the locked book line
expect(r.locked_odds).toBe('-115'); // real odds at grade time
expect(r.book).toBe('draftkings');
expect(r.model_value).toBe(0.9); // MODEL output, distinct from line
expect(r.game_date).toBe('2026-07-10'); // 23:05Z = 19:05 ET same day
expect(r.game_id).toBe('mlb:2026-07-10:BOS@NYY');
});
// Session 59 — team/opponent addendum (migration 020).
test('captures team + opponent from the real feed (full-name MLB teams)', () => {
const grade = { ...GRADE, team: 'New York Yankees' };
const prop = { ...PROP, home_team: 'New York Yankees', away_team: 'Boston Red Sox' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('New York Yankees');
expect(r.opponent).toBe('Boston Red Sox');
});
test('opponent is NEVER guessed: team not in the game → opponent null', () => {
const grade = { ...GRADE, team: 'Tampa Bay Rays' };
const prop = { ...PROP, home_team: 'Milwaukee Brewers', away_team: 'Pittsburgh Pirates' };
const [r] = rowsFromSnapshot('mlb', [grade], [prop], NOW);
expect(r.team).toBe('Tampa Bay Rays');
expect(r.opponent).toBeNull();
});
test('no team from the stats resolve → both null (absent beats wrong)', () => {
const [r] = rowsFromSnapshot('mlb', [GRADE], [PROP], NOW);
expect(r.team).toBeNull();
expect(r.opponent).toBeNull();
});
test('refused reads and grade-less entries never become rows', () => {
const refused = { ...GRADE, grade: null, insufficient_data: true };
const gradeless = { ...GRADE, grade: undefined };
expect(rowsFromSnapshot('mlb', [refused, gradeless], [PROP], NOW)).toEqual([]);
});
test('a grade with no captured line is dropped — never a fabricated line', () => {
const noLine = { ...GRADE, line: undefined, gradedAt: { line: null, odds: null, timestamp: NOW } };
expect(rowsFromSnapshot('mlb', [noLine], [PROP], NOW)).toEqual([]);
});
});
describe('recordPipelineGrades — idempotent upsert', () => {
test('upserts with ignoreDuplicates on the dedupe constraint', async () => {
const sb = fakeSb();
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], { sb, now: () => NOW });
expect(res.written).toBe(1);
expect(sb._calls.upserts).toHaveLength(1);
const { opts } = sb._calls.upserts[0];
expect(opts.onConflict).toBe('user_id,player_key,stat,line,side,game_id');
expect(opts.ignoreDuplicates).toBe(true); // re-runs never overwrite the lock
});
test('no-ops without supabase env when no client injected', async () => {
const res = await ledger.recordPipelineGrades('mlb', [GRADE], [PROP], {});
expect(res.skipped).toBeTruthy();
});
});
describe('computeClv — signed by side (Phase 1 amendment)', () => {
test('OVER: closing below the locked line = positive = beat', () => {
expect(computeClv('over', 1.5, 1.0)).toBe(0.5);
expect(clvResultOf(computeClv('over', 1.5, 1.0))).toBe('beat');
});
test('OVER: closing above the locked line = negative = faded', () => {
expect(computeClv('over', 1.5, 2.0)).toBe(-0.5);
expect(clvResultOf(computeClv('over', 1.5, 2.0))).toBe('faded');
});
test('UNDER: inverse signs', () => {
expect(computeClv('under', 1.5, 2.0)).toBe(0.5); // market rose toward the under
expect(clvResultOf(computeClv('under', 1.5, 2.0))).toBe('beat');
expect(computeClv('under', 1.5, 1.0)).toBe(-0.5);
expect(clvResultOf(computeClv('under', 1.5, 1.0))).toBe('faded');
});
test('unchanged line = flat; missing closing = null (absent, not zero)', () => {
expect(clvResultOf(computeClv('over', 1.5, 1.5))).toBe('flat');
expect(computeClv('over', 1.5, null)).toBeNull();
expect(clvResultOf(null)).toBeNull();
});
});
describe('settleLedger — outcome + CLV vs the real result', () => {
test('settles hit/miss/push from the game log and stamps CLV', async () => {
const sb = fakeSb();
// 1st select: open ids; 2nd: full rows.
sb._state.selectResults = [
[{ id: 'r1' }, { id: 'r2' }],
[
{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: 0.5, game_date: '2026-07-09' },
{ id: 'r2', player_name: 'Aaron Judge', stat: 'hits', line: 1.5, side: 'over', closing_line: 2.5, game_date: '2026-07-09' },
],
];
const getPlayerStats = async () => ({
found: true,
last10: [{ date: '2026-07-09', stat: { homeRuns: 1, hits: 1 } }],
});
const res = await ledger.settleLedger('mlb', { sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10' });
expect(res.settled).toBe(2);
const byOutcome = sb._calls.updates.map((u) => u.values);
expect(byOutcome[0]).toMatchObject({ outcome: 'hit', actual_value: 1, clv: 0, clv_result: 'flat' });
// 1 hit vs o1.5 = miss; locked 1.5 closed 2.5 for an over = faded.
expect(byOutcome[1]).toMatchObject({ outcome: 'miss', actual_value: 1, clv: -1, clv_result: 'faded' });
});
// Session 64 — BEHAVIOUR CHANGED ON PURPOSE. A missing game-log row used to
// mean "pending forever". It now depends on what the day's games actually did.
test('no row + game FINAL → NOT voided (date may be wrong); attempts bump', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1' }],
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
];
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Final' }],
});
expect(res.voided).toBe(0);
expect(res.settled).toBe(0);
expect(res.pending).toBe(1);
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
});
test('no row + game NOT FINAL → stays pending, never voided', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1' }],
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09' }],
];
const getPlayerStats = async () => ({ found: true, last10: [{ date: '2026-07-08', stat: { homeRuns: 2 } }] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Suspended' }],
});
expect(res.pending).toBe(1);
expect(res.voided).toBe(0);
// only the attempt counter is touched — no outcome written
expect(sb._calls.updates[0].values).toMatchObject({ settle_attempts: 1 });
expect(sb._calls.updates[0].values.outcome).toBeUndefined();
});
test('undetermined state becomes UNRECOVERABLE at the retry cap', async () => {
const sb = fakeSb();
sb._state.selectResults = [
[{ id: 'r1' }],
[{ id: 'r1', player_name: 'Aaron Judge', stat: 'home_runs', line: 0.5, side: 'over', closing_line: null, game_date: '2026-07-09', settle_attempts: 3 }],
];
const getPlayerStats = async () => ({ found: true, last10: [] });
const res = await ledger.settleLedger('mlb', {
sb, getPlayerStats, now: () => NOW, beforeDate: '2026-07-10',
getSchedule: async () => [{ status: 'Final' }], // player absent, unconfirmed
});
expect(res.unrecoverable).toBe(1);
expect(sb._calls.updates[0].values).toMatchObject({ outcome: 'unrecoverable' });
});
});
describe('captureClosing — real feed values only', () => {
test('updates open rows from the current odds; unmatched rows untouched', async () => {
const sb = fakeSb();
sb._state.selectResults = [[
{ id: 'r1', player_key: 'aaron judge', stat: 'home_runs', side: 'over' },
{ id: 'r2', player_key: 'ghost player', stat: 'hits', side: 'over' },
]];
const res = await ledger.captureClosing('mlb', [PROP], { sb, gameDate: '2026-07-10' });
expect(res.updated).toBe(1); // only the matched prop
expect(sb._calls.updates[0].values).toEqual({ closing_line: 0.5, closing_odds: '-115' });
expect(sb._calls.updates[0].ids).toEqual(['r1']);
});
});
describe('getModelAggregate — never a % under min sample', () => {
// beat_close/CLV are suppressed by default (item 7 — CLV capture broken until
// C4). These tests exercise the CLV MATH, so enable the reliable flag; a
// separate test below locks the default-suppressed behavior.
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
test('beat_close is SUPPRESSED by default until C4 (CLV capture broken)', async () => {
delete process.env.CLV_CAPTURE_RELIABLE; // default state
const sb = fakeSb();
sb._state.selectResults = [[
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
]];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.hit_pct).toBe(65); // hit rate still renders (it's real)
expect(agg.beat_close_pct).toBeNull(); // BEAT CLOSE hidden — measured-wrong
expect(agg.clv_distribution).toBeNull();
process.env.CLV_CAPTURE_RELIABLE = '1'; // restore for the rest of the block
});
test('below 20 settles → hit_pct/beat_close_pct null, counts real', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
];
sb._state.countResult = 42;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(5);
expect(agg.hits).toBe(5);
expect(agg.hit_pct).toBeNull(); // n<20 — RECORD BUILDING
expect(agg.beat_close_pct).toBeNull();
expect(agg.pending).toBe(42);
});
test('at 20+ settles → both percentages render', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 13 }, () => ({ outcome: 'hit', clv_result: 'beat' })),
...Array.from({ length: 7 }, () => ({ outcome: 'miss', clv_result: 'faded' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 3;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.settled).toBe(20);
expect(agg.hit_pct).toBe(65); // 13 / (13+7)
expect(agg.beat_close_pct).toBe(65); // 13 beat / 20 with clv
});
});
// Session 60 (night2/F, 5.5) — calibration by grade tier.
describe('getModelAggregate — per-tier calibration (n≥20 per tier)', () => {
test('a tier at 20+ settles gets a pct; a small tier stays building (null)', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 14 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
...Array.from({ length: 6 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
...Array.from({ length: 3 }, () => ({ outcome: 'hit', clv_result: null, grade: 'B' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
expect(agg.by_tier.A.settled).toBe(20); // A + A- bucket together
expect(agg.by_tier.A.hit_pct).toBe(70); // 14/(14+6)
expect(agg.by_tier.B.settled).toBe(3);
expect(agg.by_tier.B.hit_pct).toBeNull(); // under 20 → building
});
test('A+ stands ALONE; A/A- do NOT fold into it (Addition 2 bucketing)', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A+' })),
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: null, grade: 'A' })),
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: null, grade: 'A-' })),
];
sb._state.selectResults = [rows];
sb._state.countResult = 0;
const agg = await ledger.getModelAggregate({ sb });
// A+ is its own bucket — never merged into the first-letter A bucket.
expect(agg.by_tier['A+'].settled).toBe(5);
expect(agg.by_tier['A+'].hits).toBe(5);
// A + A- fold together by first letter (but NOT A+).
expect(agg.by_tier.A.settled).toBe(6);
expect(agg.by_tier.A.hits).toBe(4);
expect(agg.by_tier.A.misses).toBe(2);
});
});
// Session 61 — the odds index prefers a fully-priced book row (the null
// locked_odds rows from day one: first-seen betmgm SB unders had no juice
// while another book priced both sides).
describe('indexProps — prefers a book row with both sides priced', () => {
const { indexProps } = ledger.__internals;
test('a later both-sided row replaces a one-sided first row', () => {
const oneSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'betmgm', over_odds: 120, under_odds: null };
const bothSided = { player: 'A Guy', stat_type: 'stolen_bases', line: 0.5, book: 'fanduel', over_odds: 130, under_odds: -170 };
const idx = indexProps([oneSided, bothSided]);
expect(idx['a guy|stolen_bases'].book).toBe('fanduel');
});
test('a both-sided first row is never displaced', () => {
const bothSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'fanduel', over_odds: -110, under_odds: -110 };
const oneSided = { player: 'A Guy', stat_type: 'hits', line: 1.5, book: 'betmgm', over_odds: -115, under_odds: null };
const idx = indexProps([bothSided, oneSided]);
expect(idx['a guy|hits'].book).toBe('fanduel');
});
});
// S6 (A1 board) — CLV distribution on the model aggregate. The n>=20 gate is
// centralized HERE (getModelAggregate) — consumers never re-derive it.
describe('getModelAggregate — clv_distribution (n>=20 gate lives in the service)', () => {
const { clvBucketIndex, CLV_BUCKETS } = ledger.__internals;
// CLV suppressed by default (item 7); enable to test the distribution math.
beforeAll(() => { process.env.CLV_CAPTURE_RELIABLE = '1'; });
afterAll(() => { delete process.env.CLV_CAPTURE_RELIABLE; });
test('below 20 settles → clv_distribution is null (never a small-sample chart)', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 5 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
];
const agg = await ledger.getModelAggregate({ sb });
expect(agg.clv_distribution).toBeNull();
});
test('at 20+ settles → seven ordered buckets, counts bucketed by signed clv', async () => {
const sb = fakeSb();
const rows = [
...Array.from({ length: 8 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 0.5 })),
...Array.from({ length: 4 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: 1.5 })),
...Array.from({ length: 5 }, () => ({ outcome: 'miss', clv_result: 'faded', clv: -0.5 })),
...Array.from({ length: 2 }, () => ({ outcome: 'miss', clv_result: 'flat', clv: 0 })),
{ outcome: 'push', clv_result: 'faded', clv: -3 }, // outlier clamps into the edge bucket
];
sb._state.selectResults = [rows];
const agg = await ledger.getModelAggregate({ sb });
const dist = agg.clv_distribution;
expect(dist).toHaveLength(7);
expect(dist.map((b) => b.label)).toEqual(CLV_BUCKETS.map((b) => b.label));
expect(dist[0]).toMatchObject({ side: 'faded', count: 1 }); // [-2,-1) — clamped -3
expect(dist[2]).toMatchObject({ side: 'faded', count: 5 }); // [-.5,0) — -0.5 closed on the left
expect(dist[1]).toMatchObject({ side: 'faded', count: 0 }); // [-1,-.5) — empty
expect(dist[3]).toMatchObject({ side: 'flat', count: 2 }); // exactly 0
expect(dist[4]).toMatchObject({ side: 'beat', count: 8 }); // (0,.5]
expect(dist[6]).toMatchObject({ side: 'beat', count: 4 }); // (1,2] — 1.5
});
test('rows without a settled clv value never fabricate a bucket', async () => {
const sb = fakeSb();
sb._state.selectResults = [
Array.from({ length: 20 }, () => ({ outcome: 'hit', clv_result: 'beat', clv: null })),
];
const agg = await ledger.getModelAggregate({ sb });
expect(agg.clv_distribution).toBeNull(); // no real clv values → nothing
});
test('clvBucketIndex edges: boundaries land per the spec intervals', () => {
expect(clvBucketIndex(0)).toBe(3);
expect(clvBucketIndex(0.5)).toBe(4); // (0,.5] closed on the right
expect(clvBucketIndex(0.51)).toBe(5);
expect(clvBucketIndex(1)).toBe(5); // (.5,1]
expect(clvBucketIndex(1.01)).toBe(6);
expect(clvBucketIndex(-0.5)).toBe(2); // [-.5,0) closed on the left
expect(clvBucketIndex(-0.51)).toBe(1); // [-1,-.5)
expect(clvBucketIndex(-1)).toBe(1); // [-1,-.5) closed on the left
expect(clvBucketIndex(-1.01)).toBe(0); // [-2,-1)
expect(clvBucketIndex(-9)).toBe(0); // clamp
expect(clvBucketIndex(9)).toBe(6); // clamp
expect(clvBucketIndex(null)).toBe(-1); // absent beats wrong
});
});