fix: stop discarding live predictions the moment they mature

Six live predictions were marked unresolvable, including MSFT twice, WMT and
ITW. Re-running the calculation against yahoo resolves all six, so they were
never unresolvable, they were scored before the market data existed and then
thrown away permanently.

The due-check counted calendar days while calculateOutcome finds the exit bar by
trading days. A friday horizon-1 prediction therefore looked due on saturday,
when monday's close cannot exist. calculateOutcome returned null and the worker
treated null as permanently dead. This hit short horizons hardest, which is
exactly the cohort that produces the first live evidence.

The sql filter stays loose because it cannot know about weekends, and trading day
arithmetic now decides what is genuinely ready. A null result waits for the
horizon to be properly past before anything is retired, and says so when it
finally gives up.

Separately, at horizon 1 the entry and exit lookups could land on the same bar
and produce an excess return of exactly zero, which was recorded as a real
outcome and scored as a directional miss. ITW and WMT both did this. A horizon
that has not elapsed is no longer a measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
ImBenji
2026-09-03 16:28:57 +01:00
co-authored by Claude Opus 5
parent 73d09c943f
commit 859d0719b3
3 changed files with 81 additions and 10 deletions
+18 -5
View File
@@ -9,18 +9,31 @@ function addTradingDays(date, days) {
return value.toISOString().slice(0, 10); return value.toISOString().slice(0, 10);
} }
function barOnOrAfter(history, date) {
return history.find((row) => row.date >= date) || null;
}
function nearestOnOrAfter(history, date) { function nearestOnOrAfter(history, date) {
return history.find((row) => row.date >= date)?.close ?? null; return barOnOrAfter(history, date)?.close ?? null;
} }
function calculateOutcome(prediction, instrumentHistory, benchmarkHistory) { function calculateOutcome(prediction, instrumentHistory, benchmarkHistory) {
const eventDate = String(prediction.information_cutoff).slice(0, 10); const eventDate = String(prediction.information_cutoff).slice(0, 10);
const horizonDate = addTradingDays(eventDate, prediction.horizon_days); const horizonDate = addTradingDays(eventDate, prediction.horizon_days);
const price0 = nearestOnOrAfter(instrumentHistory, eventDate); const entryBar = barOnOrAfter(instrumentHistory, eventDate);
const priceHorizon = nearestOnOrAfter(instrumentHistory, horizonDate); const exitBar = barOnOrAfter(instrumentHistory, horizonDate);
const benchmark0 = nearestOnOrAfter(benchmarkHistory, eventDate); const benchEntryBar = barOnOrAfter(benchmarkHistory, eventDate);
const benchmarkHorizon = nearestOnOrAfter(benchmarkHistory, horizonDate); const benchExitBar = barOnOrAfter(benchmarkHistory, horizonDate);
const price0 = entryBar?.close ?? null;
const priceHorizon = exitBar?.close ?? null;
const benchmark0 = benchEntryBar?.close ?? null;
const benchmarkHorizon = benchExitBar?.close ?? null;
if (![price0, priceHorizon, benchmark0, benchmarkHorizon].every(Number.isFinite)) return null; if (![price0, priceHorizon, benchmark0, benchmarkHorizon].every(Number.isFinite)) return null;
// On a short horizon the entry and exit lookups can land on the same bar, which
// yields an excess return of exactly zero and gets scored as a directional miss.
// That is not a result, it means the horizon has not actually elapsed yet.
if (entryBar.date === exitBar.date || benchEntryBar.date === benchExitBar.date) return null;
const instrumentReturn = (priceHorizon - price0) / price0; const instrumentReturn = (priceHorizon - price0) / price0;
const benchmarkReturn = (benchmarkHorizon - benchmark0) / benchmark0; const benchmarkReturn = (benchmarkHorizon - benchmark0) / benchmark0;
const excessReturn = instrumentReturn - benchmarkReturn; const excessReturn = instrumentReturn - benchmarkReturn;
+31 -1
View File
@@ -8,7 +8,7 @@ const { normalizeProposal, acceptProposal } = require('../src/autonomy/coordinat
const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration'); const { calibrateOutcomes, cohortKey } = require('../src/autonomy/calibration');
const { decide } = require('../src/autonomy/policy'); const { decide } = require('../src/autonomy/policy');
const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution'); const { validatePaperIntent, createSimulator } = require('../src/autonomy/execution');
const { calculateOutcome } = require('../src/autonomy/outcomes'); const { calculateOutcome, addTradingDays } = require('../src/autonomy/outcomes');
const { yahooSymbol } = require('../workers/outcomeAutonomyWorker'); const { yahooSymbol } = require('../workers/outcomeAutonomyWorker');
const { createOrderIntent } = require('../src/autonomy/orderIntents'); const { createOrderIntent } = require('../src/autonomy/orderIntents');
const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker'); const { enqueueCoordinatorEvent, reconcileArchiveBatch, reconcileLiveBatch } = require('../workers/autonomyWorker');
@@ -306,3 +306,33 @@ test('dotted tickers are translated to the format the price feed expects', () =>
assert.equal(yahooSymbol('NVDA'), 'NVDA'); assert.equal(yahooSymbol('NVDA'), 'NVDA');
assert.equal(yahooSymbol(' spy '), 'SPY'); assert.equal(yahooSymbol(' spy '), 'SPY');
}); });
test('an outcome whose horizon has not actually elapsed is not a result', () => {
// ITW and WMT both scored exactly 0.00% excess in production because at
// horizon 1 the entry and exit lookups landed on the same bar. Zero is not a
// measurement, and it was being recorded as a directional miss.
const sameBar = calculateOutcome(
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 1, direction: 'positive' },
[{ date: '2026-01-05', close: 100 }],
[{ date: '2026-01-05', close: 100 }]
);
assert.equal(sameBar, null);
// the exit bar genuinely existing still scores normally
const real = calculateOutcome(
{ information_cutoff: '2026-01-02T00:00:00Z', horizon_days: 1, direction: 'positive' },
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-05', close: 104 }],
[{ date: '2026-01-02', close: 100 }, { date: '2026-01-05', close: 102 }]
);
assert.equal(real.directionCorrect, 1);
assert.ok(Math.abs(real.excessReturn - 0.02) < 1e-9);
});
test('trading day arithmetic steps over weekends', () => {
// friday + 1 trading day is monday, not saturday. the old due-check counted
// calendar days and so called a friday horizon-1 prediction due on saturday,
// when monday's close cannot exist yet.
assert.equal(addTradingDays('2026-01-02', 1), '2026-01-05');
assert.equal(addTradingDays('2026-01-02', 5), '2026-01-09');
assert.equal(addTradingDays('2026-01-02', 0), '2026-01-02');
});
+31 -3
View File
@@ -2,7 +2,7 @@ const os = require('os');
const https = require('https'); const https = require('https');
const { openRuntimeDb } = require('../src/db/runtime'); const { openRuntimeDb } = require('../src/db/runtime');
const { initAutonomySchema } = require('../src/autonomy/schema'); const { initAutonomySchema } = require('../src/autonomy/schema');
const { calculateOutcome } = require('../src/autonomy/outcomes'); const { calculateOutcome, addTradingDays } = require('../src/autonomy/outcomes');
function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }
function httpGet(url) { function httpGet(url) {
@@ -20,6 +20,8 @@ function httpGet(url) {
} }
const MAX_OUTCOME_ATTEMPTS = 5; const MAX_OUTCOME_ATTEMPTS = 5;
// how far past the horizon we keep trying before accepting there is no data
const UNRESOLVABLE_GRACE_DAYS = 3;
// Yahoo writes class shares with a dash, BRK.B is BRK-B there. Our allowlist is // Yahoo writes class shares with a dash, BRK.B is BRK-B there. Our allowlist is
// full of dotted symbols, and every one of them 404s forever otherwise. // full of dotted symbols, and every one of them 404s forever otherwise.
@@ -48,20 +50,46 @@ async function resolveAutonomyOutcomes({ intelligencePath, workerId = `outcome-$
const cache = new Map(); const cache = new Map();
const failures = new Map(); const failures = new Map();
while (true) { while (true) {
const predictions = db.prepare(` // The sql filter is deliberately loose, it only counts calendar days and cannot
// know about weekends or when a close actually publishes. Trading day
// arithmetic, the same arithmetic calculateOutcome uses to find the exit bar,
// then decides what is genuinely ready.
const candidates = db.prepare(`
SELECT p.* FROM autonomy_predictions p SELECT p.* FROM autonomy_predictions p
LEFT JOIN autonomy_outcomes o ON o.prediction_id = p.id LEFT JOIN autonomy_outcomes o ON o.prediction_id = p.id
WHERE p.status = 'open' AND o.prediction_id IS NULL WHERE p.status = 'open' AND o.prediction_id IS NULL
AND datetime(p.information_cutoff, '+' || p.horizon_days || ' days') <= datetime('now') AND datetime(p.information_cutoff, '+' || p.horizon_days || ' days') <= datetime('now')
ORDER BY p.information_cutoff ASC LIMIT 25 ORDER BY p.information_cutoff ASC LIMIT 100
`).all(); `).all();
const today = new Date().toISOString().slice(0, 10);
const predictions = candidates.filter((p) => {
const horizonDate = addTradingDays(String(p.information_cutoff).slice(0, 10), p.horizon_days);
// strictly before today, so the exit session has closed and published
return horizonDate < today;
}).slice(0, 25);
for (const prediction of predictions) { for (const prediction of predictions) {
try { try {
if (!cache.has(prediction.instrument)) cache.set(prediction.instrument, await history(prediction.instrument)); if (!cache.has(prediction.instrument)) cache.set(prediction.instrument, await history(prediction.instrument));
if (!cache.has('SPY')) cache.set('SPY', await history('SPY')); if (!cache.has('SPY')) cache.set('SPY', await history('SPY'));
const result = calculateOutcome(prediction, cache.get(prediction.instrument), cache.get('SPY')); const result = calculateOutcome(prediction, cache.get(prediction.instrument), cache.get('SPY'));
if (!result) { if (!result) {
// A null here almost always means the exit bar has not published yet, not
// that the prediction can never be scored. The sql due-check counts
// calendar days while the price lookup counts trading days, so a friday
// horizon-1 call looks due on saturday when monday's close cannot exist.
// Retiring it there permanently destroyed exactly the short-horizon live
// predictions we are waiting on. Wait until the horizon is properly past
// before giving up on it.
const horizonDate = addTradingDays(String(prediction.information_cutoff).slice(0, 10), prediction.horizon_days);
const graceExpired = addTradingDays(horizonDate, UNRESOLVABLE_GRACE_DAYS) < new Date().toISOString().slice(0, 10);
if (graceExpired) {
db.prepare("UPDATE autonomy_predictions SET status = 'unresolvable' WHERE id = ?").run(prediction.id); db.prepare("UPDATE autonomy_predictions SET status = 'unresolvable' WHERE id = ?").run(prediction.id);
console.error(`[autonomy-outcome] ${workerId} prediction ${prediction.id} (${prediction.instrument})`
+ ` unresolvable: no market data ${UNRESOLVABLE_GRACE_DAYS} trading days past horizon ${horizonDate}`);
} else {
cache.delete(prediction.instrument);
}
continue; continue;
} }
db.prepare(` db.prepare(`