From 13a984a84d1d9fa2fd01983f15a769baef1cdb03 Mon Sep 17 00:00:00 2001
From: Dennis Thiessen
Date: Tue, 11 Aug 2026 22:09:23 +0200
Subject: [PATCH] refactor(signals): split the Track Record tab and cut the
backtest page down
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.../src/components/signals/BacktestPanel.tsx | 394 +++---------------
.../signals/BacktestRecommendationCard.tsx | 92 ++++
.../components/signals/EquityCurveChart.tsx | 89 ++++
.../components/signals/EvaluationPanel.tsx | 30 +-
.../src/components/signals/MyTradesPanel.tsx | 49 +--
.../signals/PortfolioMonitorPanel.tsx | 179 ++++++++
frontend/src/components/ui/StatTile.tsx | 35 ++
frontend/src/lib/format.ts | 66 +++
frontend/src/lib/types.ts | 14 +
frontend/src/pages/SignalsPage.tsx | 48 ++-
10 files changed, 599 insertions(+), 397 deletions(-)
create mode 100644 frontend/src/components/signals/BacktestRecommendationCard.tsx
create mode 100644 frontend/src/components/signals/EquityCurveChart.tsx
create mode 100644 frontend/src/components/signals/PortfolioMonitorPanel.tsx
create mode 100644 frontend/src/components/ui/StatTile.tsx
diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx
index 3b76fad..c1e314e 100644
--- a/frontend/src/components/signals/BacktestPanel.tsx
+++ b/frontend/src/components/signals/BacktestPanel.tsx
@@ -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 (
-
-
{label}
-
{value}
- {sub &&
{sub}
}
-
- );
-}
-
-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 No equity curve points for this selection.;
- }
-
- 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 No dated equity curve points for this selection.;
- }
- 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 (
-
- );
-}
+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.
+
+ Live GTL is the exact target path the scanner and the
+ scheduled backtest use; Structural S/R is a comparison
+ arm sourcing targets from chart structure. Weekly steps
+ five sessions at a time and is what the server runs; Daily
+ {' '}is roughly 5× the replay work.
+
-
-
-
+
+ {/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
+ narrow viewport otherwise. */}
+
+
+
+ setTargetModel(v as BacktestTargetModel)}
+ options={TARGET_MODEL_OPTIONS}
+ />
+
+
+
+ setCadence(v as BacktestCadence)}
+ options={CADENCE_OPTIONS}
+ />
+
+ {/* 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') && (
+
+ {cadence === 'daily' && (
+
Daily replays ~5× the work — prefer the offline snapshot runner.
+ )}
+ {targetModel === 'structural_sr' && (
+
Comparison arm — not the live scanner's target path.
- ) : (
-
- This report predates the portfolio monitor — re-run the backtest to populate it.
-
+ {report.recommendation && (
+
)}
-
- {report.recommendation && report.recommendation.items.length > 0 && (
-
-
What this backtest recommends
- {report.recommendation.headline && (
-
- {report.recommendation.headline}
-
- )}
-
- {report.recommendation.items.map((item) => (
-
- {item.text}
-
- ))}
-
- {report.recommendation.note && (
-
{report.recommendation.note}
- )}
-
- )}
-
-
- 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.
-
>
)}
diff --git a/frontend/src/components/signals/BacktestRecommendationCard.tsx b/frontend/src/components/signals/BacktestRecommendationCard.tsx
new file mode 100644
index 0000000..42d4095
--- /dev/null
+++ b/frontend/src/components/signals/BacktestRecommendationCard.tsx
@@ -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 (
+
+
+ {/* 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 && (
+
+
+ {secondary.map((item) => (
+
+ {item.text}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/signals/EquityCurveChart.tsx b/frontend/src/components/signals/EquityCurveChart.tsx
new file mode 100644
index 0000000..970bf0e
--- /dev/null
+++ b/frontend/src/components/signals/EquityCurveChart.tsx
@@ -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 No equity curve points for this selection.;
+ }
+
+ 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 No dated equity curve points for this selection.;
+ }
+ 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 (
+
+ );
+}
diff --git a/frontend/src/components/signals/EvaluationPanel.tsx b/frontend/src/components/signals/EvaluationPanel.tsx
index 5b107ac..e22c2a8 100644
--- a/frontend/src/components/signals/EvaluationPanel.tsx
+++ b/frontend/src/components/signals/EvaluationPanel.tsx
@@ -5,8 +5,7 @@ import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button';
import { Disclosure } from '../ui/Disclosure';
import { useToast } from '../ui/Toast';
-import { BacktestPanel } from './BacktestPanel';
-import { MyTradesPanel } from './MyTradesPanel';
+import { fmtR, rColor } from '../../lib/format';
// Need at least this many matured setups before the pipeline check means anything;
// 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';
-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 }) {
const styles: Record = {
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 {s.label};
}
-export function TrackRecordPanel() {
+export function EvaluationPanel() {
const queryClient = useQueryClient();
const toast = useToast();
@@ -101,19 +88,14 @@ export function TrackRecordPanel() {
return (
- {/* Your real, realized results come first; the strategy simulation follows. */}
-
-
-
-
-
+
Diagnostic only — not production P&L.{' '}
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.
- Target before stop = win, stop first = loss (same-bar both = loss), neither in 30 trading
- days = expired at 0R. Only matured windows count. Scores{' '}
+ initial stop / ATR trail / max hold — see the Paper Trades tab and the portfolio monitor
+ above. Target before stop = win, stop first = loss (same-bar both = loss), neither in 30
+ trading days = expired at 0R. Only matured windows count. Scores{' '}
all setups as a control group; runs nightly.
diff --git a/frontend/src/components/signals/MyTradesPanel.tsx b/frontend/src/components/signals/MyTradesPanel.tsx
index 1678270..b890e8c 100644
--- a/frontend/src/components/signals/MyTradesPanel.tsx
+++ b/frontend/src/components/signals/MyTradesPanel.tsx
@@ -2,22 +2,10 @@ import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { usePaperTrades } from '../../hooks/usePaperTrades';
import { tradePnl } from '../../lib/paperTrade';
-import { formatPrice } from '../../lib/format';
+import { formatPrice, fmtR, fmtSignedMoney, rColor } from '../../lib/format';
import { Section } from '../ui/Section';
import { Callout } from '../ui/Callout';
-
-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';
-}
+import { StatTile } from '../ui/StatTile';
// How the trade was closed — useful context on real trades at almost no cost.
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 (
-
-
{label}
-
{value}
- {sub &&
{sub}
}
-
- );
-}
-
export function MyTradesPanel() {
const { data: closed, isLoading } = usePaperTrades('closed');
@@ -70,7 +46,10 @@ export function MyTradesPanel() {
if (isLoading) return null;
return (
-
+
{stats.total === 0 ? (
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
@@ -79,11 +58,11 @@ export function MyTradesPanel() {
) : (
-
-
-
-
-
+
+
+
+
+
@@ -112,9 +91,9 @@ export function MyTradesPanel() {
+
+ {/* Tier 2 — how good that return was. Smaller and labelled on purpose:
+ ten equal tiles would read as ten equally important facts. */}
+ {isLegacyRun ? (
+
+ Risk-adjusted quality metrics appear after the next backtest run.
+
+ ) : (
+
+
Risk-adjusted quality
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ {/* avg_trade_pnl is a tile now (EV / trade) — not repeated here. */}
+
+ 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}
+
+ );
+}
diff --git a/frontend/src/components/ui/StatTile.tsx b/frontend/src/components/ui/StatTile.tsx
new file mode 100644
index 0000000..948b45e
--- /dev/null
+++ b/frontend/src/components/ui/StatTile.tsx
@@ -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 (
+
+
{label}
+
{value}
+ {sub &&
{sub}
}
+
+ );
+}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
index b754e5c..f107527 100644
--- a/frontend/src/lib/format.ts
+++ b/frontend/src/lib/format.ts
@@ -72,3 +72,69 @@ export function formatDateTime(d: string): string {
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';
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 8ab13c2..c245bf4 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -295,6 +295,20 @@ export interface BacktestPortfolioPolicy {
cagr_pct: number | null;
max_drawdown_pct: number;
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;
win_rate: number | null;
avg_trade_pnl: number | null;
diff --git a/frontend/src/pages/SignalsPage.tsx b/frontend/src/pages/SignalsPage.tsx
index e03d188..ae063f6 100644
--- a/frontend/src/pages/SignalsPage.tsx
+++ b/frontend/src/pages/SignalsPage.tsx
@@ -1,31 +1,61 @@
+import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader';
import { Tabs } from '../components/ui/Tabs';
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];
+// `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 = {
+ track: 'Paper Trades',
+ backtest: 'Backtest',
+};
+const TAB_TO_SLUG: Record = {
+ Setups: '',
+ 'Paper Trades': 'track',
+ Backtest: 'backtest',
+};
+const SUBTITLE: Record = {
+ 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() {
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) => {
- setSearchParams(tab === 'Track Record' ? { tab: 'track' } : {}, { replace: true });
+ const slug = TAB_TO_SLUG[tab];
+ setSearchParams(slug ? { tab: slug } : {}, { replace: true });
+ };
+
+ const body: Record = {
+ Setups: ,
+ 'Paper Trades': ,
+ // The backtest and the diagnostic that checks it against live outcomes.
+ Backtest: (
+