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>
90 lines
3.7 KiB
TypeScript
90 lines
3.7 KiB
TypeScript
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>
|
|
);
|
|
}
|