Slim Track Record page to validation + how-to-trade
Strategy research now runs locally against DB snapshots (see README), so the deployed Track Record page no longer needs the strategy-tuning output. Keep only what answers "did my trades work / is the strategy working / what do I trade": - Reshape BacktestPanel into an "Is the strategy working?" block: portfolio monitor (unchanged), a deliberate metric set (CAGR, Sharpe, Max DD, Total Return vs SPY, per-year returns), plus the folded-in live-vs-backtest verdict and the backtest recommendation. - Fold the standalone portfolio-sim table's unique rows (per-year returns, avg hold, best/worst, avg P&L) into the monitor; drop the duplicate table. - Slim TrackRecordPanel to My Trades -> Is it working? -> maintenance disclosure (Evaluate/Reset demoted). - Cut the local-research tables: percentile sweep, gate ablation, time-exit sweep, strategy variants, signal-edge rank-IC, research candidates, by-action/by-confidence breakdowns, and the bucket comparison. Frontend-only; the weekly server backtest still computes the cut tables (they feed the local report). tsc -b && vite build pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,19 +1,22 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
||||||
|
import { usePerformance } from '../../hooks/usePerformance';
|
||||||
import { triggerJob } from '../../api/admin';
|
import { triggerJob } from '../../api/admin';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Callout } from '../ui/Callout';
|
import { Callout } from '../ui/Callout';
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { Section } from '../ui/Section';
|
import { Section } from '../ui/Section';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import type {
|
import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } from '../../lib/types';
|
||||||
BacktestBucket,
|
|
||||||
BacktestCurvePoint,
|
// Need at least this many matured setups before a live-vs-backtest verdict means
|
||||||
BacktestPortfolioMonitorRun,
|
// anything; below it the live sample is too noisy to compare.
|
||||||
BacktestPortfolioPolicy,
|
const MIN_MATURED = 20;
|
||||||
BacktestStrategyVariant,
|
// Live expectancy this far (in R) below the backtest counts as drift, not noise.
|
||||||
} from '../../lib/types';
|
const DRIFT_TOLERANCE_R = 0.2;
|
||||||
|
|
||||||
|
type TrackingStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
||||||
|
|
||||||
function fmtR(v: number | null | undefined): string {
|
function fmtR(v: number | null | undefined): string {
|
||||||
if (v === null || v === undefined) return '—';
|
if (v === null || v === undefined) return '—';
|
||||||
@@ -36,10 +39,6 @@ function fmtDrawdown(v: number | null | undefined): string {
|
|||||||
function fmtDays(v: number | null | undefined): string {
|
function fmtDays(v: number | null | undefined): string {
|
||||||
return v === null || v === undefined ? '—' : `${v.toFixed(1)}d`;
|
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 {
|
function rColor(v: number | null): string {
|
||||||
if (v === null) return 'text-gray-400';
|
if (v === null) return 'text-gray-400';
|
||||||
if (v > 0) return 'text-emerald-400';
|
if (v > 0) return 'text-emerald-400';
|
||||||
@@ -47,49 +46,6 @@ function rColor(v: number | null): string {
|
|||||||
return 'text-gray-300';
|
return 'text-gray-300';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIGNAL_LABELS: Record<string, string> = {
|
|
||||||
mom_12_1: '12–1 month momentum',
|
|
||||||
mom_12_1_resid: '12–1 residual 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 {
|
function timeAgo(iso: string): string {
|
||||||
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000);
|
||||||
if (mins < 1) return 'just now';
|
if (mins < 1) return 'just now';
|
||||||
@@ -111,23 +67,15 @@ function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
|
function VerdictChip({ status }: { status: TrackingStatus }) {
|
||||||
return (
|
const styles: Record<TrackingStatus, { cls: string; label: string }> = {
|
||||||
<tr className="border-b border-white/[0.04]">
|
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ tracking' },
|
||||||
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
|
drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' },
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{b.total}</td>
|
building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' },
|
||||||
<td className="num px-4 py-2.5 text-right text-emerald-400">{b.wins}</td>
|
'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' },
|
||||||
<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>
|
const s = styles[status];
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
|
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function curvePath(
|
function curvePath(
|
||||||
@@ -210,16 +158,12 @@ function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) {
|
|||||||
|
|
||||||
export function BacktestPanel() {
|
export function BacktestPanel() {
|
||||||
const { data: report, isLoading } = useBacktestReport();
|
const { data: report, isLoading } = useBacktestReport();
|
||||||
|
const { data: perf } = usePerformance({ qualified_only: true });
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [selectedStrategy, setSelectedStrategy] = useState('');
|
const [selectedStrategy, setSelectedStrategy] = useState('');
|
||||||
const [selectedLookback, setSelectedLookback] = useState('');
|
const [selectedLookback, setSelectedLookback] = useState('');
|
||||||
|
|
||||||
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 monitor = report?.portfolio_monitor ?? null;
|
const monitor = report?.portfolio_monitor ?? null;
|
||||||
const activeStrategy =
|
const activeStrategy =
|
||||||
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
|
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
|
||||||
@@ -234,6 +178,22 @@ export function BacktestPanel() {
|
|||||||
[monitor, activeStrategy, activeLookback],
|
[monitor, activeStrategy, activeLookback],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Live matured qualified cohort vs the backtest's qualified expectancy — the
|
||||||
|
// out-of-sample check that the running system faithfully implements the backtest.
|
||||||
|
const liveAvgR = perf?.overall.avg_r ?? null;
|
||||||
|
const liveN = perf?.overall.total ?? 0;
|
||||||
|
const btAvgR = report?.overall_qualified.avg_r ?? null;
|
||||||
|
let status: TrackingStatus = 'building';
|
||||||
|
if (liveAvgR != null && liveN >= MIN_MATURED) {
|
||||||
|
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
|
||||||
|
}
|
||||||
|
const verdictNote: Record<TrackingStatus, string> = {
|
||||||
|
building: `Fewer than ~${MIN_MATURED} matured setups so far — until then the backtest is the edge estimate. This turns into a live check as setups age past their ~30-day window.`,
|
||||||
|
'no-backtest': 'Run the backtest to get a baseline to compare the live record against.',
|
||||||
|
tracking: 'Live setups are resolving in line with the backtest — the running system is faithfully implementing it.',
|
||||||
|
drift: 'Live expectancy is running materially below the backtest — small-sample noise, a regime shift, or a live/backtest gap. Worth a look.',
|
||||||
|
};
|
||||||
|
|
||||||
const run = useMutation({
|
const run = useMutation({
|
||||||
mutationFn: () => triggerJob('backtest'),
|
mutationFn: () => triggerJob('backtest'),
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
@@ -248,17 +208,17 @@ export function BacktestPanel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section title="Backtest" hint="historical replay of the current config">
|
<Section title="Is the strategy working?" hint="portfolio simulation vs S&P 500, validated against the live record">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<Disclosure summary="How the backtest works">
|
<Disclosure summary="How this is measured">
|
||||||
<p className="text-xs text-gray-400">
|
<p className="max-w-2xl text-xs text-gray-400">
|
||||||
At each weekly point in history, the setup is rebuilt using only data up to that day
|
The backtest replays the current config weekly through history — at each point the setup is
|
||||||
(no lookahead), then the actual following ~30 trading days decide its outcome. This
|
rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide
|
||||||
shows how the <em>current</em> settings would have performed. Sentiment and
|
its outcome — then simulates one capital-constrained book against the S&P 500. Sentiment and
|
||||||
fundamentals are held neutral (no point-in-time history), so this calibrates the
|
fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime,
|
||||||
price / support-resistance / probability machinery. ~6 months of data is roughly one
|
so read it as directional. The <em>tracking</em> check compares the backtest's qualified
|
||||||
market regime — read it as directional, not a guarantee.
|
expectancy with what live qualified setups have actually realized once matured.
|
||||||
</p>
|
</p>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
|
||||||
@@ -270,8 +230,8 @@ export function BacktestPanel() {
|
|||||||
|
|
||||||
{!isLoading && !report && (
|
{!isLoading && !report && (
|
||||||
<Callout variant="empty">
|
<Callout variant="empty">
|
||||||
No backtest yet. Click “Run backtest” (or trigger it in Admin → Jobs) — it replays every
|
No backtest yet. Click “Run backtest” (or trigger it in Admin → Jobs) — it replays every ticker
|
||||||
ticker over history and takes a minute or two.
|
over history and takes a minute or two.
|
||||||
</Callout>
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -281,17 +241,17 @@ export function BacktestPanel() {
|
|||||||
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
|
||||||
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
|
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
|
||||||
{report.params.cost_per_side_pct != null && (
|
{report.params.cost_per_side_pct != null && (
|
||||||
<> · net assumes {report.params.cost_per_side_pct}%/side costs</>
|
<> · net of {report.params.cost_per_side_pct}%/side costs</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{monitor && monitorRun && (
|
{monitor && monitorRun ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="section-index">Portfolio monitor</p>
|
<p className="section-index">Portfolio monitor</p>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Cached portfolio simulation for supported strategies, compared with S&P 500.
|
Simulated book for the selected strategy and lookback, compared with the S&P 500.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@@ -328,13 +288,63 @@ export function BacktestPanel() {
|
|||||||
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
<Stat label="CAGR" value={fmtSignedPct(monitorRun.cagr_pct)} valueClass={rColor(monitorRun.cagr_pct)} />
|
||||||
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
<Stat label="Sharpe" value={monitorRun.sharpe == null ? '—' : monitorRun.sharpe.toFixed(2)} />
|
||||||
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
<Stat label="Max Drawdown" value={fmtDrawdown(monitorRun.max_drawdown_pct)} valueClass="text-amber-400" />
|
||||||
<Stat label="Total Return" value={fmtSignedPct(monitorRun.total_return_pct)} valueClass={rColor(monitorRun.total_return_pct)} />
|
<Stat
|
||||||
|
label="Total Return"
|
||||||
|
value={fmtSignedPct(monitorRun.total_return_pct)}
|
||||||
|
valueClass={rColor(monitorRun.total_return_pct)}
|
||||||
|
sub={`vs S&P 500 ${fmtSignedPct(monitorRun.spy_return_pct)}`}
|
||||||
|
/>
|
||||||
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
<Stat label="Trades" value={String(monitorRun.trades)} sub={`${fmtPct(monitorRun.win_rate)} win rate`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EquityCurveChart run={monitorRun} />
|
<EquityCurveChart run={monitorRun} />
|
||||||
|
|
||||||
|
<p className="text-[11px] text-gray-500">
|
||||||
|
Avg hold {fmtDays(monitorRun.avg_hold_days)} · Best {fmtR(monitorRun.best_trade_r)} / Worst{' '}
|
||||||
|
{fmtR(monitorRun.worst_trade_r)} · Avg P&L per trade {fmtMoney(monitorRun.avg_trade_pnl)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{monitorRun.yearly_returns && monitorRun.yearly_returns.length > 0 && (
|
||||||
|
<div className="glass overflow-x-auto p-4">
|
||||||
|
<p className="section-index mb-2">Per-year returns</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{monitorRun.yearly_returns.map((y) => (
|
||||||
|
<div key={y.year} className="rounded border border-white/10 px-3 py-1.5">
|
||||||
|
<span className="num text-xs text-gray-500">{y.year}</span>{' '}
|
||||||
|
<span className={`num text-sm font-semibold ${rColor(y.return_pct)}`}>
|
||||||
|
{fmtSignedPct(y.return_pct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Live-vs-backtest validation: does the running system realize what the backtest promised? */}
|
||||||
|
<div className="glass-sm space-y-2 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-400">
|
||||||
|
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{liveN} matured{perf ? ` · ${perf.maturing} maturing` : ''} · qualified expectancy
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<VerdictChip status={status} />
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-relaxed text-gray-500">{verdictNote[status]}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
{monitor.note && <p className="text-[11px] text-gray-600">{monitor.note}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<Callout variant="empty">
|
||||||
|
This report predates the portfolio monitor — re-run the backtest to populate it.
|
||||||
|
</Callout>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{report.recommendation && report.recommendation.items.length > 0 && (
|
{report.recommendation && report.recommendation.items.length > 0 && (
|
||||||
@@ -361,453 +371,11 @@ export function BacktestPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{report.research_recommendation && report.research_recommendation.items.length > 0 && (
|
<p className="text-[11px] text-gray-600">
|
||||||
<div className="glass border border-emerald-400/15 p-4">
|
Strategy research — gate tuning, exit sweeps, factor rank-IC — now runs locally against a
|
||||||
<p className="section-index">Research candidates</p>
|
database snapshot (see README). This page keeps only what says whether the promoted strategy
|
||||||
<ul className="mt-2 space-y-1">
|
is worth trading and being delivered live.
|
||||||
{report.research_recommendation.items.map((item) => (
|
</p>
|
||||||
<li
|
|
||||||
key={item.topic + item.text}
|
|
||||||
className={`text-xs ${item.candidate ? 'text-emerald-400' : 'text-gray-400'}`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
{report.research_recommendation.note && (
|
|
||||||
<p className="mt-2 text-[11px] text-gray-600">{report.research_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">
|
|
||||||
Residual-momentum percentile sweep
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
|
||||||
How many setups qualify — and how they perform — at each production-rank cutoff (floors
|
|
||||||
held fixed). 80 = only the top 20% of the universe by residual 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 residual %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>
|
|
||||||
<th className="px-4 py-2.5 text-right">Median Net R</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Ex-Top-5%</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>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.median_net_r ?? null)}`}>{fmtR(row.median_net_r)}</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.net_avg_r_ex_top5 ?? null)}`}>{fmtR(row.net_avg_r_ex_top5)}</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 ?? 'policy'} className="px-4 py-2.5 text-right">
|
|
||||||
{POLICY_LABELS[p.policy ?? ''] ?? p.policy ?? '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 ?? label} className={`num px-4 py-2.5 text-right ${color(p)}`}>
|
|
||||||
{fmt(p)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{report.strategy_variants && report.strategy_variants.variants.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
|
|
||||||
Strategy variants
|
|
||||||
</p>
|
|
||||||
<p className="mb-2 text-[11px] text-gray-500">
|
|
||||||
{report.strategy_variants.note ?? 'Research-only portfolio variants.'}
|
|
||||||
</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">Rank</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Cutoff</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Max Pos</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Risk</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">CAGR</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Max DD</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Sharpe</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Total Ret</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Trades</th>
|
|
||||||
<th className="px-4 py-2.5 text-right">Skipped</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{report.strategy_variants.variants.map((row: BacktestStrategyVariant) => (
|
|
||||||
<tr key={row.variant} className="border-b border-white/[0.04]">
|
|
||||||
<td className="px-4 py-2.5 font-medium text-gray-200">{row.label}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.ranking}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.cutoff.toFixed(0)}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.max_positions}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">
|
|
||||||
{`${row.risk_per_trade_pct.toFixed(1)}%`}
|
|
||||||
</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.cagr_pct)}`}>{fmtSignedPct(row.cagr_pct)}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-amber-400">−{row.max_drawdown_pct.toFixed(1)}%</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-200">
|
|
||||||
{row.sharpe === null ? '—' : row.sharpe.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className={`num px-4 py-2.5 text-right ${rColor(row.total_return_pct)}`}>{fmtSignedPct(row.total_return_pct)}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-300">{row.trades}</td>
|
|
||||||
<td className="num px-4 py-2.5 text-right text-gray-500">{row.skipped_book_full}</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>
|
</div>
|
||||||
|
|||||||
@@ -1,126 +1,12 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useActivation } from '../../hooks/useActivation';
|
|
||||||
import { activationSummary } from '../../lib/qualification';
|
|
||||||
import { usePerformance } from '../../hooks/usePerformance';
|
|
||||||
import { useBacktestReport } from '../../hooks/useMarketRegime';
|
|
||||||
import { triggerJob, resetTrackRecord } from '../../api/admin';
|
import { triggerJob, resetTrackRecord } from '../../api/admin';
|
||||||
import { Button } from '../ui/Button';
|
import { Button } from '../ui/Button';
|
||||||
import { Callout } from '../ui/Callout';
|
|
||||||
import { Disclosure } from '../ui/Disclosure';
|
import { Disclosure } from '../ui/Disclosure';
|
||||||
import { Section } from '../ui/Section';
|
|
||||||
import { SkeletonCard } from '../ui/Skeleton';
|
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
|
||||||
import { BacktestPanel } from './BacktestPanel';
|
import { BacktestPanel } from './BacktestPanel';
|
||||||
import { MyTradesPanel } from './MyTradesPanel';
|
import { MyTradesPanel } from './MyTradesPanel';
|
||||||
import type { OutcomeBucketStats } from '../../lib/types';
|
|
||||||
|
|
||||||
// Need at least this many matured setups before a live-vs-backtest verdict means
|
|
||||||
// anything; below it the live sample is too noisy to compare.
|
|
||||||
const MIN_MATURED = 20;
|
|
||||||
// Live expectancy this far (in R) below the backtest counts as drift, not noise.
|
|
||||||
const DRIFT_TOLERANCE_R = 0.2;
|
|
||||||
|
|
||||||
type TrackingStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
|
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
|
||||||
if (value === null) return '—';
|
|
||||||
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtPct(value: number | null): string {
|
|
||||||
return value === null ? '—' : `${value.toFixed(1)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rColor(value: number | null): string {
|
|
||||||
if (value === null) return 'text-gray-400';
|
|
||||||
if (value > 0) return 'text-emerald-400';
|
|
||||||
if (value < 0) return 'text-red-400';
|
|
||||||
return 'text-gray-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function VerdictChip({ status }: { status: TrackingStatus }) {
|
|
||||||
const styles: Record<TrackingStatus, { cls: string; label: string }> = {
|
|
||||||
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ tracking' },
|
|
||||||
drift: { cls: 'border-amber-500/30 bg-amber-500/15 text-amber-300', label: '⚠ drift' },
|
|
||||||
building: { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'building' },
|
|
||||||
'no-backtest': { cls: 'border-white/10 bg-white/[0.05] text-gray-400', label: 'no backtest' },
|
|
||||||
};
|
|
||||||
const s = styles[status];
|
|
||||||
return <span className={`shrink-0 rounded-full border px-2.5 py-1 text-xs font-medium ${s.cls}`}>{s.label}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
valueClass?: string;
|
|
||||||
sub?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="glass p-5">
|
|
||||||
<p className="section-index">{label}</p>
|
|
||||||
<p className={`num mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
|
|
||||||
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function actionLabel(key: string): string {
|
|
||||||
return RECOMMENDATION_ACTION_LABELS[key as keyof typeof RECOMMENDATION_ACTION_LABELS] ?? key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function BreakdownTable({ rows, labelHeader, mapLabel }: {
|
|
||||||
rows: Record<string, OutcomeBucketStats>;
|
|
||||||
labelHeader: string;
|
|
||||||
mapLabel?: (key: string) => string;
|
|
||||||
}) {
|
|
||||||
const entries = Object.entries(rows);
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return <Callout variant="empty">No matured setups in this breakdown yet.</Callout>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<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-3">{labelHeader}</th>
|
|
||||||
<th className="px-4 py-3 text-right">Setups</th>
|
|
||||||
<th className="px-4 py-3 text-right">Wins</th>
|
|
||||||
<th className="px-4 py-3 text-right">Losses</th>
|
|
||||||
<th className="px-4 py-3 text-right">Expired</th>
|
|
||||||
<th className="px-4 py-3 text-right">Hit Rate</th>
|
|
||||||
<th className="px-4 py-3 text-right">Avg R</th>
|
|
||||||
<th className="px-4 py-3 text-right">Total R</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map(([key, stats]) => (
|
|
||||||
<tr key={key} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
|
|
||||||
<td className="px-4 py-3 font-medium text-gray-200">{mapLabel ? mapLabel(key) : key}</td>
|
|
||||||
<td className="num px-4 py-3 text-right text-gray-300">{stats.total}</td>
|
|
||||||
<td className="num px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
|
|
||||||
<td className="num px-4 py-3 text-right text-red-400">{stats.losses}</td>
|
|
||||||
<td className="num px-4 py-3 text-right text-gray-400">{stats.expired}</td>
|
|
||||||
<td className="num px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
|
|
||||||
<td className={`num px-4 py-3 text-right ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
|
|
||||||
<td className={`num px-4 py-3 text-right ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TrackRecordPanel() {
|
export function TrackRecordPanel() {
|
||||||
const [qualifiedOnly, setQualifiedOnly] = useState(true);
|
|
||||||
const activation = useActivation();
|
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = usePerformance(
|
|
||||||
qualifiedOnly ? { qualified_only: true } : undefined,
|
|
||||||
);
|
|
||||||
const backtest = useBacktestReport();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -158,148 +44,34 @@ export function TrackRecordPanel() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live (matured cohort) vs the backtest, like-for-like with the qualified toggle.
|
|
||||||
const live = data?.overall ?? null;
|
|
||||||
const btBucket = qualifiedOnly ? backtest.data?.overall_qualified : backtest.data?.overall_all;
|
|
||||||
const liveAvgR = live?.avg_r ?? null;
|
|
||||||
const liveN = live?.total ?? 0;
|
|
||||||
const btAvgR = btBucket?.avg_r ?? null;
|
|
||||||
|
|
||||||
let status: TrackingStatus = 'building';
|
|
||||||
if (liveAvgR != null && liveN >= MIN_MATURED) {
|
|
||||||
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
|
|
||||||
}
|
|
||||||
|
|
||||||
const verdictNote: Record<TrackingStatus, string> = {
|
|
||||||
building: `Not enough matured setups yet (need ~${MIN_MATURED}). Only setups whose full ~30-day window has elapsed are counted — the rest are still maturing. Until then, the backtest is your edge estimate; this becomes a live check as setups age past ~6 weeks.`,
|
|
||||||
'no-backtest': 'Run the backtest below to get a baseline to compare the live record against.',
|
|
||||||
tracking: 'Live setups are resolving in line with the backtest — the running system is faithfully implementing it (no look-ahead, config or data drift).',
|
|
||||||
drift: 'Live expectancy is running materially below the backtest. Could be small-sample noise, a regime shift, or a config/data/look-ahead gap between live and the backtest — worth a look.',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Your real, realized results come first; the live-vs-backtest check follows. */}
|
{/* Your real, realized results come first; the strategy validation follows. */}
|
||||||
<MyTradesPanel />
|
<MyTradesPanel />
|
||||||
<div className="border-t border-white/[0.06]" />
|
<div className="border-t border-white/[0.06]" />
|
||||||
|
<BacktestPanel />
|
||||||
|
|
||||||
<Section title="Live vs Backtest" hint="is the live system tracking the backtest?">
|
<Disclosure summary="Track-record maintenance">
|
||||||
{isError ? (
|
<div className="space-y-3 pt-1">
|
||||||
<Callout variant="error">
|
<p className="max-w-2xl text-xs text-gray-500">
|
||||||
{error instanceof Error ? error.message : 'Failed to load performance stats'}
|
The live check replays every setup against the daily bars after detection: target before stop =
|
||||||
</Callout>
|
win, stop first = loss (both in one bar counts conservatively as a loss), neither within 30
|
||||||
) : (
|
trading days = expired at 0R. Only setups whose full window has elapsed count; younger ones are
|
||||||
<div className="glass-sm space-y-2.5 p-4">
|
still maturing (near stops resolve fast, far targets need time, so early numbers skew negative).
|
||||||
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
|
The evaluator scores <span className="text-gray-300">all</span> setups — qualified or not, so
|
||||||
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-1">
|
unqualified ones stay a control group — and runs nightly. Reset permanently clears all setups and
|
||||||
<span className="text-sm text-gray-400">
|
their outcomes; live setups regenerate on the next scan.
|
||||||
Live <span className={`num font-semibold ${rColor(liveAvgR)}`}>{fmtR(liveAvgR)}</span>
|
</p>
|
||||||
</span>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-gray-400">
|
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
|
||||||
Backtest <span className={`num font-semibold ${rColor(btAvgR)}`}>{fmtR(btAvgR)}</span>
|
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
||||||
</span>
|
</Button>
|
||||||
<span className="text-xs text-gray-500">
|
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
|
||||||
{liveN} matured{data ? ` · ${data.maturing} maturing` : ''} · {qualifiedOnly ? 'qualified' : 'all setups'}
|
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
|
||||||
</span>
|
</Button>
|
||||||
</div>
|
|
||||||
<VerdictChip status={status} />
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] leading-relaxed text-gray-500">{verdictNote[status]}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Disclosure summary="Outcome details (matured cohort)">
|
|
||||||
<div className="space-y-4 pt-1">
|
|
||||||
<label className="flex w-fit cursor-pointer items-center gap-2.5 text-sm text-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={qualifiedOnly}
|
|
||||||
onChange={(e) => setQualifiedOnly(e.target.checked)}
|
|
||||||
className="h-4 w-4 cursor-pointer accent-blue-400"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Qualified signals only
|
|
||||||
{activation.data && (
|
|
||||||
<span className="num ml-2 text-xs text-gray-500">{activationSummary(activation.data)}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{isLoading && (
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && data.overall.total === 0 && (
|
|
||||||
<Callout variant="empty">
|
|
||||||
{data.maturing > 0
|
|
||||||
? `No setups have completed their ~30-day window yet — ${data.maturing} still maturing. ` +
|
|
||||||
'Counting them earlier would skew toward quick stop-outs.'
|
|
||||||
: 'No matured setups yet. Outcomes appear once setups complete their evaluation window — the evaluator runs nightly, or click Evaluate Now.'}
|
|
||||||
</Callout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data && data.overall.total > 0 && (
|
|
||||||
<>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
||||||
<StatCard
|
|
||||||
label="Hit Rate"
|
|
||||||
value={fmtPct(data.overall.hit_rate)}
|
|
||||||
sub={`${data.overall.wins} wins / ${data.overall.losses} losses`}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Expectancy"
|
|
||||||
value={fmtR(data.overall.avg_r)}
|
|
||||||
valueClass={rColor(data.overall.avg_r)}
|
|
||||||
sub="average R per trade"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Total R"
|
|
||||||
value={fmtR(data.overall.total_r)}
|
|
||||||
valueClass={rColor(data.overall.total_r)}
|
|
||||||
sub="cumulative risk-adjusted result"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Matured"
|
|
||||||
value={String(data.overall.total)}
|
|
||||||
sub={`${data.maturing} maturing · ${data.overall.expired} expired`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Section title="By Recommended Action">
|
|
||||||
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="By Confidence" hint="at detection time · all setups">
|
|
||||||
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
|
|
||||||
</Section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-white/[0.06] pt-3">
|
|
||||||
<p className="max-w-2xl text-xs text-gray-500">
|
|
||||||
Each setup is replayed against the daily bars after detection: target before stop = win,
|
|
||||||
stop first = loss (both in one bar counts conservatively as a loss), neither within 30
|
|
||||||
trading days = expired at 0R. Only setups whose full window has elapsed are counted; younger
|
|
||||||
ones are still <span className="text-gray-300">maturing</span> (near stops resolve fast, far
|
|
||||||
targets need time, so early numbers would skew negative). The evaluator runs nightly.
|
|
||||||
</p>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
|
|
||||||
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="danger" onClick={onReset} loading={resetMutation.isPending}>
|
|
||||||
{resetMutation.isPending ? 'Resetting…' : 'Reset'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|
||||||
<div className="border-t border-white/[0.06] pt-2" />
|
|
||||||
<BacktestPanel />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user