feat(backtest): make an incomplete run visibly incomplete

A snapshot whose schema predated a migration made every _fetch_columns call
raise, both dispatch paths caught the per-ticker exception and continued, and
the run wrote a report with zero candidates that was indistinguishable from an
honest run that found no setups. Nothing said the run had lost its universe.

run_backtest now builds a completeness ledger and emits it as
report["data_quality"]: expected / loaded / evaluated counts, per-symbol
failures with stage and reason, bar-less symbols named, and benchmark
availability. Every universe symbol lands in exactly one terminal bucket and
the arithmetic is checked, so a counter that stops being maintained reports
itself as a failure rather than under-counting quietly.

Benchmark availability is a separate axis: without SPY, residual 12-1 momentum
silently degrades to raw 12-1, and the report now says so instead of leaving it
in a log line.

Three statuses, because severity differs. "failed" (nothing evaluated, empty
universe, unbalanced ledger, missing benchmark) replaces the performance tiles
with the callout rather than warning above them — a banner over ordinary-looking
numbers is exactly how the zero-candidate run would still have read as a quiet
week. "degraded" keeps the tiles with a note. A report with no data_quality
block reads as unknown, not clean; defaulting old reports to complete would
reproduce the bug being fixed.

The same gate applies to the two other consumers of backtest numbers:
productionBaseRates returns null for a failed run, and EvaluationPanel will not
report drift against a broken baseline.

"complete" means the processing and coverage checks passed. It does not mean
the prices are correct — a complete run over silently wrong bars is still
complete, and the card says so.

Also threads an explicit terminal-events option through the replay, validated
before dispatch so a stale events file aborts the run instead of quietly
degrading it. Verified against a healthy snapshot (degraded, 505/506) and one
with tickers.cik dropped (failed, 0/506, 506 fetch failures).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HAH7vVtn7qbcn1EVheecHQ
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-09-07 19:29:48 +02:00
1 parent ebe9047403
commit f2ef8e5438
11 files changed
+1013 -49

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
"""Replay bounds for a symbol carrying a research terminal mark.
``_replay_ticker`` normally stops ``HORIZON`` bars before the end so every setup
has a full resolution window. For a delisted name that is exactly wrong: the
collapse happens in those final bars, so the signals that would have been caught
by it are the ones the bound hides. ``terminal_date`` opts a single symbol into
walking through its last real bar.
The fixtures build a controlled setup rather than replaying a real ticker, so
the tests do not depend on whether SIVB happened to qualify on a given day.
"""
from __future__ import annotations
from datetime import date, timedelta
from types import SimpleNamespace
import pytest
from app.services import backtest_service as bt
def _bar(day: date, close: float, *, high: float | None = None, low: float | None = None):
return SimpleNamespace(
date=day,
open=close,
high=close * 1.01 if high is None else high,
low=close * 0.99 if low is None else low,
close=close,
volume=1_000_000,
)
def _series(n: int, *, start: date = date(2021, 1, 4)) -> list:
"""A steadily rising series long enough to clear MIN_LOOKBACK."""
bars = []
day = start
for i in range(n):
while day.weekday() >= 5:
day += timedelta(days=1)
bars.append(_bar(day, 100.0 + i * 0.25))
day += timedelta(days=1)
return bars
def _terminal_bar(previous: date) -> SimpleNamespace:
day = previous + timedelta(days=1)
while day.weekday() >= 5:
day += timedelta(days=1)
return SimpleNamespace(
date=day, open=0.01, high=0.01, low=0.01, close=0.01, volume=0
)
@pytest.fixture()
def replay(monkeypatch):
"""Force one long setup on every evaluated bar so signal dates are countable."""
def _fake_setups(window, config, activation, *, target_model):
close = float(window[-1].close)
return [{
"direction": "long",
"entry": close,
"stop": close * 0.95,
# Reachable within HORIZON on the rising fixture, but not within the
# four bars left before a terminal event.
"target": close * 1.02,
"rr": 2.0,
"confidence": 80,
"momentum": 0.1,
"best_prob": 0.5,
"primary_prob": 0.5,
"meets_core": True,
"action": "BUY",
"risk_level": "medium",
"target_model": target_model,
"primary_sources": [],
"primary_strength": 1.0,
"primary_rejection_count": 0,
"primary_last_rejection_age": None,
"primary_distance_atr": 1.0,
"raw_level_count": 1,
"gate_level_count": 1,
}]
monkeypatch.setattr(bt, "_window_setups", _fake_setups)
def _run(records, terminal_date=None):
return bt._replay_ticker(
"TEST", records, {}, {}, None,
bt.PRODUCTION_GTL_TARGET_MODEL, "daily", terminal_date,
)
return _run
def test_setup_near_a_terminal_event_is_emitted_and_resolves_at_the_mark(replay):
"""A setup five sessions before the halt is replayed, and if it is still open
at the terminal bar the near-zero price resolves it as a loss."""
real = _series(bt.MIN_LOOKBACK + 40)
records = real + [_terminal_bar(real[-1].date)]
without = replay(records)
with_mark = replay(records, terminal_date=records[-1].date)
last_real = real[-1].date
fifth_last = real[-5].date
without_dates = {c["date"] for c in without}
with_dates = {c["date"] for c in with_mark}
assert fifth_last.isoformat() not in without_dates, (
"baseline should hide signals inside the final HORIZON bars"
)
assert fifth_last.isoformat() in with_dates
assert last_real.isoformat() in with_dates
late = next(c for c in with_mark if c["date"] == fifth_last.isoformat())
assert late["outcome"] == bt.OUTCOME_STOP_HIT
assert late["realized_r"] < -1.0, "a gap to 0.01 must fill below the stop, not at it"
def test_no_setup_is_generated_on_the_terminal_bar(replay):
real = _series(bt.MIN_LOOKBACK + 40)
terminal = _terminal_bar(real[-1].date)
records = real + [terminal]
candidates = replay(records, terminal_date=terminal.date)
assert terminal.date.isoformat() not in {c["date"] for c in candidates}
assert max(c["date"] for c in candidates) == real[-1].date.isoformat()
def test_a_position_that_exits_before_the_event_keeps_its_own_exit(replay):
"""An early setup resolves on its own bars; the terminal mark must not
retroactively turn it into a total loss."""
real = _series(bt.MIN_LOOKBACK + 40)
records = real + [_terminal_bar(real[-1].date)]
with_mark = replay(records, terminal_date=records[-1].date)
early_date = real[bt.MIN_LOOKBACK - 1].date.isoformat()
early = next(c for c in with_mark if c["date"] == early_date)
assert early["outcome"] == bt.OUTCOME_TARGET_HIT
assert early["realized_r"] == pytest.approx(2.0)
def test_unmapped_symbols_and_runs_without_the_option_are_unchanged(replay):
"""The ordinary replay bounds are untouched when no terminal date is given."""
records = _series(bt.MIN_LOOKBACK + 40)
baseline = replay(records)
again = replay(records, terminal_date=None)
assert [c["date"] for c in baseline] == [c["date"] for c in again]
assert max(c["date"] for c in baseline) < records[-1].date.isoformat(), (
"without a terminal mark the walk must still stop HORIZON bars early"
)
def test_a_mismatched_terminal_date_is_rejected(replay):
"""The date is validated against the final bar rather than trusted, so a
stale events file fails loudly instead of shifting the replay window."""
records = _series(bt.MIN_LOOKBACK + 40)
with pytest.raises(ValueError, match="does not match the final bar"):
replay(records, terminal_date=date(1999, 1, 1))