Files
signal-platform/frontend/src/components/signals/BacktestPanel.tsx
T
dennisthiessen 243e369e9a
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 54s
Deploy / deploy (push) Successful in 32s
feat: robustness stats + dynamic recommendation; retire settled report sections
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>
2026-07-02 12:33:22 +02:00

587 lines
31 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, 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: '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',
};
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&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>
);
}