Files
signal-platform/frontend/src/components/charts/horizon.tsx
T
dennisthiessenandClaude Opus 5 07d864cf64
Deploy / lint (push) Successful in 10s
Deploy / test (push) Successful in 1m20s
Deploy / deploy (push) Successful in 39s
fix: draw the trade chart for positions older than the window
TradeChart shows a fixed 21-bar window. Once a trade is older than that,
its entry bar precedes the window and entryIdx goes negative, so the price
and trail paths index past the start of series/stopPath and emit NaN
coordinates -- the browser then drops both paths entirely, leaving only the
horizontal level lines. Clamp the index to the left edge and drop the entry
marker when the entry bar is outside the window; the full-width entry line
already carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 21:12:32 +02:00

612 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 → 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 (
<div className="hz-rail" role="img" aria-label={
`Stop ${fmt(stop)}, entry ${fmt(entry)}, now ${current != null ? fmt(current) : 'unknown'}, gate ${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)}%` }} title="Gate level — screening only, not a take-profit">
<span className="hz-rail-ring" />
<span className="hz-rail-label">
<em>gate</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;
}
/** 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<number | null>(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 (
<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 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 (
<svg
viewBox={`0 0 ${w} ${h}`}
className="hz-tradechart"
role="img"
aria-label={
`Price since entry ${fmt(entry)}, now ${fmt(now)}, stop ${fmt(initialStop)}`
+ (trailMoved ? `, trail ${fmt(liveStop)}` : '')
+ `, gate ${fmt(target)}`
}
>
{/* Full-width hard stop — always visible, including for day-0 trades. */}
<line
x1={padL}
x2={w - padR}
y1={stopY}
y2={stopY}
stroke="var(--down)"
strokeWidth="1.25"
opacity="0.55"
/>
<text x={labelX} y={(ly.stop ?? stopY) + 3.5} className="hz-lvl hz-lvl-down">
stop {fmt(initialStop)}
</text>
{/* Gate (screening level) — violet, matches ticker chart */}
<line
x1={padL}
x2={w - padR}
y1={py(target)}
y2={py(target)}
stroke={GATE_STROKE}
strokeWidth="1.25"
strokeDasharray={gateIsExit ? undefined : '4 3'}
opacity="0.85"
/>
<text
x={labelX}
y={(ly.gate ?? gateY) + 3.5}
className="hz-lvl"
style={{ fill: GATE_LABEL }}
>
gate {fmt(target)}
</text>
{/* Entry */}
<line x1={padL} x2={w - padR} y1={entryY} y2={entryY} stroke="var(--ink-3)" strokeWidth="1" opacity="0.5" />
<text x={labelX} y={(ly.entry ?? entryY) + 3.5} className="hz-lvl">entry {fmt(entry)}</text>
{/* Now (current price) */}
<line
x1={padL}
x2={w - padR}
y1={nowY}
y2={nowY}
stroke={nowCol}
strokeWidth="1"
opacity="0.45"
/>
<text
x={labelX}
y={(ly.now ?? nowY) + 3.5}
className="hz-lvl"
style={{ fill: nowProfit ? 'var(--up-text)' : 'var(--down-text)' }}
>
now {fmt(now)}
</text>
{/* Trail path from entry (stepped) — only once it has ratcheted off the hard stop */}
{trailActive && trailMoved && trailPath && (
<path
d={trailPath}
fill="none"
stroke="var(--down)"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
opacity="0.95"
/>
)}
{trailActive && trailMoved && (
<text x={labelX} y={trailLabelY + 3.5} className="hz-lvl hz-lvl-down">
trail {fmt(liveStop)}
</text>
)}
{contextPath && (
<path d={contextPath} fill="none" stroke="var(--ink-3)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" opacity="0.65" />
)}
{tradeSegs.map((s, i) => (
<path
key={i}
d={s.d}
fill="none"
stroke={s.profit ? 'var(--up)' : 'var(--down)'}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
/>
))}
{/* Single-bar trade: no segment yet — mark entry→now with a short stem if needed */}
{entryIdx === lastIdx && (
<circle cx={px(entryIdx)} cy={py(series[entryIdx])} r="2" fill="var(--ink-3)" />
)}
{/* Entry marker only when the entry bar is actually in the window — for an
older trade the entry level line carries it instead. */}
{!entryBeforeWindow && (
<circle cx={px(entryIdx)} cy={py(entry)} r="3.5" fill="var(--ink-2)" stroke="var(--surface)" strokeWidth="1.5" />
)}
<circle cx={px(lastIdx)} cy={py(series[lastIdx])} r="4" fill={nowCol} stroke="var(--surface)" strokeWidth="2" />
</svg>
);
}