Files
signal-platform/frontend/src/components/signals/BacktestPanel.tsx
T
dennisthiessenandClaude Opus 5 21a5fc8a52 fix(backtest): flag a lookback the recommendation was not computed on
Selecting a different window or a comparison strategy silently made the tiles
stop matching the recommendation below, which is baked into the report and
cannot follow a dropdown. On load they now agree by construction; moving off
that basis says so.

Also: an absent production row produced no headline and no benchmark, but any
passing gate finding still rendered a green "no warnings" chip — a success badge
for missing data, directly beside "this report predates the portfolio monitor".
Missing baseline now reads "baseline unavailable".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 08:03:12 +02:00

203 lines
9.2 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 { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob } from '../../api/admin';
import type { BacktestCadence, BacktestTargetModel } from '../../api/admin';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Dropdown } from '../ui/Dropdown';
import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast';
import { BacktestRecommendationCard } from './BacktestRecommendationCard';
import { PortfolioMonitorPanel } from './PortfolioMonitorPanel';
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`;
}
const TARGET_MODEL_OPTIONS = [
{ value: 'production_gtl', label: 'Live GTL — production' },
{ value: 'structural_sr', label: 'Structural S/R — comparison' },
];
const CADENCE_OPTIONS = [
{ value: 'weekly', label: 'Weekly — default' },
{ value: 'daily', label: 'Daily — research' },
];
export function BacktestPanel() {
const { data: report, isLoading } = useBacktestReport();
const queryClient = useQueryClient();
const toast = useToast();
const [selectedStrategy, setSelectedStrategy] = useState('');
const [selectedLookback, setSelectedLookback] = useState('');
const [targetModel, setTargetModel] = useState<BacktestTargetModel>('production_gtl');
const [cadence, setCadence] = useState<BacktestCadence>('weekly');
const monitor = report?.portfolio_monitor ?? null;
const activeStrategy =
selectedStrategy || monitor?.production_strategy || monitor?.strategies[0]?.strategy || '';
// Default to the window the recommendation was computed on, so the tiles and
// the recommendation never open showing different numbers. They used to: the
// backend preferred "all" while this defaulted to "3y". The 3y fallback is
// only for reports predating basis_lookback.
const basisLookback = report?.recommendation?.basis_lookback ?? null;
const activeLookback =
selectedLookback ||
(basisLookback && monitor?.lookbacks.some((l) => l.lookback === basisLookback)
? basisLookback
: monitor?.lookbacks.some((l) => l.lookback === '3y')
? '3y'
: monitor?.lookbacks[0]?.lookback) ||
'';
const monitorRun = useMemo(
() =>
monitor?.runs.find((row) => row.strategy === activeStrategy && row.lookback === activeLookback) ??
monitor?.runs.find((row) => row.strategy === activeStrategy) ??
monitor?.runs[0] ??
null,
[monitor, activeStrategy, activeLookback],
);
const run = useMutation({
mutationFn: () => triggerJob('backtest', { target_model: targetModel, cadence }),
onSuccess: (res) => {
if (res.status === 'triggered') {
const label = targetModel === 'production_gtl' ? 'Live GTL' : 'Structural S/R comparison';
toast.addToast('success', `${label} ${cadence} backtest started — results appear when it finishes.`);
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="Is the strategy working?" hint="portfolio simulation of the promoted strategy vs S&P 500">
<div className="space-y-4">
{/* Run status and the controls that start a new run, on one line. The
explainer sits BELOW this row rather than beside it — sharing a flex
row meant expanding it shoved every control down the page. */}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="min-w-0">
<p className="section-index">Last run</p>
{report ? (
<p className="mt-1 text-xs text-gray-400">
{timeAgo(report.generated_at)} · {report.tickers} tickers ·{' '}
{report.candidates} setups ({report.qualified} qualified) ·{' '}
{report.params.entry_cadence ?? 'weekly'},{' '}
{report.params.horizon_days}d horizon
{report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side</>
)}
{' · '}
<span className={report.params.is_production_target_model === false ? 'text-amber-300' : 'text-blue-300'}>
{report.params.target_model_label ?? 'Unknown (legacy report)'}
</span>
</p>
) : (
<p className="mt-1 text-xs text-gray-500">Never run</p>
)}
</div>
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a
narrow viewport otherwise. */}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
<label htmlFor="backtest-target-model">Target model</label>
<Dropdown
id="backtest-target-model"
className="w-56 normal-case tracking-normal"
value={targetModel}
onChange={(v) => setTargetModel(v as BacktestTargetModel)}
options={TARGET_MODEL_OPTIONS}
/>
</div>
<div className="flex flex-col gap-1 text-[11px] uppercase tracking-wider text-gray-500">
<label htmlFor="backtest-cadence">Entry cadence</label>
<Dropdown
id="backtest-cadence"
className="w-44 normal-case tracking-normal"
value={cadence}
onChange={(v) => setCadence(v as BacktestCadence)}
options={CADENCE_OPTIONS}
/>
</div>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run' : 'Run backtest'}
</Button>
</div>
</div>
<div>
<Disclosure summary="How this is measured">
<p className="max-w-2xl text-xs text-gray-400">
The backtest replays the current config at the selected cadence 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.
</p>
<p className="mt-2 max-w-2xl text-xs text-gray-400">
<strong className="text-gray-300">Live GTL</strong> is the exact target path the scanner and the
scheduled backtest use; <strong className="text-gray-300">Structural S/R</strong> is a comparison
arm sourcing targets from chart structure. <strong className="text-gray-300">Weekly</strong> steps
five sessions at a time and is what the server runs; <strong className="text-gray-300">Daily</strong>
{' '}is roughly 5× the replay work.
</p>
</Disclosure>
</div>
{/* Only surfaced for non-default choices — zero noise on the common path,
but a non-production selection still announces itself, which is what
the old always-amber cards were really for. */}
{(cadence === 'daily' || targetModel === 'structural_sr') && (
<div className="space-y-1 text-[11px] text-amber-300/80">
{cadence === 'daily' && (
<p>Daily replays ~5× the work prefer the offline snapshot runner.</p>
)}
{targetModel === 'structural_sr' && (
<p>Comparison arm not the live scanner's target path.</p>
)}
</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 && (
<>
<PortfolioMonitorPanel
monitor={monitor}
monitorRun={monitorRun}
activeStrategy={activeStrategy}
activeLookback={activeLookback}
onStrategyChange={setSelectedStrategy}
onLookbackChange={setSelectedLookback}
basisLookback={basisLookback}
basisLookbackLabel={report.recommendation?.basis_lookback_label ?? null}
productionStrategy={monitor?.production_strategy ?? null}
/>
{report.recommendation && (
<BacktestRecommendationCard recommendation={report.recommendation} />
)}
</>
)}
</div>
</Section>
);
}