Compare commits
3
Commits
247a7889b9
...
14cfa44fc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14cfa44fc5 | ||
|
|
13a984a84d | ||
|
|
442dc3f04b |
@@ -2625,6 +2625,19 @@ def _simulate_portfolio(
|
||||
diag = sharpe_diagnostics(rets)
|
||||
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
|
||||
# contributed or one exceptional stretch carried the result.
|
||||
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]
|
||||
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: sum(1 for t in trades if t["reason"] == reason)
|
||||
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),
|
||||
"cagr_pct": round(cagr_pct, 1) if cagr_pct is not None else None,
|
||||
"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,
|
||||
# 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_se": diag["sharpe_se"],
|
||||
"psr": diag["psr"],
|
||||
|
||||
@@ -9,35 +9,8 @@ import { Disclosure } from '../ui/Disclosure';
|
||||
import { Dropdown } from '../ui/Dropdown';
|
||||
import { Section } from '../ui/Section';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
||||
|
||||
function fmtR(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
|
||||
}
|
||||
function fmtPct(v: number | null): string {
|
||||
return v === null ? '—' : `${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtMoney(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
function fmtSignedPct(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(1)}%`;
|
||||
}
|
||||
function fmtDrawdown(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `-${Math.abs(v).toFixed(1)}%`;
|
||||
}
|
||||
function fmtDays(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||
}
|
||||
function rColor(v: number | null): string {
|
||||
if (v === null) return 'text-gray-400';
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
if (v < 0) return 'text-red-400';
|
||||
return 'text-gray-300';
|
||||
}
|
||||
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
|
||||
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
|
||||
|
||||
function timeAgo(iso: string): string {
|
||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||
@@ -48,95 +21,14 @@ function timeAgo(iso: string): string {
|
||||
return `${Math.floor(hrs / 24)}d ago`;
|
||||
}
|
||||
|
||||
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
||||
label: string; value: string; valueClass?: string; sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass p-4">
|
||||
<p className="section-index">{label}</p>
|
||||
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function curvePath(
|
||||
points: BacktestCurvePoint[],
|
||||
min: number,
|
||||
max: number,
|
||||
w: number,
|
||||
h: number,
|
||||
pad: number,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): string {
|
||||
if (points.length < 2) return '';
|
||||
const span = Math.max(max - min, 1);
|
||||
const timeSpan = Math.max(endMs - startMs, 1);
|
||||
return points
|
||||
.map((p, i) => {
|
||||
const t = new Date(p.date).getTime();
|
||||
const x = pad + ((t - startMs) / timeSpan) * (w - pad * 2);
|
||||
const value = p.return_pct ?? 0;
|
||||
const y = pad + (1 - (value - min) / span) * (h - pad * 2);
|
||||
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
||||
const portfolio = run.equity_curve ?? [];
|
||||
const benchmark = run.benchmark_curve ?? [];
|
||||
const values = [...portfolio, ...benchmark]
|
||||
.map((p) => p.return_pct)
|
||||
.filter((v): v is number => v !== null && v !== undefined);
|
||||
if (portfolio.length < 2 || values.length === 0) {
|
||||
return <Callout variant="empty">No equity curve points for this selection.</Callout>;
|
||||
}
|
||||
|
||||
const min = Math.min(0, ...values);
|
||||
const max = Math.max(0, ...values);
|
||||
const times = [...portfolio, ...benchmark]
|
||||
.map((p) => new Date(p.date).getTime())
|
||||
.filter((v) => Number.isFinite(v));
|
||||
if (times.length === 0) {
|
||||
return <Callout variant="empty">No dated equity curve points for this selection.</Callout>;
|
||||
}
|
||||
const startMs = Math.min(...times);
|
||||
const endMs = Math.max(...times);
|
||||
const w = 720;
|
||||
const h = 240;
|
||||
const pad = 28;
|
||||
const portfolioPath = curvePath(portfolio, min, max, w, h, pad, startMs, endMs);
|
||||
const benchmarkPath = curvePath(benchmark, min, max, w, h, pad, startMs, endMs);
|
||||
const lastPortfolio = portfolio[portfolio.length - 1]?.return_pct ?? null;
|
||||
const lastBenchmark = benchmark[benchmark.length - 1]?.return_pct ?? run.spy_return_pct;
|
||||
|
||||
return (
|
||||
<div className="glass overflow-hidden">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-white/[0.05] px-4 py-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-100">{run.label}</p>
|
||||
<p className="text-[11px] text-gray-500">{run.start_date} - {run.end_date}</p>
|
||||
</div>
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span className="text-blue-300">Portfolio {fmtSignedPct(lastPortfolio)}</span>
|
||||
<span className="text-gray-400">S&P 500 {fmtSignedPct(lastBenchmark)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-64 w-full" role="img" aria-label="Portfolio return compared with S&P 500">
|
||||
<line x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||
<line x1={pad} y1={pad} x2={pad} y2={h - pad} stroke="rgba(255,255,255,0.12)" />
|
||||
{benchmarkPath && (
|
||||
<path d={benchmarkPath} fill="none" stroke="rgba(156,163,175,0.9)" strokeWidth="2" strokeDasharray="5 5" />
|
||||
)}
|
||||
<path d={portfolioPath} fill="none" stroke="rgb(96,165,250)" strokeWidth="3" />
|
||||
<text x={pad} y={pad - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(max)}</text>
|
||||
<text x={pad} y={h - 8} className="fill-gray-500 text-[10px]">{fmtSignedPct(min)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const TARGET_MODEL_OPTIONS = [
|
||||
{ value: 'production_gtl', label: 'Live GTL — production' },
|
||||
{ value: 'structural_sr', label: 'Structural S/R — comparison' },
|
||||
];
|
||||
const CADENCE_OPTIONS = [
|
||||
{ value: 'weekly', label: 'Weekly — default' },
|
||||
{ value: 'daily', label: 'Daily — research' },
|
||||
];
|
||||
|
||||
export function BacktestPanel() {
|
||||
const { data: report, isLoading } = useBacktestReport();
|
||||
@@ -187,114 +79,58 @@ export function BacktestPanel() {
|
||||
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||
so read it as directional.
|
||||
</p>
|
||||
<p className="mt-2 max-w-2xl text-xs text-gray-400">
|
||||
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
|
||||
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
|
||||
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
|
||||
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
|
||||
{' '}is roughly 5× the replay work.
|
||||
</p>
|
||||
</Disclosure>
|
||||
<div className="flex w-full flex-col gap-3 sm:w-auto sm:items-end">
|
||||
<fieldset className="grid w-full grid-cols-1 gap-2 sm:w-[34rem] sm:grid-cols-2">
|
||||
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
|
||||
Target model for this run
|
||||
</legend>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
|
||||
targetModel === 'production_gtl'
|
||||
? 'border-blue-400/60 bg-blue-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="production_gtl"
|
||||
checked={targetModel === 'production_gtl'}
|
||||
onChange={() => setTargetModel('production_gtl')}
|
||||
|
||||
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
|
||||
narrow viewport otherwise. */}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="backtest-target-model">Target model</label>
|
||||
<Dropdown
|
||||
id="backtest-target-model"
|
||||
className="w-56 normal-case tracking-normal"
|
||||
value={targetModel}
|
||||
onChange={(v) => setTargetModel(v as BacktestTargetModel)}
|
||||
options={TARGET_MODEL_OPTIONS}
|
||||
/>
|
||||
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
|
||||
Live GTL
|
||||
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
|
||||
Production
|
||||
</span>
|
||||
</span>
|
||||
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
|
||||
Exact target path used by the live scanner and scheduled backtest.
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
|
||||
targetModel === 'structural_sr'
|
||||
? 'border-amber-400/50 bg-amber-500/10'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
className="sr-only"
|
||||
type="radio"
|
||||
name="backtest-target-model"
|
||||
value="structural_sr"
|
||||
checked={targetModel === 'structural_sr'}
|
||||
onChange={() => setTargetModel('structural_sr')}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="backtest-cadence">Entry cadence</label>
|
||||
<Dropdown
|
||||
id="backtest-cadence"
|
||||
className="w-44 normal-case tracking-normal"
|
||||
value={cadence}
|
||||
onChange={(v) => setCadence(v as BacktestCadence)}
|
||||
options={CADENCE_OPTIONS}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Only surfaced for non-default choices — zero noise on the common path,
|
||||
but a non-production selection still announces itself, which is what
|
||||
the old always-amber cards were really for. */}
|
||||
{(cadence === 'daily' || targetModel === 'structural_sr') && (
|
||||
<div className="space-y-1 text-[11px] text-amber-300/80">
|
||||
{cadence === 'daily' && (
|
||||
<p>Daily replays ~5× the work — prefer the offline snapshot runner.</p>
|
||||
)}
|
||||
{targetModel === 'structural_sr' && (
|
||||
<p>Comparison arm — not the live scanner's target path.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||||
|
||||
{!isLoading && !report && (
|
||||
@@ -319,118 +155,18 @@ export function BacktestPanel() {
|
||||
</span>
|
||||
</p>
|
||||
|
||||
{monitor && monitorRun ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="section-index">Portfolio monitor</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<label htmlFor="monitor-strategy">Strategy</label>
|
||||
<Dropdown
|
||||
id="monitor-strategy"
|
||||
className="w-64 normal-case tracking-normal"
|
||||
value={activeStrategy}
|
||||
onChange={setSelectedStrategy}
|
||||
options={monitor.strategies.map((s) => ({
|
||||
value: s.strategy,
|
||||
label: `${s.is_production ? 'Production: ' : ''}${s.label}`,
|
||||
}))}
|
||||
<PortfolioMonitorPanel
|
||||
monitor={monitor}
|
||||
monitorRun={monitorRun}
|
||||
activeStrategy={activeStrategy}
|
||||
activeLookback={activeLookback}
|
||||
onStrategyChange={setSelectedStrategy}
|
||||
onLookbackChange={setSelectedLookback}
|
||||
/>
|
||||
</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">
|
||||
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
||||
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
||||
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
||||
<Stat
|
||||
label="Total Return"
|
||||
value={fmtSignedPct(monitorRun.total_return_pct)}
|
||||
valueClass={rColor(monitorRun.total_return_pct)}
|
||||
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
||||
/>
|
||||
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
||||
</div>
|
||||
|
||||
<EquityCurveChart run={monitorRun} />
|
||||
|
||||
<p className="text-[11px] text-gray-500">
|
||||
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
||||
{monitorRun.reentry_policy === 'gate_reset' ? (
|
||||
<> · Re-entry after gate failure and fresh qualification</>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||
<div className="glass overflow-x-auto p-4">
|
||||
<p className="section-index mb-2">Per-year returns</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{monitorRun.yearly_returns.map((y) => (
|
||||
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
||||
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
||||
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
||||
{fmtSignedPct(y.return_pct)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{report.recommendation && (
|
||||
<BacktestRecommendationCard recommendation={report.recommendation} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+6
-24
@@ -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<PipelineStatus, { cls: string; label: string }> = {
|
||||
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>;
|
||||
}
|
||||
|
||||
export function TrackRecordPanel() {
|
||||
export function EvaluationPanel() {
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useToast();
|
||||
|
||||
@@ -101,19 +88,14 @@ export function TrackRecordPanel() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Your real, realized results come first; the strategy simulation follows. */}
|
||||
<MyTradesPanel />
|
||||
<div className="border-t border-white/[0.06]" />
|
||||
<BacktestPanel />
|
||||
|
||||
<Disclosure summary="Track-record maintenance">
|
||||
<Disclosure summary="Setup-grading diagnostic & maintenance">
|
||||
<div className="space-y-4 pt-1">
|
||||
<p className="max-w-2xl text-xs text-gray-500">
|
||||
<span className="text-amber-300/90">Diagnostic only — not production P&L.</span>{' '}
|
||||
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{' '}
|
||||
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
|
||||
</p>
|
||||
|
||||
@@ -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 (
|
||||
<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() {
|
||||
const { data: closed, isLoading } = usePaperTrades('closed');
|
||||
|
||||
@@ -70,7 +46,10 @@ export function MyTradesPanel() {
|
||||
if (isLoading) return null;
|
||||
|
||||
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 ? (
|
||||
<Callout variant="empty">
|
||||
No closed trades yet. Take setups as paper trades and they’ll resolve here when price hits
|
||||
@@ -79,11 +58,11 @@ export function MyTradesPanel() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<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`} />
|
||||
<Stat label="Expectancy" value={fmtR(stats.avgR)} valueClass={color(stats.avgR)} sub="avg R per closed trade" />
|
||||
<Stat label="Total R" value={fmtR(stats.totalR)} valueClass={color(stats.totalR)} sub={`${stats.total} closed`} />
|
||||
<Stat label="Total P&L" value={money(stats.totalPnl)} valueClass={color(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="Hit Rate" value={stats.hitRate != null ? `${stats.hitRate.toFixed(1)}%` : '—'} sub={`${stats.wins}W / ${stats.losses}L`} />
|
||||
<StatTile label="Expectancy" value={fmtR(stats.avgR)} valueClass={rColor(stats.avgR)} sub="avg R per closed trade" />
|
||||
<StatTile label="Total R" value={fmtR(stats.totalR)} valueClass={rColor(stats.totalR)} sub={`${stats.total} closed`} />
|
||||
<StatTile label="Total P&L" value={fmtSignedMoney(stats.totalPnl)} valueClass={rColor(stats.totalPnl)} sub="realized, all closed" />
|
||||
<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 className="glass overflow-x-auto">
|
||||
@@ -112,9 +91,9 @@ export function MyTradesPanel() {
|
||||
</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 font-semibold ${p ? color(p.pnl) : 'text-gray-500'}`}>{p ? money(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 ${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 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 ? 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 ? 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">
|
||||
<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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -72,3 +72,58 @@ 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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() {
|
||||
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<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 (
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="Signals"
|
||||
subtitle="Detected trade setups and how past signals actually performed"
|
||||
/>
|
||||
<PageHeader title="Signals" subtitle={SUBTITLE[activeTab]} />
|
||||
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setTab} />
|
||||
|
||||
<div className="animate-fade-in" key={activeTab}>
|
||||
{activeTab === 'Setups' ? <SetupsPanel /> : <TrackRecordPanel />}
|
||||
{body[activeTab]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 rolled_back, "a failed portfolio-sim load left the session un-rolled-back"
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user