add backtest report UI to the Track Record tab
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 23s

New BacktestPanel: shows qualified hit-rate/expectancy vs the all-setups baseline,
a by-direction breakdown, and the probability calibration table (predicted vs
realized, over-confident buckets flagged amber). Includes a "Run backtest" button
that triggers the job and a plain explanation of the method and its limits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:16:12 +02:00
parent 6df67ad7ae
commit b00e482258
6 changed files with 256 additions and 2 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
import apiClient from './client';
import type { MarketRegime } from '../lib/types';
import type { BacktestReport, MarketRegime } from '../lib/types';
export function getMarketRegime() {
return apiClient.get<MarketRegime>('market/regime').then((r) => r.data);
}
export function getBacktestReport() {
return apiClient.get<BacktestReport | null>('backtest/report').then((r) => r.data);
}
@@ -0,0 +1,208 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useBacktestReport } from '../../hooks/useMarketRegime';
import { triggerJob } from '../../api/admin';
import { Button } from '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { useToast } from '../ui/Toast';
import type { BacktestBucket } from '../../lib/types';
function fmtR(v: number | null): string {
if (v === null) return '—';
return `${v > 0 ? '+' : ''}${v.toFixed(2)}R`;
}
function fmtPct(v: number | null): string {
return v === null ? '—' : `${v.toFixed(1)}%`;
}
function rColor(v: number | null): string {
if (v === null) return 'text-gray-400';
if (v > 0) return 'text-emerald-400';
if (v < 0) return 'text-red-400';
return 'text-gray-300';
}
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`;
}
function Stat({ label, value, valueClass = 'text-gray-100', sub }: {
label: string; value: string; valueClass?: string; sub?: string;
}) {
return (
<div className="glass p-4">
<p className="section-index">{label}</p>
<p className={`num mt-1.5 text-2xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
function BucketRow({ label, b }: { label: string; b: BacktestBucket }) {
return (
<tr className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 font-medium text-gray-200">{label}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{b.total}</td>
<td className="num px-4 py-2.5 text-right text-emerald-400">{b.wins}</td>
<td className="num px-4 py-2.5 text-right text-red-400">{b.losses}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{b.expired}</td>
<td className="num px-4 py-2.5 text-right text-gray-200">{fmtPct(b.hit_rate)}</td>
<td className={`num px-4 py-2.5 text-right ${rColor(b.avg_r)}`}>{fmtR(b.avg_r)}</td>
</tr>
);
}
export function BacktestPanel() {
const { data: report, isLoading } = useBacktestReport();
const queryClient = useQueryClient();
const toast = useToast();
const run = useMutation({
mutationFn: () => triggerJob('backtest'),
onSuccess: (res) => {
if (res.status === 'triggered') {
toast.addToast('success', 'Backtest started — results appear when it finishes (a minute or two).');
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="Backtest" hint="historical replay of the current config">
<div className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<Disclosure summary="How the backtest works">
<p className="text-xs text-gray-400">
At each weekly point in history, the setup is rebuilt using only data up to that day
(no lookahead), then the actual following ~30 trading days decide its outcome. This
shows how the <em>current</em> settings would have performed. Sentiment and
fundamentals are held neutral (no point-in-time history), so this calibrates the
price / support-resistance / probability machinery. ~6 months of data is roughly one
market regime read it as directional, not a guarantee.
</p>
</Disclosure>
<Button onClick={() => run.mutate()} loading={run.isPending} className="shrink-0">
{run.isPending ? 'Starting…' : report ? 'Re-run backtest' : 'Run backtest'}
</Button>
</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 && (
<>
<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
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Stat
label="Qualified Hit Rate"
value={fmtPct(report.overall_qualified.hit_rate)}
sub={`${report.overall_qualified.wins}W / ${report.overall_qualified.losses}L`}
/>
<Stat
label="Qualified Expectancy"
value={fmtR(report.overall_qualified.avg_r)}
valueClass={rColor(report.overall_qualified.avg_r)}
sub="avg R per qualified setup"
/>
<Stat
label="All Setups Expectancy"
value={fmtR(report.overall_all.avg_r)}
valueClass={rColor(report.overall_all.avg_r)}
sub={`${report.overall_all.total} setups · baseline`}
/>
<Stat
label="Qualified Total R"
value={fmtR(report.overall_qualified.total_r)}
valueClass={rColor(report.overall_qualified.total_r)}
sub="cumulative, risk-adjusted"
/>
</div>
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Set</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Wins</th>
<th className="px-4 py-2.5 text-right">Losses</th>
<th className="px-4 py-2.5 text-right">Expired</th>
<th className="px-4 py-2.5 text-right">Hit Rate</th>
<th className="px-4 py-2.5 text-right">Avg R</th>
</tr>
</thead>
<tbody>
<BucketRow label="Qualified" b={report.overall_qualified} />
<BucketRow label="All" b={report.overall_all} />
{report.by_direction.long && <BucketRow label="Long (qual.)" b={report.by_direction.long} />}
{report.by_direction.short && <BucketRow label="Short (qual.)" b={report.by_direction.short} />}
</tbody>
</table>
</div>
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-widest text-gray-500">
Probability calibration
</p>
<p className="mb-2 text-[11px] text-gray-500">
Do targets we call X% likely actually hit that often? Realized below predicted =
the model is over-confident.
</p>
{report.calibration.length === 0 ? (
<Callout variant="empty">Not enough resolved setups to calibrate.</Callout>
) : (
<div className="glass overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-2.5">Predicted Bucket</th>
<th className="px-4 py-2.5 text-right">Setups</th>
<th className="px-4 py-2.5 text-right">Avg Predicted</th>
<th className="px-4 py-2.5 text-right">Realized Hit Rate</th>
</tr>
</thead>
<tbody>
{report.calibration.map((row) => {
const over = row.realized_hit_rate < row.predicted_avg;
return (
<tr key={row.bucket} className="border-b border-white/[0.04]">
<td className="px-4 py-2.5 text-gray-200">{row.bucket}</td>
<td className="num px-4 py-2.5 text-right text-gray-300">{row.n}</td>
<td className="num px-4 py-2.5 text-right text-gray-400">{row.predicted_avg.toFixed(0)}%</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${over ? 'text-amber-400' : 'text-emerald-400'}`}>
{row.realized_hit_rate.toFixed(0)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<p className="text-[11px] text-gray-600">{report.note}</p>
</>
)}
</div>
</Section>
);
}
@@ -11,6 +11,7 @@ import { Section } from '../ui/Section';
import { SkeletonCard } from '../ui/Skeleton';
import { useToast } from '../ui/Toast';
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
import { BacktestPanel } from './BacktestPanel';
import type { OutcomeBucketStats } from '../../lib/types';
function fmtR(value: number | null): string {
@@ -239,6 +240,9 @@ export function TrackRecordPanel() {
</Section>
</>
)}
<div className="border-t border-white/[0.06] pt-2" />
<BacktestPanel />
</div>
);
}
+8
View File
@@ -8,3 +8,11 @@ export function useMarketRegime() {
staleTime: 60_000,
});
}
export function useBacktestReport() {
return useQuery({
queryKey: ['backtest-report'],
queryFn: () => marketApi.getBacktestReport(),
staleTime: 60_000,
});
}
+30
View File
@@ -179,6 +179,36 @@ export interface SentimentProviderConfig {
custom_base_url_providers: string[];
}
export interface BacktestBucket {
total: number;
wins: number;
losses: number;
expired: number;
hit_rate: number | null;
avg_r: number | null;
total_r: number | null;
}
export interface BacktestCalibrationRow {
bucket: string;
n: number;
predicted_avg: number;
realized_hit_rate: number;
}
export interface BacktestReport {
generated_at: string;
tickers: number;
candidates: number;
qualified: number;
params: { step_days: number; horizon_days: number; min_lookback: number };
overall_qualified: BacktestBucket;
overall_all: BacktestBucket;
by_direction: Record<string, BacktestBucket>;
calibration: BacktestCalibrationRow[];
note: string;
}
export interface MarketRegime {
label: 'bullish' | 'bearish' | 'neutral' | 'unknown';
benchmark?: string;
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/activation.ts","./src/api/admin.ts","./src/api/auth.ts","./src/api/client.ts","./src/api/fundamentals.ts","./src/api/health.ts","./src/api/indicators.ts","./src/api/ingestion.ts","./src/api/jobs.ts","./src/api/market.ts","./src/api/ohlcv.ts","./src/api/performance.ts","./src/api/scores.ts","./src/api/sentiment.ts","./src/api/sr-levels.ts","./src/api/tickers.ts","./src/api/trades.ts","./src/api/watchlist.ts","./src/components/admin/activationsettings.tsx","./src/components/admin/alertsettings.tsx","./src/components/admin/datacleanup.tsx","./src/components/admin/jobcontrols.tsx","./src/components/admin/pipelinereadinesspanel.tsx","./src/components/admin/recommendationsettings.tsx","./src/components/admin/sentimentprovidersettings.tsx","./src/components/admin/settingsform.tsx","./src/components/admin/tickermanagement.tsx","./src/components/admin/tickeruniversebootstrap.tsx","./src/components/admin/usertable.tsx","./src/components/auth/protectedroute.tsx","./src/components/charts/candlestickchart.tsx","./src/components/layout/appshell.tsx","./src/components/layout/mobilenav.tsx","./src/components/layout/sidebar.tsx","./src/components/rankings/rankingstable.tsx","./src/components/rankings/weightsform.tsx","./src/components/scanner/tradetable.tsx","./src/components/signals/backtestpanel.tsx","./src/components/signals/setupspanel.tsx","./src/components/signals/trackrecordpanel.tsx","./src/components/ticker/dimensionbreakdownpanel.tsx","./src/components/ticker/fundamentalspanel.tsx","./src/components/ticker/indicatorselector.tsx","./src/components/ticker/recommendationpanel.tsx","./src/components/ticker/sroverlay.tsx","./src/components/ticker/sentimentpanel.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/callout.tsx","./src/components/ui/confirmdialog.tsx","./src/components/ui/disclosure.tsx","./src/components/ui/dropdown.tsx","./src/components/ui/field.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/scorecard.tsx","./src/components/ui/section.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/watchlist/addtickerform.tsx","./src/components/watchlist/watchlisttable.tsx","./src/hooks/useactivation.ts","./src/hooks/useadmin.ts","./src/hooks/useauth.ts","./src/hooks/usefetchsymboldata.ts","./src/hooks/usemarketregime.ts","./src/hooks/useperformance.ts","./src/hooks/userisksettings.ts","./src/hooks/usescores.ts","./src/hooks/usetickerdetail.ts","./src/hooks/usetickers.ts","./src/hooks/usetrades.ts","./src/hooks/usewatchlist.ts","./src/lib/format.ts","./src/lib/ingestionstatus.ts","./src/lib/position.ts","./src/lib/qualification.ts","./src/lib/recommendation.ts","./src/lib/regime.ts","./src/lib/types.ts","./src/pages/adminpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/marketpage.tsx","./src/pages/registerpage.tsx","./src/pages/signalspage.tsx","./src/pages/tickerdetailpage.tsx","./src/stores/authstore.ts"],"version":"5.6.3"}