243e369e9a
Robustness (answers 'is the edge just outliers?'):
- _bucket_stats gains median_net_r, profit_factor, and net_avg_r_ex_top5
(expectancy with the top 5% of winners removed); shown as stat tiles.
- Portfolio sim gains per-calendar-year returns, shown in the sim table.
Dynamic recommendation ('What this backtest recommends' panel):
- _build_recommendation derives advice from the report's own numbers on
every run — exit policy (target vs best hold, with sim CAGRs), which
gate floors earn their keep (ablation Hold column), best momentum
cutoff, book-vs-SPY verdict, and an outlier-dependence warning when
the trimmed expectancy goes non-positive.
Retired (conclusions reached, tables removed from report + UI):
- Take-profit sweep (no interior optimum — fixed TP is the wrong tool
for momentum), trailing sweep (converged to the hold-to-horizon exit),
probability calibration (model is display-only by decision).
- _tp_primitives slimmed to _risk_and_stop_day; trailing machinery gone.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
587 lines
31 KiB
TypeScript
587 lines
31 KiB
TypeScript
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||
import { triggerJob } from '../../api/admin';
|
||
import { Button } from '../ui/Button';
|
||
import { Callout } from '../ui/Callout';
|
||
import { Disclosure } from '../ui/Disclosure';
|
||
import { Section } from '../ui/Section';
|
||
import { useToast } from '../ui/Toast';
|
||
import type { BacktestBucket, BacktestPortfolioPolicy } 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 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';
|
||
if (v < 0) return 'text-red-400';
|
||
return 'text-gray-300';
|
||
}
|
||
|
||
const SIGNAL_LABELS: Record<string, string> = {
|
||
mom_12_1: '12–1 month momentum',
|
||
mom_6_1: '6–1 month momentum',
|
||
mom_3_1: '3–1 month momentum',
|
||
reversal_1m: '1-month reversal',
|
||
trend_200: 'Price vs 200-day SMA',
|
||
high_52w: 'Proximity to 52-week high',
|
||
vol_6m: '6-month realized volatility',
|
||
};
|
||
|
||
const ABLATION_LABELS: Record<string, string> = {
|
||
all_floors: 'All floors (current gate)',
|
||
no_confidence_floor: 'Without confidence floor',
|
||
no_rr_floor: 'Without R:R floor',
|
||
no_neutral_exclusion: 'Without NEUTRAL exclusion',
|
||
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 {
|
||
return r.net_avg_r ?? r.avg_r;
|
||
}
|
||
|
||
// An |IC| this large, with a consistent sign, is a real (if small) edge worth
|
||
// building on; below it, ranking on the signal sorts essentially nothing.
|
||
const IC_EDGE_THRESHOLD = 0.03;
|
||
|
||
function icColor(v: number): string {
|
||
if (Math.abs(v) < 0.02) return 'text-gray-400';
|
||
return v > 0 ? 'text-emerald-400' : 'text-red-400';
|
||
}
|
||
function fmtSpread(v: number | null): string {
|
||
if (v === null) return '—';
|
||
return `${v > 0 ? '+' : ''}${(v * 100).toFixed(2)}%`;
|
||
}
|
||
|
||
function timeAgo(iso: string): string {
|
||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||
if (mins < 1) return 'just now';
|
||
if (mins < 60) return `${mins}m ago`;
|
||
const hrs = Math.floor(mins / 60);
|
||
if (hrs < 24) return `${hrs}h ago`;
|
||
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 BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
|
||
return (
|
||
<tr className="border-b border-white/[0.04]">
|
||
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-300">{b.total}</td>
|
||
<td className="num px-4 py-2.5 text-right text-emerald-400">{b.wins}</td>
|
||
<td className="num px-4 py-2.5 text-right text-red-400">{b.losses}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
|
||
<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>
|
||
);
|
||
}
|
||
|
||
export function BacktestPanel() {
|
||
const { data: report, isLoading } = useBacktestReport();
|
||
const queryClient = useQueryClient();
|
||
const toast = useToast();
|
||
|
||
const bestTimeAvgR =
|
||
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'),
|
||
onSuccess: (res) => {
|
||
if (res.status === 'triggered') {
|
||
toast.addToast('success', 'Backtest started — results appear when it finishes (a minute or two).');
|
||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['backtest-report'] }), 8000);
|
||
} else {
|
||
toast.addToast('info', res.message || 'Could not start backtest');
|
||
}
|
||
},
|
||
onError: () => toast.addToast('error', 'Failed to start backtest'),
|
||
});
|
||
|
||
return (
|
||
<Section title="Backtest" hint="historical replay of the current config">
|
||
<div className="space-y-4">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<Disclosure summary="How the backtest works">
|
||
<p className="text-xs text-gray-400">
|
||
At each weekly point in history, the setup is rebuilt using only data up to that day
|
||
(no lookahead), then the actual following ~30 trading days decide its outcome. This
|
||
shows how the <em>current</em> settings would have performed. Sentiment and
|
||
fundamentals are held neutral (no point-in-time history), so this calibrates the
|
||
price / support-resistance / probability machinery. ~6 months of data is roughly one
|
||
market regime — read it as directional, not a guarantee.
|
||
</p>
|
||
</Disclosure>
|
||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
|
||
</Button>
|
||
</div>
|
||
|
||
{isLoading && <Callout variant="empty">Loading…</Callout>}
|
||
|
||
{!isLoading && !report && (
|
||
<Callout variant="empty">
|
||
No backtest yet. Click “Run backtest” (or trigger it in Admin → Jobs) — it replays every
|
||
ticker over history and takes a minute or two.
|
||
</Callout>
|
||
)}
|
||
|
||
{report && (
|
||
<>
|
||
<p className="text-[11px] text-gray-500">
|
||
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
||
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
|
||
{report.params.cost_per_side_pct != null && (
|
||
<> · net assumes {report.params.cost_per_side_pct}%/side costs</>
|
||
)}
|
||
</p>
|
||
|
||
{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>
|
||
)}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||
<Stat
|
||
label="Qualified Hit Rate"
|
||
value={fmtPct(report.overall_qualified.hit_rate)}
|
||
sub={`${report.overall_qualified.wins}W / ${report.overall_qualified.losses}L`}
|
||
/>
|
||
<Stat
|
||
label="Qualified Expectancy"
|
||
value={fmtR(report.overall_qualified.avg_r)}
|
||
valueClass={rColor(report.overall_qualified.avg_r)}
|
||
sub="avg R per qualified setup"
|
||
/>
|
||
<Stat
|
||
label="All Setups Expectancy"
|
||
value={fmtR(report.overall_all.avg_r)}
|
||
valueClass={rColor(report.overall_all.avg_r)}
|
||
sub={`${report.overall_all.total} setups · baseline`}
|
||
/>
|
||
<Stat
|
||
label="Qualified Total R"
|
||
value={fmtR(report.overall_qualified.total_r)}
|
||
valueClass={rColor(report.overall_qualified.total_r)}
|
||
sub="cumulative, risk-adjusted"
|
||
/>
|
||
{report.overall_qualified.median_net_r != null && (
|
||
<Stat
|
||
label="Median Net R"
|
||
value={fmtR(report.overall_qualified.median_net_r)}
|
||
valueClass={rColor(report.overall_qualified.median_net_r)}
|
||
sub="qualified · the typical trade"
|
||
/>
|
||
)}
|
||
{report.overall_qualified.profit_factor != null && (
|
||
<Stat
|
||
label="Profit Factor"
|
||
value={report.overall_qualified.profit_factor.toFixed(2)}
|
||
valueClass={report.overall_qualified.profit_factor > 1 ? 'text-emerald-400' : 'text-red-400'}
|
||
sub="qualified · net wins / net losses"
|
||
/>
|
||
)}
|
||
{report.overall_qualified.net_avg_r_ex_top5 != null && (
|
||
<Stat
|
||
label="Ex-Top-5% Net R"
|
||
value={fmtR(report.overall_qualified.net_avg_r_ex_top5)}
|
||
valueClass={rColor(report.overall_qualified.net_avg_r_ex_top5)}
|
||
sub="expectancy without the biggest winners"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<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">Set</th>
|
||
<th className="px-4 py-2.5 text-right">Setups</th>
|
||
<th className="px-4 py-2.5 text-right">Wins</th>
|
||
<th className="px-4 py-2.5 text-right">Losses</th>
|
||
<th className="px-4 py-2.5 text-right">Expired</th>
|
||
<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>
|
||
<BucketRow label="Qualified" b={report.overall_qualified} />
|
||
<BucketRow label="All" b={report.overall_all} />
|
||
{report.by_direction.long && <BucketRow label="Long (qual.)" b={report.by_direction.long} />}
|
||
{report.by_direction.short && <BucketRow label="Short (qual.)" b={report.by_direction.short} />}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* Guard on the new field so a stale cached report (pre-momentum,
|
||
with min_expected_value rows) hides the sweep instead of crashing
|
||
the whole page. Re-running the backtest repopulates it. */}
|
||
{report.sweep && report.sweep.length > 0 && report.sweep[0].min_momentum_percentile != null && (
|
||
<div>
|
||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||
Momentum-percentile sweep
|
||
</p>
|
||
<p className="mb-2 text-[11px] text-gray-500">
|
||
How many setups qualify — and how they perform — at each momentum-rank cutoff (floors
|
||
held fixed). 80 = only the top 20% of the universe by 12-1 momentum each week; 0 =
|
||
floors only. Lower = more trades, watch that expectancy holds. Your current setting is
|
||
highlighted; set it in Admin → Settings → Activation.
|
||
</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">Min momentum %ile</th>
|
||
<th className="px-4 py-2.5 text-right">Qualified</th>
|
||
<th className="px-4 py-2.5 text-right">Wins</th>
|
||
<th className="px-4 py-2.5 text-right">Losses</th>
|
||
<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">Total R</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{report.sweep.map((row) => {
|
||
const current = Math.abs(row.min_momentum_percentile - report.min_momentum_percentile) < 0.001;
|
||
return (
|
||
<tr key={row.min_momentum_percentile} className={`border-b border-white/[0.04] ${current ? 'bg-blue-400/10' : ''}`}>
|
||
<td className="num px-4 py-2.5 text-gray-200">
|
||
{current && <span className="mr-1 text-blue-300">★</span>}
|
||
{row.min_momentum_percentile.toFixed(0)}
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
||
<td className="num px-4 py-2.5 text-right text-red-400">{row.losses}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
|
||
<td className={`num px-4 py-2.5 text-right font-semibold ${rColor(row.avg_r)}`}>{fmtR(row.avg_r)}</td>
|
||
<td className={`num px-4 py-2.5 text-right ${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>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{report.gate_ablation && report.gate_ablation.length > 0 && (
|
||
<div>
|
||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||
Gate ablation — which floors earn their keep
|
||
</p>
|
||
<p className="mb-2 text-[11px] text-gray-500">
|
||
{report.gate_ablation_note ??
|
||
'Each row re-qualifies the same candidates at the current momentum cutoff with one floor removed (long-only throughout).'}
|
||
</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">Variant</th>
|
||
<th className="px-4 py-2.5 text-right">Setups</th>
|
||
<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">Total R</th>
|
||
<th className="px-4 py-2.5 text-right">Hold Net Avg R</th>
|
||
<th className="px-4 py-2.5 text-right">Hold Total R</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{report.gate_ablation.map((row) => (
|
||
<tr
|
||
key={row.variant}
|
||
className={`border-b border-white/[0.04] ${row.variant === 'all_floors' ? 'bg-blue-400/10' : ''}`}
|
||
>
|
||
<td className="px-4 py-2.5 font-medium text-gray-200">
|
||
{ABLATION_LABELS[row.variant] ?? row.variant}
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.hit_rate)}</td>
|
||
<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 font-semibold ${rColor(row.hold_net_avg_r ?? null)}`}>
|
||
{fmtR(row.hold_net_avg_r ?? null)}
|
||
</td>
|
||
<td className={`num px-4 py-2.5 text-right ${rColor(row.hold_total_r ?? null)}`}>
|
||
{fmtR(row.hold_total_r ?? null)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{report.time_exit_sweep && report.time_exit_sweep.length > 0 && (
|
||
<div>
|
||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||
Time-based exit
|
||
</p>
|
||
<p className="mb-2 text-[11px] text-gray-500">
|
||
Buy at detection, keep the initial ATR stop, and exit at the{' '}
|
||
<span className="text-gray-300">day-N close</span> — no target, no trailing. This is the
|
||
classic cross-sectional momentum implementation (hold ~a month, re-rank).{' '}
|
||
<span className="text-gray-300">Win Rate = share closed in profit.</span> ★ = best net avg R.
|
||
</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">Hold</th>
|
||
<th className="px-4 py-2.5 text-right">Setups</th>
|
||
<th className="px-4 py-2.5 text-right">Profitable</th>
|
||
<th className="px-4 py-2.5 text-right">Win 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">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>
|
||
{report.time_exit_sweep.map((row) => {
|
||
const best = netOrGross(row) != null && netOrGross(row) === bestTimeAvgR;
|
||
return (
|
||
<tr key={row.hold_days} className={`border-b border-white/[0.04] ${best ? 'bg-emerald-400/[0.06]' : ''}`}>
|
||
<td className="num px-4 py-2.5 text-gray-200">
|
||
{best && <span className="mr-1 text-emerald-300">★</span>}
|
||
{row.hold_days}d
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{row.total}</td>
|
||
<td className="num px-4 py-2.5 text-right text-emerald-400">{row.wins}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(row.win_rate)}</td>
|
||
<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>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</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'],
|
||
[
|
||
'Per-year returns',
|
||
(p) =>
|
||
p.yearly_returns && p.yearly_returns.length > 0
|
||
? p.yearly_returns
|
||
.map((y) => `${y.year} ${fmtSignedPct(y.return_pct)}`)
|
||
.join(' · ')
|
||
: '—',
|
||
() => '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>
|
||
)}
|
||
|
||
{report.signal_eval && report.signal_eval.length > 0 && (
|
||
<div>
|
||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
||
Signal edge (cross-sectional)
|
||
</p>
|
||
<p className="mb-2 text-[11px] text-gray-500">
|
||
Does ranking the universe by a signal predict the forward {report.params.horizon_days}-day
|
||
return? Mean IC is the rank correlation between signal and return, averaged over
|
||
non-overlapping windows. <span className="text-emerald-400">|IC| ≳ {IC_EDGE_THRESHOLD}</span> with a
|
||
consistent sign (high IC>0 %) is a real, if small, edge; near 0 means it sorts nothing.
|
||
Momentum skips the last month; <em>reversal_1m is expected negative</em> if the universe
|
||
mean-reverts. Q5−Q1 is the top-minus-bottom-quintile forward return. <span className="text-gray-600">Greyed
|
||
rows have too few independent windows to trust — deepen history via the Data Backfill job.</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">Signal</th>
|
||
<th className="px-4 py-2.5 text-right">Weeks</th>
|
||
<th className="px-4 py-2.5 text-right">Avg N</th>
|
||
<th className="px-4 py-2.5 text-right">Mean IC</th>
|
||
<th className="px-4 py-2.5 text-right">t-stat</th>
|
||
<th className="px-4 py-2.5 text-right">IC>0 %</th>
|
||
<th className="px-4 py-2.5 text-right">Q5−Q1 fwd</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{report.signal_eval.map((row) => {
|
||
// Only trust the edge highlight when the IC rests on enough
|
||
// independent windows; thin signals are dimmed, not starred.
|
||
const edge = row.reliable && Math.abs(row.mean_ic) >= IC_EDGE_THRESHOLD;
|
||
return (
|
||
<tr
|
||
key={row.signal}
|
||
className={`border-b border-white/[0.04] ${edge ? 'bg-emerald-400/[0.06]' : ''} ${row.reliable ? '' : 'opacity-40'}`}
|
||
title={row.reliable ? undefined : `Only ${row.weeks} independent window(s) — not enough to trust`}
|
||
>
|
||
<td className="px-4 py-2.5 font-medium text-gray-200">
|
||
{edge && <span className="mr-1 text-emerald-300">★</span>}
|
||
{SIGNAL_LABELS[row.signal] ?? row.signal}
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-400">{row.weeks}</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-400">{row.avg_cross_section ?? '—'}</td>
|
||
<td className={`num px-4 py-2.5 text-right font-semibold ${icColor(row.mean_ic)}`}>
|
||
{row.mean_ic.toFixed(3)}
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-300">
|
||
{row.ic_t_stat === null ? '—' : row.ic_t_stat.toFixed(2)}
|
||
</td>
|
||
<td className="num px-4 py-2.5 text-right text-gray-300">{fmtPct(row.ic_positive_pct)}</td>
|
||
<td className={`num px-4 py-2.5 text-right ${rColor(row.mean_quintile_spread)}`}>
|
||
{fmtSpread(row.mean_quintile_spread)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{report.signal_eval_note && (
|
||
<p className="mt-2 text-[11px] text-gray-600">{report.signal_eval_note}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<p className="text-[11px] text-gray-600">{report.note}</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Section>
|
||
);
|
||
}
|