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('production_gtl'); const [cadence, setCadence] = useState('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 (
{/* 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. */}

Last run

{report ? (

{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 )} {' · '} {report.params.target_model_label ?? 'Unknown (legacy report)'}

) : (

Never run

)}
{/* flex-wrap is load-bearing: two dropdowns plus the button overflow a narrow viewport otherwise. */}
setTargetModel(v as BacktestTargetModel)} options={TARGET_MODEL_OPTIONS} />
setCadence(v as BacktestCadence)} options={CADENCE_OPTIONS} />

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.

Live GTL is the exact target path the scanner and the scheduled backtest use; Structural S/R is a comparison arm sourcing targets from chart structure. Weekly steps five sessions at a time and is what the server runs; Daily {' '}is roughly 5× the replay work.

{/* 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') && (
{cadence === 'daily' && (

Daily replays ~5× the work — prefer the offline snapshot runner.

)} {targetModel === 'structural_sr' && (

Comparison arm — not the live scanner's target path.

)}
)} {isLoading && Loading…} {!isLoading && !report && ( No backtest yet. Click “Run backtest” (or trigger it in Admin → Jobs) — it replays every ticker over history and takes a minute or two. )} {report && ( <> {report.recommendation && ( )} )}
); }