feat: add selectable daily backtest cadence

This commit is contained in:
2026-07-17 14:41:24 +02:00
parent bc50ba9136
commit 65a462271c
12 changed files with 550 additions and 37 deletions
+6 -1
View File
@@ -201,9 +201,11 @@ export interface TriggerJobResponse {
status: 'triggered' | 'busy' | 'blocked' | 'not_found';
message: string;
target_model?: BacktestTargetModel;
cadence?: BacktestCadence;
}
export type BacktestTargetModel = 'production_gtl' | 'structural_sr';
export type BacktestCadence = 'weekly' | 'daily';
export function listJobs() {
return apiClient.get<JobStatus[]>('admin/jobs').then((r) => r.data);
@@ -219,7 +221,10 @@ export function toggleJob(jobName: string, enabled: boolean) {
.then((r) => r.data);
}
export function triggerJob(jobName: string, options?: { target_model?: BacktestTargetModel }) {
export function triggerJob(
jobName: string,
options?: { target_model?: BacktestTargetModel; cadence?: BacktestCadence },
) {
return apiClient
.post<TriggerJobResponse>(`admin/jobs/${jobName}/trigger`, options)
.then((r) => r.data);
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob } from '../../api/admin';
import type { BacktestTargetModel } 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';
@@ -145,6 +145,7 @@ export function BacktestPanel() {
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 =
@@ -161,11 +162,11 @@ export function BacktestPanel() {
);
const run = useMutation({
mutationFn: () => triggerJob('backtest', { target_model: targetModel }),
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} backtest started — results appear when it finishes.`);
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');
@@ -180,7 +181,7 @@ export function BacktestPanel() {
<div className="flex flex-wrap items-start justify-between gap-3">
<Disclosure summary="How this is measured">
<p className="max-w-2xl text-xs text-gray-400">
The backtest replays the current config weekly through history at each point the setup is
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,
@@ -238,6 +239,56 @@ export function BacktestPanel() {
</span>
</label>
</fieldset>
<fieldset className="grid w-full grid-cols-2 gap-2 sm:w-[34rem]">
<legend className="mb-1 text-[11px] font-medium uppercase tracking-wider text-gray-500">
Entry cadence
</legend>
<label
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-blue-400/60 ${
cadence === 'weekly'
? 'border-blue-400/60 bg-blue-500/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
}`}
>
<input
className="sr-only"
type="radio"
name="backtest-cadence"
value="weekly"
checked={cadence === 'weekly'}
onChange={() => setCadence('weekly')}
/>
<span className="flex items-center justify-between gap-2 text-sm font-medium text-gray-100">
Weekly
<span className="rounded-full border border-blue-400/40 bg-blue-400/10 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-widest text-blue-300">
Default
</span>
</span>
<span className="mt-1 block text-[11px] leading-4 text-gray-500">
Resource-safe server run at five-session intervals.
</span>
</label>
<label
className={`cursor-pointer rounded-lg border px-3 py-2 transition-colors focus-within:ring-2 focus-within:ring-amber-400/60 ${
cadence === 'daily'
? 'border-amber-400/50 bg-amber-500/10'
: 'border-white/10 bg-white/[0.03] hover:border-white/20'
}`}
>
<input
className="sr-only"
type="radio"
name="backtest-cadence"
value="daily"
checked={cadence === 'daily'}
onChange={() => setCadence('daily')}
/>
<span className="text-sm font-medium text-gray-200">Daily</span>
<span className="mt-1 block text-[11px] leading-4 text-amber-300/80">
Research run: roughly 5× the replay work; prefer the offline snapshot runner.
</span>
</label>
</fieldset>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button>
@@ -257,7 +308,8 @@ export function BacktestPanel() {
<>
<p className="text-[11px] text-gray-500">
Ran {timeAgo(report.generated_at)} · {report.tickers} tickers · {report.candidates} setups
({report.qualified} qualified) · weekly cadence, {report.params.horizon_days}-day horizon
({report.qualified} qualified) · {report.params.entry_cadence ?? 'weekly'} cadence,
{' '}{report.params.horizon_days}-day horizon
{report.params.cost_per_side_pct != null && (
<> · net of {report.params.cost_per_side_pct}%/side costs</>
)}
+5
View File
@@ -356,6 +356,7 @@ export interface BacktestPortfolioMonitorRun extends BacktestPortfolioPolicy {
label: string;
description: string;
is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
entry_variant: string;
exit_policy: string;
reentry_lockdown_sessions?: number;
@@ -370,6 +371,7 @@ export interface BacktestPortfolioMonitor {
label: string;
description: string;
is_production: boolean;
comparison_arm?: 'live_no_lockdown' | 'live_lockdown_5' | null;
reentry_lockdown_sessions?: number;
}[];
lookbacks: { lookback: string; label: string }[];
@@ -404,6 +406,9 @@ export interface BacktestReport {
qualified: number;
params: {
step_days: number;
step_sessions?: number;
entry_cadence?: 'weekly' | 'daily';
signal_eval_cadence?: 'weekly';
horizon_days: number;
min_lookback: number;
cost_per_side_pct?: number;