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:
1 parent
ebe9047403
commit
f2ef8e5438
11 files changed
+1013
-49
No files matched your search
@@ -50,14 +50,16 @@ import statistics
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import settings_store
|
||||
from app.services.admin_service import get_activation_config, update_setting
|
||||
@@ -497,16 +499,40 @@ def _replay_ticker(
|
||||
benchmark_closes: dict[date, float] | None = None,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
terminal_date: date | None = None,
|
||||
) -> list[dict]:
|
||||
"""Walk one ticker at the selected cadence and resolve each setup outcome."""
|
||||
"""Walk one ticker at the selected cadence and resolve each setup outcome.
|
||||
|
||||
``terminal_date`` marks a symbol whose final bar is a research terminal mark
|
||||
(a confirmed near-total loss written by ``prep_survivorship_snapshot.py``).
|
||||
The normal loop stops ``HORIZON`` bars early so every setup has a full
|
||||
resolution window, which would hide every signal in the last 29 real bars —
|
||||
exactly the ones a collapse resolves. For a marked symbol the walk instead
|
||||
runs through the last *real* bar and never proposes a setup on the synthetic
|
||||
one. Nothing else changes: outcomes still come from the real forward bars,
|
||||
and a setup the shortened history cannot resolve is skipped rather than
|
||||
counted as an expiry.
|
||||
"""
|
||||
cadence = validate_backtest_cadence(cadence)
|
||||
step_sessions = backtest_step_sessions(cadence)
|
||||
candidates: list[dict] = []
|
||||
n = len(records)
|
||||
if n < MIN_LOOKBACK + HORIZON:
|
||||
return candidates
|
||||
|
||||
for i in range(MIN_LOOKBACK - 1, n - HORIZON, step_sessions):
|
||||
if terminal_date is not None:
|
||||
if not records or records[-1].date != terminal_date:
|
||||
raise ValueError(
|
||||
f"{symbol}: terminal event {terminal_date} does not match the final bar "
|
||||
f"{records[-1].date if records else None}"
|
||||
)
|
||||
if n < MIN_LOOKBACK + 1:
|
||||
return candidates
|
||||
stop_index = n - 1 # exclusive: the last signal date is the last real bar
|
||||
else:
|
||||
if n < MIN_LOOKBACK + HORIZON:
|
||||
return candidates
|
||||
stop_index = n - HORIZON
|
||||
|
||||
for i in range(MIN_LOOKBACK - 1, stop_index, step_sessions):
|
||||
window = records[: i + 1]
|
||||
forward = records[i + 1 :]
|
||||
forward_bars = [Bar(date=r.date, high=r.high, low=r.low) for r in forward]
|
||||
@@ -1278,6 +1304,7 @@ def _replay_and_signals(
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
signal_only: bool = False,
|
||||
terminal_date: date | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
"""The CPU-bound per-ticker work, as a top-level (picklable) function so it can
|
||||
run in a worker process. Takes primitive column arrays (cheap to pickle),
|
||||
@@ -1303,6 +1330,7 @@ def _replay_and_signals(
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
terminal_date,
|
||||
)
|
||||
return (
|
||||
candidates,
|
||||
@@ -1475,6 +1503,217 @@ async def _rollback_quietly(db: AsyncSession, context: str) -> None:
|
||||
logger.exception("Session rollback after %s also failed", context)
|
||||
|
||||
|
||||
# Failures are listed individually up to this many; beyond it the report keeps
|
||||
# the counts and says the list was truncated.
|
||||
DATA_QUALITY_FAILURE_SAMPLE = 25
|
||||
# Bar-less universe rows are named rather than counted while the list is short.
|
||||
NO_DATA_SAMPLE = 5
|
||||
|
||||
|
||||
def _has_short_history(columns: tuple) -> int:
|
||||
"""1 when a loaded ticker has too few bars for ``_replay_ticker`` to resolve
|
||||
a single outcome — a recent listing, not an error, but worth counting since
|
||||
it looks identical to "found no setups" in the report."""
|
||||
return int(len(columns[0]) < MIN_LOOKBACK + HORIZON)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DataQuality:
|
||||
"""Completeness ledger for one backtest run.
|
||||
|
||||
Both dispatch paths swallow per-ticker exceptions so a single unreadable
|
||||
name can't abort a 500-symbol replay. That is right for a long run and wrong
|
||||
for its report: a snapshot whose schema predates a migration fails *every*
|
||||
``_fetch_columns`` call and still writes a report that reads like an
|
||||
ordinary one that simply found no setups. This records what actually
|
||||
happened so the report — and the UI — can say so.
|
||||
|
||||
Every universe symbol ends in exactly one of ``no_data``, ``failures`` or
|
||||
``evaluated``. ``short_history`` is a subset of ``evaluated`` (loaded, but
|
||||
too few bars to produce a setup), not a fourth bucket. :meth:`as_report`
|
||||
checks the three against ``expected`` and treats an unbalanced ledger as a
|
||||
failure in its own right — a counter that stopped being maintained should
|
||||
surface as loudly as a missing ticker.
|
||||
|
||||
**What ``complete`` does and does not claim.** It means the run's processing
|
||||
and coverage checks passed: every expected symbol was loaded and evaluated,
|
||||
and the benchmark was there. It says nothing about whether the underlying
|
||||
prices are *correct* — a complete run over silently wrong bars is still
|
||||
complete. Splits, adjustment drift and bad vendor prints are a different
|
||||
problem, and this ledger must not be read as clearing them.
|
||||
"""
|
||||
|
||||
expected: int = 0
|
||||
evaluated: int = 0
|
||||
short_history: int = 0
|
||||
no_data_symbols: list[str] = field(default_factory=list)
|
||||
failures: list[dict] = field(default_factory=list)
|
||||
benchmark_sessions: int = 0
|
||||
benchmark_error: str | None = None
|
||||
benchmark_first: date | None = None
|
||||
benchmark_last: date | None = None
|
||||
|
||||
def record_failure(self, symbol: str, stage: str, exc: BaseException) -> None:
|
||||
"""Stage is ``fetch`` (reading bars) or ``replay`` (evaluating them).
|
||||
|
||||
Only the first line is kept: a SQLAlchemy error appends the full SQL and
|
||||
its parameters, which buries the one useful clause ("no such column:
|
||||
tickers.cik") under 300 characters of SELECT. The full traceback is
|
||||
already in the run log.
|
||||
"""
|
||||
first_line = str(exc).strip().splitlines()[0] if str(exc).strip() else ""
|
||||
self.failures.append({
|
||||
"symbol": symbol,
|
||||
"stage": stage,
|
||||
"error": f"{type(exc).__name__}: {first_line}"[:300],
|
||||
})
|
||||
|
||||
def record_benchmark(self, closes: dict[date, float], error: BaseException | None) -> None:
|
||||
self.benchmark_sessions = len(closes)
|
||||
self.benchmark_error = f"{type(error).__name__}: {error}"[:300] if error else None
|
||||
if closes:
|
||||
self.benchmark_first = min(closes)
|
||||
self.benchmark_last = max(closes)
|
||||
|
||||
def as_report(self) -> dict:
|
||||
# Lazily imported like ``_load_benchmark_closes_for_backtest`` does, so
|
||||
# building a report never drags in the provider stack.
|
||||
from app.services.benchmark_service import BENCHMARK_SYMBOL
|
||||
|
||||
by_stage: dict[str, int] = defaultdict(int)
|
||||
for entry in self.failures:
|
||||
by_stage[entry["stage"]] += 1
|
||||
no_data = len(self.no_data_symbols)
|
||||
accounted = no_data + len(self.failures) + self.evaluated
|
||||
balanced = accounted == self.expected
|
||||
|
||||
# A run is "failed" when its headline numbers can't be read as the
|
||||
# strategy the report claims to describe; "degraded" when the strategy
|
||||
# is intact but ran on less than the whole universe.
|
||||
reasons: list[str] = []
|
||||
if self.expected == 0:
|
||||
reasons.append("no symbols in the backtest universe")
|
||||
elif self.evaluated == 0:
|
||||
reasons.append("no symbol was evaluated successfully")
|
||||
if not balanced:
|
||||
reasons.append(
|
||||
f"ledger does not balance: {accounted} symbols accounted for, "
|
||||
f"{self.expected} expected"
|
||||
)
|
||||
if self.benchmark_sessions == 0:
|
||||
# Residual 12-1 momentum silently degrades to raw 12-1 without a
|
||||
# benchmark stream, so the gate is not the production gate.
|
||||
reasons.append(
|
||||
"benchmark unavailable — residual momentum fell back to raw momentum"
|
||||
+ (f" ({self.benchmark_error})" if self.benchmark_error else "")
|
||||
)
|
||||
failed = bool(reasons)
|
||||
|
||||
# These strings are shown verbatim in the UI, so they read as sentences.
|
||||
def _symbols(n: int) -> str:
|
||||
return f"{n} symbol" if n == 1 else f"{n} symbols"
|
||||
|
||||
if by_stage.get("fetch"):
|
||||
reasons.append(f"{_symbols(by_stage['fetch'])} failed to load")
|
||||
if by_stage.get("replay"):
|
||||
reasons.append(f"{_symbols(by_stage['replay'])} failed during replay")
|
||||
if no_data:
|
||||
# Named while the list is short, because a universe row with no bars
|
||||
# is a standing wart that recurs on every run until someone clears
|
||||
# it — an unnamed count would just nag.
|
||||
named = ", ".join(sorted(self.no_data_symbols)[:NO_DATA_SAMPLE])
|
||||
if no_data > NO_DATA_SAMPLE:
|
||||
named += ", …"
|
||||
reasons.append(f"{_symbols(no_data)} had no stored bars ({named})")
|
||||
|
||||
return {
|
||||
"status": "failed" if failed else ("degraded" if reasons else "ok"),
|
||||
"complete": not reasons,
|
||||
"reasons": reasons,
|
||||
"expected": self.expected,
|
||||
"loaded": self.expected - no_data - by_stage.get("fetch", 0),
|
||||
"evaluated": self.evaluated,
|
||||
"no_data": no_data,
|
||||
"no_data_symbols": sorted(self.no_data_symbols)[:NO_DATA_SAMPLE],
|
||||
"short_history": self.short_history,
|
||||
"failed": len(self.failures),
|
||||
"failures_by_stage": dict(by_stage),
|
||||
"failures": self.failures[:DATA_QUALITY_FAILURE_SAMPLE],
|
||||
"failures_truncated": len(self.failures) > DATA_QUALITY_FAILURE_SAMPLE,
|
||||
"benchmark": {
|
||||
"symbol": BENCHMARK_SYMBOL,
|
||||
"sessions": self.benchmark_sessions,
|
||||
"first": self.benchmark_first.isoformat() if self.benchmark_first else None,
|
||||
"last": self.benchmark_last.isoformat() if self.benchmark_last else None,
|
||||
"momentum_basis": "residual" if self.benchmark_sessions else "raw_fallback",
|
||||
"error": self.benchmark_error,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _load_backtest_symbols(db: AsyncSession) -> list[str]:
|
||||
"""Every ticker with stored history — deliberately *not* ``active_only``.
|
||||
|
||||
A replay grades what the strategy could have selected at each past date, so
|
||||
it must see symbols that have since been delisted or dropped out of the
|
||||
index. Filtering on today's membership here is precisely how a universe
|
||||
becomes the survivors: the audit in ``docs/research/survivorship-audit.md``
|
||||
measured 2.09 Sharpe against 1.57 for the same window on that difference
|
||||
alone. Guarded by
|
||||
``tests/unit/test_universe_membership.py::test_todays_membership_does_not_erase_yesterdays_candidates``.
|
||||
|
||||
Plain strings, not Ticker instances: the rollbacks in the replay loop expire
|
||||
any ORM objects held across them, and touching an expired attribute
|
||||
afterwards triggers sync lazy-loading, which raises on an AsyncSession.
|
||||
"""
|
||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
class TerminalEventError(RuntimeError):
|
||||
"""A terminal-event map does not describe the snapshot it was given."""
|
||||
|
||||
|
||||
async def _validate_terminal_events(
|
||||
db: AsyncSession, terminal_events: dict[str, date], symbols: list[str]
|
||||
) -> None:
|
||||
"""Check every mapped symbol before any replay is dispatched.
|
||||
|
||||
``_replay_ticker`` also rejects a mismatch, but both dispatch paths swallow
|
||||
per-ticker exceptions so the replay survives one unreadable name — which
|
||||
would turn a stale events file into a quietly incomplete report instead of a
|
||||
failure. Validating up front makes the whole run fail, before any work.
|
||||
"""
|
||||
tracked = set(symbols)
|
||||
missing = sorted(set(terminal_events) - tracked)
|
||||
|
||||
rows = await db.execute(
|
||||
select(Ticker.symbol, func.max(OHLCVRecord.date))
|
||||
.join(OHLCVRecord, OHLCVRecord.ticker_id == Ticker.id)
|
||||
.where(Ticker.symbol.in_(list(terminal_events)))
|
||||
.group_by(Ticker.symbol)
|
||||
)
|
||||
final_bar = dict(rows.all())
|
||||
|
||||
mismatched = []
|
||||
for symbol, expected in sorted(terminal_events.items()):
|
||||
if symbol in missing:
|
||||
continue
|
||||
actual = final_bar.get(symbol)
|
||||
if actual != expected:
|
||||
mismatched.append(f"{symbol}: events file says {expected}, final bar is {actual}")
|
||||
|
||||
problems = []
|
||||
if missing:
|
||||
problems.append(f"symbols not in this snapshot: {', '.join(missing)}")
|
||||
if mismatched:
|
||||
problems.append("; ".join(mismatched))
|
||||
if problems:
|
||||
raise TerminalEventError(
|
||||
"Terminal-event map does not match the snapshot — " + " | ".join(problems)
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_columns(db: AsyncSession, symbol: str) -> tuple | None:
|
||||
"""Read one ticker's OHLCV and detach it to primitive column arrays in the
|
||||
event loop (safe ORM access), ready to hand to a worker. None if no data."""
|
||||
@@ -4066,19 +4305,25 @@ async def run_backtest(
|
||||
*,
|
||||
target_model: str = PRODUCTION_GTL_TARGET_MODEL,
|
||||
cadence: str = DEFAULT_BACKTEST_CADENCE,
|
||||
terminal_events: dict[str, date] | None = None,
|
||||
) -> dict:
|
||||
"""Replay every ticker and aggregate the Phase-1 reports for the current config."""
|
||||
"""Replay every ticker and aggregate the Phase-1 reports for the current config.
|
||||
|
||||
``terminal_events`` maps a symbol to the date of a research terminal mark on
|
||||
its final bar (see ``scripts/prep_survivorship_snapshot.py``). Unmapped
|
||||
symbols keep the ordinary replay bounds, so an empty or omitted map leaves
|
||||
every existing run byte-identical.
|
||||
"""
|
||||
target_model = validate_backtest_target_model(target_model)
|
||||
cadence = validate_backtest_cadence(cadence)
|
||||
terminal_events = terminal_events or {}
|
||||
config = await get_recommendation_config(db)
|
||||
activation = await get_activation_config(db)
|
||||
|
||||
# Plain strings, not Ticker instances: the rollbacks below expire any ORM
|
||||
# objects held across them, and touching an expired attribute afterwards
|
||||
# triggers sync lazy-loading, which raises on an AsyncSession.
|
||||
result = await db.execute(select(Ticker.symbol).order_by(Ticker.symbol))
|
||||
symbols = list(result.scalars().all())
|
||||
symbols = await _load_backtest_symbols(db)
|
||||
total = len(symbols)
|
||||
if terminal_events:
|
||||
await _validate_terminal_events(db, terminal_events, symbols)
|
||||
rank_only_symbols = await _load_research_rank_only_symbols(db)
|
||||
if rank_only_symbols:
|
||||
logger.info(json.dumps({
|
||||
@@ -4086,6 +4331,7 @@ async def run_backtest(
|
||||
"count": len(rank_only_symbols),
|
||||
}))
|
||||
|
||||
quality = _DataQuality(expected=total)
|
||||
candidates: list[dict] = []
|
||||
# Signal IC remains a weekly, non-overlapping diagnostic regardless of the
|
||||
# entry cadence. Production activation ranks are assigned from candidates
|
||||
@@ -4096,13 +4342,16 @@ async def run_backtest(
|
||||
# if SPY benchmark data is unavailable, the residual signal simply won't be
|
||||
# emitted and the rest of the report remains valid.
|
||||
benchmark_closes: dict[date, float] = {}
|
||||
benchmark_error: Exception | None = None
|
||||
try:
|
||||
benchmark_closes = await _load_benchmark_closes_for_backtest(
|
||||
db, days=settings.ohlcv_history_days + 365
|
||||
)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
benchmark_error = exc
|
||||
logger.exception("Benchmark load for residual momentum failed")
|
||||
await _rollback_quietly(db, "benchmark load")
|
||||
quality.record_benchmark(benchmark_closes, benchmark_error)
|
||||
|
||||
def _merge(result: tuple[list[dict], dict]) -> None:
|
||||
cands, series = result
|
||||
@@ -4135,31 +4384,48 @@ async def run_backtest(
|
||||
with pool:
|
||||
for start in range(0, total, chunk):
|
||||
batch = symbols[start : start + chunk]
|
||||
futures = []
|
||||
# Symbol travels with its future: ``gather`` returns bare
|
||||
# exceptions, so without the pairing a worker failure can't name
|
||||
# the ticker it lost.
|
||||
dispatched: list[tuple[str, Any]] = []
|
||||
for symbol in batch:
|
||||
try:
|
||||
columns = await _fetch_columns(db, symbol)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
quality.record_failure(symbol, "fetch", exc)
|
||||
logger.exception("Backtest fetch failed for %s", symbol)
|
||||
await _rollback_quietly(db, f"fetch for {symbol}")
|
||||
continue
|
||||
if columns is not None:
|
||||
futures.append(loop.run_in_executor(
|
||||
pool,
|
||||
_replay_and_signals,
|
||||
symbol,
|
||||
columns,
|
||||
config,
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
symbol in rank_only_symbols,
|
||||
))
|
||||
for result in await asyncio.gather(*futures, return_exceptions=True):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(json.dumps({"event": "backtest_worker_error", "message": str(result)}))
|
||||
if columns is None:
|
||||
quality.no_data_symbols.append(symbol)
|
||||
continue
|
||||
quality.short_history += _has_short_history(columns)
|
||||
dispatched.append((symbol, loop.run_in_executor(
|
||||
pool,
|
||||
_replay_and_signals,
|
||||
symbol,
|
||||
columns,
|
||||
config,
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
symbol in rank_only_symbols,
|
||||
terminal_events.get(symbol),
|
||||
)))
|
||||
results = await asyncio.gather(
|
||||
*(fut for _, fut in dispatched), return_exceptions=True
|
||||
)
|
||||
for (symbol, _), result in zip(dispatched, results):
|
||||
if isinstance(result, BaseException):
|
||||
quality.record_failure(symbol, "replay", result)
|
||||
logger.error(json.dumps({
|
||||
"event": "backtest_worker_error",
|
||||
"symbol": symbol,
|
||||
"message": str(result),
|
||||
}))
|
||||
else:
|
||||
quality.evaluated += 1
|
||||
_merge(result)
|
||||
done += len(batch)
|
||||
if progress_cb is not None:
|
||||
@@ -4172,21 +4438,34 @@ async def run_backtest(
|
||||
progress_cb(index, total, symbol)
|
||||
try:
|
||||
columns = await _fetch_columns(db, symbol)
|
||||
if columns is not None:
|
||||
_merge(await asyncio.to_thread(
|
||||
_replay_and_signals,
|
||||
symbol,
|
||||
columns,
|
||||
config,
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
symbol in rank_only_symbols,
|
||||
))
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
quality.record_failure(symbol, "fetch", exc)
|
||||
logger.exception("Backtest fetch failed for %s", symbol)
|
||||
await _rollback_quietly(db, f"fetch for {symbol}")
|
||||
continue
|
||||
if columns is None:
|
||||
quality.no_data_symbols.append(symbol)
|
||||
continue
|
||||
quality.short_history += _has_short_history(columns)
|
||||
try:
|
||||
_merge(await asyncio.to_thread(
|
||||
_replay_and_signals,
|
||||
symbol,
|
||||
columns,
|
||||
config,
|
||||
activation,
|
||||
benchmark_closes,
|
||||
target_model,
|
||||
cadence,
|
||||
symbol in rank_only_symbols,
|
||||
terminal_events.get(symbol),
|
||||
))
|
||||
except Exception as exc:
|
||||
quality.record_failure(symbol, "replay", exc)
|
||||
logger.exception("Backtest replay failed for %s", symbol)
|
||||
await _rollback_quietly(db, f"replay for {symbol}")
|
||||
continue
|
||||
quality.evaluated += 1
|
||||
|
||||
if progress_cb is not None and total:
|
||||
progress_cb(total, total, "")
|
||||
@@ -4298,12 +4577,21 @@ async def run_backtest(
|
||||
# backstop for whichever DB call actually failed.
|
||||
await _rollback_quietly(db, "portfolio simulation")
|
||||
|
||||
data_quality = quality.as_report()
|
||||
if not data_quality["complete"]:
|
||||
logger.warning(json.dumps({
|
||||
"event": "backtest_incomplete",
|
||||
"status": data_quality["status"],
|
||||
"reasons": data_quality["reasons"],
|
||||
}))
|
||||
|
||||
report = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"tickers": total,
|
||||
"rank_only_tickers": len(rank_only_symbols),
|
||||
"candidates": len(candidates),
|
||||
"qualified": len(qualified),
|
||||
"data_quality": data_quality,
|
||||
"params": {
|
||||
# Keep step_days for old report consumers; the value counts stored
|
||||
# market sessions rather than calendar days.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import type { BacktestDataQuality } from '../../lib/types';
|
||||
|
||||
/** How many stocks were expected, loaded and actually evaluated.
|
||||
*
|
||||
* A run that lost every ticker still writes a report — the replay loop catches
|
||||
* per-ticker errors so one bad name can't abort a 500-symbol run — and that
|
||||
* report used to be indistinguishable from an honest one that found no setups.
|
||||
* When the status is `failed` the panel shows this instead of the performance
|
||||
* tiles, because a headline computed from a broken run is worse than none.
|
||||
*/
|
||||
export function BacktestDataQualityCard({ quality }: { quality: BacktestDataQuality }) {
|
||||
const failed = quality.status === 'failed';
|
||||
const { benchmark } = quality;
|
||||
|
||||
return (
|
||||
<Callout variant={failed ? 'error' : 'warning'}>
|
||||
<p className="font-medium">
|
||||
{failed ? 'This run is not usable' : 'This run is incomplete'}
|
||||
</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-4 text-xs opacity-90">
|
||||
{quality.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-xs opacity-80">
|
||||
{quality.evaluated} of {quality.expected} tickers evaluated
|
||||
{quality.loaded !== quality.evaluated && <> · {quality.loaded} loaded</>}
|
||||
{quality.short_history > 0 && (
|
||||
<> · {quality.short_history} too short to trade</>
|
||||
)}
|
||||
{' · '}
|
||||
{benchmark.momentum_basis === 'residual' ? (
|
||||
<>
|
||||
{benchmark.symbol} {benchmark.sessions} sessions
|
||||
{benchmark.first && benchmark.last && (
|
||||
<> ({benchmark.first} → {benchmark.last})</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>no {benchmark.symbol} benchmark — momentum ran raw, not residual</>
|
||||
)}
|
||||
</p>
|
||||
{failed && (
|
||||
<p className="mt-2 text-xs opacity-80">
|
||||
Performance figures are hidden for this run. Re-run the backtest once the
|
||||
underlying data is readable.
|
||||
</p>
|
||||
)}
|
||||
{/* Says what the check covers, so a clean run isn't read as a price audit. */}
|
||||
<p className="mt-2 text-[11px] opacity-70">
|
||||
Processing and coverage checks only — this does not verify that the underlying
|
||||
prices are correct.
|
||||
</p>
|
||||
{quality.failures.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<Disclosure summary={`Failures (${quality.failed})`}>
|
||||
<ul className="space-y-1 font-mono text-[11px] opacity-80">
|
||||
{quality.failures.map((f) => (
|
||||
<li key={`${f.symbol}-${f.stage}`}>
|
||||
{f.symbol} · {f.stage} · {f.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{quality.failures_truncated && (
|
||||
<p className="mt-1 text-[11px] opacity-70">
|
||||
Showing the first {quality.failures.length} of {quality.failed} — the
|
||||
rest are in the run log.
|
||||
</p>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
)}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Disclosure } from '../ui/Disclosure';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import { Section } from '../ui/Section';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { BacktestDataQualityCard } from './BacktestDataQualityCard';
|
||||
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
|
||||
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
|
||||
|
||||
@@ -39,6 +40,8 @@ export function BacktestPanel() {
|
||||
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
|
||||
const [cadence, setCadence] = useState<BacktestCadence>('weekly');
|
||||
|
||||
const quality = report?.data_quality ?? null;
|
||||
const runIsUnusable = quality?.status === 'failed';
|
||||
const monitor = report?.portfolio_monitor ?? null;
|
||||
const activeStrategy =
|
||||
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
|
||||
@@ -89,8 +92,15 @@ export function BacktestPanel() {
|
||||
<p className="section-index">Last run</p>
|
||||
{report ? (
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{timeAgo(report.generated_at)} · {report.tickers} tickers ·{' '}
|
||||
{report.candidates} setups ({report.qualified} qualified) ·{' '}
|
||||
{timeAgo(report.generated_at)} ·{' '}
|
||||
{/* Evaluated over expected, not the universe size: "506 tickers"
|
||||
on a run that evaluated none was the misleading part. */}
|
||||
<span className={quality && quality.status !== 'ok' ? 'text-amber-300' : undefined}>
|
||||
{quality
|
||||
? `${quality.evaluated}/${quality.expected} tickers`
|
||||
: `${report.tickers} tickers`}
|
||||
</span>{' '}
|
||||
· {report.candidates} setups ({report.qualified} qualified) ·{' '}
|
||||
{report.params.entry_cadence ?? 'weekly'},{' '}
|
||||
{report.params.horizon_days}d horizon
|
||||
{report.params.cost_per_side_pct != null && (
|
||||
@@ -177,7 +187,20 @@ export function BacktestPanel() {
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
{/* Completeness gates the headline. `failed` replaces the tiles rather
|
||||
than warning above them — a banner over ordinary-looking numbers is
|
||||
exactly how a zero-candidate run reads as a quiet week. */}
|
||||
{report && quality && quality.status !== 'ok' && (
|
||||
<BacktestDataQualityCard quality={quality} />
|
||||
)}
|
||||
|
||||
{report && !quality && (
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Completeness was not recorded for this run — it predates the check.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report && !runIsUnusable && (
|
||||
<>
|
||||
<PortfolioMonitorPanel
|
||||
monitor={monitor}
|
||||
|
||||
@@ -38,14 +38,18 @@ export function EvaluationPanel() {
|
||||
const { data: report } = useBacktestReport();
|
||||
const liveAvgR = perf?.overall.avg_r ?? null;
|
||||
const liveN = perf?.overall.total ?? 0;
|
||||
const btAvgR = report?.overall_qualified.avg_r ?? null;
|
||||
// A failed run's expectancy is not a baseline — comparing live results to it
|
||||
// would report "drift" or "tracking" off numbers from a broken replay.
|
||||
const btAvgR =
|
||||
report?.data_quality?.status === 'failed' ? null : (report?.overall_qualified.avg_r ?? null);
|
||||
let status: PipelineStatus = 'building';
|
||||
if (liveAvgR != null && liveN >= MIN_MATURED) {
|
||||
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
|
||||
}
|
||||
const statusNote: Record<PipelineStatus, string> = {
|
||||
building: `Fewer than ~${MIN_MATURED} matured setups so far — too few to compare.`,
|
||||
'no-backtest': 'Run the backtest to get a target/stop baseline to check against.',
|
||||
'no-backtest':
|
||||
'No usable backtest baseline to check against — run the backtest, or fix the data the last run reported as incomplete.',
|
||||
tracking:
|
||||
"Live setup outcomes are resolving in line with the backtest's target/stop model — the outcome-evaluation pipeline shows no look-ahead, config or data drift. (Checks the setup-grading pipeline, not the ATR-trail production book above.)",
|
||||
drift:
|
||||
|
||||
@@ -44,9 +44,15 @@ const EXIT_LABELS: Record<string, string> = {
|
||||
|
||||
/**
|
||||
* Pull the production strategy's full-history row out of a backtest report.
|
||||
* Returns null when the report hasn't run or has no production row.
|
||||
* Returns null when the report hasn't run, failed its completeness check, or
|
||||
* has no production row.
|
||||
*/
|
||||
export function productionBaseRates(report: unknown): BaseRates | null {
|
||||
// Base rates quoted next to a live setup carry no caveat, so a run that lost
|
||||
// its universe must yield none at all rather than plausible-looking ones.
|
||||
if ((report as { data_quality?: { status?: string } })?.data_quality?.status === 'failed') {
|
||||
return null;
|
||||
}
|
||||
const monitor = (report as { portfolio_monitor?: { production_strategy?: string; runs?: MonitorRun[] } })
|
||||
?.portfolio_monitor;
|
||||
if (!monitor?.runs?.length) return null;
|
||||
|
||||
@@ -427,11 +427,39 @@ export interface BacktestSignalEvalRow {
|
||||
reliable: boolean;
|
||||
}
|
||||
|
||||
export interface BacktestDataQuality {
|
||||
/** `failed` = the headline can't be read as the strategy it claims to be. */
|
||||
status: 'ok' | 'degraded' | 'failed';
|
||||
complete: boolean;
|
||||
reasons: string[];
|
||||
expected: number;
|
||||
loaded: number;
|
||||
evaluated: number;
|
||||
no_data: number;
|
||||
no_data_symbols: string[];
|
||||
short_history: number;
|
||||
failed: number;
|
||||
failures_by_stage: Record<string, number>;
|
||||
failures: { symbol: string; stage: string; error: string }[];
|
||||
failures_truncated: boolean;
|
||||
benchmark: {
|
||||
symbol: string;
|
||||
sessions: number;
|
||||
first: string | null;
|
||||
last: string | null;
|
||||
momentum_basis: 'residual' | 'raw_fallback';
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BacktestReport {
|
||||
generated_at: string;
|
||||
tickers: number;
|
||||
candidates: number;
|
||||
qualified: number;
|
||||
/** Absent on reports generated before completeness tracking — treat as
|
||||
* unknown rather than clean; that assumption is the bug this block fixes. */
|
||||
data_quality?: BacktestDataQuality;
|
||||
params: {
|
||||
step_days: number;
|
||||
step_sessions?: number;
|
||||
@@ -1010,9 +1038,23 @@ export interface TickerUniverseBootstrapResult {
|
||||
total_universe_symbols: number;
|
||||
added: number;
|
||||
already_tracked: number;
|
||||
/** Always 0 — pruning marks symbols untracked and never deletes rows. */
|
||||
deleted: number;
|
||||
/** Symbols dropped from the scanner; their bars and linked records remain. */
|
||||
untracked?: number;
|
||||
untracked_symbols?: string[];
|
||||
/** Symbols that rejoined the universe and were returned to the live path. */
|
||||
retracked?: number;
|
||||
/** Absent from the universe but manually owned, so a prune left them alone. */
|
||||
kept_manual?: string[];
|
||||
kept_manual_count?: number;
|
||||
/** False when the live fetch failed (cache/seed fallback) — nothing was recorded. */
|
||||
membership_observed?: boolean;
|
||||
membership_opened?: number;
|
||||
membership_closed?: number;
|
||||
/** Sample of newly added symbols (capped server-side). */
|
||||
added_symbols?: string[];
|
||||
prune_note?: string;
|
||||
}
|
||||
|
||||
export interface PipelineReadiness {
|
||||
|
||||
@@ -72,9 +72,29 @@ def _parse_args() -> argparse.Namespace:
|
||||
default=None,
|
||||
help="Add a disjoint train/test portfolio report split at YYYY-MM-DD.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--terminal-events",
|
||||
default=None,
|
||||
help=(
|
||||
"JSON map of {symbol: YYYY-MM-DD} marking symbols whose final bar is a "
|
||||
"research terminal mark, written by prep_survivorship_snapshot.py. Those "
|
||||
"symbols are replayed through their last real bar instead of stopping 30 "
|
||||
"bars early; every other symbol is unaffected."
|
||||
),
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_terminal_events(path: str | None) -> dict[str, date]:
|
||||
"""Parse the terminal-event map. Explicit input only — never inferred."""
|
||||
if not path:
|
||||
return {}
|
||||
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise SystemExit(f"Terminal events file must be a JSON object: {path}")
|
||||
return {str(sym).upper(): date.fromisoformat(str(value)) for sym, value in raw.items()}
|
||||
|
||||
|
||||
def _default_output_path(cadence: str) -> Path:
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
return Path("reports") / f"backtest-{cadence}-{stamp}.json"
|
||||
@@ -190,6 +210,10 @@ async def _main() -> None:
|
||||
raise SystemExit("--holdout-split must use YYYY-MM-DD") from exc
|
||||
os.environ["BACKTEST_HOLDOUT_SPLIT"] = args.holdout_split
|
||||
|
||||
terminal_events = _load_terminal_events(args.terminal_events)
|
||||
if terminal_events:
|
||||
print(f"terminal events: {len(terminal_events)} symbols ({', '.join(sorted(terminal_events))})")
|
||||
|
||||
from app.config import settings
|
||||
from app.services.backtest_service import run_backtest
|
||||
|
||||
@@ -222,6 +246,7 @@ async def _main() -> None:
|
||||
progress_cb=progress,
|
||||
target_model=args.target_model,
|
||||
cadence=args.cadence,
|
||||
terminal_events=terminal_events,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Guards for the backtest completeness ledger.
|
||||
|
||||
The failure this exists to prevent is concrete: a snapshot whose schema predates
|
||||
a migration makes every ``_fetch_columns`` call raise, both dispatch paths catch
|
||||
per-ticker exceptions and continue, and the run writes a report with zero
|
||||
candidates that is indistinguishable from an honest run that found no setups.
|
||||
The ledger has to turn that into a report that says so.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.services.backtest_service import (
|
||||
DATA_QUALITY_FAILURE_SAMPLE,
|
||||
HORIZON,
|
||||
MIN_LOOKBACK,
|
||||
_DataQuality,
|
||||
_has_short_history,
|
||||
)
|
||||
|
||||
|
||||
def _healthy(expected: int = 3, evaluated: int = 3) -> _DataQuality:
|
||||
q = _DataQuality(expected=expected, evaluated=evaluated)
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0, date(2024, 1, 3): 471.0}, None)
|
||||
return q
|
||||
|
||||
|
||||
def test_a_complete_run_is_ok():
|
||||
report = _healthy().as_report()
|
||||
assert report["status"] == "ok"
|
||||
assert report["complete"] is True
|
||||
assert report["reasons"] == []
|
||||
assert report["loaded"] == report["evaluated"] == 3
|
||||
assert report["benchmark"]["momentum_basis"] == "residual"
|
||||
assert report["benchmark"]["first"] == "2024-01-02"
|
||||
assert report["benchmark"]["last"] == "2024-01-03"
|
||||
|
||||
|
||||
def test_every_symbol_failing_to_load_reads_as_failed_not_as_no_setups():
|
||||
"""The unsynced-snapshot case: 3 expected, 3 fetch errors, 0 evaluated."""
|
||||
q = _DataQuality(expected=3)
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0}, None)
|
||||
for symbol in ("AAPL", "MSFT", "NVDA"):
|
||||
q.record_failure(symbol, "fetch", RuntimeError("no such column: tickers.cik"))
|
||||
|
||||
report = q.as_report()
|
||||
assert report["status"] == "failed"
|
||||
assert report["complete"] is False
|
||||
assert report["evaluated"] == 0
|
||||
assert report["loaded"] == 0
|
||||
assert report["failed"] == 3
|
||||
assert report["failures_by_stage"] == {"fetch": 3}
|
||||
assert "no symbol was evaluated successfully" in report["reasons"]
|
||||
assert report["failures"][0]["symbol"] == "AAPL"
|
||||
assert "tickers.cik" in report["failures"][0]["error"]
|
||||
|
||||
|
||||
def test_a_few_failures_degrade_but_do_not_fail_the_run():
|
||||
q = _DataQuality(expected=10, evaluated=8, no_data_symbols=["RHM"])
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0}, None)
|
||||
q.record_failure("HBI", "replay", ValueError("bad bar"))
|
||||
|
||||
report = q.as_report()
|
||||
assert report["status"] == "degraded"
|
||||
assert report["complete"] is False
|
||||
assert report["loaded"] == 9 # no_data is not a load failure
|
||||
assert report["reasons"] == [
|
||||
"1 symbol failed during replay",
|
||||
"1 symbol had no stored bars (RHM)",
|
||||
]
|
||||
|
||||
|
||||
def test_bar_less_symbols_are_named_so_the_reason_is_actionable():
|
||||
"""A universe row with no bars recurs on every run until someone clears it.
|
||||
An unnamed count would just nag; the symbol makes it a one-line fix."""
|
||||
q = _DataQuality(expected=9, evaluated=2, no_data_symbols=list("ABCDEFG"))
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0}, None)
|
||||
q.evaluated = 2
|
||||
|
||||
report = q.as_report()
|
||||
assert report["no_data"] == 7
|
||||
assert report["no_data_symbols"] == ["A", "B", "C", "D", "E"]
|
||||
assert report["reasons"] == ["7 symbols had no stored bars (A, B, C, D, E, …)"]
|
||||
|
||||
|
||||
def test_a_missing_benchmark_fails_the_run_because_the_signal_changed():
|
||||
"""Without SPY the gate ranks on raw 12-1 momentum, so the headline no
|
||||
longer describes the production strategy even though every ticker loaded."""
|
||||
q = _DataQuality(expected=3, evaluated=3)
|
||||
q.record_benchmark({}, ConnectionError("alpaca unreachable"))
|
||||
|
||||
report = q.as_report()
|
||||
assert report["status"] == "failed"
|
||||
assert report["benchmark"]["momentum_basis"] == "raw_fallback"
|
||||
assert report["benchmark"]["sessions"] == 0
|
||||
assert "alpaca unreachable" in report["benchmark"]["error"]
|
||||
assert any("fell back to raw momentum" in r for r in report["reasons"])
|
||||
|
||||
|
||||
def test_an_unbalanced_ledger_is_itself_a_failure():
|
||||
"""If a counter stops being maintained the block must not quietly report a
|
||||
clean run — the arithmetic is the self-check."""
|
||||
q = _healthy(expected=10, evaluated=3) # 7 symbols unaccounted for
|
||||
report = q.as_report()
|
||||
assert report["status"] == "failed"
|
||||
assert any("ledger does not balance" in r for r in report["reasons"])
|
||||
|
||||
|
||||
def test_an_empty_universe_is_a_failure():
|
||||
q = _DataQuality(expected=0)
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0}, None)
|
||||
assert q.as_report()["status"] == "failed"
|
||||
|
||||
|
||||
def test_the_failure_list_is_capped_but_the_count_is_not():
|
||||
q = _DataQuality(expected=100)
|
||||
q.record_benchmark({date(2024, 1, 2): 470.0}, None)
|
||||
for i in range(60):
|
||||
q.record_failure(f"SYM{i}", "fetch", RuntimeError("boom"))
|
||||
q.evaluated = 40
|
||||
|
||||
report = q.as_report()
|
||||
assert report["failed"] == 60
|
||||
assert len(report["failures"]) == DATA_QUALITY_FAILURE_SAMPLE
|
||||
assert report["failures_truncated"] is True
|
||||
|
||||
|
||||
def test_short_history_counts_a_recent_listing_without_failing_the_run():
|
||||
q = _healthy()
|
||||
q.short_history = 1
|
||||
report = q.as_report()
|
||||
assert report["short_history"] == 1
|
||||
assert report["status"] == "ok", "a recent listing is not a data-quality failure"
|
||||
|
||||
|
||||
def test_has_short_history_uses_the_replay_minimum():
|
||||
enough = ([0] * (MIN_LOOKBACK + HORIZON),) + ((),) * 5
|
||||
one_short = ([0] * (MIN_LOOKBACK + HORIZON - 1),) + ((),) * 5
|
||||
assert _has_short_history(enough) == 0
|
||||
assert _has_short_history(one_short) == 1
|
||||
@@ -1689,6 +1689,74 @@ async def test_run_backtest_rolls_back_a_failed_ticker_fetch(session, monkeypatc
|
||||
assert report["tickers"] == 2
|
||||
assert report["candidates"] >= 1
|
||||
|
||||
# ...and the report says one ticker was lost rather than presenting the
|
||||
# remaining one as the whole universe.
|
||||
quality = report["data_quality"]
|
||||
assert quality["expected"] == 2
|
||||
assert quality["evaluated"] == 1
|
||||
assert quality["failed"] == 1
|
||||
assert quality["failures"][0]["symbol"] == "AAA"
|
||||
assert quality["failures"][0]["stage"] == "fetch"
|
||||
assert "simulated OHLCV read failure" in quality["failures"][0]["error"]
|
||||
assert quality["complete"] is False
|
||||
assert quality["status"] == "degraded"
|
||||
|
||||
|
||||
async def test_run_backtest_reports_its_own_completeness(session):
|
||||
"""The healthy case: every ticker accounted for, so the report says so."""
|
||||
await _seed_oscillating_ticker(session, "OSC")
|
||||
report = await bt.run_backtest(session)
|
||||
|
||||
quality = report["data_quality"]
|
||||
assert quality["expected"] == report["tickers"] == 1
|
||||
assert quality["evaluated"] == 1
|
||||
assert quality["failed"] == 0
|
||||
assert quality["no_data"] == 0
|
||||
# Ledger identity — the three terminal buckets must cover the universe.
|
||||
assert quality["no_data"] + quality["failed"] + quality["evaluated"] == quality["expected"]
|
||||
|
||||
|
||||
async def test_run_backtest_marks_a_run_failed_when_every_ticker_fails(session, monkeypatch):
|
||||
"""The demonstrated failure: an unsynced snapshot raises on every read, the
|
||||
replay loop swallows each one, and the run writes a report with no
|
||||
candidates. It must come back "failed", not look like a quiet run.
|
||||
"""
|
||||
await _seed_oscillating_ticker(session, "AAA")
|
||||
await _seed_oscillating_ticker(session, "OSC")
|
||||
|
||||
async def always_failing_fetch(db, symbol):
|
||||
raise RuntimeError("no such column: tickers.cik")
|
||||
|
||||
monkeypatch.setattr(bt, "_fetch_columns", always_failing_fetch)
|
||||
|
||||
report = await bt.run_backtest(session)
|
||||
|
||||
assert report["candidates"] == 0
|
||||
quality = report["data_quality"]
|
||||
assert quality["status"] == "failed"
|
||||
assert quality["evaluated"] == 0
|
||||
assert quality["loaded"] == 0
|
||||
assert quality["failed"] == 2
|
||||
assert "no symbol was evaluated successfully" in quality["reasons"]
|
||||
|
||||
|
||||
async def test_run_backtest_flags_a_missing_benchmark(session, monkeypatch):
|
||||
"""Residual momentum degrades to raw without SPY. The numbers then describe
|
||||
a different gate than the one the report claims, so the run is not clean."""
|
||||
await _seed_oscillating_ticker(session, "OSC")
|
||||
|
||||
async def no_benchmark(db, **kwargs):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(bt, "_load_benchmark_closes_for_backtest", no_benchmark)
|
||||
|
||||
report = await bt.run_backtest(session)
|
||||
benchmark = report["data_quality"]["benchmark"]
|
||||
|
||||
assert benchmark["sessions"] == 0
|
||||
assert benchmark["momentum_basis"] == "raw_fallback"
|
||||
assert report["data_quality"]["status"] == "failed"
|
||||
|
||||
|
||||
async def test_run_backtest_rolls_back_a_failed_portfolio_sim_load(session, monkeypatch):
|
||||
"""The portfolio-sim block loads the benchmark and the live exit policy from
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Runner-level guard: a stale terminal-event map must abort the whole run.
|
||||
|
||||
``_replay_ticker`` rejects a mismatched date, but both dispatch paths in
|
||||
``run_backtest`` swallow per-ticker exceptions so one unreadable symbol cannot
|
||||
kill a long replay. That safety net would turn a wrong events file into a
|
||||
quietly incomplete report, so the map is validated before any replay starts —
|
||||
and the runner must therefore write no report at all.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.database import Base
|
||||
from app.models.ohlcv import OHLCVRecord
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.backtest_service import TerminalEventError, run_backtest
|
||||
|
||||
|
||||
async def _make_snapshot(path: Path, *, symbol: str = "DEAD", bars: int = 40) -> date:
|
||||
"""A minimal snapshot with one ticker. Returns its final bar date."""
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
day = date(2022, 1, 3)
|
||||
last = day
|
||||
async with Session() as db:
|
||||
ticker = Ticker(symbol=symbol, name=symbol, created_at=datetime.utcnow())
|
||||
db.add(ticker)
|
||||
await db.flush()
|
||||
for i in range(bars):
|
||||
while day.weekday() >= 5:
|
||||
day += timedelta(days=1)
|
||||
price = 100.0 + i
|
||||
db.add(OHLCVRecord(
|
||||
ticker_id=ticker.id, date=day, open=price, high=price * 1.01,
|
||||
low=price * 0.99, close=price, volume=1_000_000,
|
||||
created_at=datetime.utcnow(),
|
||||
))
|
||||
last = day
|
||||
day += timedelta(days=1)
|
||||
await db.commit()
|
||||
await engine.dispose()
|
||||
return last
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_mismatched_terminal_date_aborts_the_run(tmp_path):
|
||||
snapshot = tmp_path / "snap.sqlite"
|
||||
final_bar = await _make_snapshot(snapshot)
|
||||
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{snapshot.as_posix()}")
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
try:
|
||||
async with Session() as db:
|
||||
with pytest.raises(TerminalEventError, match="final bar"):
|
||||
await run_backtest(db, terminal_events={"DEAD": final_bar + timedelta(days=5)})
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_symbol_aborts_the_run(tmp_path):
|
||||
snapshot = tmp_path / "snap.sqlite"
|
||||
await _make_snapshot(snapshot)
|
||||
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{snapshot.as_posix()}")
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
try:
|
||||
async with Session() as db:
|
||||
with pytest.raises(TerminalEventError, match="not in this snapshot"):
|
||||
await run_backtest(db, terminal_events={"NOPE": date(2022, 2, 1)})
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_runner_writes_no_report_when_validation_fails(tmp_path, monkeypatch):
|
||||
"""The report is written only after run_backtest returns, so an abort must
|
||||
leave the output path untouched rather than a partial or empty report."""
|
||||
snapshot = tmp_path / "snap.sqlite"
|
||||
final_bar = await _make_snapshot(snapshot)
|
||||
|
||||
events = tmp_path / "events.json"
|
||||
events.write_text(json.dumps({"DEAD": (final_bar + timedelta(days=5)).isoformat()}))
|
||||
out = tmp_path / "report.json"
|
||||
|
||||
import scripts.run_backtest_snapshot as runner
|
||||
|
||||
monkeypatch.setattr(
|
||||
runner.sys, "argv",
|
||||
["run_backtest_snapshot.py", str(snapshot), "--out", str(out),
|
||||
"--terminal-events", str(events), "--quiet", "--workers", "1"],
|
||||
)
|
||||
|
||||
with pytest.raises(TerminalEventError):
|
||||
await runner._main()
|
||||
|
||||
assert not out.exists(), "a failed run must not leave a report behind"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_matching_map_is_accepted(tmp_path):
|
||||
"""The guard must not reject a correct map — the run proceeds normally."""
|
||||
snapshot = tmp_path / "snap.sqlite"
|
||||
final_bar = await _make_snapshot(snapshot)
|
||||
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{snapshot.as_posix()}")
|
||||
Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
try:
|
||||
async with Session() as db:
|
||||
report = await run_backtest(db, terminal_events={"DEAD": final_bar})
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
assert isinstance(report, dict)
|
||||
Reference in new issue
Block a user