Horizon redesign: space theme, visual dashboard, chart reskin
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 1m9s
Deploy / deploy (push) Successful in 36s

Replaces the citron/green glass theme with the Horizon direction
(mockup at /design-horizon): space-void background, starfield, dim
Mars horizon, rim-cyan accent, rose for negative. Palette validated
for CVD separation and contrast on the dark surface.

- tailwind.config: gray -> cool space neutrals, blue/emerald -> cyan
  scale, red -> rose; display font Space Grotesk
- globals.css: Horizon tokens, atmosphere, denser glass, cyan
  buttons/inputs, price-rail / R-bar / radar chart CSS
- Dashboard rebuilt: verdict hero, top-pick card with spatial price
  rail, KPI tiles, open positions as diverging R-bars with expandable
  detail + trade chart (OHLCV since entry, entry/stop/target levels),
  radar list with per-setup disqualify reason, watchlist chips
- OpenTradesPanel: table replaced by R-bar rows; all fields kept in
  the drill-down (shares, P&L, alpha, trailing stop, sell)
- qualification: disqualifyReason() mirrors qualifiesSetup rule order
- CandlestickChart: canvas colors moved to Horizon palette; S/R now
  cyan/neutral so rose stays reserved for the stop level
- ScoreCard: radar score fingerprint with hover values
- Design mockup pages included (routed in App.tsx)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 07:24:17 +02:00
co-authored by Claude Fable 5
parent 744ea4ddc4
commit 20f6981712
15 changed files with 3283 additions and 370 deletions
+263 -164
View File
@@ -10,10 +10,12 @@ 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 { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { formatPrice } from '../lib/format';
import { tradePnl } from '../lib/paperTrade';
import { qualifiesSetup, activationSummary, primaryTargetProbability } from '../lib/qualification';
import {
qualifiesSetup, disqualifyReason, activationSummary, primaryTargetProbability,
} from '../lib/qualification';
import type { TradeSetup } from '../lib/types';
function fmtR(value: number | null): string {
@@ -23,8 +25,8 @@ function fmtR(value: number | null): string {
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';
if (value > 0) return 'text-emerald-300';
if (value < 0) return 'text-red-300';
return 'text-gray-300';
}
@@ -42,7 +44,7 @@ function Metric({ label, value, sub, valueClass = 'text-gray-100' }: {
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>
<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>
);
@@ -52,7 +54,7 @@ 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'
isLong ? 'bg-emerald-500/15 text-emerald-300' : 'bg-red-500/15 text-red-300'
}`}>
{direction}
</span>
@@ -66,10 +68,90 @@ function convictionLabel(action: TradeSetup['recommended_action']): string {
return '—';
}
function convictionClass(action: TradeSetup['recommended_action']): string {
if (action?.endsWith('_HIGH')) return 'text-emerald-300';
if (action?.endsWith('_MODERATE')) return 'text-amber-300';
return 'text-gray-400';
/** The one focal card: today's top qualified setup as a spatial price rail. */
function FocusCard({ setup, name, gateNote }: {
setup: TradeSetup;
name: string | undefined;
gateNote: string;
}) {
const prob = primaryTargetProbability(setup);
return (
<section className="glass p-6 sm:p-7" aria-label="Top qualified setup">
<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 border-[#ff6a45]/40 px-2.5 py-1 text-[9.5px] font-semibold uppercase tracking-[0.2em] text-[#ff6a45]">
top pick
</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>
{setup.momentum_percentile != null && (
<span className="rounded-full border border-white/[0.09] px-2.5 py-0.5 text-[11.5px] text-gray-400">
residual momentum {Math.round(setup.momentum_percentile)}th %ile
</span>
)}
</div>
</div>
</div>
<div className="flex gap-10 text-right">
<div>
<p className="section-index">reward / risk</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{setup.rr_ratio.toFixed(1)}
<span className="text-lg text-gray-400"> : 1</span>
</p>
</div>
{prob != null && (
<div>
<p className="section-index">target probability</p>
<p className="font-display mt-1 text-3xl font-semibold text-gray-100">
{Math.round(prob)}
<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.round(prob)}%` }} />
</div>
</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">cleared the gate · {gateNote}</span>
<span className="flex gap-2.5">
<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() {
@@ -101,11 +183,28 @@ export default function DashboardPage() {
.slice(0, 5);
}, [qualifiedSetups]);
// Radar: every live setup ranked by the same score, with the gate verdict.
const radar = useMemo(() => {
if (!activation.data) return [];
return [...(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!),
}));
}, [trades.data, activation.data]);
const topWatchlist = useMemo(
() =>
[...(watchlist.data ?? [])]
.sort((a, b) => (b.composite_score ?? -1) - (a.composite_score ?? -1))
.slice(0, 10),
.slice(0, 12),
[watchlist.data],
);
@@ -131,14 +230,37 @@ export default function DashboardPage() {
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];
return (
<div className="space-y-8 animate-slide-up">
{/* Hero */}
{/* 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-bold tracking-tight text-gray-100">
Market overview
<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>
{/* Market regime banner */}
@@ -158,28 +280,34 @@ export default function DashboardPage() {
</div>
)}
{/* Focal setup */}
{(trades.isLoading || activation.isLoading) && <SkeletonCard />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && activation.data && (
topPick ? (
<FocusCard
setup={topPick}
name={tickerNames.get(topPick.symbol.toUpperCase())}
gateNote={activationSummary(activation.data)}
/>
) : (
<Callout variant="empty">
No qualified actionable setups right now the radar below shows what's close and why it doesn't qualify.
</Callout>
)
)}
{/* Metric strip */}
{(trades.isLoading || openTrades.isLoading) ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard /><SkeletonCard />
<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-5">
<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'}
/>
<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 · risk to stops` : 'no open trades'}
sub={exposure.count > 0 ? `${exposure.count} open · distance to stops` : 'no open trades'}
/>
<Metric
label="Unrealized"
@@ -196,10 +324,16 @@ export default function DashboardPage() {
value={exposure.alphaPriced > 0 ? money(exposure.alphaUsd) : '—'}
valueClass={
exposure.alphaPriced > 0
? exposure.alphaUsd >= 0 ? 'text-emerald-400' : 'text-red-400'
? exposure.alphaUsd >= 0 ? 'text-emerald-300' : 'text-red-300'
: 'text-gray-100'
}
sub={exposure.alphaPriced > 0 ? `${exposure.alphaPriced} open · vs buy-and-hold SPY` : 'vs buy-and-hold SPY'}
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>
)}
@@ -207,142 +341,107 @@ export default function DashboardPage() {
{/* Open paper trades */}
<OpenTradesPanel />
<div className="grid gap-8 xl:grid-cols-5">
{/* Top setups */}
<div className="xl:col-span-3">
<Section
title="Top Setups"
hint="current qualified signals"
>
{trades.isLoading && <SkeletonTable rows={5} cols={7} />}
{trades.isError && <Callout variant="error">Failed to load setups</Callout>}
{trades.data && topSetups.length === 0 && (
<Callout variant="empty">No qualified actionable setups right now.</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">Current / Target</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">Residual</th>
<th className="hidden px-4 py-3 md:table-cell">Conviction</th>
</tr>
</thead>
<tbody>
{topSetups.map((setup, i) => {
const isTopPick = i === 0;
const displayedPrice = setup.current_price ?? setup.entry_price;
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>
{tickerNames.get(setup.symbol.toUpperCase()) && (
<div className="max-w-[160px] truncate text-[11px] text-gray-500">
{tickerNames.get(setup.symbol.toUpperCase())}
</div>
)}
</td>
<td className="px-4 py-3"><DirectionTag direction={setup.direction} /></td>
<td className="num px-4 py-3 text-right">
<div className="text-gray-200">{formatPrice(displayedPrice)}</div>
<div className="text-[11px] text-gray-500">target {formatPrice(setup.target)}</div>
</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 text-gray-400">
{setup.momentum_percentile != null ? `${Math.round(setup.momentum_percentile)}%ile` : '—'}
</td>
<td className={`hidden px-4 py-3 text-xs font-semibold md:table-cell ${convictionClass(setup.recommended_action)}`}>
{convictionLabel(setup.recommended_action)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="flex justify-end border-t border-white/[0.04] px-4 py-2.5">
<Link to="/signals" className="text-xs font-medium text-blue-300 hover:text-blue-200 transition-colors">
All setups
</Link>
</div>
</div>
)}
</Section>
</div>
{/* My watchlist */}
<div className="xl:col-span-2">
<Section title="My Watchlist" hint="today's move">
{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">
Your watchlist is empty open any ticker and tap Add to watchlist.
</Callout>
)}
{topWatchlist.length > 0 && (
<div className="glass overflow-hidden">
<ul className="divide-y divide-white/[0.04]">
{topWatchlist.map((entry) => (
<li key={entry.symbol}>
{/* 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/${entry.symbol}`}
className="flex items-center justify-between px-4 py-3 transition-colors duration-150 hover:bg-white/[0.03]"
to={`/ticker/${setup.symbol}`}
className="block font-medium text-blue-300 transition-colors hover:text-blue-200"
>
<span className="flex items-baseline gap-2">
<span className="font-medium text-gray-200">{entry.symbol}</span>
{entry.composite_score != null && (
<span className="num text-[10px] text-gray-500">score {entry.composite_score.toFixed(0)}</span>
)}
</span>
<span className="flex items-baseline gap-3">
<span className="num text-sm text-gray-200">
{entry.last_close != null ? formatPrice(entry.last_close) : '—'}
</span>
<span className={`num w-16 text-right text-sm font-semibold ${rColor(entry.change_pct)}`}>
{entry.change_pct != null
? `${entry.change_pct >= 0 ? '+' : ''}${entry.change_pct.toFixed(2)}%`
: '—'}
</span>
</span>
{setup.symbol}
</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>
{tickerNames.get(setup.symbol.toUpperCase()) && (
<span className="block max-w-[130px] truncate text-[10.5px] text-gray-500">
{tickerNames.get(setup.symbol.toUpperCase())}
</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>
</div>
</div>
)}
</Section>
{/* Watchlist — compact chips, drill into any ticker */}
<Section title="My Watchlist" hint="today's move">
{watchlist.isLoading && <SkeletonTable rows={2} cols={4} />}
{watchlist.isError && <Callout variant="error">Failed to load watchlist</Callout>}
{watchlist.data && topWatchlist.length === 0 && (
<Callout variant="empty">
Your watchlist is empty open any ticker and tap Add to watchlist.
</Callout>
)}
{topWatchlist.length > 0 && (
<div className="flex flex-wrap items-center gap-2.5">
{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.5 py-1.5 text-[13px] 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-[11.5px] ${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>
)}
</Section>
</div>
);
}