Files
vyndr/src/services/ledgerService.js
T
builtbykev dcdad60896 Directional CLV — per-read signal, side-bound, with a real compute trigger
PER-READ ONLY. No aggregate CLV stat, no CLV marketing un-held.

PHASE 0 FINDING THAT SHAPED THE BUILD: the LOCK end must come from
model_snapshots, NOT ledger_entries. The ledger stores only the graded
side's locked_odds (694 rows, single-side) which CANNOT be de-vigged.
model_snapshots retains BOTH side prices on 520/520 graded rows AND an
already-de-vigged fair_prob on 520/520 — produced by the same
devig.devigTwoWay the close uses, so "same method both ends" holds by
construction rather than by convention.

THE COMPUTE TRIGGER is the SETTLE PASS (ledgerService.settleLedger). At
settle the game is final, so the close has landed and the read is final —
the only moment both ends of the comparison exist. Grade and locked prices
are written hours earlier and the close at lock, so without this trigger a
correct CLV function would simply never populate.

JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date),
WITHOUT line — a close that moved off the graded line is the entire point.
Verified clean earlier: 164 identity groups, zero ambiguity. Rows whose
capture refused (missed/ambiguous/one-sided) are UNKNOWN for CLV, matching
the capture layer's own honesty.

SIGN IS SIDE-BOUND and proven by test before the logic existed — the
badge-inverting trap. Same market move:
  OVER-graded  -> positive  clv +0.0800  (fair .500 -> .580)
  UNDER-graded -> negative  clv -0.0800  (fair .500 -> .420)
exact mirrors. FLAT is a PROBABILITY-space threshold always (1.5pp): a
40-cent price move on a deep favourite reads flat, correctly, because
price space lies about magnitude.

UNKNOWN is a first-class state, never 0 — zero asserts "the market did not
move", which is a claim; a missing close asserts nothing. describe()
returns null for unknown so a badge can never render for it.

migration 030 adds dclv/dclv_state/dclv_fair_lock/dclv_fair_close/
dclv_computed_at as NEW columns rather than reusing the C4 clv fields —
conflating a verified per-read signal with a known-broken one would be the
worst kind of quiet lie.

Caught pre-deploy: the trigger call passed `deps`, which is not in scope in
settleLedger (it uses `opts`) — a ReferenceError at the call site, outside
the helper's try/catch, which broke two settlement suites.

Suite 286/3447 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 13:17:34 -04:00

810 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* ledgerService — the truth infrastructure (Session 58, work-order Phase 1).
*
* Persists every grade to Supabase `ledger_entries`:
* - Pipeline pre-grades (user_id NULL) — the PUBLIC model record. Written by
* snapshotService after each snapshot locks. This is the priority path:
* hundreds of settles per night vs user scans trickling in.
* - User scans are written by the web /api/scan route (it owns the user
* identity); this service owns settlement + closing capture for BOTH.
*
* DATA SEMANTICS: `line` / `locked_odds` / `book` / `closing_line` /
* `closing_odds` are REAL book values captured at grade / refresh time —
* never computed. `model_value` is VYNDR's projection. A grade with no
* projection (insufficient_data) is never written — no hollow rows.
*
* Closing-line value: captureClosing runs on EVERY snapshot and overwrites
* closing_line/closing_odds for today's unsettled rows with the CURRENT feed
* values — the last write before the game starts is the closing line (once a
* game starts its props leave the feed, so updates stop naturally).
* settleLedger then computes `clv` SIGNED BY SIDE: for an OVER, a closing
* line BELOW the locked line = the market moved toward the graded side =
* positive = 'beat'. For an UNDER, the inverse.
*
* Everything is injectable; without SUPABASE env the service no-ops
* gracefully (tests / local dev without a database).
*/
const { nameKey, normalizeName } = require('../utils/playerName');
const { settleResult, statValue, logRowOnDate } = require('./outcomeService');
const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
const UPSERT_CHUNK = 200;
const SETTLE_FETCH_LIMIT = 500;
// Session 64 — bounded retry: a row that cannot be resolved after this many
// date-targeted attempts becomes 'unrecoverable' rather than pending forever.
const SETTLE_ATTEMPT_CAP = Number(process.env.SETTLE_ATTEMPT_CAP || 4);
// Bump when the settlement RULE changes, so healed rows are distinguishable
// from originals and the harness can filter by how a row was scored.
const SETTLEMENT_VERSION = Number(process.env.SETTLEMENT_VERSION || 2);
const settleSource = require('./settleSource');
const MODEL_ERA_VERSION = process.env.MODEL_VERSION || 'engine1@2026-07-20';
/** Day's games for a sport (date-pinned since S57) — tells us what a player's
* ABSENCE means: DNP, postponed, or simply not final yet. */
async function getScheduleFn(sport, date) {
// Never reach the network from a test run (the opsNotify/refreshTeamStats
// precedent) — a schedule lookup inside the settle loop would hang the suite.
if (process.env.NODE_ENV === 'test') return [];
try {
return await require('./scheduleService').getSchedule(sport, date);
} catch { return []; }
}
const AGG_WINDOW_DAYS = 30;
const AGG_FETCH_LIMIT = 5000;
/** Below this many settled rows, callers must not render a percentage. */
const MIN_AGG_SAMPLE = 20;
// Truth-Everywhere Part 2 (item 7) — CLV capture is broken (closing_line ==
// locked_line; see the C4 finding). Until C4 records a real closing line,
// beat_close/CLV are suppressed everywhere. Read at call time (not module load)
// so C4 can flip it via CLV_CAPTURE_RELIABLE=1 without a redeploy, and tests can
// exercise the CLV math directly.
function clvCaptureReliable() { return process.env.CLV_CAPTURE_RELIABLE === '1'; }
/**
* S6 (A1 board) — CLV distribution buckets (the MODEL tab strip). Signed CLV:
* positive = beat the close. Outliers clamp into the edge buckets so every
* settled clv lands somewhere. `side` drives the UI color (green = beat,
* red = faded, dim = flat) — same meanings as clv_result.
*/
const CLV_BUCKETS = [
{ label: '[-2,-1)', min: -2, max: -1, side: 'faded' },
{ label: '[-1,-.5)', min: -1, max: -0.5, side: 'faded' },
{ label: '[-.5,0)', min: -0.5, max: 0, side: 'faded' },
{ label: '0', min: 0, max: 0, side: 'flat' },
{ label: '(0,.5]', min: 0, max: 0.5, side: 'beat' },
{ label: '(.5,1]', min: 0.5, max: 1, side: 'beat' },
{ label: '(1,2]', min: 1, max: 2, side: 'beat' },
];
/** Bucket index for one signed clv value (clamped into the edge buckets). */
function clvBucketIndex(clv) {
const v = numOrNull(clv); // strict — Number(null) is 0, a fabricated CLV
if (v == null) return -1;
if (v === 0) return 3;
if (v < 0) {
if (v >= -0.5) return 2;
if (v >= -1) return 1;
return 0; // ≤ -1 clamps into [-2,-1)
}
if (v <= 0.5) return 4;
if (v <= 1) return 5;
return 6; // > 1 clamps into (1,2]
}
function isConfigured() {
return Boolean(process.env.SUPABASE_URL
&& (process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY));
}
function defaultClient() {
return require('../utils/supabase').getSupabaseServiceClient();
}
/** ET calendar date (YYYY-MM-DD) of an ISO timestamp; null when unparseable. */
function dateET(iso) {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit',
}).format(d);
}
const todayET = () => dateET(new Date().toISOString());
/** Derived game id when the feed carries no event id: sport:date:AWAY@HOME. */
function gameIdFor(sport, prop, gameDate) {
const away = String((prop && prop.away_team) || 'UNK').replace(/\s+/g, '');
const home = String((prop && prop.home_team) || 'UNK').replace(/\s+/g, '');
return `${sport}:${gameDate}:${away}@${home}`;
}
const sideOf = (direction) =>
(String(direction || 'over').toLowerCase() === 'under' ? 'under' : 'over');
// Strict numeric parse: null/undefined stay null (Number(null) is 0 — a
// fabricated zero line/odds is exactly what the data-semantics rule forbids).
const numOrNull = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
// Nickname token (last word, lowercased) — the stable cross-source team
// identifier ("New York Yankees" ↔ "Yankees" ↔ "NYY" won't match, but
// full-name feeds match full-name feeds; abbr feeds match abbr feeds).
const nickToken = (name) => {
const w = String(name || '').trim().split(/\s+/);
return (w[w.length - 1] || '').toLowerCase().replace(/[^a-z]/g, '');
};
const teamsMatch = (a, b) => {
if (!a || !b) return false;
const sa = String(a).toLowerCase(), sb = String(b).toLowerCase();
return sa === sb || nickToken(a) === nickToken(b);
};
/**
* Session 59 — team/opponent from the REAL feed. The player's team comes
* from the stats resolve (g.team); the opponent is the other side of the
* prop's game IF the team matches one of its participants. No match →
* opponent stays null — never guessed.
*/
function teamOpponentFor(g, prop) {
const team = g && g.team ? String(g.team) : null;
if (!team || !prop) return { team, opponent: null };
if (teamsMatch(team, prop.home_team)) return { team, opponent: prop.away_team || null };
if (teamsMatch(team, prop.away_team)) return { team, opponent: prop.home_team || null };
return { team, opponent: null };
}
/** Index odds props by nameKey|stat for lock/closing lookups.
* Session 61 — prefer a book row with BOTH sides priced (same rule as
* snapshotService.indexOdds): fewer genuinely-absent locked/closing odds
* when another book carried the side. Real rows only, never synthesized. */
function indexProps(props) {
const map = {};
const bothSides = (p) => p && p.over_odds != null && p.under_odds != null;
for (const p of props || []) {
if (!p || !p.player || !p.stat_type) continue;
const k = `${nameKey(p.player)}|${String(p.stat_type).toLowerCase()}`;
if (!map[k] || (!bothSides(map[k]) && bothSides(p))) map[k] = p;
}
return map;
}
function oddsForSide(prop, side) {
if (!prop) return null;
const v = side === 'under'
? (prop.under_odds ?? prop.under ?? null)
: (prop.over_odds ?? prop.over ?? null);
return v == null ? null : String(v);
}
/**
* Build ledger rows from a snapshot's enriched grades + the raw odds props.
* Skips anything without a real grade or without a captured line — the
* ledger never holds a fabricated market value or a refused read.
*/
function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
const sp = String(sport || '').toLowerCase();
let skippedUnbound = 0;
const byKey = indexProps(oddsProps);
const rows = [];
for (const g of grades || []) {
if (!g || !g.grade || g.insufficient_data) continue;
const player = g.player || g.player_name;
if (!player) continue;
const stat = String(g.stat_type || g.stat || '').toLowerCase();
if (!stat) continue;
const side = sideOf(g.direction);
const locked = g.gradedAt || {};
const line = numOrNull(locked.line) ?? numOrNull(g.line);
if (line == null) continue; // no real captured line → no row
const prop = byKey[`${nameKey(player)}|${stat}`] || null;
const gradedTs = locked.timestamp || nowIso;
// Session 64 (Order 1.5) — a game date comes from the GAME, never from the
// grade clock. The old `|| dateET(gradedTs) || todayET()` fallback is what
// filed tonight's props under yesterday and made settlement impossible.
// gameBinder attaches game_time upstream; if a prop still has none, the
// row is UNRESOLVED and is skipped — the ledger holds real values or
// nothing, and a mis-dated row is fabricated data.
const gameDate = dateET(prop && prop.game_time) || (prop && prop.game_date) || null;
if (!gameDate) { skippedUnbound += 1; continue; }
const { team, opponent } = teamOpponentFor(g, prop);
rows.push({
team,
opponent,
user_id: null,
player_key: nameKey(player),
player_name: normalizeName(player).display || player,
sport: sp,
stat,
line,
side,
locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(prop, side),
book: (prop && prop.book) || g.book || null,
grade: g.grade,
edge: numOrNull(g.edge_pct),
confidence: numOrNull(g.confidence),
model_value: numOrNull(g.projection),
graded_at: gradedTs,
// Session 64 — stamp the model era on every NEW row. Pre-cutoff rows are
// labelled 'pre-retention-unknown' by migration 026; they cannot be
// resolved retroactively.
model_version: MODEL_ERA_VERSION,
game_id: gameIdFor(sp, prop, gameDate),
game_date: gameDate,
});
}
if (skippedUnbound > 0) {
console.warn(`[ledger] ${skippedUnbound} ${sp} rows SKIPPED — no real game time; refusing to date them from the grade clock`);
}
return rows;
}
/**
* Upsert the pipeline's pre-grades (user_id NULL — the public model record).
* Idempotent: re-runs hit the dedupe constraint and are IGNORED, so the
* original locked line/odds are never overwritten by a later run.
*/
async function recordPipelineGrades(sport, grades, oddsProps, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', written: 0 };
const sb = opts.sb || defaultClient();
const nowIso = (opts.now || (() => new Date().toISOString()))();
const rows = rowsFromSnapshot(sport, grades, oddsProps, nowIso);
if (rows.length === 0) return { written: 0 };
let written = 0;
for (let i = 0; i < rows.length; i += UPSERT_CHUNK) {
const chunk = rows.slice(i, i + UPSERT_CHUNK);
const { error } = await sb.from('ledger_entries')
.upsert(chunk, { onConflict: CONFLICT, ignoreDuplicates: true });
if (error) return { written, error: error.message };
written += chunk.length;
}
return { written };
}
/**
* Overwrite closing_line/closing_odds on today's UNSETTLED rows from the
* current (real) odds feed. Runs on every snapshot; the last capture before
* game start is the closing line. Matches by player_key+stat — the closing
* line may legitimately differ from the locked line (that's CLV).
*/
async function captureClosing(sport, oddsProps, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
const sb = opts.sb || defaultClient();
const sp = String(sport || '').toLowerCase();
const gameDate = opts.gameDate || todayET();
const byKey = indexProps(oddsProps);
if (Object.keys(byKey).length === 0) return { updated: 0 };
const { data: open, error } = await sb.from('ledger_entries')
.select('id, player_key, stat, side')
.eq('sport', sp)
.eq('game_date', gameDate)
.is('outcome', null)
.limit(SETTLE_FETCH_LIMIT);
if (error) return { updated: 0, error: error.message };
let updated = 0;
// Group row ids by identical closing values → one UPDATE per prop.
const groups = new Map();
for (const row of open || []) {
const prop = byKey[`${row.player_key}|${row.stat}`];
const closingLine = prop ? numOrNull(prop.line) : null;
if (closingLine == null) continue;
const closingOdds = oddsForSide(prop, row.side);
const gk = `${closingLine}|${closingOdds ?? ''}`;
if (!groups.has(gk)) groups.set(gk, { line: closingLine, odds: closingOdds, ids: [] });
groups.get(gk).ids.push(row.id);
}
for (const g of groups.values()) {
const { error: upErr } = await sb.from('ledger_entries')
.update({ closing_line: g.line, closing_odds: g.odds })
.in('id', g.ids);
if (!upErr) updated += g.ids.length;
}
return { updated };
}
/**
* Signed CLV per the Phase 1 amendment: positive = the market moved TOWARD
* the graded side. OVER: locked closing (closing dropped ⇒ positive).
* UNDER: closing locked.
*/
function computeClv(side, lockedLine, closingLine) {
const locked = numOrNull(lockedLine);
const closing = numOrNull(closingLine);
if (locked == null || closing == null) return null;
const raw = sideOf(side) === 'over' ? locked - closing : closing - locked;
return Math.round(raw * 100) / 100;
}
function clvResultOf(clv) {
if (clv == null) return null;
if (clv > 0) return 'beat';
if (clv < 0) return 'faded';
return 'flat';
}
/**
* Settle unsettled ledger rows with game_date <= yesterday against the real
* stat result (same free MLB game-log source outcomeService uses; other
* sports stay pending until they have a settled-result feed). Also computes
* CLV from the captured closing line. Idempotent: only rows with
* outcome IS NULL are fetched, and a row is written at most once.
*/
/**
* Per-read directional CLV for one settling row (Session 64).
*
* THE JOIN INHERITS THE PROVEN KEY: (sport, player_key, stat, side, game_date)
* — WITHOUT `line`, because a close that moved off the graded line is the whole
* point. Verified clean: 164 identity groups, zero ambiguity.
*
* The LOCK end reads model_snapshots (both side prices + an already-de-vigged
* fair_prob from the same devig function), NOT ledger_entries.locked_odds,
* which is single-side and cannot be de-vigged.
*
* Never throws: a CLV failure must not block a settlement.
*/
async function computeDirectionalForRow(sb, sport, row, deps = {}) {
try {
const dclv = deps.directionalClv || require('./directionalClv');
const [{ data: snaps }, { data: closes }] = await Promise.all([
sb.from('model_snapshots')
.select('fair_prob, over_odds, under_odds, 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)
.order('captured_at', { ascending: true }).limit(1),
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)
.order('captured_at', { ascending: false }).limit(1),
]);
const lock = snaps && snaps[0];
const close = closes && closes[0];
if (!lock) return null; // no retained lock → nothing to compare
return dclv.computeDirectionalClv({
side: row.side,
lockFairProb: lock.fair_prob,
lockOverOdds: lock.over_odds,
lockUnderOdds: lock.under_odds,
closeOverOdds: close ? close.over_odds : null,
closeUnderOdds: close ? close.under_odds : null,
missedReason: close ? close.missed_reason : null,
});
} catch (e) {
console.warn('[ledger] directional CLV failed (settlement continues):', e.message);
return null;
}
}
async function settleLedger(sport, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', settled: 0, pending: 0 };
const sb = opts.sb || defaultClient();
const sp = String(sport || '').toLowerCase();
const nowIso = (opts.now || (() => new Date().toISOString()))();
const getPlayerStats = opts.getPlayerStats || defaultGetPlayerStats;
// Session 64 — injectable like every other dep: tests must never reach the
// network, and a schedule lookup inside the settle loop would otherwise hang
// the suite.
const getSchedule = opts.getSchedule || getScheduleFn;
const cutoff = opts.beforeDate || todayET(); // settle strictly-before today
const { data: open, error } = await sb.from('ledger_entries')
.select('id, player_key, player_name, stat, line, side, closing_line')
.eq('sport', sp)
.is('outcome', null)
.lt('game_date', cutoff)
.order('game_date', { ascending: true })
.limit(SETTLE_FETCH_LIMIT);
if (error) return { settled: 0, pending: 0, error: error.message };
if (!open || open.length === 0) return { settled: 0, pending: 0 };
// We need each row's game_date for the log match — refetch with it included.
const { data: rows } = await sb.from('ledger_entries')
.select('id, player_name, stat, line, side, closing_line, game_date, settle_attempts')
.in('id', open.map((r) => r.id));
// One game-log fetch per unique player.
const players = [...new Set((rows || []).map((r) => r.player_name))];
const logByPlayer = {};
for (const player of players) {
try {
const stats = await getPlayerStats(player, sp);
// Session 64 — prefer the FULL season log for settlement. MLB already
// fetched it (getPlayerStats was discarding it via .slice(-10)), so this
// costs nothing and removes window-decay entirely. Fall back to last10
// for sources that only expose a window (ESPN).
const full = stats && Array.isArray(stats.fullLog) ? stats.fullLog : null;
const win = stats && stats.found && Array.isArray(stats.last10) ? stats.last10 : [];
logByPlayer[player] = (full && full.length) ? full : win;
} catch { logByPlayer[player] = []; }
}
let settled = 0;
let pending = 0;
let voided = 0;
let unrecoverable = 0;
for (const row of rows || []) {
const log = logByPlayer[row.player_name] || [];
// Session 64 — DATE-TARGETED settlement. The old code searched a ROLLING
// window and, on a miss, did `pending += 1` with no terminal state: a row
// that could never settle (player DNP, or the window rolled past the game)
// looked identical to one settling tomorrow, and pended forever.
// `resolveOutcome` reads the FULL log for that exact date and, when the
// player is absent, consults the day's SCHEDULE to learn what the absence
// MEANS — DNP vs postponed vs not-yet-final.
const res = await settleSource.resolveOutcome({
sport: sp,
playerName: row.player_name,
gameDate: row.game_date,
statType: row.stat,
getFullLog: async () => log,
getSchedule,
matchesDate: (r, d) => logRowOnDate(r, d, sp),
statValue: (statObj, st) => statValue(statObj, st, sp),
});
const attempts = Number(row.settle_attempts || 0) + 1;
// Not final yet (scheduled / in progress / SUSPENDED) → stay pending. A
// suspended game resumes and settles later; voiding it would destroy a
// real bet.
if (res.state === 'pending') {
await sb.from('ledger_entries').update({ settle_attempts: attempts })
.eq('id', row.id).is('outcome', null);
pending += 1;
continue;
}
// Could not determine — bounded retry, then a terminal state. Nothing is
// immortal.
if (res.state === 'unknown') {
const terminal = attempts >= SETTLE_ATTEMPT_CAP;
await sb.from('ledger_entries').update({
settle_attempts: attempts,
...(terminal ? {
outcome: 'unrecoverable',
settled_at: nowIso,
settlement_source: res.source || 'unknown',
settlement_version: SETTLEMENT_VERSION,
} : {}),
}).eq('id', row.id).is('outcome', null);
if (terminal) unrecoverable += 1; else pending += 1;
continue;
}
// No bet existed — DNP or postponed/cancelled. VOID is a terminal truth,
// not a failure, and it is excluded from every record denominator.
if (res.state === 'void') {
const { error: vErr } = await sb.from('ledger_entries').update({
outcome: 'void',
settled_at: nowIso,
settle_attempts: attempts,
settlement_source: res.reason || 'void',
settlement_version: SETTLEMENT_VERSION,
}).eq('id', row.id).is('outcome', null);
if (vErr) { pending += 1; continue; }
voided += 1;
continue;
}
const actual = res.value;
const outcome = settleResult(row.side, actual, row.line);
if (!outcome) { pending += 1; continue; }
const clv = computeClv(row.side, row.line, row.closing_line);
// Session 64 — DIRECTIONAL CLV is computed HERE, in the settle pass. This
// is the trigger: at settle the game is final, so the close has landed and
// the read is final — the only moment BOTH ends of the comparison exist.
// (Grade + locked prices are written hours earlier; the close at lock. A
// CLV function without this trigger would be a correct dead wire.)
const dclvRes = await computeDirectionalForRow(sb, sp, row, opts);
const { error: upErr } = await sb.from('ledger_entries')
.update({
outcome,
actual_value: actual,
settled_at: nowIso,
clv,
clv_result: clvResultOf(clv),
...(dclvRes ? {
dclv: dclvRes.clv,
dclv_state: dclvRes.state,
dclv_fair_lock: dclvRes.fair_lock,
dclv_fair_close: dclvRes.fair_close,
dclv_computed_at: nowIso,
} : {}),
settle_attempts: attempts,
settlement_source: res.source || 'date_log',
settlement_version: SETTLEMENT_VERSION,
})
.eq('id', row.id)
.is('outcome', null); // double-settle guard even across concurrent runs
if (upErr) { pending += 1; continue; }
settled += 1;
}
return { settled, voided, unrecoverable, pending };
}
/**
* Phase 2.5 (Session 60) — a PUBLIC grade revision. The intraday refresh
* re-graded a prop whose line moved ≥1.0 against the graded side and the
* grade dropped: update the locked row's grade and set revised_from_grade
* ONCE (the original letter is preserved forever — revisions are public,
* never silent). Only unsettled public rows for today are touched.
*/
async function applyRevision(sport, { playerKey, stat, line, side, newGrade, fromGrade }, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured' };
const sb = opts.sb || defaultClient();
const { error } = await sb.from('ledger_entries')
.update({ grade: newGrade, revised_from_grade: fromGrade })
.is('user_id', null)
.eq('sport', String(sport).toLowerCase())
.eq('player_key', playerKey)
.eq('stat', stat)
.eq('line', line)
.eq('side', side)
.is('outcome', null);
return error ? { error: error.message } : { revised: true };
}
async function settleAllLedgers(opts = {}) {
const sports = opts.sports || ['mlb', 'nba', 'wnba', 'soccer'];
const results = [];
for (const sp of sports) {
try { results.push({ sport: sp, ...(await settleLedger(sp, opts)) }); }
catch (e) { results.push({ sport: sp, settled: 0, pending: 0, error: e.message }); }
}
return results;
}
async function defaultGetPlayerStats(name, sport) {
const sp = String(sport || '').toLowerCase();
if (sp === 'mlb') {
return require('./adapters/mlbStatsAdapter').getPlayerStats(name);
}
// Wave 1 — NBA/WNBA settle against the FREE ESPN per-game log.
if (sp === 'nba' || sp === 'wnba') {
return require('./adapters/espnStatsAdapter').getPlayerGameLog(name, sp);
}
return { found: false }; // soccer — no free settled-result feed yet → pending
}
/**
* Session 8 (A1 board, ops) — count of ledger rows for one game_date (the
* daily pulse's "rows written yesterday"). Returns null (NOT 0) when Supabase
* isn't configured or the count fails — the pulse renders "n/a", never a
* fabricated zero.
*/
async function countRowsForDate(gameDate, opts = {}) {
if (!gameDate) return null;
if (!opts.sb && !isConfigured()) return null;
try {
const sb = opts.sb || defaultClient();
const { count, error } = await sb.from('ledger_entries')
.select('id', { count: 'exact', head: true })
.eq('game_date', gameDate);
if (error) return null;
return count || 0;
} catch {
return null;
}
}
/**
* 30-day aggregate over the PUBLIC model record (user_id NULL): hit rate,
* beat-the-close rate, pending count. Percentages are null below
* MIN_AGG_SAMPLE — the UI must show "record building" instead.
*
* A1 Session 10 — `opts.userId` swaps the public `.is('user_id', null)`
* scoping for `.eq('user_id', uid)`: the SAME aggregate (same window, same
* n≥20 gate) over one user's own ledger, powering public profiles. The
* public default is untouched.
*/
async function getModelAggregate(opts = {}) {
const empty = {
window_days: AGG_WINDOW_DAYS, min_sample: MIN_AGG_SAMPLE,
settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null,
clv_sample: 0, clv_beat: 0, clv_faded: 0, clv_flat: 0, beat_close_pct: null,
clv_distribution: null, // S6 — set past the n≥20 gate only
pending: 0,
};
if (!opts.sb && !isConfigured()) return empty;
const sb = opts.sb || defaultClient();
const nowMs = (opts.nowMs || (() => Date.now()))();
const since = new Date(nowMs - AGG_WINDOW_DAYS * 24 * 3600 * 1000).toISOString().slice(0, 10);
let settledQ = sb.from('ledger_entries')
.select('outcome, clv_result, clv, player_key, grade, model_value');
settledQ = opts.userId ? settledQ.eq('user_id', opts.userId) : settledQ.is('user_id', null);
settledQ = settledQ
.not('outcome', 'is', null)
// Session 64 — void (no bet existed: DNP/postponed) and unrecoverable
// (truth not fetchable) are TERMINAL but are NOT results. They must never
// enter a record denominator, exactly as pushes are excluded from hit_pct.
// Without this, voiding a row would silently move the public record.
.not('outcome', 'in', '("void","unrecoverable")')
// Session 64 (Order 2) — QUARANTINE. A row whose GRADE is untrustworthy
// (wrong_opponent_grade) stays a real public settled result but must never
// train or validate a model, so it leaves the denominator exactly as
// void/unrecoverable do. NOTE: `analysis_flags` (e.g. doubleheader) is
// deliberately NOT filtered here — those rows settle validly and belong in
// the record; they are excluded only from per-game/opponent analysis.
.is('quarantine_reason', null)
// 2026-07 — a grade with a non-positive model_value had NO real projection
// (the pre-fix degradation). Those locks are kept in the append-only ledger
// but must not count toward the public model record — their hit/miss is
// noise, not model skill. `.gt` also excludes NULL model_value. Post-fix no
// such row can be written (projection<=0 now refuses), so this only filters
// the historical blast radius.
.gt('model_value', 0)
.gte('game_date', since)
.limit(AGG_FETCH_LIMIT);
if (opts.sport) settledQ = settledQ.eq('sport', String(opts.sport).toLowerCase());
if (opts.playerKey) settledQ = settledQ.eq('player_key', opts.playerKey);
if (opts.team) settledQ = settledQ.eq('team', opts.team); // Session 60 (5.2) — VYNDR-on-team
const { data: settledRows, error } = await settledQ;
if (error) return { ...empty, error: error.message };
let pendingQ = sb.from('ledger_entries')
.select('id', { count: 'exact', head: true });
pendingQ = opts.userId ? pendingQ.eq('user_id', opts.userId) : pendingQ.is('user_id', null);
pendingQ = pendingQ.is('outcome', null).gt('model_value', 0); // same real-projection filter
if (opts.sport) pendingQ = pendingQ.eq('sport', String(opts.sport).toLowerCase());
if (opts.playerKey) pendingQ = pendingQ.eq('player_key', opts.playerKey);
if (opts.team) pendingQ = pendingQ.eq('team', opts.team);
const { count: pending } = await pendingQ;
const agg = { ...empty, pending: pending || 0 };
// Session 60 (5.5) — calibration by grade tier (A+ alone, then first
// letter). Same n≥20 rule PER TIER: a tier below threshold reports a
// null pct and the UI shows "building", never a small-sample %.
const tierOf = (g) => {
const s = String(g || '').trim().toUpperCase();
if (!s) return null;
return s === 'A+' ? 'A+' : s[0];
};
const byTier = {};
for (const r of settledRows || []) {
agg.settled += 1;
if (r.outcome === 'hit') agg.hits += 1;
else if (r.outcome === 'miss') agg.misses += 1;
else if (r.outcome === 'push') agg.pushes += 1;
if (r.clv_result) {
agg.clv_sample += 1;
if (r.clv_result === 'beat') agg.clv_beat += 1;
else if (r.clv_result === 'faded') agg.clv_faded += 1;
else agg.clv_flat += 1;
}
const t = tierOf(r.grade);
if (t) {
byTier[t] = byTier[t] || { settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null };
const b = byTier[t];
b.settled += 1;
if (r.outcome === 'hit') b.hits += 1;
else if (r.outcome === 'miss') b.misses += 1;
else if (r.outcome === 'push') b.pushes += 1;
}
}
for (const t of Object.keys(byTier)) {
const b = byTier[t];
const d = b.hits + b.misses;
if (b.settled >= MIN_AGG_SAMPLE && d > 0) b.hit_pct = Math.round((b.hits / d) * 100);
}
agg.by_tier = byTier;
const decided = agg.hits + agg.misses;
// n<20 → null: never render a percentage on a small sample.
if (agg.settled >= MIN_AGG_SAMPLE && decided > 0) {
agg.hit_pct = Math.round((agg.hits / decided) * 100);
}
// Truth-Everywhere Part 2 (item 7) — CLV is currently MEASURED WRONG:
// captureClosing re-records the LOCKED line as the "closing" line
// (closing_line == locked_line across the whole sample), so every row's CLV
// computes to 0/flat and beat_close reads a fabricated-looking 0%. That's
// comparing a number to itself. Until C4 (real closing-line capture) lands,
// CLV_CAPTURE_RELIABLE stays false and beat_close_pct / clv_distribution are
// suppressed at the SOURCE — every public surface hides BEAT CLOSE rather
// than showing a measured-wrong zero. Flip this to true when C4 ships.
if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
agg.beat_close_pct = Math.round((agg.clv_beat / agg.clv_sample) * 100);
}
// S6 (A1 board) — clv_distribution rides the SAME n≥20 gate (this is the
// single home of the gate — consumers never re-derive it). Null below the
// sample floor or with zero settled clv values; the UI renders nothing.
agg.clv_distribution = null;
if (clvCaptureReliable() && agg.settled >= MIN_AGG_SAMPLE && agg.clv_sample > 0) {
const dist = CLV_BUCKETS.map((b) => ({ ...b, count: 0 }));
let counted = 0;
for (const r of settledRows || []) {
const i = clvBucketIndex(r.clv);
if (i >= 0) { dist[i].count += 1; counted += 1; }
}
if (counted > 0) agg.clv_distribution = dist;
}
return agg;
}
// Truth-Everywhere Part 2 (item 7) — the public 30D accuracy VIEW, built from
// the CLEAN ledger aggregate (model_value > 0), NOT the Redis outcome log
// (which still counts degraded projection-0 rows and can't be filtered). Same
// shape the AccuracyBadge / buckets consumed from outcomeService, so no
// frontend change. Redis is a cache; when a cache can't be filtered, read truth.
const ACCURACY_VIEW_SPORTS = ['mlb', 'wnba', 'nba', 'soccer'];
function _aggToRecord(agg, sport) {
const byGrade = {};
for (const [tier, b] of Object.entries(agg.by_tier || {})) {
byGrade[tier] = {
hits: b.hits, misses: b.misses, pushes: b.pushes,
total: b.hits + b.misses + b.pushes, pct: b.hit_pct ?? null,
};
}
return {
sport,
updated_at: null,
window_days: agg.window_days,
sample: agg.settled,
min_sample: agg.min_sample,
overall: {
hits: agg.hits, misses: agg.misses, pushes: agg.pushes,
total: agg.hits + agg.misses + agg.pushes, pct: agg.hit_pct ?? null,
},
byGrade,
};
}
async function getAccuracyView(opts = {}) {
const base = { sb: opts.sb, nowMs: opts.nowMs };
const overallAgg = await getModelAggregate(base);
const sports = {};
for (const s of ACCURACY_VIEW_SPORTS) {
const a = await getModelAggregate({ ...base, sport: s });
if (a.settled > 0) sports[s] = _aggToRecord(a, s);
}
return {
overall: _aggToRecord(overallAgg, 'overall'),
sports,
min_sample: overallAgg.min_sample,
updated_at: null,
};
}
// Grade-tier buckets for the ledger accuracy strip, from the clean aggregate.
function accuracyBucketsFromAgg(agg) {
// First-letter buckets (A+ folds into A for the public strip, matching the
// old outcomeService.accuracyBuckets contract), n≥20 gate per bucket.
const order = ['A', 'B', 'C', 'D', 'F'];
const rolled = {};
for (const [tier, b] of Object.entries(agg.by_tier || {})) {
const k = tier === 'A+' ? 'A' : tier[0];
rolled[k] = rolled[k] || { hits: 0, misses: 0, total: 0 };
rolled[k].hits += b.hits;
rolled[k].misses += b.misses;
rolled[k].total += b.hits + b.misses + b.pushes;
}
return order
.filter((k) => rolled[k] && rolled[k].total > 0)
.map((k) => {
const r = rolled[k];
const decided = r.hits + r.misses;
const pct = r.total >= MIN_AGG_SAMPLE && decided > 0 ? Math.round((r.hits / decided) * 100) : null;
return { grade: k, hits: r.hits, total: r.total, pct };
});
}
module.exports = {
recordPipelineGrades,
captureClosing,
settleLedger,
settleAllLedgers,
applyRevision,
countRowsForDate,
getModelAggregate,
getAccuracyView,
accuracyBucketsFromAgg,
MIN_AGG_SAMPLE,
__internals: {
rowsFromSnapshot, computeClv, clvResultOf, indexProps, gameIdFor,
dateET, sideOf, oddsForSide, isConfigured, CONFLICT,
teamOpponentFor, teamsMatch, clvBucketIndex, CLV_BUCKETS,
},
};