Files
signal-platform/frontend/src/components/signals/TrackRecordPanel.tsx
T
dennisthiessen 9c6a0a72fa
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 31s
Deploy / deploy (push) Successful in 22s
Redesign: phosphor-terminal identity and simplified 4-page structure
Information architecture (6 nav destinations -> 4):
- New Overview home: metric strip (live setups, high confidence,
  hit rate, expectancy), top-5 setups, watchlist pulse
- Market = Watchlist + Rankings merged as tabs; scoring weights
  moved into a collapsible disclosure
- Signals = Scanner + Performance merged as tabs (Setups | Track
  Record) with actions inside the panels
- Legacy routes redirect (/watchlist, /rankings, /scanner,
  /performance)

Visual identity:
- Warm ash-green dark palette replaces cold navy; citron lime
  accent replaces blue (Tailwind gray/blue remapped at config
  level so all components reskin)
- Primary buttons: lime with ink text; long/short stays
  emerald/red
- Typography: Bricolage Grotesque display, Instrument Sans body,
  IBM Plex Mono for all numerals incl. chart canvas labels
- Atmosphere: graph-paper grid + citron glow + film grain;
  pulsing brand dot; mono-numbered nav

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:42:05 +02:00

190 lines
7.6 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 '../ui/Button';
import { Callout } from '../ui/Callout';
import { Disclosure } from '../ui/Disclosure';
import { Section } from '../ui/Section';
import { SkeletonCard } from '../ui/Skeleton';
import { useToast } from '../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="section-index">{label}</p>
<p className={`num 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="num px-4 py-3 text-right text-gray-300">{stats.total}</td>
<td className="num px-4 py-3 text-right text-emerald-400">{stats.wins}</td>
<td className="num px-4 py-3 text-right text-red-400">{stats.losses}</td>
<td className="num px-4 py-3 text-right text-gray-400">{stats.expired}</td>
<td className="num px-4 py-3 text-right text-gray-200">{fmtPct(stats.hit_rate)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.avg_r)}`}>{fmtR(stats.avg_r)}</td>
<td className={`num px-4 py-3 text-right ${rColor(stats.total_r)}`}>{fmtR(stats.total_r)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function TrackRecordPanel() {
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">
<div className="flex items-start justify-between gap-4">
<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>
<Button onClick={() => evaluateMutation.mutate()} loading={evaluateMutation.isPending} className="shrink-0">
{evaluateMutation.isPending ? 'Evaluating…' : 'Evaluate Now'}
</Button>
</div>
{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>
);
}