refactor(signals): split the Track Record tab and cut the backtest page down

One tab stacked three things that all called themselves a track record:
realized paper P&L, setup-outcome grading under the rejected take-profit model,
and the backtest portfolio simulation. Split into Setups | Paper Trades |
Backtest, one subject each. `track` stays the Paper Trades slug so the legacy
/performance redirect keeps working. The grading diagnostic and its Evaluate /
Reset controls go with Backtest, not Paper Trades — reset_track_record deletes
trade_setups, not paper trades.

BacktestPanel 439 -> 175 lines. Its run settings alone were 106 lines of
hand-rolled sr-only radio cards for two binary choices; they are now two
Dropdowns and a button on one wrapping row, with the per-option prose moved into
the existing explainer. The amber warnings survive as a conditional slot, so a
non-default choice still announces itself but the common path is silent.

The recommendation printed eight findings at equal weight, burying the verdict
in tuning detail. `topic` now splits them: production, benchmark and robustness
stay inline, gate/exit/cutoff collapse behind a disclosure, and any WARNING or
LAGS item is promoted out of the collapsed group regardless of topic. No topic
chips — every backend string already self-prefixes, so a chip would render
"GATE | Gate: ...".

Portfolio metrics are now two tiers: five headline tiles for what the book
returned, then a smaller labelled row for how good that return was (Sortino,
Calmar (MAR), Gain/Pain, Profit Factor $, EV/trade). Reports cached before those
metrics existed hide the second row rather than showing a half-populated line of
dashes.

Extracted EquityCurveChart, PortfolioMonitorPanel and BacktestRecommendationCard,
plus a StatTile primitive and shared formatters for the duplication in the files
this touched. DashboardPage and OpenTradesPanel deliberately keep their own
copies — migrating them is separate scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dennisthiessenandClaude Opus 5 committed 2026-08-11 22:09:23 +02:00
1 parent 442dc3f04b
commit 13a984a84d
10 files changed
+599 -397

No files matched your search

@@ -0,0 +1,89 @@
import { Callout } from '../ui/Callout';
import { fmtSignedPct } from '../../lib/format';
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
/**
* Portfolio return vs S&P 500 for one monitor run.
*
* Hand-rolled SVG on purpose: two polylines and two axis rules do not justify a
* charting dependency, and the shape is fixed. Lives in `signals/` rather than
* `ui/` because it is typed to the backtest payload — generalising it for a
* single caller would be the wrong trade.
*/
function curvePath(
points: BacktestCurvePoint[],
min: number,
max: number,
w: number,
h: number,
pad: number,
startMs: number,
endMs: number,
): string {
if (points.length < 2) return '';
const span = Math.max(max - min, 1);
const timeSpan = Math.max(endMs - startMs, 1);
return points
.map((p, i) => {
const t = new Date(p.date).getTime();
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
const value = p.return_pct ?? 0;
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
}
export function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
const portfolio = run.equity_curve ?? [];
const benchmark = run.benchmark_curve ?? [];
const values = [...portfolio, ...benchmark]
.map((p) => p.return_pct)
.filter((v): v is number => v !== null && v !== undefined);
if (portfolio.length < 2 || values.length === 0) {
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
}
const min = Math.min(0, ...values);
const max = Math.max(0, ...values);
const times = [...portfolio, ...benchmark]
.map((p) => new Date(p.date).getTime())
.filter((v) => Number.isFinite(v));
if (times.length === 0) {
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
}
const startMs = Math.min(...times);
const endMs = Math.max(...times);
const w = 720;
const h = 240;
const pad = 28;
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
return (
<div className="glass overflow-hidden">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
<div>
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
</div>
<div className="flex gap-4 text-xs">
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
</div>
</div>
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
{benchmarkPath && (
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
)}
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
</svg>
</div>
);
}