add cross-sectional signal evaluation (factor rank-IC) to the backtest
The per-setup hit-rate report can't tell whether a signal predicts returns — only how a target/stop structure built on one performs. This adds a cross-sectional factor-IC pass: each week the universe is ranked by a price-only signal and graded by its rank correlation (Spearman IC) and top-minus-bottom- quintile spread against the forward 30-day return. Candidate signals (point-in-time from price; sentiment/fundamentals have no history in the replay): 12-1/6-1/3-1 month momentum, 1-month reversal, price-vs-200d SMA, proximity to the 52-week high (George/Hwang), and 126-day realized volatility (low-vol anomaly). Reuses the existing per-ticker replay loop (no new data, no second DB pass); results land in the cached backtest_report as `signal_eval` and render as a "Signal edge" table in BacktestPanel beside the calibration curve. 330 backend tests pass (10 new in test_signal_eval); frontend build clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,29 @@ function rColor(v: number | null): string {
|
||||
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',
|
||||
};
|
||||
|
||||
// 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';
|
||||
@@ -247,6 +270,65 @@ export function BacktestPanel() {
|
||||
)}
|
||||
</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 weekly
|
||||
rebalances. <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.
|
||||
</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) => {
|
||||
const edge = 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]' : ''}`}>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user