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 apiClient from './client';
|
||||||
import type { MarketRegime } from '../lib/types';
|
import type { BacktestReport, MarketRegime } from '../lib/types';
|
||||||
|
|
||||||
export function getMarketRegime() {
|
export function getMarketRegime() {
|
||||||
return apiClient.get<MarketRegime>('market/regime').then((r) => r.data);
|
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 { SkeletonCard } from '../ui/Skeleton';
|
||||||
import { useToast } from '../ui/Toast';
|
import { useToast } from '../ui/Toast';
|
||||||
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
import { RECOMMENDATION_ACTION_LABELS } from '../../lib/recommendation';
|
||||||
|
import { BacktestPanel } from './BacktestPanel';
|
||||||
import type { OutcomeBucketStats } from '../../lib/types';
|
import type { OutcomeBucketStats } from '../../lib/types';
|
||||||
|
|
||||||
function fmtR(value: number | null): string {
|
function fmtR(value: number | null): string {
|
||||||
@@ -239,6 +240,9 @@ export function TrackRecordPanel() {
|
|||||||
</Section>
|
</Section>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="border-t border-white/[0.06] pt-2" />
|
||||||
|
<BacktestPanel />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,3 +8,11 @@ export function useMarketRegime() {
|
|||||||
staleTime: 60_000,
|
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[];
|
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 {
|
export interface MarketRegime {
|
||||||
label: 'bullish' | 'bearish' | 'neutral' | 'unknown';
|
label: 'bullish' | 'bearish' | 'neutral' | 'unknown';
|
||||||
benchmark?: string;
|
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