diff --git a/frontend/src/components/signals/BacktestPanel.tsx b/frontend/src/components/signals/BacktestPanel.tsx index d75d779..4a191c2 100644 --- a/frontend/src/components/signals/BacktestPanel.tsx +++ b/frontend/src/components/signals/BacktestPanel.tsx @@ -1,19 +1,22 @@ import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useBacktestReport } from '../../hooks/useMarketRegime'; +import { usePerformance } from '../../hooks/usePerformance'; 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, - BacktestCurvePoint, - BacktestPortfolioMonitorRun, - BacktestPortfolioPolicy, - BacktestStrategyVariant, -} from '../../lib/types'; +import type { BacktestCurvePoint, BacktestPortfolioMonitorRun } 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(v: number | null | undefined): string { if (v === null || v === undefined) return '—'; @@ -36,10 +39,6 @@ function fmtDrawdown(v: number | null | undefined): string { 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'; @@ -47,49 +46,6 @@ function rColor(v: number | null): string { return 'text-gray-300'; } -const SIGNAL_LABELS: Record = { - 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 = { - 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 = { - 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'; @@ -111,23 +67,15 @@ function Stat({ label, value, valueClass = 'text-gray-100', sub }: { ); } -function BucketRow({ label, b }: { label: string; b: BacktestBucket }) { - return ( - - {label} - {b.total} - {b.wins} - {b.losses} - {b.expired} - {fmtPct(b.hit_rate)} - {fmtR(b.avg_r)} - {fmtR(b.net_avg_r ?? null)} - {fmtR(b.best_r)} - {fmtR(b.worst_r)} - {fmtDays(b.avg_hold_days)} - {fmtRPerDay(b.net_r_per_day)} - - ); +function VerdictChip({ status }: { status: TrackingStatus }) { + const styles: Record = { + 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 {s.label}; } function curvePath( @@ -210,16 +158,12 @@ function EquityCurveChart({ run }: { run: BacktestPortfolioMonitorRun }) { export function BacktestPanel() { const { data: report, isLoading } = useBacktestReport(); + const { data: perf } = usePerformance({ qualified_only: true }); const queryClient = useQueryClient(); const toast = useToast(); const [selectedStrategy, setSelectedStrategy] = 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 activeStrategy = selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || ''; @@ -234,6 +178,22 @@ export function BacktestPanel() { [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 = { + 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({ mutationFn: () => triggerJob('backtest'), onSuccess: (res) => { @@ -248,17 +208,17 @@ export function BacktestPanel() { }); return ( -
+
- -

- 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 current 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. + +

+ The backtest replays the current config weekly through history — at each point the setup is + rebuilt using only data up to that day (no lookahead) and the following ~30 trading days decide + its outcome — then simulates one capital-constrained book against the S&P 500. Sentiment and + fundamentals are held neutral (no point-in-time history). ~6 months is roughly one market regime, + so read it as directional. The tracking check compares the backtest's qualified + expectancy with what live qualified setups have actually realized once matured.

diff --git a/frontend/src/components/signals/TrackRecordPanel.tsx b/frontend/src/components/signals/TrackRecordPanel.tsx index 78dc811..5ff9eca 100644 --- a/frontend/src/components/signals/TrackRecordPanel.tsx +++ b/frontend/src/components/signals/TrackRecordPanel.tsx @@ -1,126 +1,12 @@ -import { useState } from 'react'; 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 { Button } from '../ui/Button'; -import { Callout } from '../ui/Callout'; import { Disclosure } from '../ui/Disclosure'; -import { Section } from '../ui/Section'; -import { SkeletonCard } from '../ui/Skeleton'; import { useToast } from '../ui/Toast'; -import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation'; import { BacktestPanel } from './BacktestPanel'; 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 = { - 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 {s.label}; -} - -function StatCard({ label, value, valueClass = 'text-gray-100', sub }: { - label: string; - value: string; - valueClass?: string; - sub?: string; -}) { - return ( -
-

{label}

-

{value}

- {sub &&

{sub}

} -
- ); -} - -function actionLabel(key: string): string { - return RECOMMENDATION_ACTION_LABELS[key as keyof typeof RECOMMENDATION_ACTION_LABELS] ?? key; -} - -function BreakdownTable({ rows, labelHeader, mapLabel }: { - rows: Record; - labelHeader: string; - mapLabel?: (key: string) => string; -}) { - const entries = Object.entries(rows); - if (entries.length === 0) { - return No matured setups in this breakdown yet.; - } - return ( -
- - - - - - - - - - - - - - - {entries.map(([key, stats]) => ( - - - - - - - - - - - ))} - -
{labelHeader}SetupsWinsLossesExpiredHit RateAvg RTotal R
{mapLabel ? mapLabel(key) : key}{stats.total}{stats.wins}{stats.losses}{stats.expired}{fmtPct(stats.hit_rate)}{fmtR(stats.avg_r)}{fmtR(stats.total_r)}
-
- ); -} 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 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 = { - 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 (
- {/* Your real, realized results come first; the live-vs-backtest check follows. */} + {/* Your real, realized results come first; the strategy validation follows. */}
+ -
- {isError ? ( - - {error instanceof Error ? error.message : 'Failed to load performance stats'} - - ) : ( -
-
-
- - Live {fmtR(liveAvgR)} - - - Backtest {fmtR(btAvgR)} - - - {liveN} matured{data ? ` · ${data.maturing} maturing` : ''} · {qualifiedOnly ? 'qualified' : 'all setups'} - -
- -
-

{verdictNote[status]}

-
- )} -
- - -
- - - {isLoading && ( -
- -
- )} - - {data && data.overall.total === 0 && ( - - {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.'} - - )} - - {data && data.overall.total > 0 && ( - <> -
- - - - -
- -
- -
- -
- -
- - )} - -
-

- 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 maturing (near stops resolve fast, far - targets need time, so early numbers would skew negative). The evaluator runs nightly. -

-
- - -
+ +
+

+ The live check replays every setup 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 count; younger ones are + still maturing (near stops resolve fast, far targets need time, so early numbers skew negative). + The evaluator scores all setups — qualified or not, so + unqualified ones stay a control group — and runs nightly. Reset permanently clears all setups and + their outcomes; live setups regenerate on the next scan. +

+
+ +
- -
-
); }