feat: portfolio simulation + per-trade stats (gaps, hold time, best/worst)
Per-trade additions to the report: - Gap-through-stop fills: stops now fill at the worse of the stop or the bar's open across every exit model (target, TP, trailing, time), so a loss can exceed -1R; targets never fill better than their level. - best_r / worst_r, avg holding days, and net R per day of capital deployed on the summary buckets and the time-exit sweep. Portfolio simulation (the stats a per-setup replay cannot give): - One capital-constrained book over the qualified setups: 10k start, max 10 concurrent positions (one per ticker, best momentum first), 1% fixed-fractional risk with a 20% no-leverage notional cap, entries at the detection close, 0.1%/side costs, daily mark-to-market. - Two exit policies compared: S/R target race vs hold-to-horizon. - Equity-curve stats: final equity, total return, CAGR, max drawdown, annualized daily Sharpe, win rate, avg P&L, best/worst trade, avg hold, entries skipped on a full book, and SPY price return over the same window (benchmark history refreshed to cover the replay span). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,15 +6,30 @@ import { Callout } from '../ui/Callout';
|
||||
import { Disclosure } from '../ui/Disclosure';
|
||||
import { Section } from '../ui/Section';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import type { BacktestBucket } from '../../lib/types';
|
||||
import type { BacktestBucket, BacktestPortfolioPolicy } from '../../lib/types';
|
||||
|
||||
function fmtR(v: number | null): string {
|
||||
if (v === null) return '—';
|
||||
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 fmtDays(v: number | null | undefined): string {
|
||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
||||
}
|
||||
function fmtRPerDay(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
return `${v > 0 ? '+' : ''}${v.toFixed(3)}R`;
|
||||
}
|
||||
function rColor(v: number | null): string {
|
||||
if (v === null) return 'text-gray-400';
|
||||
if (v > 0) return 'text-emerald-400';
|
||||
@@ -40,6 +55,11 @@ const ABLATION_LABELS: Record<string, string> = {
|
||||
momentum_only: 'Momentum only (no floors)',
|
||||
};
|
||||
|
||||
const POLICY_LABELS: Record<string, string> = {
|
||||
target: 'S/R target exit',
|
||||
hold: 'Hold to horizon',
|
||||
};
|
||||
|
||||
// Prefer the net-of-costs number when the report carries it; older cached
|
||||
// reports (pre-cost model) fall back to gross.
|
||||
function netOrGross(r: { avg_r: number | null; net_avg_r?: number | null }): number | null {
|
||||
@@ -91,6 +111,10 @@ function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
|
||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_avg_r ?? null)}`}>{fmtR(b.net_avg_r ?? null)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(b.best_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(b.worst_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(b.avg_hold_days)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(b.net_r_per_day ?? null)}`}>{fmtRPerDay(b.net_r_per_day)}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -112,6 +136,7 @@ export function BacktestPanel() {
|
||||
report?.time_exit_sweep && report.time_exit_sweep.length > 0
|
||||
? Math.max(...report.time_exit_sweep.map((r) => netOrGross(r) ?? -Infinity))
|
||||
: null;
|
||||
const sim = report?.portfolio_sim ?? null;
|
||||
|
||||
const run = useMutation({
|
||||
mutationFn: () => triggerJob('backtest'),
|
||||
@@ -202,6 +227,10 @@ export function BacktestPanel() {
|
||||
<th className="px-4 py-2.5 text-right">Hit Rate</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Best R</th>
|
||||
<th className="px-4 py-2.5 text-right">Worst R</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg Hold</th>
|
||||
<th className="px-4 py-2.5 text-right">Net R/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -326,8 +355,9 @@ export function BacktestPanel() {
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
Models a realistic exit instead of waiting for the far S/R target: bank{' '}
|
||||
<span className="text-gray-300">+X%</span> if price reaches it before the stop, else −1R on
|
||||
the stop, else exit at the {report.params.horizon_days}-day close. In R, so it compares to the
|
||||
<span className="text-gray-300">+X%</span> if price reaches it before the stop, else the
|
||||
stop-fill loss (a gap through the stop fills at the open, so it can exceed −1R), else exit
|
||||
at the {report.params.horizon_days}-day close. In R, so it compares to the
|
||||
target model above. <span className="text-gray-300">Hit Rate = how often you'd have banked
|
||||
+X%</span> (how far winners actually run) — no top-ticking, it's the level you'd really set.
|
||||
The setup's own S/R target is <em>not</em> used here (exiting at that target is the model
|
||||
@@ -440,6 +470,10 @@ export function BacktestPanel() {
|
||||
<th className="px-4 py-2.5 text-right">Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Net Avg R</th>
|
||||
<th className="px-4 py-2.5 text-right">Total R</th>
|
||||
<th className="px-4 py-2.5 text-right">Best R</th>
|
||||
<th className="px-4 py-2.5 text-right">Worst R</th>
|
||||
<th className="px-4 py-2.5 text-right">Avg Hold</th>
|
||||
<th className="px-4 py-2.5 text-right">Net R/d</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -457,6 +491,10 @@ export function BacktestPanel() {
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.net_avg_r ?? null)}`}>{fmtR(row.net_avg_r ?? null)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_r)}`}>{fmtR(row.total_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{fmtR(row.best_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-red-400">{fmtR(row.worst_r)}</td>
|
||||
<td className="num px-4 py-2.5 text-right text-gray-400">{fmtDays(row.avg_hold_days)}</td>
|
||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_r_per_day ?? null)}`}>{fmtRPerDay(row.net_r_per_day)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -466,6 +504,63 @@ export function BacktestPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sim && sim.policies.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Portfolio simulation
|
||||
</p>
|
||||
<p className="mb-2 text-[11px] text-gray-500">
|
||||
{sim.note ?? 'One capital-constrained book over the qualified setups.'}{' '}
|
||||
<span className="text-gray-300">
|
||||
Start {fmtMoney(sim.params.starting_capital)} · max {sim.params.max_positions} positions ·{' '}
|
||||
{sim.params.risk_per_trade_pct}% risk/trade · {sim.params.notional_cap_pct}% notional cap ·{' '}
|
||||
{sim.params.cost_per_side_pct}%/side costs · {sim.policies[0].start_date} → {sim.policies[0].end_date}
|
||||
</span>
|
||||
</p>
|
||||
<div className="glass overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
|
||||
<th className="px-4 py-2.5">Metric</th>
|
||||
{sim.policies.map((p) => (
|
||||
<th key={p.policy} className="px-4 py-2.5 text-right">
|
||||
{POLICY_LABELS[p.policy] ?? p.policy}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(
|
||||
[
|
||||
['Final equity', (p) => fmtMoney(p.final_equity), (p) => rColor(p.final_equity - p.starting_capital)],
|
||||
['Total return', (p) => fmtSignedPct(p.total_return_pct), (p) => rColor(p.total_return_pct)],
|
||||
['SPY return (same window)', (p) => fmtSignedPct(p.spy_return_pct), () => 'text-gray-300'],
|
||||
['CAGR', (p) => fmtSignedPct(p.cagr_pct), (p) => rColor(p.cagr_pct)],
|
||||
['Max drawdown', (p) => `−${p.max_drawdown_pct.toFixed(1)}%`, () => 'text-amber-400'],
|
||||
['Sharpe (daily, annualized)', (p) => (p.sharpe === null ? '—' : p.sharpe.toFixed(2)), () => 'text-gray-200'],
|
||||
['Trades', (p) => String(p.trades), () => 'text-gray-300'],
|
||||
['Win rate', (p) => fmtPct(p.win_rate), () => 'text-gray-200'],
|
||||
['Avg P&L / trade', (p) => fmtMoney(p.avg_trade_pnl), (p) => rColor(p.avg_trade_pnl)],
|
||||
['Best / worst trade', (p) => `${fmtR(p.best_trade_r)} / ${fmtR(p.worst_trade_r)}`, () => 'text-gray-300'],
|
||||
['Avg holding time', (p) => fmtDays(p.avg_hold_days), () => 'text-gray-300'],
|
||||
['Entries skipped (book full)', (p) => String(p.skipped_book_full), () => 'text-gray-500'],
|
||||
] as [string, (p: BacktestPortfolioPolicy) => string, (p: BacktestPortfolioPolicy) => string][]
|
||||
).map(([label, fmt, color]) => (
|
||||
<tr key={label} className="border-b border-white/[0.04]">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
|
||||
{sim.policies.map((p) => (
|
||||
<td key={p.policy} className={`num px-4 py-2.5 text-right ${color(p)}`}>
|
||||
{fmt(p)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||||
Probability calibration
|
||||
|
||||
@@ -232,6 +232,10 @@ export interface BacktestBucket {
|
||||
// Net of transaction costs — optional so a stale cached report still renders.
|
||||
net_avg_r?: number | null;
|
||||
net_total_r?: number | null;
|
||||
best_r?: number | null;
|
||||
worst_r?: number | null;
|
||||
avg_hold_days?: number | null;
|
||||
net_r_per_day?: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestCalibrationRow {
|
||||
@@ -276,6 +280,45 @@ export interface BacktestTimeExitRow {
|
||||
total_r: number | null;
|
||||
net_avg_r?: number | null;
|
||||
net_total_r?: number | null;
|
||||
best_r?: number | null;
|
||||
worst_r?: number | null;
|
||||
avg_hold_days?: number | null;
|
||||
net_r_per_day?: number | null;
|
||||
}
|
||||
|
||||
export interface BacktestPortfolioPolicy {
|
||||
policy: string;
|
||||
starting_capital: number;
|
||||
final_equity: number;
|
||||
total_return_pct: number;
|
||||
cagr_pct: number | null;
|
||||
max_drawdown_pct: number;
|
||||
sharpe: number | null;
|
||||
trades: number;
|
||||
win_rate: number | null;
|
||||
avg_trade_pnl: number | null;
|
||||
best_trade_r: number | null;
|
||||
worst_trade_r: number | null;
|
||||
best_trade_pnl: number | null;
|
||||
worst_trade_pnl: number | null;
|
||||
avg_hold_days: number | null;
|
||||
skipped_book_full: number;
|
||||
spy_return_pct: number | null;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
}
|
||||
|
||||
export interface BacktestPortfolioSim {
|
||||
params: {
|
||||
starting_capital: number;
|
||||
max_positions: number;
|
||||
risk_per_trade_pct: number;
|
||||
notional_cap_pct: number;
|
||||
cost_per_side_pct: number;
|
||||
hold_days: number;
|
||||
};
|
||||
policies: BacktestPortfolioPolicy[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BacktestGateAblationRow extends BacktestBucket {
|
||||
@@ -319,6 +362,7 @@ export interface BacktestReport {
|
||||
take_profit_sweep?: BacktestTakeProfitRow[];
|
||||
trailing_sweep?: BacktestTrailingRow[];
|
||||
time_exit_sweep?: BacktestTimeExitRow[];
|
||||
portfolio_sim?: BacktestPortfolioSim;
|
||||
calibration: BacktestCalibrationRow[];
|
||||
signal_eval?: BacktestSignalEvalRow[];
|
||||
signal_eval_note?: string;
|
||||
|
||||
Reference in New Issue
Block a user