Files
signal-platform/frontend/src/components/signals/EvaluationPanel.tsx
T
dennisthiessenandClaude Opus 5 13a984a84d refactor(signals): split the Track Record tab and cut the backtest page down
One tab stacked three things that all called themselves a track record:
realized paper P&L, setup-outcome grading under the rejected take-profit model,
and the backtest portfolio simulation. Split into Setups | Paper Trades |
Backtest, one subject each. `track` stays the Paper Trades slug so the legacy
/performance redirect keeps working. The grading diagnostic and its Evaluate /
Reset controls go with Backtest, not Paper Trades — reset_track_record deletes
trade_setups, not paper trades.

BacktestPanel 439 -> 175 lines. Its run settings alone were 106 lines of
hand-rolled sr-only radio cards for two binary choices; they are now two
Dropdowns and a button on one wrapping row, with the per-option prose moved into
the existing explainer. The amber warnings survive as a conditional slot, so a
non-default choice still announces itself but the common path is silent.

The recommendation printed eight findings at equal weight, burying the verdict
in tuning detail. `topic` now splits them: production, benchmark and robustness
stay inline, gate/exit/cutoff collapse behind a disclosure, and any WARNING or
LAGS item is promoted out of the collapsed group regardless of topic. No topic
chips — every backend string already self-prefixes, so a chip would render
"GATE | Gate: ...".

Portfolio metrics are now two tiers: five headline tiles for what the book
returned, then a smaller labelled row for how good that return was (Sortino,
Calmar (MAR), Gain/Pain, Profit Factor $, EV/trade). Reports cached before those
metrics existed hide the second row rather than showing a half-populated line of
dashes.

Extracted EquityCurveChart, PortfolioMonitorPanel and BacktestRecommendationCard,
plus a StatTile primitive and shared formatters for the duplication in the files
this touched. DashboardPage and OpenTradesPanel deliberately keep their own
copies — migrating them is separate scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:09:23 +02:00

135 lines
6.6 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usePerformance } from '../../hooks/usePerformance';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob, resetTrackRecord } from '../../api/admin';
import { Button } from '../ui/Button';
import { Disclosure } from '../ui/Disclosure';
import { useToast } from '../ui/Toast';
import { fmtR, rColor } from '../../lib/format';
// Need at least this many matured setups before the pipeline check 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 PipelineStatus = 'building' | 'tracking' | 'drift' | 'no-backtest';
function StatusChip({ status }: { status: PipelineStatus }) {
const styles: Record<PipelineStatus, { cls: string; label: string }> = {
tracking: { cls: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300', label: '✓ in sync' },
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>;
}
export function EvaluationPanel() {
const queryClient = useQueryClient();
const toast = useToast();
// Setup-outcome pipeline check: does the live outcome evaluator reproduce the
// backtest's target/stop grading? Both sides use the SAME target/stop/expired
// model — this is a plumbing/QA signal (no look-ahead, config or data drift),
// NOT validation of the ATR-trail production strategy shown in the monitor.
const { data: perf } = usePerformance({ qualified_only: true });
const { data: report } = useBacktestReport();
const liveAvgR = perf?.overall.avg_r ?? null;
const liveN = perf?.overall.total ?? 0;
const btAvgR = report?.overall_qualified.avg_r ?? null;
let status: PipelineStatus = 'building';
if (liveAvgR != null && liveN >= MIN_MATURED) {
status = btAvgR == null ? 'no-backtest' : liveAvgR >= btAvgR - DRIFT_TOLERANCE_R ? 'tracking' : 'drift';
}
const statusNote: Record<PipelineStatus, string> = {
building: `Fewer than ~${MIN_MATURED} matured setups so far — too few to compare.`,
'no-backtest': 'Run the backtest to get a target/stop baseline to check against.',
tracking:
"Live setup outcomes are resolving in line with the backtest's target/stop model — the outcome-evaluation pipeline shows no look-ahead, config or data drift. (Checks the setup-grading pipeline, not the ATR-trail production book above.)",
drift:
"Live setup outcomes are running materially below the backtest's target/stop model — small-sample noise, a regime shift, or a live/backtest pipeline gap. Worth a look.",
};
const evaluateMutation = useMutation({
mutationFn: () => triggerJob('outcome_evaluator'),
onSuccess: () => {
toast.addToast('success', 'Outcome evaluation triggered. Stats will refresh shortly.');
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['performance'] }), 3000);
},
onError: () => {
toast.addToast('error', 'Failed to trigger outcome evaluation');
},
});
const resetMutation = useMutation({
mutationFn: () => resetTrackRecord(),
onSuccess: (data) => {
toast.addToast('success', `Track record reset — ${data.trade_setups} setups cleared. Run the scanner to rebuild.`);
queryClient.invalidateQueries({ queryKey: ['performance'] });
queryClient.invalidateQueries({ queryKey: ['trades'] });
},
onError: () => {
toast.addToast('error', 'Failed to reset track record');
},
});
const onReset = () => {
if (
window.confirm(
'Reset the track record? This permanently deletes ALL trade setups and their outcomes. ' +
'Live setups will regenerate on the next R:R scan. This cannot be undone.',
)
) {
resetMutation.mutate();
}
};
return (
<div className="space-y-6">
<Disclosure summary="Setup-grading diagnostic & maintenance">
<div className="space-y-4 pt-1">
<p className="max-w-2xl text-xs text-gray-500">
<span className="text-amber-300/90">Diagnostic only not production P&amp;L.</span>{' '}
Grades gate-level touch vs stop (the rejected take-profit model). Production exits are
initial stop / ATR trail / max hold see the Paper Trades tab and the portfolio monitor
above. Target before stop = win, stop first = loss (same-bar both = loss), neither in 30
trading days = expired at 0R. Only matured windows count. Scores{' '}
<span className="text-gray-300">all</span> setups as a control group; runs nightly.
</p>
{/* Diagnostic, not strategy validation: live target/stop outcomes vs the backtest's target/stop model. */}
<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-300">Gate barrier pipeline check</span>
<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` : ''} · not ATR-trail book
</span>
</div>
<StatusChip status={status} />
</div>
<p className="text-[11px] leading-relaxed text-gray-500">{statusNote[status]}</p>
</div>
<div className="flex flex-wrap 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>
</Disclosure>
</div>
);
}