/**
* 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 && (
)}
);
}