Files
signal-platform/frontend/src/pages/PerformancePage.tsx
T
dennisthiessen 21ed83c56c
Deploy / lint (push) Successful in 25s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 25s
Add trade setup outcome tracking and performance stats
Closes the feedback loop on R:R scanner signals:

- Nightly outcome_evaluator job replays unresolved setups against daily
  OHLCV bars: target_hit / stop_hit / ambiguous (same-bar, counted as
  loss) / expired after OUTCOME_EVALUATION_MAX_BARS (default 30)
- Migration 004: evaluated_at + outcome_date on trade_setups
- GET /trades/performance: hit rate, expectancy (avg R), total R with
  breakdowns by direction, recommended action, and confidence bucket
- New Performance page (stat cards, breakdown tables, Evaluate Now,
  methodology disclosure) wired into sidebar and mobile nav
- 17 new unit tests for evaluation logic and stats aggregation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:23:57 +02:00

196 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usePerformance } from '../hooks/usePerformance';
import { triggerJob } from '../api/admin';
import { Button } from '../components/ui/Button';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
import { PageHeader } from '../components/ui/PageHeader';
import { Section } from '../components/ui/Section';
import { SkeletonCard } from '../components/ui/Skeleton';
import { useToast } from '../components/ui/Toast';
import { RECOMMENDATION_ACTION_LABELS } from '../lib/recommendation';
import type { OutcomeBucketStats } from '../lib/types';
function fmtR(value: number | null): string {
if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
}
function fmtPct(value: number | null): string {
return value === null ? '—' : `${value.toFixed(1)}%`;
}
function rColor(value: number | null): string {
if (value === null) return 'text-gray-400';
if (value > 0) return 'text-emerald-400';
if (value < 0) return 'text-red-400';
return 'text-gray-300';
}
function StatCard({ label, value, valueClass = 'text-gray-100', sub }: {
label: string;
value: string;
valueClass?: string;
sub?: string;
}) {
return (
<div className="glass p-5">
<p className="text-xs uppercase tracking-widest text-gray-500">{label}</p>
<p className={`mt-2 text-2xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
function actionLabel(key: string): string {
return RECOMMENDATION_ACTION_LABELS[key as keyof typeof RECOMMENDATION_ACTION_LABELS] ?? key;
}
function BreakdownTable({ rows, labelHeader, mapLabel }: {
rows: Record<string, OutcomeBucketStats>;
labelHeader: string;
mapLabel?: (key: string) => string;
}) {
const entries = Object.entries(rows);
if (entries.length === 0) {
return <Callout variant="empty">No evaluated setups in this breakdown yet.</Callout>;
}
return (
<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-3">{labelHeader}</th>
<th className="px-4 py-3 text-right">Setups</th>
<th className="px-4 py-3 text-right">Wins</th>
<th className="px-4 py-3 text-right">Losses</th>
<th className="px-4 py-3 text-right">Expired</th>
<th className="px-4 py-3 text-right">Hit Rate</th>
<th className="px-4 py-3 text-right">Avg R</th>
<th className="px-4 py-3 text-right">Total R</th>
</tr>
</thead>
<tbody>
{entries.map(([key, stats]) => (
<tr key={key} className="border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03]">
<td className="px-4 py-3 font-medium text-gray-200">{mapLabel ? mapLabel(key) : key}</td>
<td className="px-4 py-3 text-right text-gray-300">{stats.total}</td>
<td className="px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
<td className="px-4 py-3 text-right text-red-400">{stats.losses}</td>
<td className="px-4 py-3 text-right text-gray-400">{stats.expired}</td>
<td className="px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
<td className={`px-4 py-3 text-right font-mono ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export default function PerformancePage() {
const { data, isLoading, isError, error } = usePerformance();
const queryClient = useQueryClient();
const toast = useToast();
const evaluateMutation = useMutation({
mutationFn: () => triggerJob('outcome_evaluator'),
onSuccess: () => {
toast.addToast('success', 'Outcome evaluation triggered. Stats will refresh shortly.');
setTimeout(() => queryClient.invalidateQueries({ queryKey: ['performance'] }), 3000);
},
onError: () => {
toast.addToast('error', 'Failed to trigger outcome evaluation');
},
});
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Performance"
subtitle="Do the signals actually win? Outcomes of past trade setups"
actions={
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending}>
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
}
/>
<Disclosure summary="How outcomes are measured">
<p className="text-xs text-gray-400">
Each setup is replayed against the daily bars after its detection: a{' '}
<span className="text-emerald-400">win</span> means the target was reached before the
stop, a <span className="text-red-400">loss</span> means the stop was hit first (bars
where both levels fall inside the same day count conservatively as losses). Setups with
neither level hit within 30 trading days <span className="text-gray-300">expire</span> at
0R. Avg R is the expectancy per trade: wins earn their R:R ratio, losses cost 1R a
positive value means the signals have been profitable on a risk-adjusted basis. The
evaluator runs nightly after OHLCV collection.
</p>
</Disclosure>
{isLoading && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
)}
{isError && (
<Callout variant="error">
{error instanceof Error ? error.message : 'Failed to load performance stats'}
</Callout>
)}
{data && data.overall.total === 0 && (
<Callout variant="empty">
No evaluated setups yet. Outcomes appear once setups are old enough for their stop or
target to be hit the evaluator runs nightly, or click Evaluate Now.
{data.pending > 0 && ` ${data.pending} setup${data.pending === 1 ? '' : 's'} pending evaluation.`}
</Callout>
)}
{data && data.overall.total > 0 && (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label="Hit Rate"
value={fmtPct(data.overall.hit_rate)}
sub={`${data.overall.wins} wins / ${data.overall.losses} losses`}
/>
<StatCard
label="Expectancy"
value={fmtR(data.overall.avg_r)}
valueClass={rColor(data.overall.avg_r)}
sub="average R per trade"
/>
<StatCard
label="Total R"
value={fmtR(data.overall.total_r)}
valueClass={rColor(data.overall.total_r)}
sub="cumulative risk-adjusted result"
/>
<StatCard
label="Evaluated"
value={String(data.overall.total)}
sub={`${data.pending} pending · ${data.overall.expired} expired`}
/>
</div>
<Section title="By Direction">
<BreakdownTable rows={data.by_direction} labelHeader="Direction" />
</Section>
<Section title="By Recommended Action">
<BreakdownTable rows={data.by_action} labelHeader="Action" mapLabel={actionLabel} />
</Section>
<Section title="By Confidence" hint="at detection time">
<BreakdownTable rows={data.by_confidence} labelHeader="Confidence" />
</Section>
</>
)}
</div>
);
}