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:
@@ -9,35 +9,8 @@ import { Disclosure } from '../ui/Disclosure';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import { Section } from '../ui/Section';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
||||
|
||||
function fmtR(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||
}
|
||||
function fmtPct(v: number | null): string {
|
||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtMoney(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
function fmtSignedPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtDrawdown(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
||||
}
|
||||
function fmtDays(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||
}
|
||||
function rColor(v: number | null): string {
|
||||
if (v === null) return 'text-gray-400';
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
if (v < 0) return 'text-red-400';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
|
||||
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||
@@ -48,95 +21,14 @@ function timeAgo(iso: string): string {
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
||||
label: string; value: string; valueClass?: string; sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass p-4">
|
||||
<p className="section-index">{label}</p>
|
||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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(' ');
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
const TARGET_MODEL_OPTIONS = [
|
||||
{ value: 'production_gtl', label: 'Live GTL — production' },
|
||||
{ value: 'structural_sr', label: 'Structural S/R — comparison' },
|
||||
];
|
||||
const CADENCE_OPTIONS = [
|
||||
{ value: 'weekly', label: 'Weekly — default' },
|
||||
{ value: 'daily', label: 'Daily — research' },
|
||||
];
|
||||
|
||||
export function BacktestPanel() {
|
||||
const { data: report, isLoading } = useBacktestReport();
|
||||
@@ -187,114 +79,58 @@ export function BacktestPanel() {
|
||||
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||
so read it as directional.
|
||||
</p>
|
||||
<p className="mt-2 max-w-2xl text-xs text-gray-400">
|
||||
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
|
||||
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
|
||||
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
|
||||
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
|
||||
{' '}is roughly 5× the replay work.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
|
||||
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
|
||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
||||
Target model for this run
|
||||
</legend>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
||||
targetModel === 'production_gtl'
|
||||
? 'border-blue-400/60 bg-blue-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="production_gtl"
|
||||
checked={targetModel === 'production_gtl'}
|
||||
onChange={() => setTargetModel('production_gtl')}
|
||||
/>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
||||
Live GTL
|
||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
||||
Production
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Exact target path used by the live scanner and scheduled backtest.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
||||
targetModel === 'structural_sr'
|
||||
? 'border-amber-400/50 bg-amber-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="structural_sr"
|
||||
checked={targetModel === 'structural_sr'}
|
||||
onChange={() => setTargetModel('structural_sr')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200">Structural S/R</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Comparison only; uses chart structure as the target source.
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset className="grid w-full grid-cols-2 gap-2 sm:w-[34rem]">
|
||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
||||
Entry cadence
|
||||
</legend>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
||||
cadence === 'weekly'
|
||||
? 'border-blue-400/60 bg-blue-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-cadence"
|
||||
value="weekly"
|
||||
checked={cadence === 'weekly'}
|
||||
onChange={() => setCadence('weekly')}
|
||||
/>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
||||
Weekly
|
||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
||||
Default
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Resource-safe server run at five-session intervals.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
||||
cadence === 'daily'
|
||||
? 'border-amber-400/50 bg-amber-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-cadence"
|
||||
value="daily"
|
||||
checked={cadence === 'daily'}
|
||||
onChange={() => setCadence('daily')}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-200">Daily</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-amber-300/80">
|
||||
Research run: roughly 5× the replay work; prefer the offline snapshot runner.
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
|
||||
narrow viewport otherwise. */}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="backtest-target-model">Target model</label>
|
||||
<Dropdown
|
||||
id="backtest-target-model"
|
||||
className="w-56 normal-case tracking-normal"
|
||||
value={targetModel}
|
||||
onChange={(v) => setTargetModel(v as BacktestTargetModel)}
|
||||
options={TARGET_MODEL_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="backtest-cadence">Entry cadence</label>
|
||||
<Dropdown
|
||||
id="backtest-cadence"
|
||||
className="w-44 normal-case tracking-normal"
|
||||
value={cadence}
|
||||
onChange={(v) => setCadence(v as BacktestCadence)}
|
||||
options={CADENCE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only surfaced for non-default choices — zero noise on the common path,
|
||||
but a non-production selection still announces itself, which is what
|
||||
the old always-amber cards were really for. */}
|
||||
{(cadence === 'daily' || targetModel === 'structural_sr') && (
|
||||
<div className="space-y-1 text-[11px] text-amber-300/80">
|
||||
{cadence === 'daily' && (
|
||||
<p>Daily replays ~5× the work — prefer the offline snapshot runner.</p>
|
||||
)}
|
||||
{targetModel === 'structural_sr' && (
|
||||
<p>Comparison arm — not the live scanner's target path.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||||
|
||||
{!isLoading && !report && (
|
||||
@@ -319,118 +155,18 @@ export function BacktestPanel() {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{monitor && monitorRun ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-index">Portfolio monitor</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="monitor-strategy">Strategy</label>
|
||||
<Dropdown
|
||||
id="monitor-strategy"
|
||||
className="w-64 normal-case tracking-normal"
|
||||
value={activeStrategy}
|
||||
onChange={setSelectedStrategy}
|
||||
options={monitor.strategies.map((s) => ({
|
||||
value: s.strategy,
|
||||
label: `${s.is_production ? 'Production: ' : ''}${s.label}`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="monitor-lookback">Lookback</label>
|
||||
<Dropdown
|
||||
id="monitor-lookback"
|
||||
className="w-36 normal-case tracking-normal"
|
||||
value={activeLookback}
|
||||
onChange={setSelectedLookback}
|
||||
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PortfolioMonitorPanel
|
||||
monitor={monitor}
|
||||
monitorRun={monitorRun}
|
||||
activeStrategy={activeStrategy}
|
||||
activeLookback={activeLookback}
|
||||
onStrategyChange={setSelectedStrategy}
|
||||
onLookbackChange={setSelectedLookback}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
||||
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
||||
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
||||
<Stat
|
||||
label="Total Return"
|
||||
value={fmtSignedPct(monitorRun.total_return_pct)}
|
||||
valueClass={rColor(monitorRun.total_return_pct)}
|
||||
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
||||
/>
|
||||
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
||||
</div>
|
||||
|
||||
<EquityCurveChart run={monitorRun} />
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
||||
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||
<> · Re-entry after gate failure and fresh qualification</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||
<div className="glass overflow-x-auto p-4">
|
||||
<p className="section-index mb-2">Per-year returns</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{monitorRun.yearly_returns.map((y) => (
|
||||
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
||||
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
||||
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
||||
{fmtSignedPct(y.return_pct)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Callout variant="empty">
|
||||
This report predates the portfolio monitor — re-run the backtest to populate it.
|
||||
</Callout>
|
||||
{report.recommendation && (
|
||||
<BacktestRecommendationCard recommendation={report.recommendation} />
|
||||
)}
|
||||
|
||||
{report.recommendation && report.recommendation.items.length > 0 && (
|
||||
<div className="glass border border-blue-400/20 p-4">
|
||||
<p className="section-index">What this backtest recommends</p>
|
||||
{report.recommendation.headline && (
|
||||
<p className="mt-1.5 text-sm font-semibold text-gray-100">
|
||||
{report.recommendation.headline}
|
||||
</p>
|
||||
)}
|
||||
<ul className="mt-2 space-y-1">
|
||||
{report.recommendation.items.map((item) => (
|
||||
<li
|
||||
key={item.topic + item.text}
|
||||
className={`text-xs ${item.text.includes('WARNING') || item.text.includes('LAGS') ? 'text-amber-400' : 'text-gray-400'}`}
|
||||
>
|
||||
{item.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{report.recommendation.note && (
|
||||
<p className="mt-2 text-[11px] text-gray-600">{report.recommendation.note}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-gray-600">
|
||||
Strategy research — gate tuning, exit sweeps, factor rank-IC — now runs locally against a
|
||||
database snapshot (see README). This page keeps only what says whether the promoted strategy
|
||||
is worth trading; your realized results up top show what it is actually delivering.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user