Files
signal-platform/frontend/src/pages/DashboardPage.tsx
T
dennisthiessen d892c46fbb
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 37s
Deploy / deploy (push) Successful in 24s
rank Top Setups by expected value, badge the top pick
The dashboard Top Setups list showed raw fields in arbitrary order with no
indication of why a ticker was listed or which was best. Now sort by expected
value (R) — probability-weighted payoff per unit risk — so the strongest
opportunity is row 1, badged "Top pick", with a new Exp. Value column that
folds R:R and target probability into one "is this worth taking" number.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:09:33 +02:00

258 lines
11 KiB
TypeScript

import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { useActivation } from '../hooks/useActivation';
import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist';
import { usePerformance } from '../hooks/usePerformance';
import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { recommendationActionLabel } from '../lib/recommendation';
import { qualifiesSetup, activationSummary, primaryTargetProbability, expectedValueR } from '../lib/qualification';
import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string {
if (value === null) return '—';
return `${value > 0 ? '+' : ''}${value.toFixed(2)}R`;
}
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 Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
label: string;
value: string;
sub?: string;
valueClass?: string;
}) {
return (
<div className="glass glass-hover p-5">
<p className="section-index">{label}</p>
<p className={`num mt-2 text-3xl font-semibold ${valueClass}`}>{value}</p>
{sub && <p className="mt-1 text-xs text-gray-500">{sub}</p>}
</div>
);
}
function DirectionTag({ direction }: { direction: string }) {
const isLong = direction === 'long';
return (
<span className={`num inline-block rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${
isLong ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-400'
}`}>
{direction}
</span>
);
}
export default function DashboardPage() {
const trades = useTrades();
const watchlist = useWatchlist();
const activation = useActivation();
const performance = usePerformance();
const qualifiedSetups = useMemo(
() =>
activation.data
? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!))
: [],
[trades.data, activation.data],
);
// Show qualified setups first; fall back to the full list when none qualify.
// Rank by expected value (R) so the best opportunity sits at the top.
const showingQualified = qualifiedSetups.length > 0;
const topSetups: TradeSetup[] = useMemo(() => {
const pool = showingQualified ? qualifiedSetups : trades.data ?? [];
return [...pool]
.sort((a, b) => (expectedValueR(b) ?? -Infinity) - (expectedValueR(a) ?? -Infinity))
.slice(0, 5);
}, [showingQualified, qualifiedSetups, trades.data]);
const topWatchlist = useMemo(
() =>
[...(watchlist.data ?? [])]
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
.slice(0, 6),
[watchlist.data],
);
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric',
});
const stats = performance.data?.overall;
return (
<div className="space-y-8 animate-slide-up">
{/* Hero */}
<div>
<p className="num text-xs uppercase tracking-[0.22em] text-gray-500">{today}</p>
<h1 className="font-display mt-1 text-4xl font-bold tracking-tight text-gray-100">
Market overview
</h1>
</div>
{/* Metric strip */}
{(trades.isLoading || performance.isLoading) ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Metric
label="Live Setups"
value={String(trades.data?.length ?? 0)}
sub="latest per ticker & direction"
/>
<Metric
label="Qualified"
value={String(qualifiedSetups.length)}
sub={activation.data ? activationSummary(activation.data) : 'clears the activation gate'}
valueClass={qualifiedSetups.length > 0 ? 'text-blue-300' : 'text-gray-100'}
/>
<Metric
label="Hit Rate"
value={stats?.hit_rate != null ? `${stats.hit_rate.toFixed(1)}%` : '—'}
sub={stats ? `${stats.wins}W / ${stats.losses}L evaluated` : 'no outcomes yet'}
/>
<Metric
label="Expectancy"
value={fmtR(stats?.avg_r ?? null)}
valueClass={rColor(stats?.avg_r ?? null)}
sub="average R per trade"
/>
</div>
)}
<div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */}
<div className="xl:col-span-3">
<Section
title="Top Setups"
hint={showingQualified ? 'ranked by expected value' : 'none qualified — showing all'}
>
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && (
<Callout variant="empty">No active setups. Run the scanner from the Signals page.</Callout>
)}
{topSetups.length > 0 && (
<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">Ticker</th>
<th className="px-4 py-3">Dir</th>
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">R:R</th>
<th className="px-4 py-3 text-right">Target&nbsp;Prob</th>
<th className="px-4 py-3 text-right">Exp.&nbsp;Value</th>
<th className="hidden px-4 py-3 md:table-cell">Action</th>
</tr>
</thead>
<tbody>
{topSetups.map((setup, i) => {
const ev = expectedValueR(setup);
const isTopPick = i === 0;
return (
<tr
key={setup.id}
className={`border-b border-white/[0.04] transition-colors duration-150 hover:bg-white/[0.03] ${
isTopPick ? 'bg-blue-500/[0.06]' : ''
}`}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link to={`/ticker/${setup.symbol}`} className="font-medium text-blue-300 hover:text-blue-200 transition-colors">
{setup.symbol}
</Link>
{isTopPick && (
<span className="rounded bg-blue-500/20 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-300">
Top pick
</span>
)}
</div>
</td>
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
<td className="num px-4 py-3 text-right text-gray-200">{formatPrice(setup.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">{setup.rr_ratio.toFixed(1)}:1</td>
<td className="num px-4 py-3 text-right text-gray-200">
{(() => {
const p = primaryTargetProbability(setup);
return p != null ? `${Math.round(p)}%` : '—';
})()}
</td>
<td className={`num px-4 py-3 text-right font-semibold ${rColor(ev)}`}>
{fmtR(ev)}
</td>
<td className="hidden px-4 py-3 text-xs text-gray-400 md:table-cell">
{recommendationActionLabel(setup.recommended_action)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex items-center justify-between border-t border-white/[0.04] px-4 py-2.5">
<span className="text-[11px] text-gray-500">
Exp. Value = probability-weighted payoff per unit of risk
</span>
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
All setups
</Link>
</div>
</div>
)}
</Section>
</div>
{/* Watchlist pulse */}
<div className="xl:col-span-2">
<Section title="Watchlist Pulse" hint="top by score">
{watchlist.isLoading && <SkeletonTable rows={6} cols={3} />}
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
{watchlist.data && topWatchlist.length === 0 && (
<Callout variant="empty">Watchlist is empty add tickers on the Market page.</Callout>
)}
{topWatchlist.length > 0 && (
<div className="glass overflow-hidden">
<ul className="divide-y divide-white/[0.04]">
{topWatchlist.map((entry) => (
<li key={entry.symbol}>
<Link
to={`/ticker/${entry.symbol}`}
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
>
<span className="font-medium text-gray-200">{entry.symbol}</span>
<span className="flex items-center gap-4">
{entry.rr_ratio != null && (
<span className="num text-xs text-gray-500">{entry.rr_ratio.toFixed(1)}:1</span>
)}
<span className="num text-sm font-semibold text-blue-300">
{entry.composite_score != null ? entry.composite_score.toFixed(0) : '—'}
</span>
</span>
</Link>
</li>
))}
</ul>
<div className="border-t border-white/[0.04] px-4 py-2.5">
<Link to="/market" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
Full watchlist
</Link>
</div>
</div>
)}
</Section>
</div>
</div>
</div>
);
}