Files
vyndr/src/services/ledgerService.js
T
builtbykev b06a84af80 Settlement has been dead since 2026-08-01: a 500-id filter overflowed the URL
The self-learning loop stopped two days ago and reported success the whole
time. 1,444 ledger rows from 2026-08-01 sit unsettled with settle_attempts=0
-- never even attempted -- and every accruing challenger has been starved of
settled sample as a result.

ROOT CAUSE. settleLedger fetched open ids, then REFETCHED the full rows with
.in('id', ids). PostgREST puts filters in the URL, so 500 UUIDs became an
18,499-character request that the fetch layer rejects with "TypeError: fetch
failed". The result was destructured as `const { data: rows } = ...` with NO
error binding, so rows came back null, the loop body never executed, and the
function returned {settled:0, voided:0, unrecoverable:0, pending:0} --
byte-identical to a clean "nothing to settle". Reproduced against prod before
changing anything.

WHY IT HID FOR TWO DAYS. It is volume-triggered. Daily volume ran 20-260 rows
and settled perfectly for weeks; 2026-08-01 was the first day past the 500-row
fetch limit. And the zero-settle ops alarm reads these very return values, so
pending:0 told the watchdog the backlog was empty -- the alarm built to catch
exactly this could not see it.

THE FIX. The refetch existed only to add game_date/settle_attempts/
dclv_computed_at. Selecting them in the first query removes the id list
entirely, so there is no URL to overflow at any volume. A failed fetch now
surfaces its error instead of being reported as an empty backlog.

captureClosing carried the same shape one level down -- .in('id', g.ids) on an
UPDATE, which fails identically once a single line|odds group gets large on a
big slate. Its id filters are now chunked at 100 (~3.7 KB).

Tests: the regression is locked by asserting settlement issues NO id-list
filter at 500 rows, and that a failed fetch is never reported as an empty
backlog -- the two properties that would have caught this. Two existing
suites asserted the old two-query shape and were updated to the real one.

4,159 tests green (332 suites); web build exit 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1sivYNqY2TS5ftykmHBU9
2026-08-02 21:49:30 -04:00

1086 lines
50 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');
// The LEDGER takeable standard (floor on the minus side, UNCAPPED plus) — a
// DIFFERENT question from valueEngine's -160..+200 promotion band. See
// config/takeableStandard.js for why the two must not be merged.
const { LEDGER_TAKEABLE_FLOOR, isLedgerTakeable: takeableFor } = require('../config/takeableStandard');
const CONFLICT = 'user_id,player_key,stat,line,side,game_id';
const UPSERT_CHUNK = 200;
const SETTLE_FETCH_LIMIT = 500;
/**
* Max ids in a single `.in('id', …)` filter.
*
* PostgREST puts filters in the URL, so an id list is bounded by URL length, not
* by row count: 500 UUIDs is ~18.5 KB and the fetch layer rejects it outright
* with `TypeError: fetch failed`. 100 keeps it near 3.7 KB, comfortably inside
* every proxy default. This is not a tuning knob — it is the guard for the
* defect that silently killed settlement for two days (see settleLedger).
*/
const ID_FILTER_CHUNK = 100;
// 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';
// Order Zero — which fair-probability RULER produced fair_prob_lock. It is the
// denominator of every edge and CLV number, so a value computed under one ruler
// is not the same measurement as one computed under another. Never pool across
// it. Stays v1_first_book until the consensus ruler is actually promoted.
const { CURRENT_RULER_VERSION } = require('../config/bookRoles');
/** 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. */
/**
* Index odds rows by player|stat.
*
* `takeableOnly` (2026-08-02) splits ONE index into two roles, because the row
* needs two different things from a prop and they have different correctness
* rules:
*
* PRICE / BOOK / TAKEABLE — must come from a book a bettor could ACTUALLY
* have taken. Gated on TAKEABLE_BOOKS, not MODEL_BOOKS: `pinnacle` is
* model-eligible and deliberately not takeable, so a MODEL gate would
* re-break the moment pinnacle's feed recovers.
* GAME FACTS (game_time, game_date, team/opponent) — book-INDEPENDENT. First
* pitch is first pitch whichever book listed it, so these may come from any
* book. Gating them too would drop otherwise-valid rows for no gain.
*
* Collapsing those two into one index is exactly the bug this fixes: the
* display widening on 2026-08-01 turned the shared index into a DFS/exchange
* source for the lock price, and the ledger's non-takeable share went 0% ->
* 47.9% overnight.
*/
function indexProps(props, takeableOnly = false) {
const { isTakeableBook } = require('../config/bookRoles');
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;
if (takeableOnly && !isTakeableBook(p.book)) 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.
*/
/**
* The Layer-2 blend as it stood at grade time. Stored as the VECTOR, not a
* label: "did archetype-awareness help?" can only be answered against the axes
* that were live, and a single text column cannot express a blend. Null (not
* an empty object) when the grade carried no archetype — honest absence.
*/
function archetypeVectorOf(g) {
if (!g || typeof g !== 'object') return null;
if (g.archetype_axes && typeof g.archetype_axes === 'object') return g.archetype_axes;
if (Array.isArray(g.archetype_blend) && g.archetype_blend.length) {
return { blend: g.archetype_blend, primary: g.archetype || null };
}
if (g.archetype) return { blend: [], primary: g.archetype };
return null;
}
function rowsFromSnapshot(sport, grades, oddsProps, nowIso) {
const sp = String(sport || '').toLowerCase();
let skippedUnbound = 0;
// TWO indexes, two roles — see indexProps. `byKey` supplies GAME FACTS from
// any book; `byTakeable` supplies the PRICE/BOOK/TAKEABLE anchor and admits
// takeable books only.
const byKey = indexProps(oddsProps);
const byTakeable = indexProps(oddsProps, true);
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 propAnyBook = byKey[`${nameKey(player)}|${stat}`] || null;
const prop = propAnyBook; // game facts only
const priceProp = byTakeable[`${nameKey(player)}|${stat}`] || null; // price anchor
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,
// PRICE ANCHOR: the takeable book only. `locked.odds` is itself
// takeable-gated upstream (snapshotService.indexOdds); the fallback now
// reads the takeable index instead of whatever book indexed first.
locked_odds: locked.odds != null ? String(locked.odds) : oddsForSide(priceProp, side),
// TAKEABLE TAG (2026-07-31, specs/takeable-tagging.md) — was this a price a
// bettor could actually have taken? FLOOR on the minus side, UNCAPPED plus.
// 🔴 NOT `valueEngine.isTakeable` (the -160..+200 PROMOTION band the hero and
// board rank on): a +400 prop is NOT promotable but IS takeable. Keep separate.
// The floor is POLICY, not derived (C1 could not derive one — every price
// bucket's ROI interval contained zero), so each row records the floor it was
// tagged under and a re-derivation can re-tag safely. Absent price → NULL, an
// honest absence, never false.
takeable: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)),
// Same value, honest name. `takeable` never meant "can this be bet" (that
// is book identity) -- it is the ledger PRICE band. Dual-written so no
// reader breaks; `takeable` is deprecated and can be dropped later.
within_price_band: takeableFor(locked.odds != null ? locked.odds : oddsForSide(priceProp, side)),
takeable_floor: LEDGER_TAKEABLE_FLOOR,
// BOOK: the takeable book the price came from, else the book the grade was
// COMPUTED on. Never the widened display list's first match.
book: (priceProp && priceProp.book) || g.book || null,
grade: g.grade,
edge: numOrNull(g.edge_pct),
confidence: numOrNull(g.confidence),
// Session 70 — THE INSTRUMENT. p_win is the projection we ACTUALLY made,
// captured at lock and never re-derived at settle: a re-derivation would
// measure a projection that never happened. fair_prob_lock is the market
// at the same instant, so lock-vs-close movement is attributable.
// archetype_vector is the Layer-2 blend as it stood at grade time, so
// calibration can be sliced BY archetype later — a text label could not
// attribute anything.
p_win: numOrNull(g.p_win),
// Session 71 — the CHALLENGER, retained beside the champion on the SAME
// row so both join to the same outcome and the same close. The champion
// is what served the user; this is measured, never served.
p_win_challenger: numOrNull(g.p_win_challenger),
challenger_delta: numOrNull(g.challenger_delta),
challenger_adjustments: g.challenger_adjustments || null,
challenger_version: g.challenger_version || null,
// Phase A #2 — the SECOND challenger (season contact quality), retained
// SEPARATELY from arch-v1 on the same row so both join to the same outcome
// and the same close. null p_win_contact = honest abstention (no
// projection), distinct from a no-lean equal-to-champion.
p_win_contact: numOrNull(g.p_win_contact),
contact_delta: numOrNull(g.contact_delta),
contact_adjustments: g.contact_adjustments || null,
contact_version: g.contact_version || null,
// proj-v1 — the ABSOLUTE matchup projection challenger. Own columns so the
// full rung set + per-factor breakdown + book-implied comparison are
// independently measurable, per rung, per stat, after settle.
proj_version: g.proj_version || null,
proj_point: numOrNull(g.proj_point),
proj_line: numOrNull(g.proj_line),
proj_p_over_line: numOrNull(g.proj_p_over_line),
proj_book_implied: numOrNull(g.proj_book_implied),
proj_distribution: g.proj_distribution || null,
proj_ladder: g.proj_ladder || null,
proj_factors: g.proj_factors || null,
// tb-v1 CHALLENGER — total_bases as a compound outcome. Written alongside
// proj_p_over_line, never in place of it. NULL on non-TB props and when
// the components are underivable; never a fabricated 0.
proj_tb_p_over: numOrNull(g.proj_tb_p_over),
proj_tb_meta: g.proj_tb_meta || null,
// hits-v1 CHALLENGER — hits as a binomial over at-bats. Written alongside
// proj_p_over_line, never in place of it. NULL on non-hits props and when
// the at-bat inputs are underivable; never a fabricated 0.
proj_hits_p_over: numOrNull(g.proj_hits_p_over),
proj_hits_meta: g.proj_hits_meta || null,
// Session 75 — the ENVIRONMENT that drove this projection. The FORECAST,
// not the actual: this is what we knew when we projected, and it is what
// the instrument measures. The actual lands in game_context and is never
// read from here.
wx_forecast: g.wx_forecast || null,
env_multiplier: numOrNull(g.env_multiplier),
env_park_base: numOrNull(g.env_park_base),
env_weather_mod: numOrNull(g.env_weather_mod),
env_weather_state: g.env_weather_state || null,
fair_prob_lock: numOrNull(g.fair_prob),
archetype_vector: archetypeVectorOf(g),
projection_locked_at: gradedTs,
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,
ruler_version: CURRENT_RULER_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 };
}
/**
* attachClosingProb(sport, opts) — the MARKET half of the instrument.
*
* Reads the append-only `closing_captures` (100% coverage on graded props, and
* the only store with real provenance) and writes the de-vigged closing
* probability onto the matching ledger row. Write-once: a row that already has
* a `closing_prob` is never rewritten, so the FIRST true close is the one that
* survives — the same lock-wall discipline the grade itself follows.
*
* A row with no usable capture gets `market_unavailable_reason`, NOT a guessed
* price. Calibration (p_win vs outcome) still works on those rows; only
* market-comparison is absent, and it says so.
*/
async function attachClosingProb(sport, opts = {}) {
if (!opts.sb && !isConfigured()) return { skipped: 'supabase not configured', updated: 0 };
const sb = opts.sb || defaultClient();
const sp = String(sport || '').toLowerCase();
// Candidates: any locked model-record row still lacking a real close. We do
// NOT exclude rows already stamped market_unavailable_reason — that verdict is
// a RE-CHECKABLE absence, not terminal. A capture we couldn't see before (the
// read was truncated) or that landed after a premature declaration must be
// able to upgrade the row. `closing_prob` itself stays write-once (the filter
// below), so the FIRST true close still wins and is never rewritten.
const { data: rows, error } = await sb.from('ledger_entries')
.select('id, player_key, stat, side, game_date, market_unavailable_reason')
.is('user_id', null).eq('sport', sp)
.is('closing_prob', null)
.limit(opts.limit || 5000);
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.
//
// READ FIX (CLV instrument repair): the table is ~86% refusal rows, and the
// old `.limit(50000)` with no ORDER BY read an arbitrary slice — for MLB
// (730k rows) it saw ~7% and declared 200+ rows closeless that HAD a priced
// capture. Read only PRICED captures, scoped to the candidate rows' game
// dates, so the set is small AND complete.
const { devigTwoWay } = require('../utils/devig');
const dates = [...new Set(rows.map((r) => r.game_date).filter(Boolean))];
let capsQuery = sb.from('closing_captures')
.select('player_key, stat, side, game_date, over_odds, under_odds, captured_at, missed_reason')
.eq('sport', sp).is('missed_reason', null)
.not('over_odds', 'is', null).not('under_odds', 'is', null);
if (dates.length) capsQuery = capsQuery.in('game_date', dates);
const { data: caps } = await capsQuery.limit(opts.capLimit || 200000);
// Latest usable capture per identity = the TRUE close.
const best = new Map();
for (const c of caps || []) {
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, fair_prob: fp });
}
let updated = 0; let absent = 0;
// A close can still ARRIVE for a game that has not started. Marking today's
// rows market-unavailable would be premature absence — as dishonest in the
// other direction as imputing one. Only a past game can be declared closeless.
const cutoff = opts.beforeDate || todayET();
let recovered = 0;
for (const r of rows) {
const hit = best.get(`${r.player_key}|${r.stat}|${r.side}|${r.game_date}`);
if (hit) {
// A real close — write it and CLEAR any prior (premature/truncation-bug)
// market-unavailable verdict. This is the recovery path: a row wrongly
// declared closeless is upgraded the moment its genuine capture is seen.
const patch = { closing_prob: hit.fair_prob, closing_captured_at: hit.captured_at, market_unavailable_reason: null };
const { error: e } = await sb.from('ledger_entries').update(patch).eq('id', r.id);
if (e) continue;
updated += 1;
if (r.market_unavailable_reason) recovered += 1;
continue;
}
// No usable close found for this row.
if (String(r.game_date) >= String(cutoff)) continue; // future/today — a close can still arrive
if (r.market_unavailable_reason) continue; // already honestly declared absent — leave it
const { error: e } = await sb.from('ledger_entries').update({ market_unavailable_reason: 'no_usable_close' }).eq('id', r.id);
if (e) continue;
absent += 1;
}
return { updated, absent, recovered, candidates: rows.length };
}
/**
* 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()) {
// CHUNKED, for the same reason settleLedger no longer refetches by id: an
// `.in('id', …)` filter travels in the URL, and a few hundred UUIDs exceed
// what the fetch layer will send (500 ids ≈ 18.5 KB). That is the defect
// that silently killed settlement on 2026-08-01; this is the same shape,
// one prop-price group deep, and it fails the same way once a single
// line|odds pair collects enough rows on a big slate.
for (let i = 0; i < g.ids.length; i += ID_FILTER_CHUNK) {
const idChunk = g.ids.slice(i, i + ID_FILTER_CHUNK);
const { error: upErr } = await sb.from('ledger_entries')
.update({ closing_line: g.line, closing_odds: g.odds })
.in('id', idChunk);
if (upErr) {
console.warn(`[ledger] closing capture chunk failed (${idChunk.length} rows): ${upErr.message}`);
continue;
}
updated += idChunk.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
// ONE query, selecting everything the settle loop needs.
//
// THIS USED TO BE TWO QUERIES, and the second one silently killed the entire
// self-learning loop (found 2026-08-03). It refetched by primary key —
// `.in('id', open.map(r => r.id))` — which PostgREST sends as a GET query
// string: 500 UUIDs is an 18,499-character URL, and the fetch layer rejects it
// with `TypeError: fetch failed`. The result was destructured as
// `const { data: rows } = ...` with NO error binding, so `rows` came back null,
// the loop body never executed, and settleLedger returned
// `{settled:0, voided:0, unrecoverable:0, pending:0}` — BYTE-IDENTICAL to a
// clean "nothing to settle".
//
// It stayed invisible because it is volume-triggered: daily volume ran 20260
// rows and settled perfectly for weeks. 2026-08-01 was the first day over the
// 500-row fetch limit, and settlement died that night — 1,444 rows with
// `settle_attempts = 0`, never even attempted. Worse, the zero-settle ops alarm
// reads THESE return values, so `pending: 0` told the watchdog there was
// nothing pending and nobody was paged.
//
// The refetch existed only to add game_date/settle_attempts/dclv_computed_at.
// Selecting them up front removes the URL entirely — there is no id list to
// send at any volume.
const { data: rows, error } = await sb.from('ledger_entries')
.select('id, player_key, player_name, stat, line, side, closing_line, game_date, settle_attempts, dclv_computed_at')
.eq('sport', sp)
.is('outcome', null)
.lt('game_date', cutoff)
.order('game_date', { ascending: true })
.limit(SETTLE_FETCH_LIMIT);
// An errored fetch is NOT an empty backlog. Returning zeros here is what made
// a dead loop look like a healthy one for two days — surface it instead.
if (error) return { settled: 0, pending: 0, error: error.message };
if (!rows || rows.length === 0) return { settled: 0, pending: 0 };
// 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.)
// IMMUTABLE ONCE COMPUTED (Session 64). A settle can re-run — stat
// correction, protested/replayed game — and a badge that flips
// positive→negative AFTER a user saw or screenshotted it is a credibility
// failure. So the FIRST computation wins: if dclv_computed_at is already
// set, we do not recompute or overwrite. The lock is the same discipline
// the grade itself uses.
const dclvRes = row.dclv_computed_at
? null
: 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
// NOTE: dclv fields are only present in the update payload when
// dclv_computed_at was null, so a re-settle can never rewrite a shown badge.
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 = {
attachClosingProb,
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,
},
};