b06a84af80
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
1086 lines
50 KiB
JavaScript
1086 lines
50 KiB
JavaScript
'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 20–260
|
||
// 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,
|
||
},
|
||
};
|