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:
@@ -9,18 +9,31 @@ function addTradingDays(date, days) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function barOnOrAfter(history, date) {
|
||||
return history.find((row) => row.date >= date) || null;
|
||||
}
|
||||
|
||||
function nearestOnOrAfter(history, date) {
|
||||
return history.find((row) => row.date >= date)?.close ?? null;
|
||||
return barOnOrAfter(history, date)?.close ?? null;
|
||||
}
|
||||
|
||||
function calculateOutcome(prediction, instrumentHistory, benchmarkHistory) {
|
||||
const eventDate = String(prediction.information_cutoff).slice(0, 10);
|
||||
const horizonDate = addTradingDays(eventDate, prediction.horizon_days);
|
||||
const price0 = nearestOnOrAfter(instrumentHistory, eventDate);
|
||||
const priceHorizon = nearestOnOrAfter(instrumentHistory, horizonDate);
|
||||
const benchmark0 = nearestOnOrAfter(benchmarkHistory, eventDate);
|
||||
const benchmarkHorizon = nearestOnOrAfter(benchmarkHistory, horizonDate);
|
||||
const entryBar = barOnOrAfter(instrumentHistory, eventDate);
|
||||
const exitBar = barOnOrAfter(instrumentHistory, horizonDate);
|
||||
const benchEntryBar = barOnOrAfter(benchmarkHistory, eventDate);
|
||||
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;
|
||||
|
||||
// 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 benchmarkReturn = (benchmarkHorizon - benchmark0) / benchmark0;
|
||||
const excessReturn = instrumentReturn - benchmarkReturn;
|
||||
|
||||
Reference in New Issue
Block a user