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

@@ -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:
+7 -1
View File
@@ -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;
+42
View File
@@ -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 {