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 ( -
-
-
-

{run.label}

-

{run.start_date} - {run.end_date}

-
-
- Portfolio {fmtSignedPct(lastPortfolio)} - S&P 500 {fmtSignedPct(lastBenchmark)} -
-
- - - - {benchmarkPath && ( - - )} - - {fmtSignedPct(max)} - {fmtSignedPct(min)} - -
- ); -} +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. +

-
-
- - Target model for this run - - - -
-
- - Entry cadence - - - -
+ + {/* 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.

+ )} +
+ )} + {isLoading && Loading…} {!isLoading && !report && ( @@ -319,118 +155,18 @@ export function BacktestPanel() {

- {monitor && monitorRun ? ( -
-
-
-

Portfolio monitor

-

- Simulated book for the selected strategy and lookback, compared with the S&P 500. -

-
-
-
- - ({ - value: s.strategy, - label: `${s.is_production ? 'Production: ' : ''}${s.label}`, - }))} - /> -
-
- - ({ value: l.lookback, label: l.label }))} - /> -
-
-
+ -
- - - - - -
- - - -

- 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} -

- - {monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && ( -
-

Per-year returns

-
- {monitorRun.yearly_returns.map((y) => ( -
- {y.year}{' '} - - {fmtSignedPct(y.return_pct)} - -
- ))} -
-
- )} - - {monitor.note &&

{monitor.note}

} -
- ) : ( - - 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.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 ( +
+
+
+

What this backtest recommends

+ {warningCount > 0 && ( + + ⚠ {warningCount} warning{warningCount > 1 ? 's' : ''} + + )} +
+ + {recommendation.headline && ( +

{recommendation.headline}

+ )} + + {primary.length > 0 && ( +
    + {primary.map((item) => ( +
  • + {item.text} +
  • + ))} +
+ )} + + {recommendation.note && ( +

{recommendation.note}

+ )} +
+ + {/* 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 ( +
+
+
+

{run.label}

+

{run.start_date} - {run.end_date}

+
+
+ Portfolio {fmtSignedPct(lastPortfolio)} + S&P 500 {fmtSignedPct(lastBenchmark)} +
+
+ + + + {benchmarkPath && ( + + )} + + {fmtSignedPct(max)} + {fmtSignedPct(min)} + +
+ ); +} 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() { {formatPrice(t.entry_price)} {t.close_price != null ? formatPrice(t.close_price) : '—'} - {p ? money(p.pnl) : '—'} - {p?.r != null ? fmtR(p.r) : '—'} - {t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'} + {p ? fmtSignedMoney(p.pnl) : '—'} + {p?.r != null ? fmtR(p.r) : '—'} + {t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'} {reasonMeta(t.close_reason).label} diff --git a/frontend/src/components/signals/PortfolioMonitorPanel.tsx b/frontend/src/components/signals/PortfolioMonitorPanel.tsx new file mode 100644 index 0000000..29d296e --- /dev/null +++ b/frontend/src/components/signals/PortfolioMonitorPanel.tsx @@ -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 ( + + This report predates the portfolio monitor — re-run the backtest to populate it. + + ); + } + + // 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 ( +
+
+
+

Portfolio monitor

+

+ Simulated book for the selected strategy and lookback, compared with the S&P 500. +

+
+
+
+ + ({ + value: s.strategy, + label: `${s.is_production ? 'Production: ' : ''}${s.label}`, + }))} + /> +
+
+ + ({ value: l.lookback, label: l.label }))} + /> +
+
+
+ + {/* Tier 1 — what the book returned. */} +
+ + + + + +
+ + {/* 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} +

+ + {monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && ( +
+

Per-year returns

+
+ {monitorRun.yearly_returns.map((y) => ( +
+ {y.year}{' '} + + {fmtSignedPct(y.return_pct)} + +
+ ))} +
+
+ )} + + {monitor.note &&

{monitor.note}

} +
+ ); +} 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: ( +
+ + +
+ ), }; return (
- +
- {activeTab === 'Setups' ? : } + {body[activeTab]}
);