Files
signal-platform/frontend/src/components/signals/BacktestPanel.tsx
T
dennisthiessen e71c07e554
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 24s
fix: blank Track Record page when the cached backtest report is pre-momentum
The momentum-sweep table read row.min_momentum_percentile.toFixed(), but a report
cached before the EV->momentum change only has min_expected_value rows. undefined
.toFixed() threw during render and — with no error boundary — blanked the whole
Track Record tab. Guard the sweep block on the new field so a stale report just
hides the sweep; re-running the backtest repopulates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:47:36 +02:00

349 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from '../../lib/types';
function fmtR(v: number | null): string {
if (v === null) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
function fmtPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(1)}%`;
}
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: '121 month momentum',
mom_6_1: '61 month momentum',
mom_3_1: '31 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',
};
// 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>
</tr>
);
}
export function BacktestPanel() {
const { data: report, isLoading } = useBacktestReport();
const queryClient = useQueryClient();
const toast = useToast();
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
</p>
<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"
/>
</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>
</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">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.total_r)}`}>{fmtR(row.total_r)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration
</p>
<p className="mb-2 text-[11px] text-gray-500">
Do targets we call X% likely actually hit that often? Realized below predicted =
the model is over-confident.
</p>
{report.calibration.length === 0 ? (
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
) : (
<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">Predicted Bucket</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
</tr>
</thead>
<tbody>
{report.calibration.map((row) => {
const over = row.realized_hit_rate < row.predicted_avg;
return (
<tr key={row.bucket} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
{row.realized_hit_rate.toFixed(0)}%
</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&gt;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. Q5Q1 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&gt;0 %</th>
<th className="px-4 py-2.5 text-right">Q5Q1 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>
);
}