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>
203 lines
9.2 KiB
TypeScript
203 lines
9.2 KiB
TypeScript
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>
|
||
);
|
||
}
|