/** * 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 (
{r != null && ( = 0 ? '50%' : `${50 - pct}%`, width: `${pct}%`, background: r >= 0 ? 'var(--up)' : 'var(--down)', borderRadius: r >= 0 ? '0 4px 4px 0' : '4px 0 0 4px', }} /> )}
); } /* ------------------------------------------------------------------ */ /* PriceRail — stop → entry → now → gate level laid out spatially */ /* ------------------------------------------------------------------ */ export function PriceRail({ direction, entry, stop, target, current, scaleTo, }: { direction: string; entry: number; stop: number; target: number; current: number | null; /** Extra prices the scale must span (e.g. every target in the ladder), so * switching targets moves the target marker, not stop/entry/now. */ scaleTo?: number[]; }) { const points = [entry, stop, target, ...(current != null ? [current] : []), ...(scaleTo ?? [])]; 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 (
{current != null && (
)}
stop {fmt(stop)} −1R
entry {fmt(entry)}
{current != null && (
now {fmt(current)} {rNow != null && {signedR(rNow)}R}
)}
gate {fmt(target)} {rTarget != null && +{fmt(rTarget, 1)}R}
); } /* ------------------------------------------------------------------ */ /* RadarChart — score fingerprint over n axes (0–100), hover for values */ /* ------------------------------------------------------------------ */ export interface RadarAxis { label: string; value: number; /** Longer name for the tooltip / screen reader (falls back to label). */ full?: string; } /** Canonical axis order — mirrors the backend's DIMENSIONS list * (app/services/scoring_service.py). Every radar in the app must use this so * fingerprint shapes stay comparable between the dashboard and ticker pages. */ const RADAR_DIMENSION_ORDER = ['technical', 'sr_quality', 'sentiment', 'fundamental', 'momentum']; export function radarAxesFromDimensions( dimensions: { dimension: string; score: number }[], ): RadarAxis[] { const rank = (d: string) => { const i = RADAR_DIMENSION_ORDER.indexOf(d.toLowerCase()); return i === -1 ? RADAR_DIMENSION_ORDER.length : i; }; return [...dimensions] .sort((a, b) => rank(a.dimension) - rank(b.dimension) || a.dimension.localeCompare(b.dimension)) .map((d) => ({ label: d.dimension.length > 9 ? `${d.dimension.slice(0, 8)}.` : d.dimension, full: d.dimension, value: d.score, })); } export function RadarChart({ axes, size = 120, labels = true, }: { axes: RadarAxis[]; size?: number; labels?: boolean }) { const [hover, setHover] = useState(null); const n = axes.length; if (n < 3) return null; // Label pad must fit "momentum 100" fully outside the left/right vertices. const pad = labels ? 96 : 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 (
`${a.full ?? a.label} ${Math.round(a.value)}`).join(', ')} (0–100)`} > {[50, 100].map((ring) => ( ))} {axes.map((a, i) => { const [x, y] = pt(100, i); return ; })} 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 ( ); })} {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 ( {a.label} {Math.round(a.value)} ); })} {/* hit targets on top — comfortably larger than the dots, keyboard-reachable */} {axes.map((a, i) => { const [x, y] = pt(a.value, i); return ( setHover(i)} onMouseLeave={() => setHover(null)} onFocus={() => setHover(i)} onBlur={() => setHover(null)} /> ); })} {hover !== null && tip && (
{axes[hover].full ?? axes[hover].label} {Math.round(axes[hover].value)} / 100
)}
); } /* ------------------------------------------------------------------ */ /* TradeChart — close path since entry + entry / stop trail / target */ /* ------------------------------------------------------------------ */ /** Wilder ATR series matching paper_trade_service._atr_series_from_rows. */ function wilderAtrSeries(bars: OHLCVBar[], period = 14): (number | null)[] { const n = bars.length; const out: (number | null)[] = Array(n).fill(null); if (n < period + 1) return out; const tr: number[] = Array(n).fill(0); for (let i = 1; i < n; i++) { const h = bars[i].high; const l = bars[i].low; const pc = bars[i - 1].close; tr[i] = Math.max(h - l, Math.abs(h - pc), Math.abs(l - pc)); } let running = 0; for (let i = 1; i <= period; i++) running += tr[i]; running /= period; let rounded = Math.round(running * 10000) / 10000; out[period] = rounded > 0 ? rounded : null; for (let j = period + 1; j < n; j++) { running = (running * (period - 1) + tr[j]) / period; rounded = Math.round(running * 10000) / 10000; out[j] = rounded > 0 ? rounded : null; } return out; } /** * Per-bar stop level after that bar's update — mirrors the production trail * ratchet so the chart can show a *moving* trail, not a flat line at the * current level. */ function buildStopPath( bars: OHLCVBar[], direction: string, entry: number, initStop: number, openedDate: string, mode: 'atr_trailing' | 'trailing' | 'fixed', atrMultiplier = 3, trailingPct = 12, ): number[] { const long = direction !== 'short'; const out = bars.map(() => initStop); if (mode === 'fixed') return out; let stop = initStop; let anchor = entry; const atr = mode === 'atr_trailing' ? wilderAtrSeries(bars) : null; const trailFrac = trailingPct / 100; for (let i = 0; i < bars.length; i++) { const d = bars[i].date.slice(0, 10); if (d <= openedDate) { out[i] = initStop; continue; } if (mode === 'atr_trailing' && atr) { const close = bars[i].close; if (long) { anchor = Math.max(anchor, close); const a = atr[i]; if (a != null) { const next = anchor - atrMultiplier * a; if (next < close) stop = Math.max(stop, next); } } else { anchor = Math.min(anchor, close); const a = atr[i]; if (a != null) { const next = anchor + atrMultiplier * a; if (next > close) stop = Math.min(stop, next); } } } else { // % trailing: peak on high/low, ratchet only. if (long) { anchor = Math.max(anchor, bars[i].high); stop = Math.max(initStop, anchor * (1 - trailFrac)); } else { anchor = Math.min(anchor, bars[i].low); stop = Math.min(initStop, anchor * (1 + trailFrac)); } } out[i] = stop; } return out; } /** Violet used for Gate on the ticker chart — keep the overview chart in step. */ const GATE_STROKE = 'rgba(139, 92, 246, 0.75)'; const GATE_LABEL = 'rgba(196, 181, 253, 0.95)'; export function TradeChart({ direction, entry, initialStop, target, bars, openedAt, exitMode = 'atr_trailing', atrMultiplier = 3, trailingPct = 12, currentPrice, }: { direction: string; entry: number; /** Hard stop from the setup (floor for the trail). */ initialStop: number; /** Screening gate level (not the live exit under trailing modes). */ target: number; bars: OHLCVBar[]; openedAt: string; exitMode?: 'time' | 'trailing' | 'atr_trailing' | 'target'; atrMultiplier?: number; trailingPct?: number; /** Live mark; falls back to the latest close in the window. */ currentPrice?: number | null; }) { const openedDate = openedAt.slice(0, 10); const absEntry = bars.findIndex((b) => b.date.slice(0, 10) >= openedDate); if (bars.length < 2) return null; const entryAbs = absEntry === -1 ? bars.length - 1 : absEntry; const postCount = bars.length - entryAbs; // bars from entry through latest // Fixed virtual window so a brand-new trade doesn't glue entry to the right // edge. Entry sits at the middle until the trade fills the right half; then // it wanders left as more post-entry bars arrive. const WINDOW = 21; const MID = 10; const start = postCount <= MID + 1 ? Math.max(0, entryAbs - MID) // Enough history: keep the latest WINDOW bars; entry falls where it falls. : Math.max(0, bars.length - WINDOW); // A trade older than the window entered before the first visible bar. Clamp to // the left edge — a negative index reads past the start of `series`/`stopPath` // and NaNs out the price and trail paths entirely. const entryBeforeWindow = entryAbs < start; const entryIdx = Math.max(0, entryAbs - start); const windowBars = bars.slice(start); const series = windowBars.map((b) => b.close); if (series.length < 2) return null; // Slot map: young trades keep entry near MID with empty space to the right. const lastIdx = series.length - 1; const young = postCount <= MID + 1; const slotOf = (i: number) => { if (!young) return i; return i + (MID - entryIdx); }; const maxSlot = young ? Math.max(WINDOW - 1, slotOf(lastIdx)) : lastIdx; const trailMode: 'atr_trailing' | 'trailing' | 'fixed' = exitMode === 'atr_trailing' || exitMode === 'trailing' ? exitMode : 'fixed'; const stopPathFull = buildStopPath( bars, direction, entry, initialStop, openedDate, trailMode, atrMultiplier, trailingPct, ); const stopPath = stopPathFull.slice(start); const trailActive = trailMode !== 'fixed'; // Gate is always drawn as structure (violet). Under target-exit mode it is // the real exit; otherwise it's screening context. const gateIsExit = exitMode === 'target'; const now = currentPrice != null && Number.isFinite(currentPrice) ? currentPrice : series[lastIdx]; const w = 560; const h = 150; const padL = 8; const padR = 96; const padT = 10; const padB = 12; const isShort = direction === 'short'; // Always include the gate so the top of the chart isn't "open" — it's // screening structure, same as the ticker chart's Gate line. const vals = [...series, ...stopPath, entry, initialStop, now, target]; let lo = Math.min(...vals); let hi = Math.max(...vals); const pad = (hi - lo) * 0.07 || 0.5; lo -= pad; hi += pad; const plotW = w - padL - padR; const plotH = h - padT - padB; const px = (i: number) => padL + (slotOf(i) / maxSlot) * plotW; const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH; const inProfit = (price: number) => (isShort ? price <= entry : price >= entry); // Context (pre-entry) in muted ink. const contextPath = entryIdx > 0 ? series .slice(0, entryIdx + 1) .map((v, i) => `${i === 0 ? 'M' : 'L'}${px(i).toFixed(1)},${py(v).toFixed(1)}`) .join(' ') : null; // Trade path: color each segment by whether the *end* bar is through entry // (not one color for the whole trade). const tradeSegs: { d: string; profit: boolean }[] = []; for (let i = entryIdx; i < lastIdx; i++) { const profit = inProfit(series[i + 1]); const d = `M${px(i).toFixed(1)},${py(series[i]).toFixed(1)}L${px(i + 1).toFixed(1)},${py(series[i + 1]).toFixed(1)}`; const prev = tradeSegs[tradeSegs.length - 1]; if (prev && prev.profit === profit) prev.d += `L${px(i + 1).toFixed(1)},${py(series[i + 1]).toFixed(1)}`; else tradeSegs.push({ d, profit }); } // Trail path from entry → now (stepped). Always drawn so young trades still // show a stop even before the trail has ratcheted. let trailPath = ''; if (entryIdx <= lastIdx) { trailPath = `M${px(entryIdx).toFixed(1)},${py(stopPath[entryIdx]).toFixed(1)}`; for (let i = entryIdx + 1; i <= lastIdx; i++) { trailPath += `H${px(i).toFixed(1)}V${py(stopPath[i]).toFixed(1)}`; } } const liveStop = stopPath[lastIdx] ?? initialStop; const trailMoved = Math.abs(liveStop - initialStop) > 1e-6 * Math.max(1, Math.abs(entry)); const nowProfit = inProfit(now); const nowCol = nowProfit ? 'var(--up)' : 'var(--down)'; const labelX = w - padR + 10; const entryY = py(entry); const nowY = py(now); const stopY = py(initialStop); let trailLabelY = py(liveStop); const gateY = py(target); // Nudge overlapping labels on the right rail. const labelYs: { key: string; y: number }[] = [ { key: 'entry', y: entryY }, { key: 'now', y: nowY }, { key: 'stop', y: stopY }, { key: 'gate', y: gateY }, ]; if (trailActive && trailMoved) labelYs.push({ key: 'trail', y: trailLabelY }); labelYs.sort((a, b) => a.y - b.y); for (let i = 1; i < labelYs.length; i++) { if (labelYs[i].y - labelYs[i - 1].y < 11) { labelYs[i].y = labelYs[i - 1].y + 11; } } const ly = Object.fromEntries(labelYs.map((l) => [l.key, l.y])); if (ly.trail != null) trailLabelY = ly.trail; return ( {/* Full-width hard stop — always visible, including for day-0 trades. */} stop {fmt(initialStop)} {/* Gate (screening level) — violet, matches ticker chart */} gate {fmt(target)} {/* Entry */} entry {fmt(entry)} {/* Now (current price) */} now {fmt(now)} {/* Trail path from entry (stepped) — only once it has ratcheted off the hard stop */} {trailActive && trailMoved && trailPath && ( )} {trailActive && trailMoved && ( trail {fmt(liveStop)} )} {contextPath && ( )} {tradeSegs.map((s, i) => ( ))} {/* Single-bar trade: no segment yet — mark entry→now with a short stem if needed */} {entryIdx === lastIdx && ( )} {/* Entry marker only when the entry bar is actually in the window — for an older trade the entry level line carries it instead. */} {!entryBeforeWindow && ( )} ); }