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:
2026-08-11 22:09:23 +02:00
co-authored by Claude Opus 5
parent 442dc3f04b
commit 13a984a84d
10 changed files with 599 additions and 397 deletions
+65 -329
View File
@@ -9,35 +9,8 @@ import { Disclosure } from '../ui/Disclosure';
import { Dropdown } from '../ui/Dropdown'; import { Dropdown } from '../ui/Dropdown';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types'; import { BacktestRecommendationCard } from './BacktestRecommendationCard';
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
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';
}
function timeAgo(iso: string): string { function timeAgo(iso: string): string {
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); 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`; return `${Math.floor(hrs / 24)}d ago`;
} }
function Stat({ label, value, valueClass = 'text-gray-100', sub }: { const TARGET_MODEL_OPTIONS = [
label: string; value: string; valueClass?: string; sub?: string; { value: 'production_gtl', label: 'Live GTL — production' },
}) { { value: 'structural_sr', label: 'Structural S/R — comparison' },
return ( ];
<div className="glass p-4"> const CADENCE_OPTIONS = [
<p className="section-index">{label}</p> { value: 'weekly', label: 'Weekly — default' },
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p> { value: 'daily', label: 'Daily — research' },
{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>
);
}
export function BacktestPanel() { export function BacktestPanel() {
const { data: report, isLoading } = useBacktestReport(); 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, fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
so read it as directional. so read it as directional.
</p> </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> </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"> {/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500"> narrow viewport otherwise. */}
Target model for this run <div className="flex flex-wrap items-end gap-2">
</legend> <div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
<label <label htmlFor="backtest-target-model">Target model</label>
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${ <Dropdown
targetModel === 'production_gtl' id="backtest-target-model"
? 'border-blue-400/60 bg-blue-500/10' className="w-56 normal-case tracking-normal"
: 'border-white/10 bg-white/[0.03] hover:border-white/20' value={targetModel}
}`} onChange={(v) => setTargetModel(v as BacktestTargetModel)}
> options={TARGET_MODEL_OPTIONS}
<input />
className="sr-only" </div>
type="radio" <div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
name="backtest-target-model" <label htmlFor="backtest-cadence">Entry cadence</label>
value="production_gtl" <Dropdown
checked={targetModel === 'production_gtl'} id="backtest-cadence"
onChange={() => setTargetModel('production_gtl')} className="w-44 normal-case tracking-normal"
/> value={cadence}
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100"> onChange={(v) => setCadence(v as BacktestCadence)}
Live GTL options={CADENCE_OPTIONS}
<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 </div>
</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>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0"> <Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'} {run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button> </Button>
</div> </div>
</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 && <Callout variant="empty">Loading…</Callout>}
{!isLoading && !report && ( {!isLoading && !report && (
@@ -319,118 +155,18 @@ export function BacktestPanel() {
</span> </span>
</p> </p>
{monitor && monitorRun ? ( <PortfolioMonitorPanel
<div className="space-y-3"> monitor={monitor}
<div className="flex flex-wrap items-end justify-between gap-3"> monitorRun={monitorRun}
<div> activeStrategy={activeStrategy}
<p className="section-index">Portfolio monitor</p> activeLookback={activeLookback}
<p className="mt-1 text-xs text-gray-500"> onStrategyChange={setSelectedStrategy}
Simulated book for the selected strategy and lookback, compared with the S&P 500. onLookbackChange={setSelectedLookback}
</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>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5"> {report.recommendation && (
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} /> <BacktestRecommendationCard recommendation={report.recommendation} />
<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&amp;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 && 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> </div>
@@ -0,0 +1,92 @@
import { Disclosure } from '../ui/Disclosure';
import type { BacktestRecommendation } from '../../lib/types';
/**
* The verdict, ahead of the tuning detail.
*
* All eight findings used to render as equal-weight bullets, so "does this
* strategy work" sat in the same visual register as "which cutoff scored best".
* `topic` splits them: the three that answer the question stay inline, the rest
* collapse.
*
* No topic chips — every backend string already self-prefixes ("Gate: …",
* "Robustness: …"), so a chip would render "GATE │ Gate: …", and stripping the
* prefix would drop real information ("(3y)" carries the lookback, "Legacy"
* qualifies the diagnostic).
*/
const PRIMARY_TOPICS = new Set(['production', 'benchmark', 'robustness']);
/**
* Mirrors how the backend phrases a bad result — `_build_recommendation` emits
* "Robustness WARNING: …" and "Book vs SPY: LAGS …". There is deliberately no
* `severity` field on the payload; if that changes, this is the one place to fix.
*/
function isWarning(text: string): boolean {
return text.includes('WARNING') || text.includes('LAGS');
}
export function BacktestRecommendationCard({
recommendation,
}: {
recommendation: BacktestRecommendation;
}) {
const items = recommendation.items;
if (items.length === 0) return null;
// A warning is always visible, whatever its topic — burying "the edge
// disappears without the top 5% of winners" behind a disclosure would defeat
// the point of surfacing it at all.
const primary = items.filter((i) => PRIMARY_TOPICS.has(i.topic) || isWarning(i.text));
const secondary = items.filter((i) => !PRIMARY_TOPICS.has(i.topic) && !isWarning(i.text));
const warningCount = items.filter((i) => isWarning(i.text)).length;
return (
<div className="space-y-2">
<div className="glass border border-blue-400/20 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="section-index">What this backtest recommends</p>
{warningCount > 0 && (
<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-amber-300">
{warningCount} warning{warningCount > 1 ? 's' : ''}
</span>
)}
</div>
{recommendation.headline && (
<p className="mt-1.5 text-sm font-semibold text-gray-100">{recommendation.headline}</p>
)}
{primary.length > 0 && (
<ul className="mt-3 space-y-1.5 border-t border-white/[0.06] pt-3">
{primary.map((item) => (
<li
key={item.topic + item.text}
className={`text-xs ${isWarning(item.text) ? 'text-amber-400' : 'text-gray-300'}`}
>
{item.text}
</li>
))}
</ul>
)}
{recommendation.note && (
<p className="mt-2 text-[11px] text-gray-600">{recommendation.note}</p>
)}
</div>
{/* Outside the card body on purpose: Disclosure renders its own glass-sm
panel, so nesting it inside the bordered card double-frames it. */}
{secondary.length > 0 && (
<Disclosure summary={`Gate, exit and cutoff detail (${secondary.length})`}>
<ul className="space-y-1.5">
{secondary.map((item) => (
<li key={item.topic + item.text} className="text-xs text-gray-400">
{item.text}
</li>
))}
</ul>
</Disclosure>
)}
</div>
);
}
@@ -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>
);
}
@@ -5,8 +5,7 @@ import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button'; import { Button } from '../ui/Button';
import { Disclosure } from '../ui/Disclosure'; import { Disclosure } from '../ui/Disclosure';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { BacktestPanel } from './BacktestPanel'; import { fmtR, rColor } from '../../lib/format';
import { MyTradesPanel } from './MyTradesPanel';
// Need at least this many matured setups before the pipeline check means anything; // Need at least this many matured setups before the pipeline check means anything;
// below it the live sample is too noisy to compare. // below it the live sample is too noisy to compare.
@@ -16,18 +15,6 @@ const DRIFT_TOLERANCE_R = 0.2;
type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest'; type PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
function fmtR(value: number | null): string {
if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
}
function rColor(value: number | null): string {
if (value === null) return 'text-gray-400';
if (value > 0) return 'text-emerald-400';
if (value < 0) return 'text-red-400';
return 'text-gray-300';
}
function StatusChip({ status }: { status: PipelineStatus }) { function StatusChip({ status }: { status: PipelineStatus }) {
const styles: Record<PipelineStatus, { cls: string; label: string }> = { const styles: Record<PipelineStatus, { cls: string; label: string }> = {
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' }, tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
@@ -39,7 +26,7 @@ function StatusChip({ status }: { status: PipelineStatus }) {
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>; return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
} }
export function TrackRecordPanel() { export function EvaluationPanel() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const toast = useToast(); const toast = useToast();
@@ -101,19 +88,14 @@ export function TrackRecordPanel() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Your real, realized results come first; the strategy simulation follows. */} <Disclosure summary="Setup-grading diagnostic & maintenance">
<MyTradesPanel />
<div className="border-t border-white/[0.06]" />
<BacktestPanel />
<Disclosure summary="Track-record maintenance">
<div className="space-y-4 pt-1"> <div className="space-y-4 pt-1">
<p className="max-w-2xl text-xs text-gray-500"> <p className="max-w-2xl text-xs text-gray-500">
<span className="text-amber-300/90">Diagnostic only not production P&amp;L.</span>{' '} <span className="text-amber-300/90">Diagnostic only not production P&amp;L.</span>{' '}
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
initial stop / ATR trail / max hold see paper trades and the portfolio monitor above. initial stop / ATR trail / max hold see the Paper Trades tab and the portfolio monitor
Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading above. Target before stop = win, stop first = loss (same-bar both = loss), neither in 30
days = expired at 0R. Only matured windows count. Scores{' '} trading days = expired at 0R. Only matured windows count. Scores{' '}
<span className="text-gray-300">all</span> setups as a control group; runs nightly. <span className="text-gray-300">all</span> setups as a control group; runs nightly.
</p> </p>
@@ -2,22 +2,10 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { usePaperTrades } from '../../hooks/usePaperTrades'; import { usePaperTrades } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade'; import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format'; import { formatPrice, fmtR, fmtSignedMoney, rColor } from '../../lib/format';
import { Section } from '../ui/Section'; import { Section } from '../ui/Section';
import { Callout } from '../ui/Callout'; import { Callout } from '../ui/Callout';
import { StatTile } from '../ui/StatTile';
function money(v: number): string {
return `${v >= 0 ? '+' : ''}$${Math.abs(v).toFixed(2)}`;
}
function fmtR(v: number | null): string {
return v === null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
function color(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';
}
// How the trade was closed — useful context on real trades at almost no cost. // How the trade was closed — useful context on real trades at almost no cost.
function reasonMeta(reason: string | null): { label: string; cls: string } { function reasonMeta(reason: string | null): { label: string; cls: string } {
@@ -31,18 +19,6 @@ function reasonMeta(reason: string | null): { label: string; cls: string } {
} }
} }
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>
);
}
export function MyTradesPanel() { export function MyTradesPanel() {
const { data: closed, isLoading } = usePaperTrades('closed'); const { data: closed, isLoading } = usePaperTrades('closed');
@@ -70,7 +46,10 @@ export function MyTradesPanel() {
if (isLoading) return null; if (isLoading) return null;
return ( return (
<Section title="My Trades" hint="your realized paper-trading results"> <Section
title="Closed Trades"
hint="realized paper-trading results — open positions are on the Dashboard"
>
{stats.total === 0 ? ( {stats.total === 0 ? (
<Callout variant="empty"> <Callout variant="empty">
No closed trades yet. Take setups as paper trades and theyll resolve here when price hits No closed trades yet. Take setups as paper trades and theyll resolve here when price hits
@@ -79,11 +58,11 @@ export function MyTradesPanel() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Stat label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} /> <StatTile label="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" /> <StatTile label="Expectancy" value={fmtR(stats.avgR)} valueClass={rColor(stats.avgR)} sub="avg R per closed trade" />
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} /> <StatTile label="Total R" value={fmtR(stats.totalR)} valueClass={rColor(stats.totalR)} sub={`${stats.total} closed`} />
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(stats.totalPnl)} sub="realized, all closed" /> <StatTile label="Total P&L" value={fmtSignedMoney(stats.totalPnl)} valueClass={rColor(stats.totalPnl)} sub="realized, all closed" />
<Stat label="Alpha vs S&P 500" value={stats.totalAlpha != null ? money(stats.totalAlpha) : '—'} valueClass={color(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" /> <StatTile label="Alpha vs S&P 500" value={stats.totalAlpha != null ? fmtSignedMoney(stats.totalAlpha) : '—'} valueClass={rColor(stats.totalAlpha)} sub="realized vs buy-and-hold SPY" />
</div> </div>
<div className="glass overflow-x-auto"> <div className="glass overflow-x-auto">
@@ -112,9 +91,9 @@ export function MyTradesPanel() {
</td> </td>
<td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td> <td className="num px-4 py-2.5 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td> <td className="num px-4 py-2.5 text-right text-gray-300">{t.close_price != null ? formatPrice(t.close_price) : '—'}</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(p.pnl) : '—'}</td> <td className={`num px-4 py-2.5 text-right font-semibold ${p ? rColor(p.pnl) : 'text-gray-500'}`}>{p ? fmtSignedMoney(p.pnl) : '—'}</td>
<td className={`num px-4 py-2.5 text-right ${p?.r != null ? color(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td> <td className={`num px-4 py-2.5 text-right ${p?.r != null ? rColor(p.r) : 'text-gray-500'}`}>{p?.r != null ? fmtR(p.r) : '—'}</td>
<td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? color(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td> <td className={`num px-4 py-2.5 text-right ${t.alpha_pct != null ? rColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}</td>
<td className="px-4 py-2.5"> <td className="px-4 py-2.5">
<span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed"> <span className={`num text-[10px] font-semibold uppercase tracking-wider ${reasonMeta(t.close_reason).cls}`} title="How the trade was closed">
{reasonMeta(t.close_reason).label} {reasonMeta(t.close_reason).label}
@@ -0,0 +1,179 @@
import { Callout } from '../ui/Callout';
import { Dropdown } from '../ui/Dropdown';
import { StatTile } from '../ui/StatTile';
import { EquityCurveChart } from './EquityCurveChart';
import {
fmtDays,
fmtDrawdown,
fmtPct,
fmtR,
fmtRatio,
fmtSignedMoney,
fmtSignedPct,
rColor,
} from '../../lib/format';
import type {
BacktestPortfolioMonitor,
BacktestPortfolioMonitorRun,
} from '../../lib/types';
/**
* The simulated book for one strategy/lookback selection, against the S&P 500.
*
* Selection state deliberately stays in BacktestPanel — it also resolves which
* run this panel receives, so splitting it here would mean resolving twice.
*/
export function PortfolioMonitorPanel({
monitor,
monitorRun,
activeStrategy,
activeLookback,
onStrategyChange,
onLookbackChange,
}: {
monitor: BacktestPortfolioMonitor | null | undefined;
monitorRun: BacktestPortfolioMonitorRun | null | undefined;
activeStrategy: string;
activeLookback: string;
onStrategyChange: (v: string) => void;
onLookbackChange: (v: string) => void;
}) {
if (!monitor || !monitorRun) {
return (
<Callout variant="empty">
This report predates the portfolio monitor re-run the backtest to populate it.
</Callout>
);
}
// Key ABSENT (not null) means the cached report predates these metrics.
// Gated on sortino specifically: calmar and avg_trade_pnl have always been
// emitted, so testing those would half-populate the row with dashes.
const isLegacyRun = monitorRun.sortino === undefined;
return (
<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={onStrategyChange}
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={onLookbackChange}
options={monitor.lookbacks.map((l) => ({ value: l.lookback, label: l.label }))}
/>
</div>
</div>
</div>
{/* Tier 1 — what the book returned. */}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatTile
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)}`}
/>
<StatTile label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
<StatTile label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
<StatTile label="Sharpe" value={fmtRatio(monitorRun.sharpe)} />
<StatTile label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
</div>
{/* Tier 2 — how good that return was. Smaller and labelled on purpose:
ten equal tiles would read as ten equally important facts. */}
{isLegacyRun ? (
<p className="text-[11px] text-gray-600">
Risk-adjusted quality metrics appear after the next backtest run.
</p>
) : (
<div className="space-y-2">
<p className="section-index">Risk-adjusted quality</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<StatTile
size="sm"
label="Sortino"
value={fmtRatio(monitorRun.sortino)}
title="Return per unit of downside deviation (annualized)."
/>
<StatTile
size="sm"
label="Calmar (MAR)"
value={fmtRatio(monitorRun.calmar)}
title="CAGR divided by maximum drawdown."
/>
<StatTile
size="sm"
label="Gain / Pain"
value={fmtRatio(monitorRun.gain_to_pain)}
title="Sum of monthly returns divided by the absolute sum of the negative ones."
/>
<StatTile
size="sm"
label="Profit Factor ($)"
value={fmtRatio(monitorRun.profit_factor)}
title="Gross winning dollars divided by gross losing dollars, across closed trades."
/>
<StatTile
size="sm"
label="EV / trade"
value={fmtSignedMoney(monitorRun.avg_trade_pnl)}
valueClass={rColor(monitorRun.avg_trade_pnl)}
title="Average realized P&L per closed trade."
/>
</div>
</div>
)}
<EquityCurveChart run={monitorRun} />
{/* avg_trade_pnl is a tile now (EV / trade) — not repeated here. */}
<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)}
{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>
);
}
+35
View File
@@ -0,0 +1,35 @@
/**
* One labelled metric. Lifted from the byte-identical `Stat` that lived in both
* BacktestPanel and MyTradesPanel.
*
* `size` is the hierarchy lever: `md` (default) is the headline look those two
* panels already had; `sm` marks a metric as supporting detail, which is what
* keeps a second row of ratios from reading as equally important as the returns
* above it.
*/
export function StatTile({
label,
value,
valueClass = 'text-gray-100',
sub,
title,
size = 'md',
}: {
label: string;
value: string;
valueClass?: string;
sub?: string;
/** Native tooltip — how the metric is defined. */
title?: string;
size?: 'md' | 'sm';
}) {
const pad = size === 'sm' ? 'p-3' : 'p-4';
const text = size === 'sm' ? 'text-lg' : 'text-2xl';
return (
<div className={`glass ${pad}`} title={title}>
<p className="section-index">{label}</p>
<p className={`num mt-1.5 ${text} font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
+66
View File
@@ -72,3 +72,69 @@ export function formatDateTime(d: string): string {
hour12: true, hour12: true,
})}`; })}`;
} }
// ── Metric display helpers ─────────────────────────────────────────────────
// Shared by the Signals backtest/paper-trade panels. Dashboard and
// OpenTradesPanel deliberately still carry their own copies — migrating them is
// a separate change, not drive-by scope.
/** R-multiple with an explicit sign. e.g. 1.2 → "+1.20R", null → "—" */
export function fmtR(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
/** e.g. 12.34 → "12.3%" */
export function fmtPct(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${v.toFixed(1)}%`;
}
/** e.g. 12.34 → "+12.3%" */
export function fmtSignedPct(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
}
/** Always rendered negative, whatever sign the source uses. 17.3 → "-17.3%" */
export function fmtDrawdown(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
}
/** e.g. 15.3 → "15.3d" */
export function fmtDays(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
}
/** Unitless ratios — Sharpe, Sortino, Calmar, Gain/Pain, profit factor. */
export function fmtRatio(v: number | null | undefined): string {
return v === null || v === undefined ? '—' : v.toFixed(2);
}
/**
* Bare amount, no currency symbol and no sign. e.g. 1234.5 → "1,234.50"
* Kept separate from fmtSignedMoney on purpose — they are not interchangeable.
*/
export function fmtMoney(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return v.toLocaleString('en-US', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
/**
* Signed currency, using U+2212 for negatives. e.g. -12.3 → "$12.30"
* Use wherever a value can go negative and the unit is money.
*/
export function fmtSignedMoney(v: number | null | undefined): string {
if (v === null || v === undefined) return '—';
return `${v >= 0 ? '+' : ''}$${Math.abs(v).toFixed(2)}`;
}
/** Green above zero, red below, neutral at zero or null. */
export function rColor(v: number | null | undefined): string {
if (v === null || v === undefined) return 'text-gray-400';
if (v > 0) return 'text-emerald-400';
if (v < 0) return 'text-red-400';
return 'text-gray-300';
}
+14
View File
@@ -295,6 +295,20 @@ export interface BacktestPortfolioPolicy {
cagr_pct: number | null; cagr_pct: number | null;
max_drawdown_pct: number; max_drawdown_pct: number;
sharpe: number | null; sharpe: number | null;
sharpe_se?: number | null;
psr?: number | null;
/** CAGR / max drawdown — the same number commonly called MAR. */
calmar?: number | null;
/**
* Optional because reports cached before these landed lack the keys entirely.
* An ABSENT `sortino` is how the UI detects such a report — distinct from
* `null`, which means "computed, undefined for this run".
*/
sortino?: number | null;
/** Schwager, on monthly returns. */
gain_to_pain?: number | null;
/** DOLLAR-based. Not the R-based profit_factor on BacktestBucket. */
profit_factor?: number | null;
trades: number; trades: number;
win_rate: number | null; win_rate: number | null;
avg_trade_pnl: number | null; avg_trade_pnl: number | null;
+39 -9
View File
@@ -1,31 +1,61 @@
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { Tabs } from '../components/ui/Tabs'; import { Tabs } from '../components/ui/Tabs';
import { SetupsPanel } from '../components/signals/SetupsPanel'; import { SetupsPanel } from '../components/signals/SetupsPanel';
import { TrackRecordPanel } from '../components/signals/TrackRecordPanel'; import { MyTradesPanel } from '../components/signals/MyTradesPanel';
import { BacktestPanel } from '../components/signals/BacktestPanel';
import { EvaluationPanel } from '../components/signals/EvaluationPanel';
const tabs = ['Setups', 'Track Record'] as const; const tabs = ['Setups', 'Paper Trades', 'Backtest'] as const;
type Tab = (typeof tabs)[number]; type Tab = (typeof tabs)[number];
// `track` stays the Paper Trades slug: App.tsx redirects the legacy /performance
// route to ?tab=track, and that is where realized results live.
const SLUG_TO_TAB: Record<string, Tab> = {
track: 'Paper Trades',
backtest: 'Backtest',
};
const TAB_TO_SLUG: Record<Tab, string> = {
Setups: '',
'Paper Trades': 'track',
Backtest: 'backtest',
};
const SUBTITLE: Record<Tab, string> = {
Setups: 'Detected trade setups from the latest scan',
'Paper Trades': 'What the strategy actually delivered on trades you took',
Backtest: 'Whether the promoted strategy is worth trading, replayed over history',
};
export default function SignalsPage() { export default function SignalsPage() {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const activeTab: Tab = searchParams.get('tab') === 'track' ? 'Track Record' : 'Setups'; const activeTab: Tab = SLUG_TO_TAB[searchParams.get('tab') ?? ''] ?? 'Setups';
const setTab = (tab: Tab) => { const setTab = (tab: Tab) => {
setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true }); const slug = TAB_TO_SLUG[tab];
setSearchParams(slug ? { tab: slug } : {}, { replace: true });
};
const body: Record<Tab, ReactNode> = {
Setups: <SetupsPanel />,
'Paper Trades': <MyTradesPanel />,
// The backtest and the diagnostic that checks it against live outcomes.
Backtest: (
<div className="space-y-6">
<BacktestPanel />
<EvaluationPanel />
</div>
),
}; };
return ( return (
<div className="space-y-6 animate-slide-up"> <div className="space-y-6 animate-slide-up">
<PageHeader <PageHeader title="Signals" subtitle={SUBTITLE[activeTab]} />
title="Signals"
subtitle="Detected trade setups and how past signals actually performed"
/>
<Tabs tabs={tabs} active={activeTab} onChange={setTab} /> <Tabs tabs={tabs} active={activeTab} onChange={setTab} />
<div className="animate-fade-in" key={activeTab}> <div className="animate-fade-in" key={activeTab}>
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />} {body[activeTab]}
</div> </div>
</div> </div>
); );