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 (

{label}

{value}

{sub &&

{sub}

}
); } function DirectionTag({ direction }: { direction: string }) { const isLong = direction === 'long'; return ( {direction} ); } 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 (
  • { 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={`R:R ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · target prob ${Math.round(prob)}%` : ''} · click to focus`} > {rank} {setup.symbol} {name && {name}} {setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'} {qualified ? `✓ ${setup.rr_ratio.toFixed(1)}:1${prob != null ? ` · ${Math.round(prob)}%` : ''}` : reason}
  • ); } 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 (
    {badge}

    {setup.symbol}

    {name && {name}}
    conviction {convictionLabel(setup.recommended_action)} {setup.momentum_percentile != null && ( residual momentum {Math.round(setup.momentum_percentile)}th %ile )}

    reward / risk

    {setup.rr_ratio.toFixed(1)} : 1

    {prob != null && (

    target probability

    {Math.round(prob)} %

    )}
    {footNote} {onReset && ( )} Ticker details All setups
    ); } 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(); 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(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(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 (
    {/* Hero — the verdict */}

    {today}

    {verdict}

    {trades.data && (

    {qualifiedCount} qualified of {liveCount} live setups · {exposure.count} open position{exposure.count === 1 ? '' : 's'} {exposure.rPriced > 0 && ( <> {' · '} {fmtR(exposure.unrealR)} unrealized )}

    )}
    {/* Watchlist — a slim glanceable strip right up top (regime moved to the nav bar) */} {watchlist.data && ( topWatchlist.length > 0 ? (
    Watchlist {topWatchlist.map((entry) => ( {entry.symbol} {entry.change_pct != null ? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%` : '—'} ))} Full watchlist →
    ) : (

    Watchlist empty — open any ticker and tap ☆ to add it.

    ) )} {/* Setup in focus — full width; select a radar row below to swap it */}
    {(trades.isLoading || activation.isLoading) && } {trades.isError && Failed to load setups} {trades.data && activation.data && ( focusSetup ? ( setFocusId(null)} /> ) : ( No qualified actionable setups right now — select a radar row to inspect what's close and why it doesn't qualify. ) )}
    {/* Metric strip — the account ribbons, right above the positions they describe */} {(trades.isLoading || openTrades.isLoading) ? (
    ) : (
    0 ? `$${exposure.riskUsd.toFixed(0)}` : '—'} sub={exposure.count > 0 ? `${exposure.count} open · distance to stops` : 'no open trades'} /> 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' } /> 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" /> 0 ? 'text-blue-300' : 'text-gray-100'} sub={activation.data ? activationSummary(activation.data) : 'setups clearing the gate'} />
    )} {/* Open positions + performance | Radar — side by side, half width each */}
    {trades.isLoading && } {trades.data && radar.qualified.length === 0 && radar.below.length === 0 && ( No live setups right now. )} {(radar.qualified.length > 0 || radar.below.length > 0) && (
    {/* Qualified fingerprints — same axes, shapes compare at a glance */} {fingerprints.length > 0 && (
    {fingerprints.map((f) => (
    {f.symbol} {f.rank === 1 ? 'top pick' : `rank ${f.rank}`}
    ))}

    qualified fingerprints · hover a corner for scores

    )} {/* Qualified rows — the actionable list */} {radar.qualified.length > 0 ? (
      {radar.qualified.map((row) => ( setFocusId(row.setup.id)} /> ))}
    ) : (

    None clear the gate today — the closest candidates are below.

    )} {/* Below the gate, collapsed by default when there are qualified setups */} {radar.below.length > 0 && ( <> {showBelow && (
      {radar.below.map((row) => ( setFocusId(row.setup.id)} /> ))}
    )} )}
    All setups →
    )}
    ); }