Files
signal-platform/frontend/src/pages/DashboardPage.tsx
T
dennisthiessen 8db535b889
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 39s
Fix manual refresh dropping qualified ranks and clarify trade UI.
Single-ticker fetch now attaches residual-momentum ranks so setups do not silently fail the activation gate. Exit plan is a timeline, chart labels move left of the price scale, and missing ranks surface explicitly.
2026-07-14 10:10:30 +02:00

595 lines
25 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 { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useActivation } from '../hooks/useActivation';
import { useRankings } from '../hooks/useScores';
import { useTrades } from '../hooks/useTrades';
import { useWatchlist } from '../hooks/useWatchlist';
import { usePaperTrades } from '../hooks/usePaperTrades';
import { useTickerNames } from '../hooks/useTickers';
import { Callout } from '../components/ui/Callout';
import { Section } from '../components/ui/Section';
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
import { PerfChart } from '../components/dashboard/PerfChart';
import { PriceRail, RadarChart, radarAxesFromDimensions } from '../components/charts/horizon';
import type { RadarAxis } from '../components/charts/horizon';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { tradePnl } from '../lib/paperTrade';
import {
qualifiesSetup, disqualifyReason, activationSummary, primaryTargetProbability,
} 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-300';
if (value < 0) return 'text-red-300';
return 'text-gray-300';
}
function money(value: number): string {
const sign = value >= 0 ? '+' : '';
return `${sign}$${Math.abs(value).toFixed(2)}`;
}
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={`font-display 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-300' : 'bg-red-500/15 text-red-300'
}`}>
{direction}
</span>
);
}
interface RadarRow {
setup: TradeSetup;
rank: number;
reason: string | null;
}
/** One radar row — compact enough for the half-width column; selecting it
* swaps the focus card to this setup. */
function RadarSetupRow({ setup, rank, reason, name, selected, onSelect }: RadarRow & {
name?: string;
selected?: boolean;
onSelect?: () => void;
}) {
const qualified = reason === null;
const prob = primaryTargetProbability(setup);
return (
<li
role="button"
tabIndex={0}
aria-pressed={selected}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect?.();
}
}}
className={`grid cursor-pointer grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 rounded-lg px-2 py-2.5 transition-colors ${
selected ? 'bg-blue-400/[0.08]' : 'hover:bg-white/[0.03]'
} ${qualified ? '' : 'opacity-60'}`}
title={`gate: R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · reach probability ${Math.round(prob)}%` : ''} (screening, not an exit) · click to focus`}
>
<span className="num text-[11px] text-gray-500">{rank}</span>
<span className="min-w-0">
<span className="block font-medium text-blue-300">{setup.symbol}</span>
{name && <span className="block truncate text-[10.5px] text-gray-500">{name}</span>}
</span>
<DirectionTag direction={setup.direction} />
<span className="flex items-center gap-2">
<span className="h-1 flex-1 overflow-hidden rounded-full bg-white/[0.07]">
<span
className="block h-full rounded-full"
style={{
width: `${Math.round(setup.momentum_percentile ?? 0)}%`,
background: qualified ? 'var(--up)' : 'var(--ink-3)',
}}
/>
</span>
<span className="num w-11 text-[10px] text-gray-500">
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
</span>
</span>
<span className={`max-w-[170px] text-right text-[11.5px] leading-snug ${qualified ? 'text-blue-300' : 'text-gray-500'}`}>
{qualified
? `✓ ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · ${Math.round(prob)}%` : ''}`
: reason}
</span>
</li>
);
}
function convictionLabel(action: TradeSetup['recommended_action']): string {
if (!action || action === 'NEUTRAL') return '—';
if (action.endsWith('_HIGH')) return 'High';
if (action.endsWith('_MODERATE')) return 'Moderate';
return '—';
}
/** The focal card: a setup as a spatial price rail — the top pick by default,
* or whichever radar row is selected. */
function FocusCard({ setup, name, badge, badgeTone, footNote, onReset }: {
setup: TradeSetup;
name: string | undefined;
badge: string;
badgeTone: 'ember' | 'muted';
footNote: string;
onReset?: () => void;
}) {
const prob = primaryTargetProbability(setup);
return (
<section className="glass p-6 sm:p-7" aria-label={`Setup in focus: ${setup.symbol}`}>
<div className="flex flex-wrap items-start justify-between gap-6">
<div className="flex items-start gap-4">
<span className={`num mt-2.5 whitespace-nowrap rounded-full border px-2.5 py-1 text-[9.5px] font-semibold uppercase tracking-[0.2em] ${
badgeTone === 'ember'
? 'border-[#ff6a45]/40 text-[#ff6a45]'
: 'border-white/[0.15] text-gray-400'
}`}>
{badge}
</span>
<div>
<div className="flex items-baseline gap-3">
<h2 className="font-display text-4xl font-bold tracking-tight text-gray-100 sm:text-5xl">
{setup.symbol}
</h2>
{name && <span className="text-sm text-gray-400">{name}</span>}
</div>
<div className="mt-2.5 flex flex-wrap items-center gap-2">
<DirectionTag direction={setup.direction} />
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
conviction {convictionLabel(setup.recommended_action)}
</span>
</div>
</div>
</div>
{/* The headline stat is the signal that actually selected this ticker.
R:R and reach probability are gate inputs computed from a GTL
proposal the trade never exits at — they get quiet treatment. */}
<div className="flex items-start gap-10 text-right">
<div title="Residual 12-1 month momentum percentile across the universe. This is why the ticker was selected.">
<p className="section-index">residual momentum</p>
{setup.momentum_percentile != null ? (
<>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
top {Math.max(1, Math.round(100 - setup.momentum_percentile))}
<span className="text-lg text-gray-400">%</span>
</p>
<div className="ml-auto mt-2 h-1 w-28 rounded-full bg-blue-500/20">
<span
className="block h-full rounded-full bg-blue-500"
style={{ width: `${Math.min(100, Math.round(setup.momentum_percentile))}%` }}
/>
</div>
</>
) : (
<>
<p className="font-display mt-1 text-2xl font-semibold text-amber-200">missing</p>
<p className="mt-1 text-[11px] text-gray-500">no residual rank · cannot qualify</p>
</>
)}
</div>
<div
className="max-w-[13rem]"
title="Gate metrics. The reward/risk and reach probability of the headline Gate Target Ladder proposal are what admitted this setup. The trade does NOT exit there — it exits on the trailing stop."
>
<p className="section-index">gate metrics</p>
<p className="num mt-1.5 text-sm text-gray-300">
R:R {setup.rr_ratio.toFixed(1)}:1
{prob != null && <> · reach {Math.round(prob)}%</>}
</p>
<p className="mt-1 text-[10.5px] leading-relaxed text-gray-500">
screening only exits on the trailing stop, not at the level
</p>
</div>
</div>
</div>
<PriceRail
direction={setup.direction}
entry={setup.entry_price}
stop={setup.stop_loss}
target={setup.target}
current={setup.current_price}
/>
<div className="mt-4 flex flex-wrap items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
<span className="text-xs text-gray-500">{footNote}</span>
<span className="flex items-center gap-2.5">
{onReset && (
<button
onClick={onReset}
className="text-xs font-medium text-gray-400 transition-colors hover:text-gray-200"
>
top pick
</button>
)}
<Link
to={`/ticker/${setup.symbol}`}
className="rounded-lg border border-blue-500/35 bg-blue-500/15 px-4 py-1.5 text-[13px] font-semibold text-blue-300 transition-colors hover:bg-blue-500/25"
>
Ticker details
</Link>
<Link
to="/signals"
className="rounded-lg border border-white/[0.09] px-4 py-1.5 text-[13px] font-semibold text-gray-400 transition-colors hover:bg-white/[0.04] hover:text-gray-200"
>
All setups
</Link>
</span>
</div>
</section>
);
}
export default function DashboardPage() {
const trades = useTrades();
const watchlist = useWatchlist();
const tickerNames = useTickerNames();
const activation = useActivation();
const openTrades = usePaperTrades('open');
const qualifiedSetups = useMemo(
() =>
activation.data
? (trades.data ?? []).filter((t) => qualifiesSetup(t, activation.data!))
: [],
[trades.data, activation.data],
);
// Rank only actionable/qualified setups by the production strategy score.
// Residual momentum still gates qualification; strategy_rank is the promoted
// 80/20 residual-momentum/high-vol ordering score when available.
const topSetups: TradeSetup[] = useMemo(() => {
return [...qualifiedSetups]
.sort(
(a, b) =>
(b.strategy_rank ?? b.momentum_percentile ?? -Infinity) -
(a.strategy_rank ?? a.momentum_percentile ?? -Infinity),
)
.slice(0, 5);
}, [qualifiedSetups]);
// Radar: every live setup ranked by the same score, with the gate verdict.
// Qualified setups lead (that's the actionable list); the rest sit behind a
// toggle so a 0-qualified day isn't a wall of rejections.
const radar = useMemo(() => {
if (!activation.data) return { qualified: [] as RadarRow[], below: [] as RadarRow[] };
const ranked = [...(trades.data ?? [])]
.sort(
(a, b) =>
(b.strategy_rank ?? b.momentum_percentile ?? -Infinity) -
(a.strategy_rank ?? a.momentum_percentile ?? -Infinity),
)
.map((setup, i) => ({
setup,
rank: i + 1,
reason: disqualifyReason(setup, activation.data!),
}));
return {
qualified: ranked.filter((r) => r.reason === null),
below: ranked.filter((r) => r.reason !== null).slice(0, 8),
};
}, [trades.data, activation.data]);
// Mini score fingerprints for the qualified setups (dimension scores come
// from the rankings endpoint; axis order is the canonical one shared with
// the ticker page so shapes stay comparable everywhere).
const rankings = useRankings();
const fingerprints = useMemo(() => {
const dimsBySymbol = new Map<string, RadarAxis[]>();
for (const r of rankings.data?.rankings ?? []) {
if (r.dimensions.length >= 3) {
dimsBySymbol.set(r.symbol.toUpperCase(), radarAxesFromDimensions(r.dimensions));
}
}
return radar.qualified
.slice(0, 3)
.map(({ setup, rank }) => ({ symbol: setup.symbol, rank, axes: dimsBySymbol.get(setup.symbol.toUpperCase()) }))
.filter((f): f is { symbol: string; rank: number; axes: RadarAxis[] } => !!f.axes);
}, [rankings.data, radar.qualified]);
const [belowChoice, setBelowChoice] = useState<boolean | null>(null);
// Default open when nothing qualifies — the near-misses ARE the content then.
const showBelow = belowChoice ?? radar.qualified.length === 0;
// Radar selection swaps the focus card; null = the top pick.
const [focusId, setFocusId] = useState<number | null>(null);
const topWatchlist = useMemo(
() =>
[...(watchlist.data ?? [])]
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
.slice(0, 12),
[watchlist.data],
);
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric',
});
// Current exposure from open paper trades: $ at risk to stops + mark-to-market.
const exposure = useMemo(() => {
const rows = openTrades.data ?? [];
let riskUsd = 0, unrealUsd = 0, unrealR = 0, rPriced = 0, winners = 0, losers = 0;
let alphaUsd = 0, alphaPriced = 0;
for (const t of rows) {
riskUsd += Math.abs(t.entry_price - t.stop_loss) * t.shares;
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
const p = tradePnl(t);
if (!p) continue;
unrealUsd += p.pnl;
if (p.r != null) { unrealR += p.r; rPriced += 1; }
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
}
return { count: rows.length, riskUsd, unrealUsd, unrealR, rPriced, winners, losers, alphaUsd, alphaPriced };
}, [openTrades.data]);
const liveCount = trades.data?.length ?? 0;
const qualifiedCount = qualifiedSetups.length;
const verdict = !trades.data || !activation.data
? 'Market overview'
: qualifiedCount === 0
? 'No setup clears the gate today.'
: qualifiedCount === 1
? 'One setup clears the gate today.'
: `${qualifiedCount} setups clear the gate today.`;
const topPick = topSetups[0];
// What the focus card shows: the selected radar row, else the top pick.
const focusRow = focusId != null
? [...radar.qualified, ...radar.below].find((r) => r.setup.id === focusId) ?? null
: null;
const focusSetup = focusRow?.setup ?? topPick;
const focusIsTop = focusRow == null || (topPick != null && focusRow.setup.id === topPick.id);
return (
<div className="space-y-8 animate-slide-up">
{/* Hero — the verdict */}
<div>
<p className="num text-xs uppercase tracking-[0.22em] text-gray-500">{today}</p>
<h1 className="font-display mt-1 text-4xl font-semibold tracking-tight text-gray-100">
{verdict}
</h1>
{trades.data && (
<p className="mt-2 text-[15px] text-gray-400">
{qualifiedCount} qualified of {liveCount} live setups · {exposure.count} open position{exposure.count === 1 ? '' : 's'}
{exposure.rPriced > 0 && (
<>
{' · '}
<b className={rColor(exposure.unrealR)}>{fmtR(exposure.unrealR)}</b> unrealized
</>
)}
</p>
)}
</div>
{/* Watchlist — a slim glanceable strip right up top (regime moved to the nav bar) */}
{watchlist.data && (
topWatchlist.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<span className="section-index mr-1">Watchlist</span>
{topWatchlist.map((entry) => (
<Link
key={entry.symbol}
to={`/ticker/${entry.symbol}`}
className="inline-flex items-baseline gap-2 rounded-full border border-white/[0.09] px-3 py-1 text-[12.5px] transition-colors hover:border-blue-400/40 hover:bg-white/[0.03]"
>
<b className="font-semibold text-gray-200">{entry.symbol}</b>
<span className={`num text-[11px] ${rColor(entry.change_pct)}`}>
{entry.change_pct != null
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
: '—'}
</span>
</Link>
))}
<Link to="/market" className="ml-1 text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
Full watchlist
</Link>
</div>
) : (
<p className="text-xs text-gray-600">
<span className="section-index mr-2">Watchlist</span>
empty open any ticker and tap to add it.
</p>
)
)}
{/* Setup in focus — full width; select a radar row below to swap it */}
<div>
{(trades.isLoading || activation.isLoading) && <SkeletonCard />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && activation.data && (
focusSetup ? (
<FocusCard
setup={focusSetup}
name={tickerNames.get(focusSetup.symbol.toUpperCase())}
badge={
focusIsTop
? 'top pick'
: focusRow && focusRow.reason === null
? `rank ${focusRow.rank}`
: `rank ${focusRow?.rank ?? '—'} · below gate`
}
badgeTone={focusIsTop ? 'ember' : 'muted'}
footNote={
focusRow && focusRow.reason !== null
? `does not qualify: ${focusRow.reason}`
: `cleared the gate · ${activationSummary(activation.data)}`
}
onReset={focusIsTop ? undefined : () => setFocusId(null)}
/>
) : (
<Callout variant="empty">
No qualified actionable setups right now select a radar row to inspect what's close and why it doesn't qualify.
</Callout>
)
)}
</div>
{/* Metric strip — the account ribbons, right above the positions they describe */}
{(trades.isLoading || openTrades.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="Open Risk"
value={exposure.count > 0 ? `$${exposure.riskUsd.toFixed(0)}` : '—'}
sub={exposure.count > 0 ? `${exposure.count} open · distance to stops` : 'no open trades'}
/>
<Metric
label="Unrealized"
value={exposure.rPriced > 0 ? fmtR(exposure.unrealR) : '—'}
valueClass={rColor(exposure.rPriced > 0 ? exposure.unrealR : null)}
sub={
exposure.count > 0
? `${money(exposure.unrealUsd)} · ${exposure.winners}${exposure.losers}▼`
: 'mark-to-market'
}
/>
<Metric
label="Alpha vs S&P 500"
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
valueClass={
exposure.alphaPriced > 0
? exposure.alphaUsd >= 0 ? 'text-emerald-300' : 'text-red-300'
: 'text-gray-100'
}
sub="open trades vs buy-and-hold SPY"
/>
<Metric
label="Gate"
value={trades.data ? `${qualifiedCount} / ${liveCount}` : '—'}
valueClass={qualifiedCount > 0 ? 'text-blue-300' : 'text-gray-100'}
sub={activation.data ? activationSummary(activation.data) : 'setups clearing the gate'}
/>
</div>
)}
{/* Open positions + performance | Radar — side by side, half width each */}
<div className="grid items-start gap-8 xl:grid-cols-2">
<div className="space-y-8">
<OpenTradesPanel />
<PerfChart />
</div>
<div>
<Section title="Radar" hint="ranked by strategy score · select a row to focus it">
{trades.isLoading && <SkeletonTable rows={5} cols={5} />}
{trades.data && radar.qualified.length === 0 && radar.below.length === 0 && (
<Callout variant="empty">No live setups right now.</Callout>
)}
{(radar.qualified.length > 0 || radar.below.length > 0) && (
<div className="glass px-4 py-2">
{/* Qualified fingerprints — same axes, shapes compare at a glance */}
{fingerprints.length > 0 && (
<div className="flex flex-wrap items-end gap-4 border-b border-white/[0.06] px-2 pb-3 pt-1.5">
{fingerprints.map((f) => (
<figure key={f.symbol} className="text-center">
<RadarChart axes={f.axes} size={82} labels={false} />
<figcaption className="-mt-1">
<Link to={`/ticker/${f.symbol}`} className="block text-[13px] font-semibold text-gray-200 hover:text-blue-200">
{f.symbol}
</Link>
<span className={`num text-[9px] uppercase tracking-[0.14em] ${
f.rank === 1 ? 'text-[#ff6a45]' : 'text-gray-500'
}`}>
{f.rank === 1 ? 'top pick' : `rank ${f.rank}`}
</span>
</figcaption>
</figure>
))}
<p className="num mb-2 ml-auto max-w-[170px] text-right text-[10px] leading-relaxed text-gray-500">
qualified fingerprints · hover a corner for scores
</p>
</div>
)}
{/* Qualified rows — the actionable list */}
{radar.qualified.length > 0 ? (
<ul className="divide-y divide-white/[0.04]">
{radar.qualified.map((row) => (
<RadarSetupRow
key={row.setup.id}
{...row}
name={tickerNames.get(row.setup.symbol.toUpperCase())}
selected={focusSetup?.id === row.setup.id}
onSelect={() => setFocusId(row.setup.id)}
/>
))}
</ul>
) : (
<p className="px-2 py-2.5 text-xs text-gray-500">
None clear the gate today the closest candidates are below.
</p>
)}
{/* Below the gate, collapsed by default when there are qualified setups */}
{radar.below.length > 0 && (
<>
<button
onClick={() => setBelowChoice(!showBelow)}
aria-expanded={showBelow}
className="flex w-full items-center gap-2 border-t border-white/[0.06] px-2 py-2.5 text-left text-xs text-gray-500 transition-colors hover:text-gray-300"
>
<span className={`text-[9px] transition-transform ${showBelow ? 'rotate-90' : ''}`} aria-hidden="true"></span>
{radar.below.length} below the gate why each doesn't qualify
</button>
{showBelow && (
<ul className="divide-y divide-white/[0.04] border-t border-white/[0.04]">
{radar.below.map((row) => (
<RadarSetupRow
key={row.setup.id}
{...row}
name={tickerNames.get(row.setup.symbol.toUpperCase())}
selected={focusSetup?.id === row.setup.id}
onSelect={() => setFocusId(row.setup.id)}
/>
))}
</ul>
)}
</>
)}
<div className="flex justify-end border-t border-white/[0.04] px-2 py-2.5">
<Link to="/signals" className="text-xs font-medium text-blue-300 transition-colors hover:text-blue-200">
All setups
</Link>
</div>
</div>
)}
</Section>
</div>
</div>
</div>
);
}