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.
595 lines
25 KiB
TypeScript
595 lines
25 KiB
TypeScript
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>
|
||
);
|
||
}
|