Match Horizon mockup structure; restore qualified-first radar
Fixes from first prod review of the redesign: - Top navigation bar (TopBar) replaces the sidebar, like the mockup: wordmark + sections + ticker search + jobs/status/logout; content in a centered max-width column. MobileNav unchanged. - Radar panel: qualified setups lead again (the actionable list, as the old Top Setups did); non-qualified sit behind an "N below the gate" toggle with per-rule reasons, auto-open only when nothing qualifies. Adds mini radar fingerprints for the top 3 qualified (dimension scores from the rankings endpoint, fixed axis order). - Dashboard layout: open positions and radar side by side (xl), as in the mockup. - ScoreCard: the radar fingerprint was inside the showComposite block, which the ticker page disables - it never rendered. Now it shows whenever >= 3 dimensions exist. - TradeChart: fresh positions with < 2 bars since entry rendered no chart; now pads with ~10 pre-entry context bars (gray) and marks the entry point. First open position auto-expands so the chart is visible without a click. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
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';
|
||||
@@ -10,7 +11,8 @@ import { regimeColor, regimeDot, regimeHeadline } from '../lib/regime';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
import { Section } from '../components/ui/Section';
|
||||
import { OpenTradesPanel } from '../components/dashboard/OpenTradesPanel';
|
||||
import { PriceRail } from '../components/charts/horizon';
|
||||
import { PriceRail, RadarChart } from '../components/charts/horizon';
|
||||
import type { RadarAxis } from '../components/charts/horizon';
|
||||
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
||||
import { tradePnl } from '../lib/paperTrade';
|
||||
import {
|
||||
@@ -61,6 +63,57 @@ function DirectionTag({ direction }: { direction: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface RadarRow {
|
||||
setup: TradeSetup;
|
||||
rank: number;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/** One radar row — compact enough for the half-width column. */
|
||||
function RadarSetupRow({ setup, rank, reason, name }: RadarRow & { name?: string }) {
|
||||
const qualified = reason === null;
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<li
|
||||
className={`grid grid-cols-[20px_minmax(92px,116px)_44px_1fr_auto] items-center gap-2.5 px-2 py-2.5 ${
|
||||
qualified ? '' : 'opacity-60'
|
||||
}`}
|
||||
title={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''}`}
|
||||
>
|
||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||
<span className="min-w-0">
|
||||
<Link
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
{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';
|
||||
@@ -184,22 +237,56 @@ export default function DashboardPage() {
|
||||
}, [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 [];
|
||||
return [...(trades.data ?? [])]
|
||||
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),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.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 fixed alphabetically so shapes
|
||||
// stay comparable across symbols).
|
||||
const rankings = useRankings();
|
||||
const fingerprints = useMemo(() => {
|
||||
const dimsBySymbol = new Map<string, RadarAxis[]>();
|
||||
for (const r of rankings.data?.rankings ?? []) {
|
||||
const dims = [...r.dimensions].sort((a, b) => a.dimension.localeCompare(b.dimension));
|
||||
if (dims.length >= 3) {
|
||||
dimsBySymbol.set(
|
||||
r.symbol.toUpperCase(),
|
||||
dims.map((d) => ({
|
||||
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
|
||||
full: d.dimension,
|
||||
value: d.score,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
const topWatchlist = useMemo(
|
||||
() =>
|
||||
[...(watchlist.data ?? [])]
|
||||
@@ -338,78 +425,84 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Open paper trades */}
|
||||
<OpenTradesPanel />
|
||||
{/* Open positions | Radar — side by side like the mockup */}
|
||||
<div className="grid items-start gap-8 xl:grid-cols-2">
|
||||
<OpenTradesPanel />
|
||||
|
||||
{/* Radar — every live setup, ranked, with the gate verdict */}
|
||||
<Section title="Radar" hint="ranked by strategy score · why each does or doesn't qualify">
|
||||
{trades.isLoading && <SkeletonTable rows={5} cols={6} />}
|
||||
{trades.data && radar.length === 0 && (
|
||||
<Callout variant="empty">No live setups right now.</Callout>
|
||||
)}
|
||||
{radar.length > 0 && (
|
||||
<div className="glass px-4 py-1">
|
||||
<ul className="divide-y divide-white/[0.04]">
|
||||
{radar.map(({ setup, rank, reason }) => {
|
||||
const qualified = reason === null;
|
||||
const prob = primaryTargetProbability(setup);
|
||||
return (
|
||||
<li
|
||||
key={setup.id}
|
||||
className={`grid grid-cols-[20px_130px_1fr_100px] items-center gap-3 px-2 py-2.5 sm:grid-cols-[20px_150px_46px_1fr_54px_46px_minmax(150px,1fr)] ${
|
||||
qualified ? '' : 'opacity-60'
|
||||
}`}
|
||||
>
|
||||
<span className="num text-[11px] text-gray-500">{rank}</span>
|
||||
<span>
|
||||
<Link
|
||||
to={`/ticker/${setup.symbol}`}
|
||||
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
|
||||
>
|
||||
{setup.symbol}
|
||||
</Link>
|
||||
{tickerNames.get(setup.symbol.toUpperCase()) && (
|
||||
<span className="block max-w-[130px] truncate text-[10.5px] text-gray-500">
|
||||
{tickerNames.get(setup.symbol.toUpperCase())}
|
||||
<Section title="Radar" hint="ranked by strategy score">
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
<span className="hidden sm:block"><DirectionTag direction={setup.direction} /></span>
|
||||
<span className="hidden items-center gap-2 sm:flex">
|
||||
<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 text-[10px] text-gray-500">
|
||||
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
|
||||
</span>
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{setup.rr_ratio.toFixed(1)}:1
|
||||
</span>
|
||||
<span className="num hidden text-right text-xs text-gray-400 sm:block">
|
||||
{prob != null ? `${Math.round(prob)}%` : '—'}
|
||||
</span>
|
||||
<span className={`text-right text-[11.5px] ${qualified ? 'text-blue-300' : 'text-gray-500'}`}>
|
||||
{qualified ? '✓ clears the gate' : reason}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</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())} />
|
||||
))}
|
||||
</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())} />
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{/* Watchlist — compact chips, drill into any ticker */}
|
||||
<Section title="My Watchlist" hint="today's move">
|
||||
|
||||
Reference in New Issue
Block a user