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
@@ -160,7 +160,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const nTicks = 6;
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
ctx.fillStyle = '#6b7280';
ctx.fillStyle = '#6e7484';
ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'right';
for (let i = 0; i <= nTicks; i++) {
@@ -178,7 +178,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const labelInterval = Math.max(Math.floor(visibleData.length / 8), 1);
for (let i = 0; i < visibleData.length; i += labelInterval) {
const x = ml + i * barW + barW / 2;
ctx.fillStyle = '#6b7280';
ctx.fillStyle = '#6e7484';
ctx.fillText(formatDate(visibleData[i].date), x, H - 6);
}
@@ -195,7 +195,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.stroke();
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.fillStyle = '#6b7280';
ctx.fillStyle = '#6e7484';
ctx.textAlign = 'left';
ctx.fillText('Volume', ml, volumeTop - 13);
ctx.textAlign = 'right';
@@ -206,20 +206,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const bullish = bar.close >= bar.open;
const yVolume = volumeScale(bar.volume);
const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0);
ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)';
ctx.fillStyle = bullish ? 'rgba(47, 157, 178, 0.32)' : 'rgba(227, 106, 88, 0.28)';
ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume);
});
// Nearest support/resistance only (band if it came from a zone)
markers.forEach((m) => {
const isSupport = m.role === 'support';
const color = isSupport ? '#10b981' : '#ef4444';
// Support = rim-cyan, resistance = neutral ink — rose stays reserved for
// the stop level so S/R never impersonates trade levels.
const color = isSupport ? '#2f9db2' : '#9aa0b0';
const yMid = yScale(m.price);
if (m.high > m.low) {
const yTop = yScale(m.high);
const rectHeight = Math.max(yScale(m.low) - yTop, 2);
ctx.fillStyle = isSupport ? 'rgba(16, 185, 129, 0.12)' : 'rgba(239, 68, 68, 0.12)';
ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(154, 160, 176, 0.10)';
ctx.fillRect(ml, yTop, cw, rectHeight);
}
@@ -253,10 +255,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Stop-loss zone: red semi-transparent rectangle between entry and stop-loss
const slTop = Math.min(entryY, stopY);
const slHeight = Math.max(Math.abs(stopY - entryY), 1);
ctx.fillStyle = 'rgba(239, 68, 68, 0.13)';
ctx.fillStyle = 'rgba(227, 106, 88, 0.13)';
ctx.fillRect(ml, slTop, cw, slHeight);
// Stop-loss border
ctx.strokeStyle = 'rgba(239, 68, 68, 0.4)';
ctx.strokeStyle = 'rgba(227, 106, 88, 0.45)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
@@ -268,10 +270,10 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Take-profit zone: green semi-transparent rectangle between entry and target
const tpTop = Math.min(entryY, targetY);
const tpHeight = Math.max(Math.abs(targetY - entryY), 1);
ctx.fillStyle = 'rgba(16, 185, 129, 0.13)';
ctx.fillStyle = 'rgba(47, 157, 178, 0.13)';
ctx.fillRect(ml, tpTop, cw, tpHeight);
// Target border
ctx.strokeStyle = 'rgba(16, 185, 129, 0.4)';
ctx.strokeStyle = 'rgba(47, 157, 178, 0.45)';
ctx.lineWidth = 1;
ctx.setLineDash([4, 3]);
ctx.beginPath();
@@ -280,8 +282,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
ctx.stroke();
ctx.setLineDash([]);
// Entry price: dashed horizontal line (blue/white)
ctx.strokeStyle = 'rgba(96, 165, 250, 0.9)';
// Entry price: dashed horizontal line (neutral ink)
ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)';
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 4]);
ctx.beginPath();
@@ -293,11 +295,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
// Labels on right side
ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace';
ctx.textAlign = 'left';
ctx.fillStyle = 'rgba(96, 165, 250, 0.9)';
ctx.fillStyle = 'rgba(154, 160, 176, 0.95)';
ctx.fillText(`Entry ${formatPrice(tradeSetup.entry_price)}`, ml + cw + 4, entryY + 3);
ctx.fillStyle = 'rgba(239, 68, 68, 0.8)';
ctx.fillStyle = 'rgba(239, 145, 130, 0.9)';
ctx.fillText(`SL ${formatPrice(tradeSetup.stop_loss)}`, ml + cw + 4, stopY + 3);
ctx.fillStyle = 'rgba(16, 185, 129, 0.8)';
ctx.fillStyle = 'rgba(110, 201, 219, 0.9)';
ctx.fillText(`TP ${formatPrice(tradeSetup.target)}`, ml + cw + 4, targetY + 3);
}
@@ -316,7 +318,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const tw = ctx.measureText(label).width;
ctx.fillStyle = 'rgba(226, 232, 240, 0.95)';
ctx.fillRect(ml + 2, py - 8, tw + 8, 16);
ctx.fillStyle = '#0e120f';
ctx.fillStyle = '#0a0b11';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(label, ml + 6, py);
@@ -327,7 +329,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
visibleData.forEach((bar, i) => {
const x = ml + i * barW + barW / 2;
const bullish = bar.close >= bar.open;
const color = bullish ? '#10b981' : '#ef4444';
const color = bullish ? '#2f9db2' : '#e36a58';
const yHigh = yScale(bar.high);
const yLow = yScale(bar.low);
@@ -441,11 +443,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const labelX = ml + cw + 2;
const labelY = cy - labelH / 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
ctx.beginPath();
ctx.roundRect(labelX, labelY, labelW, labelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.fillStyle = '#edeef3';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(priceText, labelX + labelPadX, cy);
@@ -462,11 +464,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
const dateLabelX = cx - dateLabelW / 2;
const dateLabelY = H - mb + 2;
ctx.fillStyle = 'rgba(55, 65, 81, 0.9)';
ctx.fillStyle = 'rgba(35, 39, 51, 0.95)';
ctx.beginPath();
ctx.roundRect(dateLabelX, dateLabelY, dateLabelW, dateLabelH, 3);
ctx.fill();
ctx.fillStyle = '#e5e7eb';
ctx.fillStyle = '#edeef3';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(dateText, cx, dateLabelY + dateLabelH / 2);
@@ -667,8 +669,8 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup,
<div class="grid grid-cols-2 gap-x-3 gap-y-0.5 text-gray-400">
<span>Direction</span><span class="text-right text-gray-200">${tradeSetup.direction}</span>
<span>Entry</span><span class="text-right text-blue-300">${formatPrice(tradeSetup.entry_price)}</span>
<span>Stop</span><span class="text-right text-red-400">${formatPrice(tradeSetup.stop_loss)}</span>
<span>Target</span><span class="text-right text-green-400">${formatPrice(tradeSetup.target)}</span>
<span>Stop</span><span class="text-right text-red-300">${formatPrice(tradeSetup.stop_loss)}</span>
<span>Target</span><span class="text-right text-emerald-300">${formatPrice(tradeSetup.target)}</span>
<span>R:R</span><span class="text-right text-gray-200">${tradeSetup.rr_ratio.toFixed(2)}</span>
</div>`;
}
+295
View File
@@ -0,0 +1,295 @@
/**
* Horizon chart primitives shared by the dashboard and ticker page.
* All colors come from the :root tokens in globals.css; the data marks
* (--up / --down) are validated against the dark surface.
*/
import { useState } from 'react';
import type { OHLCVBar } from '../../lib/types';
function fmt(n: number, digits = 2): string {
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
}
function signedR(r: number): string {
return `${r > 0 ? '+' : r < 0 ? '' : ''}${fmt(Math.abs(r))}`;
}
/* ------------------------------------------------------------------ */
/* RBar — diverging R-multiple bar growing from a centered zero line */
/* ------------------------------------------------------------------ */
export function RBar({ r, max = 1.6 }: { r: number | null; max?: number }) {
const pct = r == null ? 0 : Math.min(Math.abs(r) / Math.max(max, 0.01), 1) * 50;
return (
<div className="hz-rbar" role="img" aria-label={r == null ? 'no R value' : `${signedR(r)} R`}>
<span className="hz-rbar-zero" />
{r != null && (
<span
className="hz-rbar-fill"
style={{
left: r >= 0 ? '50%' : `${50 - pct}%`,
width: `${pct}%`,
background: r >= 0 ? 'var(--up)' : 'var(--down)',
borderRadius: r >= 0 ? '0 4px 4px 0' : '4px 0 0 4px',
}}
/>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* PriceRail — stop → entry → now → target laid out spatially */
/* ------------------------------------------------------------------ */
export function PriceRail({
direction, entry, stop, target, current,
}: {
direction: string;
entry: number;
stop: number;
target: number;
current: number | null;
}) {
const points = [entry, stop, target, ...(current != null ? [current] : [])];
const span = Math.max(...points) - Math.min(...points) || 1;
const lo = Math.min(...points) - span * 0.06;
const hi = Math.max(...points) + span * 0.06;
const pct = (v: number) => Math.min(100, Math.max(0, ((v - lo) / (hi - lo)) * 100));
const risk = Math.abs(entry - stop);
const rNow = current != null && risk > 0
? (direction === 'long' ? current - entry : entry - current) / risk
: null;
const rTarget = risk > 0 ? Math.abs(target - entry) / risk : null;
const inProfit = rNow != null && rNow >= 0;
const progressLeft = current != null ? Math.min(pct(entry), pct(current)) : 0;
const progressWidth = current != null ? Math.abs(pct(current) - pct(entry)) : 0;
return (
<div className="hz-rail" role="img" aria-label={
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, target ${fmt(target)}`
}>
<div className="hz-rail-track" />
<div
className="hz-rail-risk"
style={{ left: `${Math.min(pct(stop), pct(entry))}%`, width: `${Math.abs(pct(entry) - pct(stop))}%` }}
/>
{current != null && (
<div
className="hz-rail-progress"
style={{
left: `${progressLeft}%`,
width: `${progressWidth}%`,
background: inProfit ? 'var(--up)' : 'var(--down)',
}}
/>
)}
<div className="hz-rail-mark" style={{ left: `${pct(stop)}%` }}>
<span className="hz-rail-tick" style={{ background: 'var(--down)' }} />
<span className="hz-rail-label">
<em>stop</em>
<b>{fmt(stop)}</b>
<i>1R</i>
</span>
</div>
<div className="hz-rail-mark" style={{ left: `${pct(entry)}%` }}>
<span className="hz-rail-tick" style={{ background: 'var(--ink-2)' }} />
<span className="hz-rail-label">
<em>entry</em>
<b>{fmt(entry)}</b>
</span>
</div>
{current != null && (
<div className="hz-rail-mark" style={{ left: `${pct(current)}%` }}>
<span className="hz-rail-dot" style={{ background: inProfit ? 'var(--up)' : 'var(--down)' }} />
<span className="hz-rail-label hz-rail-label-top">
<em>now</em>
<b style={{ color: inProfit ? 'var(--up-text)' : 'var(--down-text)' }}>{fmt(current)}</b>
{rNow != null && <i>{signedR(rNow)}R</i>}
</span>
</div>
)}
<div className="hz-rail-mark" style={{ left: `${pct(target)}%` }}>
<span className="hz-rail-ring" />
<span className="hz-rail-label">
<em>target</em>
<b>{fmt(target)}</b>
{rTarget != null && <i>+{fmt(rTarget, 1)}R</i>}
</span>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* RadarChart — score fingerprint over n axes (0100), hover for values */
/* ------------------------------------------------------------------ */
export interface RadarAxis {
label: string;
value: number;
/** Longer name for the tooltip / screen reader (falls back to label). */
full?: string;
}
export function RadarChart({
axes, size = 120, labels = true,
}: { axes: RadarAxis[]; size?: number; labels?: boolean }) {
const [hover, setHover] = useState<number | null>(null);
const n = axes.length;
if (n < 3) return null;
const pad = labels ? 74 : 10;
const vb = size + pad * 2;
const c = vb / 2;
const r = size / 2;
const angle = (i: number) => ((-90 + (i * 360) / n) * Math.PI) / 180;
const pt = (v: number, i: number): [number, number] => {
const a = angle(i);
return [c + (Math.cos(a) * r * Math.max(0, Math.min(100, v))) / 100, c + (Math.sin(a) * r * Math.max(0, Math.min(100, v))) / 100];
};
const poly = (vals: number[]) =>
vals.map((v, i) => pt(v, i).map((x) => x.toFixed(1)).join(',')).join(' ');
const tip = hover !== null ? pt(axes[hover].value, hover) : null;
return (
<div className="hz-radar-wrap" style={{ width: vb, height: vb }}>
<svg
viewBox={`0 0 ${vb} ${vb}`}
width={vb}
height={vb}
role="img"
aria-label={`Score fingerprint: ${axes.map((a) => `${a.full ?? a.label} ${Math.round(a.value)}`).join(', ')} (0100)`}
>
{[50, 100].map((ring) => (
<polygon key={ring} points={poly(Array(n).fill(ring))} fill="none" stroke="var(--grid)" strokeWidth="1" />
))}
{axes.map((a, i) => {
const [x, y] = pt(100, i);
return <line key={a.label} x1={c} y1={c} x2={x} y2={y} stroke="var(--grid)" strokeWidth="1" />;
})}
<polygon
points={poly(axes.map((a) => a.value))}
fill="var(--up)" fillOpacity="0.13"
stroke="var(--up)" strokeWidth="2" strokeLinejoin="round"
/>
{axes.map((a, i) => {
const [x, y] = pt(a.value, i);
return (
<circle
key={a.label} cx={x} cy={y} r={hover === i ? 4.5 : 3}
fill="var(--up)" stroke="var(--surface)" strokeWidth="1.5"
/>
);
})}
{labels && axes.map((a, i) => {
const ang = angle(i);
const x = c + Math.cos(ang) * (r + 13);
const y = c + Math.sin(ang) * (r + 13);
const anchor = Math.cos(ang) > 0.3 ? 'start' : Math.cos(ang) < -0.3 ? 'end' : 'middle';
const dy = Math.sin(ang) < -0.3 ? -2 : Math.sin(ang) > 0.3 ? 9 : 4;
return (
<text key={a.label} x={x} y={y + dy} textAnchor={anchor} className="hz-radar-axis">
{a.label} <tspan className="hz-radar-axisval">{Math.round(a.value)}</tspan>
</text>
);
})}
{/* hit targets on top — comfortably larger than the dots, keyboard-reachable */}
{axes.map((a, i) => {
const [x, y] = pt(a.value, i);
return (
<circle
key={`hit-${a.label}`} cx={x} cy={y} r="13"
fill="transparent" style={{ cursor: 'default', outline: 'none' }}
tabIndex={0}
aria-label={`${a.full ?? a.label}: ${Math.round(a.value)} of 100`}
onMouseEnter={() => setHover(i)}
onMouseLeave={() => setHover(null)}
onFocus={() => setHover(i)}
onBlur={() => setHover(null)}
/>
);
})}
</svg>
{hover !== null && tip && (
<div
className="hz-radar-tip"
style={{ left: `${(tip[0] / vb) * 100}%`, top: `${(tip[1] / vb) * 100}%` }}
>
<b>{axes[hover].full ?? axes[hover].label}</b>
<span className="num">{Math.round(axes[hover].value)} / 100</span>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* TradeChart — close path since entry + entry / stop / target levels */
/* ------------------------------------------------------------------ */
export function TradeChart({
direction, entry, stop, target, bars, openedAt,
}: {
direction: string;
entry: number;
/** Current stop level (trailing stop if active, else the initial stop). */
stop: number;
target: number;
bars: OHLCVBar[];
openedAt: string;
}) {
const openedDate = openedAt.slice(0, 10);
const closes = bars
.filter((b) => b.date.slice(0, 10) >= openedDate)
.map((b) => b.close);
const series = [entry, ...closes];
if (series.length < 3) return null;
const w = 560; const h = 150;
const padL = 8; const padR = 96; const padT = 10; const padB = 12;
const isShort = direction === 'short';
const vals = [...series, stop, entry];
let lo = Math.min(...vals);
let hi = Math.max(...vals);
const range = hi - lo || 1;
// Only stretch the scale to the target when it doesn't flatten the price action.
const targetFits = isShort ? lo - target <= range * 0.9 : target - hi <= range * 0.9;
if (targetFits) { lo = Math.min(lo, target); hi = Math.max(hi, target); }
const pad = (hi - lo) * 0.07;
lo -= pad; hi += pad;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
const px = (i: number) => padL + (i / (series.length - 1)) * plotW;
const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH;
const path = series.map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`).join(' ');
const last = series[series.length - 1];
const perShare = isShort ? entry - last : last - entry;
const col = perShare >= 0 ? 'var(--up)' : 'var(--down)';
const labelX = w - padR + 10;
const entryY = py(entry);
let stopY = py(stop);
if (Math.abs(stopY - entryY) < 11) stopY = entryY + (stopY >= entryY ? 11 : -11);
return (
<svg
viewBox={`0 0 ${w} ${h}`}
className="hz-tradechart"
role="img"
aria-label={`Price since entry ${fmt(entry)}, now ${fmt(last)}, stop ${fmt(stop)}, target ${fmt(target)}`}
>
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
<text x={labelX} y={entryY + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
<line x1={padL} x2={w - padR} y1={py(stop)} y2={py(stop)} stroke="var(--down)" strokeWidth="1.5" opacity="0.85" />
<text x={labelX} y={stopY + 3.5} className="hz-lvl hz-lvl-down">stop {fmt(stop)}</text>
{targetFits ? (
<>
<line x1={padL} x2={w - padR} y1={py(target)} y2={py(target)} stroke="var(--up)" strokeWidth="1" opacity="0.6" />
<text x={labelX} y={py(target) + 3.5} className="hz-lvl hz-lvl-up">target {fmt(target)}</text>
</>
) : (
<text x={labelX} y={isShort ? h - padB : padT + 4} className="hz-lvl hz-lvl-up">
target {fmt(target)} {isShort ? '↓' : '↑'}
</text>
)}
<path d={path} fill="none" stroke={col} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
<circle cx={px(series.length - 1)} cy={py(last)} r="4" fill={col} stroke="var(--surface)" strokeWidth="2" />
</svg>
);
}
@@ -1,170 +1,249 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import type { KeyboardEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { usePaperTrades, useClosePaperTrade, useExitPolicy } from '../../hooks/usePaperTrades';
import { useTickerNames } from '../../hooks/useTickers';
import { getOHLCV } from '../../api/ohlcv';
import { tradePnl } from '../../lib/paperTrade';
import { formatPrice } from '../../lib/format';
import { RBar, TradeChart } from '../charts/horizon';
import { Section } from '../ui/Section';
import { Callout } from '../ui/Callout';
import type { PaperTrade } from '../../lib/types';
function money(v: number): string {
const sign = v >= 0 ? '+' : '';
return `${sign}$${Math.abs(v).toFixed(2)}`;
}
function pnlColor(v: number): string {
if (v > 0) return 'text-emerald-400';
if (v < 0) return 'text-red-400';
if (v > 0) return 'text-emerald-300';
if (v < 0) return 'text-red-300';
return 'text-gray-300';
}
function DirTag({ 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>
);
}
function Detail({ label, value, valueClass = 'text-gray-100' }: {
label: string;
value: ReactNode;
valueClass?: string;
}) {
return (
<div>
<dt className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">{label}</dt>
<dd className={`num mt-0.5 text-sm ${valueClass}`}>{value}</dd>
</div>
);
}
/** Expanded row: full trade detail + price chart with entry/stop/target levels. */
function TradeDetail({ trade, exitLabel, onClose, closing }: {
trade: PaperTrade;
exitLabel: string | null;
onClose: () => void;
closing: boolean;
}) {
const p = tradePnl(trade);
const stopLevel = trade.trailing_stop ?? trade.stop_loss;
const ohlcv = useQuery({
queryKey: ['ohlcv', trade.symbol],
queryFn: () => getOHLCV(trade.symbol),
staleTime: 5 * 60_000,
});
const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
return (
<div className="flex flex-col gap-4 px-2 pb-4 pt-1">
<dl className="grid grid-cols-2 gap-x-8 gap-y-3 sm:grid-cols-4">
<Detail label="opened" value={`${opened} · ${trade.shares} shares`} />
<Detail label="entry → now" value={
`${formatPrice(trade.entry_price)}${trade.current_price != null ? formatPrice(trade.current_price) : '—'}`
} />
<Detail
label="P&L"
value={p ? `${money(p.pnl)} · ${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
valueClass={p ? pnlColor(p.pnl) : 'text-gray-500'}
/>
<Detail
label="alpha vs SPY"
value={
trade.alpha_pct != null
? `${trade.alpha_pct >= 0 ? '+' : ''}${trade.alpha_pct.toFixed(1)}%${trade.alpha_usd != null ? ` · ${money(trade.alpha_usd)}` : ''}`
: '—'
}
valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'}
/>
<Detail
label="stop"
value={
<>
{formatPrice(stopLevel)}
{trade.trailing_stop != null && (
<span className="ml-1.5 text-[10px] text-gray-500">
trailing{trade.trailing_distance_pct != null ? ` · ${Math.abs(trade.trailing_distance_pct).toFixed(1)}% away` : ''}
</span>
)}
</>
}
/>
<Detail label="target" value={formatPrice(trade.target)} />
<Detail label="exit rule" value={exitLabel ?? 'target/stop'} />
<div className="flex items-end">
<button
onClick={onClose}
disabled={closing}
className="rounded-md border border-white/[0.1] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
Sell at market
</button>
</div>
</dl>
{ohlcv.data && (
<div>
<p className="num text-[9.5px] uppercase tracking-[0.16em] text-gray-500">
since entry · entry / stop / target
</p>
<div className="mt-2">
<TradeChart
direction={trade.direction}
entry={trade.entry_price}
stop={stopLevel}
target={trade.target}
bars={ohlcv.data}
openedAt={trade.opened_at}
/>
</div>
</div>
)}
</div>
);
}
export function OpenTradesPanel() {
const { data: trades, isLoading } = usePaperTrades('open');
const { data: policy } = useExitPolicy();
const tickerNames = useTickerNames();
const close = useClosePaperTrade();
const [expandedId, setExpandedId] = useState<number | null>(null);
const exitLabel = policy
? policy.mode === 'atr_trailing'
? `auto-exit: ${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trail / ${policy.hold_days}d max`
? `${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max`
: policy.mode === 'trailing'
? `auto-exit: trailing ${Math.round(policy.trailing_pct)}%`
? `trailing ${Math.round(policy.trailing_pct)}%`
: policy.mode === 'time'
? `auto-exit: ${policy.hold_days}d hold`
: 'auto-exit: target/stop'
? `${policy.hold_days}d hold`
: 'target/stop'
: null;
const totals = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, priced = 0, alphaUsd = 0, alphaPriced = 0;
for (const t of trades ?? []) {
const rows = trades ?? [];
const { totals, rMax } = useMemo(() => {
let pnl = 0, winners = 0, losers = 0, alphaUsd = 0, alphaPriced = 0, rMax = 1;
for (const t of rows) {
const p = tradePnl(t);
if (p) {
priced += 1;
pnl += p.pnl;
if (p.pnl > 0) winners += 1;
else if (p.pnl < 0) losers += 1;
if (p.r != null) rMax = Math.max(rMax, Math.abs(p.r));
}
if (t.alpha_usd != null) {
alphaUsd += t.alpha_usd;
alphaPriced += 1;
}
if (t.alpha_usd != null) { alphaUsd += t.alpha_usd; alphaPriced += 1; }
}
return { pnl, winners, losers, priced, alphaUsd, alphaPriced };
}, [trades]);
return { totals: { pnl, winners, losers, alphaUsd, alphaPriced }, rMax };
}, [rows]);
if (isLoading) return null;
const rows = trades ?? [];
return (
<Section
title="Open Trades"
hint={rows.length > 0 ? `${rows.length} open · ${totals.winners}${totals.losers}${exitLabel ? ` · ${exitLabel}` : ''}` : 'paper trading'}
title="Open Positions"
hint={rows.length > 0
? `${rows.length} open · ${totals.winners}${totals.losers}▼ · R-multiple to entry · select a row for detail`
: 'paper trading'}
>
{rows.length === 0 ? (
<Callout variant="empty">
No open paper trades. Open a ticker and tap Mark as taken on a setup to start.
</Callout>
) : (
<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">Shares</th>
<th className="px-4 py-3 text-right">Entry</th>
<th className="px-4 py-3 text-right">Now</th>
<th className="px-4 py-3 text-right">P&L</th>
<th className="px-4 py-3 text-right">%</th>
<th className="px-4 py-3 text-right">R</th>
<th className="px-4 py-3 text-right">Alpha</th>
<th className="px-4 py-3 text-right">Trail Stop</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{rows.map((t) => {
const p = tradePnl(t);
return (
<tr key={t.id} className="border-b border-white/[0.04] hover:bg-white/[0.03]">
<td className="px-4 py-3">
<Link to={`/ticker/${t.symbol}`} className="font-medium text-blue-300 hover:text-blue-200">
<div className="glass px-4 py-1">
<ul className="divide-y divide-white/[0.04]">
{rows.map((t) => {
const p = tradePnl(t);
const open = expandedId === t.id;
return (
<li key={t.id}>
<div
role="button"
tabIndex={0}
onClick={() => setExpandedId(open ? null : t.id)}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpandedId(open ? null : t.id);
}
}}
aria-expanded={open}
className="grid w-full cursor-pointer grid-cols-[110px_1fr_60px_16px] items-center gap-3 rounded-lg px-2 py-2.5 text-left transition-colors hover:bg-white/[0.03] sm:grid-cols-[130px_150px_1fr_70px_16px]"
>
<span className="flex items-center gap-2">
<Link
to={`/ticker/${t.symbol}`}
onClick={(e) => e.stopPropagation()}
className="font-medium text-blue-300 transition-colors hover:text-blue-200"
>
{t.symbol}
</Link>
{tickerNames.get(t.symbol.toUpperCase()) && (
<div className="max-w-[150px] truncate text-[11px] text-gray-500">
{tickerNames.get(t.symbol.toUpperCase())}
</div>
)}
</td>
<td className="px-4 py-3">
<span className={`num text-[10px] font-semibold uppercase ${t.direction === 'long' ? 'text-emerald-400' : 'text-red-400'}`}>
{t.direction}
</span>
</td>
<td className="num px-4 py-3 text-right text-gray-300">{t.shares}</td>
<td className="num px-4 py-3 text-right text-gray-300">{formatPrice(t.entry_price)}</td>
<td className="num px-4 py-3 text-right text-gray-200">
{t.current_price != null ? formatPrice(t.current_price) : '—'}
</td>
<td className={`num px-4 py-3 text-right font-semibold ${p ? pnlColor(p.pnl) : 'text-gray-500'}`}>
{p ? money(p.pnl) : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p ? pnlColor(p.pct) : 'text-gray-500'}`}>
{p ? `${p.pct >= 0 ? '+' : ''}${p.pct.toFixed(1)}%` : '—'}
</td>
<td className={`num px-4 py-3 text-right ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
<DirTag direction={t.direction} />
</span>
<span className="num hidden text-xs text-gray-400 sm:block">
{formatPrice(t.entry_price)} {t.current_price != null ? formatPrice(t.current_price) : '—'}
</span>
<RBar r={p?.r ?? null} max={rMax} />
<span className={`num text-right text-[13px] font-semibold ${p?.r != null ? pnlColor(p.r) : 'text-gray-500'}`}>
{p?.r != null ? `${p.r >= 0 ? '+' : ''}${p.r.toFixed(2)}R` : '—'}
</td>
<td className={`num px-4 py-3 text-right ${t.alpha_pct != null ? pnlColor(t.alpha_pct) : 'text-gray-500'}`} title="Return vs. S&P 500 over the holding period">
{t.alpha_pct != null ? `${t.alpha_pct >= 0 ? '+' : ''}${t.alpha_pct.toFixed(1)}%` : '—'}
</td>
<td className="num px-4 py-3 text-right text-gray-300" title="Current trailing-stop level · how far below the price">
{t.trailing_stop != null ? (
<>
{formatPrice(t.trailing_stop)}
{t.trailing_distance_pct != null && (
<span className="ml-1 text-[10px] text-gray-500">
{Math.abs(t.trailing_distance_pct).toFixed(1)}%
</span>
)}
</>
) : (
<span className="text-gray-500"></span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
close.mutate({ id: t.id });
}
}}
disabled={close.isPending}
className="rounded-md border border-white/[0.1] px-2.5 py-1 text-xs text-gray-300 transition-colors hover:bg-white/[0.06] hover:text-white disabled:opacity-50"
>
Sell
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08]">
<td className="px-4 py-2.5 text-xs text-gray-500" colSpan={5}>
Total unrealized P&L · alpha vs S&P 500
</td>
<td className={`num px-4 py-2.5 text-right font-semibold ${pnlColor(totals.pnl)}`}>
{money(totals.pnl)}
</td>
<td colSpan={2} />
<td className={`num px-4 py-2.5 text-right font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</span>
<span className={`text-[10px] text-gray-500 transition-transform ${open ? 'rotate-180' : ''}`} aria-hidden="true">
</span>
</div>
{open && (
<TradeDetail
trade={t}
exitLabel={exitLabel}
closing={close.isPending}
onClose={() => {
if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {
close.mutate({ id: t.id });
}
}}
/>
)}
{/* company name for screen readers / row context */}
<span className="sr-only">{tickerNames.get(t.symbol.toUpperCase()) ?? ''}</span>
</li>
);
})}
</ul>
<div className="flex flex-wrap items-baseline justify-between gap-2 border-t border-white/[0.06] px-2 py-2.5 text-xs text-gray-500">
<span>total unrealized · alpha vs S&P 500{exitLabel ? ` · auto-exit: ${exitLabel}` : ''}</span>
<span className="num flex gap-4">
<b className={`font-semibold ${pnlColor(totals.pnl)}`}>{money(totals.pnl)}</b>
<b className={`font-semibold ${totals.alphaPriced > 0 ? pnlColor(totals.alphaUsd) : 'text-gray-500'}`}>
{totals.alphaPriced > 0 ? money(totals.alphaUsd) : '—'}
</b>
</span>
</div>
</div>
)}
</Section>
@@ -5,6 +5,10 @@ import MobileNav from './MobileNav';
export default function AppShell() {
return (
<div className="flex min-h-screen text-gray-100">
<div className="app-horizon" aria-hidden="true">
<div className="app-horizon-rim" />
<div className="app-horizon-planet" />
</div>
<Sidebar />
<div className="flex-1 flex flex-col">
<MobileNav />
+16 -3
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { DimensionBreakdownPanel } from '../ticker/DimensionBreakdownPanel';
import { RadarChart } from '../charts/horizon';
import type { DimensionScoreDetail, CompositeBreakdown } from '../../lib/types';
interface ScoreCardProps {
@@ -18,9 +19,9 @@ function scoreColor(score: number): string {
}
function ringGradient(score: number): string {
if (score > 70) return '#10b981';
if (score > 70) return '#2f9db2';
if (score >= 40) return '#f59e0b';
return '#ef4444';
return '#e36a58';
}
function barGradient(score: number): string {
@@ -64,7 +65,7 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
return (
<div className="glass p-5">
{showComposite && (
<div className="flex items-center gap-4">
<div className="flex flex-wrap items-center gap-4">
{compositeScore !== null ? (
<ScoreRing score={compositeScore} />
) : (
@@ -87,6 +88,18 @@ export function ScoreCard({ compositeScore, dimensions, compositeBreakdown, show
</p>
)}
</div>
{dimensions.length >= 3 && (
<div className="ml-auto" title="Score fingerprint — hover a corner for the exact value">
<RadarChart
axes={dimensions.map((d) => ({
label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension,
full: d.dimension,
value: d.score,
}))}
size={104}
/>
</div>
)}
</div>
)}