add backtest report UI to the Track Record tab
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:
@@ -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,3 +8,11 @@ export function useMarketRegime() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBacktestReport() {
|
||||
return useQuery({
|
||||
queryKey: ['backtest-report'],
|
||||
queryFn: () => marketApi.getBacktestReport(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user