Compare commits

..
3 Commits
Author SHA1 Message Date
dennisthiessenandClaude Opus 5 14cfa44fc5 refactor(signals): drop the now-unused fmtMoney helper
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m16s
Deploy / deploy (push) Successful in 38s
It was lifted from BacktestPanel during the extraction, then lost its last
caller in the same branch when avg_trade_pnl became an EV / trade tile rendered
with fmtSignedMoney and disappeared from the monitor footnote. formatPrice
already covers a bare unsigned amount if one is ever needed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:40:37 +02:00
dennisthiessenandClaude Opus 5 13a984a84d 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>
2026-08-11 22:09:23 +02:00
dennisthiessenandClaude Opus 5 442dc3f04b feat(backtest): add Sortino, Gain-to-Pain and dollar profit factor
Three portfolio metrics computed where their inputs already live in
_simulate_portfolio: Sortino off the existing daily return series, Gain-to-Pain
off a monthly aggregation of the equity curve, profit factor off closed-trade
dollar P&L.

Gain-to-Pain follows Schwager — sum of ALL monthly returns over the absolute
sum of the negative ones. The profit-factor-shaped variant,
sum(positive)/|sum(negative)|, sits exactly 1.0 higher for every input since
sum(all) = sum(pos) - |sum(neg)|; the test asserts against both so the wrong one
cannot pass. Sortino divides by len(rets), the full-sample lower partial moment,
not by the count of down days, which would shrink the denominator and inflate
the ratio.

No MAR field: calmar is already CAGR / max drawdown, the same number under the
other name (docs/research/effective-risk-floor-ab.md).

All three keys are emitted unconditionally even when None — the UI reads an
absent key as "report predates these metrics", so presence is a contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:09:10 +02:00
12 changed files with 761 additions and 397 deletions
+51
View File
@@ -2625,6 +2625,19 @@ def _simulate_portfolio(
diag = sharpe_diagnostics(rets) diag = sharpe_diagnostics(rets)
sharpe = diag["sharpe"] sharpe = diag["sharpe"]
# Sortino: the same numerator as Sharpe over downside deviation about a zero
# target. The denominator divides by len(rets) — the full-sample lower partial
# moment — NOT by the count of down days, which would shrink the denominator
# and inflate the ratio. n >= 3 matches sharpe_diagnostics so the two appear
# together or not at all. No down days is +inf, reported as None.
sortino = None
downside = [r for r in rets if r < 0.0]
if len(rets) >= 3 and downside:
mean_ret = sum(rets) / len(rets)
dd = math.sqrt(sum(r * r for r in downside) / len(rets))
if dd > 0:
sortino = round(mean_ret / dd * math.sqrt(252.0), 2)
# Per-calendar-year returns off the equity curve — shows whether every year # Per-calendar-year returns off the equity curve — shows whether every year
# contributed or one exceptional stretch carried the result. # contributed or one exceptional stretch carried the result.
yearly: list[dict] = [] yearly: list[dict] = []
@@ -2650,8 +2663,40 @@ def _simulate_portfolio(
), ),
}) })
# Gain-to-Pain off the same curve, on MONTHLY returns: Schwager's ratio is
# defined monthly and the daily variant is not comparable to published
# figures. Distinct loop variables from the yearly pass above — that one exits
# with last_eq at final equity, so reusing its names silently corrupts the
# first month. The monthly series itself is not emitted: 36-120 floats per
# strategy per lookback would bloat the single stored report blob.
monthly: list[float] = []
month_start_eq = curve[0][1]
month_last_eq = curve[0][1]
cur_month = date.fromordinal(curve[0][0]).replace(day=1)
for o, eq in curve:
m = date.fromordinal(o).replace(day=1)
if m != cur_month:
if month_start_eq > 0:
monthly.append(month_last_eq / month_start_eq - 1.0)
cur_month = m
month_start_eq = month_last_eq
month_last_eq = eq
if month_start_eq > 0:
monthly.append(month_last_eq / month_start_eq - 1.0)
# Schwager: SUM OF ALL monthly returns over the absolute sum of the negative
# ones. Not sum(positive)/|sum(negative)| — that is profit-factor-shaped and
# sits exactly 1.0 higher for every input, since sum(all) = sum(pos) - |sum(neg)|.
monthly_pain = -sum(r for r in monthly if r < 0.0)
gain_to_pain = round(sum(monthly) / monthly_pain, 2) if monthly_pain > 0 else None
pnls = [t["pnl"] for t in trades] pnls = [t["pnl"] for t in trades]
wins = sum(1 for p in pnls if p > 0) wins = sum(1 for p in pnls if p > 0)
# Dollar-based, over closed-trade P&L. Distinct from the R-based profit_factor
# in _robustness_stats; the two never share an object.
gross_win = sum(p for p in pnls if p > 0)
gross_loss = -sum(p for p in pnls if p < 0)
profit_factor = round(gross_win / gross_loss, 2) if gross_loss > 0 else None
reason_counts = { reason_counts = {
reason: sum(1 for t in trades if t["reason"] == reason) reason: sum(1 for t in trades if t["reason"] == reason)
for reason in sorted({t["reason"] for t in trades}) for reason in sorted({t["reason"] for t in trades})
@@ -2706,7 +2751,13 @@ def _simulate_portfolio(
"total_return_pct": round(total_return_pct, 1), "total_return_pct": round(total_return_pct, 1),
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None, "cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
"max_drawdown_pct": round(max_dd_pct, 1), "max_drawdown_pct": round(max_dd_pct, 1),
# calmar IS MAR here (CAGR / max drawdown) — one field, two names.
"calmar": round(calmar, 2) if calmar is not None else None, "calmar": round(calmar, 2) if calmar is not None else None,
# Emitted unconditionally even when None: the UI treats an ABSENT key as
# "report predates these metrics", so presence is a contract.
"sortino": sortino,
"gain_to_pain": gain_to_pain,
"profit_factor": profit_factor,
"sharpe": sharpe, "sharpe": sharpe,
"sharpe_se": diag["sharpe_se"], "sharpe_se": diag["sharpe_se"],
"psr": diag["psr"], "psr": diag["psr"],
+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>
);
}
+55
View File
@@ -72,3 +72,58 @@ 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);
}
/**
* 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.
* (For a bare unsigned amount there is already `formatPrice` above.)
*/
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>
); );
+122
View File
@@ -1688,3 +1688,125 @@ async def test_run_backtest_rolls_back_a_failed_portfolio_sim_load(session, monk
assert called, "the portfolio-sim block never ran; test proves nothing" assert called, "the portfolio-sim block never ran; test proves nothing"
assert rolled_back, "a failed portfolio-sim load left the session un-rolled-back" assert rolled_back, "a failed portfolio-sim load left the session un-rolled-back"
assert report["tickers"] == 1 assert report["tickers"] == 1
class TestPortfolioQualityMetrics:
"""Sortino / Gain-to-Pain / dollar profit factor.
Each derives its expectation from the returned ``equity_curve`` rather than
hand-tracing position sizing, and each also asserts the *wrong* variant is
NOT what came back — the denominator and the numerator are exactly where
these ratios are usually got wrong.
"""
ORD = date(2025, 1, 6).toordinal()
@staticmethod
def _daily_returns(sim: dict) -> list[float]:
eq = [row["equity"] for row in sim["equity_curve"]]
return [b / a - 1.0 for a, b in zip(eq, eq[1:]) if a > 0]
@staticmethod
def _monthly_returns(sim: dict) -> list[float]:
monthly: list[float] = []
rows = sim["equity_curve"]
start = last = rows[0]["equity"]
cur = date.fromisoformat(rows[0]["date"]).replace(day=1)
for row in rows:
m = date.fromisoformat(row["date"]).replace(day=1)
if m != cur:
monthly.append(last / start - 1.0)
cur, start = m, last
last = row["equity"]
monthly.append(last / start - 1.0)
return monthly
def _wobbly_sim(self) -> dict:
"""~70 sessions crossing four month boundaries with a real mid drawdown,
so monthly returns include both signs (a short fixture yields one month
and zero pain, which reads as a broken formula)."""
closes = (
[100.0 + i for i in range(20)] # climb
+ [120.0 - 1.5 * i for i in range(20)] # drawdown
+ [90.0 + 1.2 * i for i in range(30)] # recovery
)
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=80.0, target=400.0)
sim = bt._simulate_portfolio(
[cand], prices, None, "hold", 65, include_curve=True
)
assert sim is not None
return sim
def test_sortino_denominator_is_full_sample_not_downside_count(self):
sim = self._wobbly_sim()
rets = self._daily_returns(sim)
downside = [r for r in rets if r < 0.0]
assert downside, "fixture must produce down days or the test proves nothing"
mean_ret = sum(rets) / len(rets)
correct = mean_ret / math.sqrt(
sum(r * r for r in downside) / len(rets)
) * math.sqrt(252.0)
# The classic error: dividing by the count of down days shrinks the
# denominator and inflates the ratio.
inflated = mean_ret / math.sqrt(
sum(r * r for r in downside) / len(downside)
) * math.sqrt(252.0)
assert sim["sortino"] == pytest.approx(round(correct, 2), abs=0.01)
assert sim["sortino"] != pytest.approx(round(inflated, 2), abs=0.01)
def test_gain_to_pain_is_schwager_on_monthly_returns(self):
sim = self._wobbly_sim()
monthly = self._monthly_returns(sim)
assert len(monthly) >= 3, "fixture must span several months"
pain = -sum(r for r in monthly if r < 0.0)
assert pain > 0, "fixture must have a losing month or pain is zero"
schwager = sum(monthly) / pain
# sum(all) = sum(pos) - |sum(neg)|, so the profit-factor-shaped variant
# sits exactly 1.0 higher for every input.
profit_factor_shaped = sum(r for r in monthly if r > 0.0) / pain
assert profit_factor_shaped == pytest.approx(schwager + 1.0, abs=1e-9)
assert sim["gain_to_pain"] == pytest.approx(round(schwager, 2), abs=0.01)
assert sim["gain_to_pain"] != pytest.approx(
round(profit_factor_shaped, 2), abs=0.01
)
def test_profit_factor_is_dollar_based(self):
"""One winner, one loser, on separate symbols so both fill."""
up = [100.0 + 2.0 * i for i in range(8)]
down = [100.0 - 2.0 * i for i in range(8)]
prices = {
"WIN": _sim_prices(self.ORD, up),
"LOSE": _sim_prices(self.ORD, down),
}
cands = [
_sim_cand("WIN", self.ORD, entry=100.0, stop=90.0, target=400.0, mp=95.0),
_sim_cand("LOSE", self.ORD, entry=100.0, stop=80.0, target=400.0, mp=94.0),
]
sim = bt._simulate_portfolio([cands[0], cands[1]], prices, None, "hold", 5)
assert sim is not None
assert sim["trades"] == 2
# With exactly two trades the reported best/worst ARE the win and the loss.
gross_win = sim["best_trade_pnl"]
gross_loss = -sim["worst_trade_pnl"]
assert gross_win > 0 and gross_loss > 0, "fixture must produce one of each"
assert sim["profit_factor"] == pytest.approx(
round(gross_win / gross_loss, 2), abs=0.01
)
def test_keys_always_present_and_no_downside_is_none(self):
"""Monotonic rise: no down days. Sortino must be None, never inf — and
all three keys must still be emitted, because the UI reads an ABSENT key
as 'report predates these metrics'."""
closes = [100.0, 102.0, 104.0, 106.0, 108.0, 110.0]
prices = {"AAA": _sim_prices(self.ORD, closes)}
cand = _sim_cand("AAA", self.ORD, entry=100.0, stop=95.0, target=130.0)
sim = bt._simulate_portfolio([cand], prices, None, "hold", 3)
assert sim is not None
for key in ("sortino", "gain_to_pain", "profit_factor"):
assert key in sim
assert sim["sortino"] is None