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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user