hits-v1: built on the right structure, measured honestly, REFUTED
Hits was diagnosed as a family mismatch: 84% of hits rows trade at 0.5, so the stat rides on P(0), and a negative binomial has unbounded support and no notion of opportunity at all. hits-v1 models it as the bounded conversion it is -- N ~ the player's empirical at-bat distribution, hits|N ~ Binomial(N,q), with the multiplier scaling q (conversion) and never N (opportunity). STEP 0 confirmed the inputs before the model existed: 30/30 real ledger players, 100% combined-input coverage. Every read goes through knownRate -- a row with no atBats is dropped, never counted as a 0-at-bat game. It FIRES: 158/159 hits props (99.4%) on the live production snapshot, through the real attachProjection path. Scoping by book IDENTITY rather than price shape kept 94 out-of-promotion-band props on the board, 93 of them modelled -- 59% that a price rule would have deleted. And it LOST. Point-in-time replay (game log truncated strictly before each row's game_date, real grade-time multiplier), hits-only, direction-aligned, n=242: resolution champion 0.195 / ladder 0.048 / hits-v1 0.026. Paired bootstrap on the same rows: hits-v1 - ladder = -0.022, CI95 excluding zero. Not promoted. The value is in what it eliminates. The family was wrong AND the mean was not the constraint -- hits-v1 moved the line-0.5 mean 0.554 -> 0.581 toward a 0.598 base rate while resolution fell. What is left is per-prop discrimination: the ladder's inputs, not its distribution. The pre-registered fallback is recorded as WRONG rather than deleted. It said hits might be genuinely low-resolution for anyone; the champion scores 0.276 on the identical 189 rows, so there is real signal and the ceiling claim was the comfortable reading, not the honest one. Its own control refuted it, and that control was already in hand when the branch was written. hits-v1 stays wired as a challenger writing its own ledger columns so the forward accrual can confirm the backtest. Champion, ladder, ranking, calibration, reference ruler and the four accruing verdicts are byte-identical -- the diff has zero deleted lines. Tests 4,156 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
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hits-input-coverage — STEP 0 of hits-v1. CONFIRM THE INPUTS EXIST.
|
||||
*
|
||||
* hits-v1 models P(hits >= line) as a binomial over AT-BATS. That needs two
|
||||
* inputs per player that the current negative-binomial ladder never asked for:
|
||||
*
|
||||
* 1. at-bats per game (the OPPORTUNITY count — the binomial's n)
|
||||
* 2. per-AB hit rate (the CONVERSION rate — the binomial's q)
|
||||
*
|
||||
* A model is not "wired" until its inputs are present on real rows at real
|
||||
* coverage. This probe pulls the actual players carrying hits props in the
|
||||
* ledger, fetches their REAL statsapi game logs, and reports what fraction
|
||||
* yields usable AB + hit-rate inputs.
|
||||
*
|
||||
* UNKNOWN IS NOT ZERO. Every read goes through `knownRate`. A game-log row with
|
||||
* no atBats field is COUNTED AS MISSING, never as a 0-AB game — reading it as
|
||||
* zero would say "this player had no opportunity", the strongest possible
|
||||
* statement, from an absence of data. That is the defect this codebase has
|
||||
* shipped seven times.
|
||||
*
|
||||
* Usage: node scripts/hits-input-coverage.js [limit]
|
||||
*/
|
||||
|
||||
const { knownRate } = require('../src/utils/known');
|
||||
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||
|
||||
// The real players carrying hits props in the public ledger (2026-08-02 pull,
|
||||
// ordered by row count). Hard-coded rather than re-queried so the probe runs
|
||||
// without Supabase credentials — these are REAL names off REAL rows.
|
||||
const PLAYERS = [
|
||||
'Esmerlyn Valdez', 'Trea Turner', 'Ryan Jeffers', 'Steven Kwan', 'Jake Mangum',
|
||||
'Jazz Chisholm Jr', 'JT Realmuto', 'Bo Bichette', 'Ben Rice', 'Brandon Lowe',
|
||||
'Nick Gonzales', 'Alan Roden', 'Junior Caminero', 'Bryce Harper', 'Travis Bazzana',
|
||||
'Jasson Dominguez', 'Trent Grisham', 'Chase DeLauter', 'Jorge Polanco', 'Wyatt Langford',
|
||||
'Petey Halpin', 'Alec Bohm', 'Munetaka Murakami', 'Kyle Schwarber', 'AJ Ewing',
|
||||
'Royce Lewis', 'Javier Sanoja', 'Ben Williamson', 'Bryson Stott', 'Francisco Lindor',
|
||||
];
|
||||
|
||||
const MIN_GAMES = Number(process.env.HITS_MIN_GAMES || 5);
|
||||
|
||||
async function main() {
|
||||
const limit = Number(process.argv[2] || PLAYERS.length);
|
||||
const names = PLAYERS.slice(0, limit);
|
||||
|
||||
const report = [];
|
||||
for (const name of names) {
|
||||
const row = { player: name, resolved: false, games: 0, ab_games: 0, hit_games: 0, ab_per_game: null, hit_rate: null, usable: false };
|
||||
try {
|
||||
const found = await mlb.searchPlayer(name);
|
||||
if (!found || !found.id) { report.push(row); continue; }
|
||||
row.resolved = true;
|
||||
const log = await mlb.getPlayerGameLog(found.id);
|
||||
row.games = (log || []).length;
|
||||
|
||||
let abSum = 0; let hSum = 0; let abGames = 0; let hitGames = 0;
|
||||
for (const g of log || []) {
|
||||
const s = (g && g.stat) || {};
|
||||
const ab = knownRate(s.atBats); // absent -> null, NOT 0
|
||||
const h = knownRate(s.hits);
|
||||
if (ab !== null) { abSum += ab; abGames += 1; }
|
||||
if (h !== null) { hSum += h; hitGames += 1; }
|
||||
}
|
||||
row.ab_games = abGames;
|
||||
row.hit_games = hitGames;
|
||||
if (abGames >= MIN_GAMES && abSum > 0) {
|
||||
row.ab_per_game = Math.round((abSum / abGames) * 1000) / 1000;
|
||||
row.hit_rate = Math.round((hSum / abSum) * 1000) / 1000;
|
||||
row.usable = true;
|
||||
}
|
||||
} catch (e) {
|
||||
row.error = e.message;
|
||||
}
|
||||
report.push(row);
|
||||
}
|
||||
|
||||
const resolved = report.filter((r) => r.resolved).length;
|
||||
const usable = report.filter((r) => r.usable).length;
|
||||
const rates = report.filter((r) => r.usable).map((r) => r.hit_rate);
|
||||
const abs = report.filter((r) => r.usable).map((r) => r.ab_per_game);
|
||||
const avg = (a) => (a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : null);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
probed: report.length,
|
||||
resolved,
|
||||
usable_combined_inputs: usable,
|
||||
coverage_pct: Math.round((usable / report.length) * 1000) / 10,
|
||||
min_games_required: MIN_GAMES,
|
||||
mean_ab_per_game: avg(abs),
|
||||
mean_hit_rate_per_ab: avg(rates),
|
||||
hit_rate_range: rates.length ? [Math.min(...rates), Math.max(...rates)] : null,
|
||||
rows: report,
|
||||
}, null, 2));
|
||||
|
||||
// Redis runs degraded locally; a reconnect timer would hold the process open
|
||||
// and piped output would be lost to SIGTERM. Same rule as verify-grade-range.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hits-v1-holdout — STEP 3. POINT-IN-TIME REPLAY, HITS ROWS ONLY.
|
||||
*
|
||||
* THREE guards this script exists to enforce, all of which have burned a
|
||||
* measurement in this codebase before:
|
||||
*
|
||||
* 1. HITS ROWS ONLY. Averaging hits into the other stats would hide the
|
||||
* effect entirely — hits is one stat among nine and the ladder's failure is
|
||||
* specific to it.
|
||||
*
|
||||
* 2. DIRECTION-ALIGNED. `p_win` is P(GRADED SIDE); `proj_p_over_line` and
|
||||
* `proj_hits_p_over` are P(OVER). 26% of matched hits rows are
|
||||
* under-graded, and comparing a raw P(over) against an under-side outcome
|
||||
* measures the model BACKWARDS. That artifact alone accounted for 41% of
|
||||
* the ladder's apparent loss when it was first measured.
|
||||
*
|
||||
* 3. NO LOOKAHEAD. This is the guard specific to a replay. For each settled
|
||||
* row, the player's game log is rebuilt STRICTLY BEFORE that row's
|
||||
* game_date, and the multiplier is the REAL `combined_multiplier` recorded
|
||||
* on the row at grade time. A replay that used today's full log would be
|
||||
* scoring a prediction with the answer in hand — a fabricated result, and
|
||||
* a worse lie than no measurement.
|
||||
*
|
||||
* CONTAMINATION EXCLUSION (mandatory). Rows whose price/book were stamped from a
|
||||
* NON-TAKEABLE book between 2026-08-01 and the write-path fix are tagged
|
||||
* `quarantine_reason LIKE 'nontakeable_book%'` and are EXCLUDED: their locked
|
||||
* price describes a market you could not have bet.
|
||||
*
|
||||
* WHAT THIS IS AND IS NOT. It is a backtest, and it is labelled one. The verdict
|
||||
* of record is the FORWARD ledger accrual, which starts at the next snapshot.
|
||||
* Stated limits: statsapi is read as it stands today (retroactive stat
|
||||
* corrections are invisible), and LEAGUE_HIT_RATE / PRIOR_AB are constants set
|
||||
* today — at 20 at-bats against a regular's 200–400 the prior moves a settled
|
||||
* hitter by thousandths, but it is not zero.
|
||||
*
|
||||
* node scripts/hits-v1-holdout.js
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const binomialHits = require('../src/services/projection/binomialHits');
|
||||
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||
const { knownNumber } = require('../src/utils/known');
|
||||
const { normalizeName } = require('../src/utils/playerName');
|
||||
|
||||
const SB_URL = process.env.SUPABASE_URL;
|
||||
const SB_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
/** Pearson correlation — the resolution measure the ladder is judged on. */
|
||||
function corr(xs, ys) {
|
||||
const n = xs.length;
|
||||
if (n < 3) return null;
|
||||
const mx = xs.reduce((a, b) => a + b, 0) / n;
|
||||
const my = ys.reduce((a, b) => a + b, 0) / n;
|
||||
let sxy = 0; let sxx = 0; let syy = 0;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const dx = xs[i] - mx; const dy = ys[i] - my;
|
||||
sxy += dx * dy; sxx += dx * dx; syy += dy * dy;
|
||||
}
|
||||
if (sxx <= 0 || syy <= 0) return null;
|
||||
return Math.round((sxy / Math.sqrt(sxx * syy)) * 10000) / 10000;
|
||||
}
|
||||
const mean = (a) => (a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 10000) / 10000 : null);
|
||||
const sd = (a) => {
|
||||
if (a.length < 2) return null;
|
||||
const m = a.reduce((x, y) => x + y, 0) / a.length;
|
||||
return Math.round(Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1)) * 10000) / 10000;
|
||||
};
|
||||
/** Brier score — lower is better. Reported beside resolution as a check. */
|
||||
const brier = (ps, ys) => (ps.length
|
||||
? Math.round((ps.reduce((s, p, i) => s + (p - ys[i]) ** 2, 0) / ps.length) * 10000) / 10000
|
||||
: null);
|
||||
|
||||
/**
|
||||
* Paired bootstrap CI on a DIFFERENCE of resolutions.
|
||||
*
|
||||
* Both models score the SAME rows, so their errors are correlated and comparing
|
||||
* two independent standard errors would overstate the uncertainty. Resampling
|
||||
* rows as pairs preserves that dependence. Deterministic seed — a measurement
|
||||
* that changes between runs is not a measurement.
|
||||
*/
|
||||
function bootstrapDiff(rowsIn, keyA, keyB, iters = 4000, seed = 20260802) {
|
||||
if (rowsIn.length < 20) return null;
|
||||
let s = seed >>> 0;
|
||||
const rnd = () => { // xorshift32 — deterministic, no Math.random
|
||||
s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
const n = rowsIn.length;
|
||||
const diffs = [];
|
||||
for (let it = 0; it < iters; it += 1) {
|
||||
const ys = []; const a = []; const bArr = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const r = rowsIn[Math.floor(rnd() * n)];
|
||||
ys.push(r.won); a.push(r[keyA]); bArr.push(r[keyB]);
|
||||
}
|
||||
const ca = corr(a, ys); const cb = corr(bArr, ys);
|
||||
if (ca == null || cb == null) continue;
|
||||
diffs.push(ca - cb);
|
||||
}
|
||||
if (diffs.length < 100) return null;
|
||||
diffs.sort((x, y) => x - y);
|
||||
const q = (p) => Math.round(diffs[Math.floor(p * (diffs.length - 1))] * 10000) / 10000;
|
||||
return {
|
||||
point: Math.round(((corr(rowsIn.map((r) => r[keyA]), rowsIn.map((r) => r.won)) || 0)
|
||||
- (corr(rowsIn.map((r) => r[keyB]), rowsIn.map((r) => r.won)) || 0)) * 10000) / 10000,
|
||||
ci95: [q(0.025), q(0.975)],
|
||||
// The question the promotion gate actually asks.
|
||||
p_improves: Math.round((diffs.filter((d) => d > 0).length / diffs.length) * 1000) / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!SB_URL || !SB_KEY) throw new Error('SUPABASE_URL / service key required');
|
||||
const sb = createClient(SB_URL, SB_KEY, { auth: { persistSession: false } });
|
||||
|
||||
const { data, error } = await sb
|
||||
.from('ledger_entries')
|
||||
.select('id, player_name, player_key, stat, line, side, outcome, game_date, p_win, proj_p_over_line, proj_factors, quarantine_reason')
|
||||
.eq('sport', 'mlb')
|
||||
.is('user_id', null)
|
||||
.eq('stat', 'hits')
|
||||
.in('outcome', ['hit', 'miss'])
|
||||
.not('p_win', 'is', null)
|
||||
.not('proj_p_over_line', 'is', null);
|
||||
if (error) throw error;
|
||||
|
||||
// Contamination exclusion, applied in JS so the filter is visible here.
|
||||
const rows = (data || []).filter((r) => !(r.quarantine_reason || '').startsWith('nontakeable_book'));
|
||||
|
||||
// Resolve each distinct player ONCE, and cache the full game log.
|
||||
const logCache = new Map();
|
||||
const names = [...new Set(rows.map((r) => r.player_name).filter(Boolean))];
|
||||
let resolved = 0;
|
||||
for (const name of names) {
|
||||
try {
|
||||
const found = await mlb.searchPlayer(name);
|
||||
if (!found || !found.id) { logCache.set(name, null); continue; }
|
||||
const log = await mlb.getPlayerGameLog(found.id);
|
||||
logCache.set(name, Array.isArray(log) ? log : null);
|
||||
if (log && log.length) resolved += 1;
|
||||
} catch { logCache.set(name, null); }
|
||||
}
|
||||
|
||||
const out = [];
|
||||
const reasons = {};
|
||||
const bump = (k) => { reasons[k] = (reasons[k] || 0) + 1; };
|
||||
|
||||
for (const r of rows) {
|
||||
const full = logCache.get(r.player_name);
|
||||
if (!full) { bump('no_game_log'); continue; }
|
||||
const gameDate = String(r.game_date || '').slice(0, 10);
|
||||
if (!gameDate) { bump('no_game_date'); continue; }
|
||||
|
||||
// ── NO LOOKAHEAD ──────────────────────────────────────────────────────
|
||||
// Strictly BEFORE the graded game. A row dated the same day is the game
|
||||
// being predicted; including it would hand the model the answer.
|
||||
const priorLog = full.filter((g) => g && g.date && String(g.date).slice(0, 10) < gameDate);
|
||||
if (priorLog.length < binomialHits.HITS_MIN_GAMES) { bump('thin_prior_log'); continue; }
|
||||
|
||||
// The REAL grade-time multiplier, recorded on the row at lock.
|
||||
const m = knownNumber(r.proj_factors && r.proj_factors.combined_multiplier);
|
||||
const proj = binomialHits.projectHits({
|
||||
rows: priorLog, line: Number(r.line), multiplier: m == null ? 1 : m,
|
||||
});
|
||||
if (!proj) { bump('inputs_underivable'); continue; }
|
||||
|
||||
// ── DIRECTION-ALIGN to the graded side ────────────────────────────────
|
||||
const under = String(r.side || '').toLowerCase() === 'under';
|
||||
const won = r.outcome === 'hit' ? 1 : 0;
|
||||
out.push({
|
||||
won,
|
||||
under,
|
||||
champ: Number(r.p_win),
|
||||
ladder: under ? 1 - Number(r.proj_p_over_line) : Number(r.proj_p_over_line),
|
||||
hitsv1: under ? 1 - proj.p_over_line : proj.p_over_line,
|
||||
line: Number(r.line),
|
||||
games_prior: priorLog.length,
|
||||
});
|
||||
}
|
||||
|
||||
const slice = (rowsIn, label) => {
|
||||
const ys = rowsIn.map((x) => x.won);
|
||||
const c = rowsIn.map((x) => x.champ);
|
||||
const l = rowsIn.map((x) => x.ladder);
|
||||
const h = rowsIn.map((x) => x.hitsv1);
|
||||
return {
|
||||
slice: label,
|
||||
n: rowsIn.length,
|
||||
under_rows: rowsIn.filter((x) => x.under).length,
|
||||
base_rate: mean(ys),
|
||||
resolution: { champion: corr(c, ys), current_ladder: corr(l, ys), hits_v1: corr(h, ys) },
|
||||
brier: { champion: brier(c, ys), current_ladder: brier(l, ys), hits_v1: brier(h, ys) },
|
||||
mean_p: { champion: mean(c), current_ladder: mean(l), hits_v1: mean(h) },
|
||||
sd_p: { champion: sd(c), current_ladder: sd(l), hits_v1: sd(h) },
|
||||
};
|
||||
};
|
||||
|
||||
console.log(JSON.stringify({
|
||||
measurement: 'POINT-IN-TIME REPLAY (backtest) — verdict of record is the forward ledger accrual',
|
||||
guards: {
|
||||
hits_rows_only: true,
|
||||
direction_aligned: true,
|
||||
no_lookahead: 'game log truncated strictly before each row game_date',
|
||||
grade_time_multiplier: 'real combined_multiplier from the row',
|
||||
contamination_excluded: 'nontakeable_book*',
|
||||
},
|
||||
candidate_rows: rows.length,
|
||||
players_resolved: `${resolved}/${names.length}`,
|
||||
matched_rows: out.length,
|
||||
dropped: reasons,
|
||||
overall: slice(out, 'all hits rows'),
|
||||
// Is the comparison a RESULT or a noise reading? Paired bootstrap, so the
|
||||
// shared rows are not double-counted as independent evidence.
|
||||
paired_bootstrap: {
|
||||
note: 'difference in resolution, 4000 paired resamples, deterministic seed',
|
||||
hits_v1_minus_ladder: bootstrapDiff(out, 'hitsv1', 'ladder'),
|
||||
champion_minus_ladder: bootstrapDiff(out, 'champ', 'ladder'),
|
||||
champion_minus_hits_v1: bootstrapDiff(out, 'champ', 'hitsv1'),
|
||||
at_line_0_5: {
|
||||
hits_v1_minus_ladder: bootstrapDiff(out.filter((x) => x.line === 0.5), 'hitsv1', 'ladder'),
|
||||
champion_minus_ladder: bootstrapDiff(out.filter((x) => x.line === 0.5), 'champ', 'ladder'),
|
||||
},
|
||||
},
|
||||
by_line: [0.5, 1.5, 2.5].map((ln) => slice(out.filter((x) => x.line === ln), `line ${ln}`))
|
||||
.filter((s) => s.n > 0),
|
||||
}, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* verify-hits-v1 — STEP 2. WIRED IS NOT FIRING.
|
||||
*
|
||||
* A challenger that exists in the source and never produces a number on a real
|
||||
* row is not a challenger, it is a comment. This induces the REAL code path —
|
||||
* `projectionChallenger.attachProjection`, the exact function the snapshot
|
||||
* calls — over the REAL hits props on the live production snapshot, with the
|
||||
* REAL statsapi game-log adapter behind it.
|
||||
*
|
||||
* It reports FIRING COVERAGE: of the real hits props on the board, how many
|
||||
* yield a hits-v1 read, how many abstain, and why. An abstention is a valid
|
||||
* answer; a silent zero is not.
|
||||
*
|
||||
* The current ladder value is computed on the same rows in the same call, so the
|
||||
* two are compared on identical inputs.
|
||||
*
|
||||
* node scripts/verify-hits-v1.js [snapshotUrl]
|
||||
*/
|
||||
|
||||
const projection = require('../src/services/projectionChallenger');
|
||||
const mlb = require('../src/services/adapters/mlbStatsAdapter');
|
||||
|
||||
const SNAPSHOT_URL = process.argv[2] || 'https://api.vyndr.app/api/snapshot/mlb';
|
||||
|
||||
async function fetchSnapshot(url) {
|
||||
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`snapshot ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const snap = await fetchSnapshot(SNAPSHOT_URL);
|
||||
const grades = (snap.grades || []).filter(
|
||||
(g) => String(g.stat_type || g.stat || '').toLowerCase() === 'hits',
|
||||
);
|
||||
|
||||
const out = await projection.attachProjection(grades, {
|
||||
// The one dep that matters here. Everything else (park/weather/platoon/
|
||||
// arsenal) is absent on the public payload and contributes a documented
|
||||
// 1.0 — which is the honest behaviour, not a fabricated push.
|
||||
gameLogFor: async (g) => {
|
||||
if (!g.playerId) return [];
|
||||
try { return (await mlb.getPlayerGameLog(g.playerId)) || []; } catch { return []; }
|
||||
},
|
||||
});
|
||||
|
||||
const fired = out.filter((g) => g.proj_hits_p_over != null);
|
||||
const abstained = out.filter((g) => g.proj_hits_p_over == null && g.proj_hits_meta);
|
||||
const ladder = out.filter((g) => g.proj_p_over_line != null);
|
||||
|
||||
// The market read — proving the model was scoped by IDENTITY, not by price.
|
||||
const withMarket = out.filter((g) => g.proj_hits_meta && g.proj_hits_meta.market);
|
||||
const takeableIdentity = withMarket.filter((g) => g.proj_hits_meta.market.market_takeable);
|
||||
const outsidePromotion = withMarket.filter((g) => g.proj_hits_meta.market.within_promotion_band === false);
|
||||
const oneSided = withMarket.filter((g) => g.proj_hits_meta.market.one_sided);
|
||||
// The rows the whole disambiguation exists for: real markets that a
|
||||
// price-shape rule would have thrown away, and which we modelled anyway.
|
||||
const juicedModelled = fired.filter((g) => {
|
||||
const m = g.proj_hits_meta.market;
|
||||
return m.market_takeable && m.within_promotion_band === false;
|
||||
});
|
||||
|
||||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : null);
|
||||
const nums = fired.map((g) => g.proj_hits_p_over);
|
||||
const avg = (a) => (a.length ? Math.round((a.reduce((x, y) => x + y, 0) / a.length) * 1000) / 1000 : null);
|
||||
const sd = (a) => {
|
||||
if (a.length < 2) return null;
|
||||
const m = a.reduce((x, y) => x + y, 0) / a.length;
|
||||
return Math.round(Math.sqrt(a.reduce((s, v) => s + (v - m) ** 2, 0) / (a.length - 1)) * 1000) / 1000;
|
||||
};
|
||||
const ladderNums = ladder.map((g) => g.proj_p_over_line);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
snapshot: { url: SNAPSHOT_URL, updated_at: snap.updated_at, total_grades: (snap.grades || []).length },
|
||||
hits_props: grades.length,
|
||||
hits_v1: {
|
||||
fired: fired.length,
|
||||
firing_coverage_pct: pct(fired.length, grades.length),
|
||||
abstained: abstained.length,
|
||||
abstain_reasons: abstained.reduce((acc, g) => {
|
||||
const r = g.proj_hits_meta.reason || 'unknown';
|
||||
acc[r] = (acc[r] || 0) + 1; return acc;
|
||||
}, {}),
|
||||
mean_p: avg(nums), sd_p: sd(nums),
|
||||
p_range: nums.length ? [Math.min(...nums), Math.max(...nums)] : null,
|
||||
},
|
||||
current_ladder: {
|
||||
fired: ladder.length,
|
||||
mean_p: avg(ladderNums), sd_p: sd(ladderNums),
|
||||
},
|
||||
takeable_axis: {
|
||||
note: 'market scope = book IDENTITY; promotion band recorded, never gates the model',
|
||||
rows_with_market_read: withMarket.length,
|
||||
takeable_by_identity: takeableIdentity.length,
|
||||
outside_promotion_band: outsidePromotion.length,
|
||||
one_sided_quotes: oneSided.length,
|
||||
juiced_or_longshot_MODELLED_anyway: juicedModelled.length,
|
||||
any_price_filtered: withMarket.some((g) => g.proj_hits_meta.market.price_filtered),
|
||||
},
|
||||
sample: fired.slice(0, 5).map((g) => ({
|
||||
player: g.player, line: g.line, side: g.direction, book: g.book,
|
||||
hits_v1_p_over: g.proj_hits_p_over,
|
||||
ladder_p_over: g.proj_p_over_line,
|
||||
champion_p_win: g.p_win ?? null,
|
||||
hit_rate: g.proj_hits_meta.hit_rate,
|
||||
ab_per_game: g.proj_hits_meta.ab_per_game,
|
||||
games_used: g.proj_hits_meta.games_used,
|
||||
market: g.proj_hits_meta.market,
|
||||
})),
|
||||
}, null, 2));
|
||||
|
||||
// Redis is degraded locally; its reconnect timer would hold the process open
|
||||
// and piped output would be lost to SIGTERM. Same rule as verify-grade-range.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
Reference in New Issue
Block a user