diff --git a/frontend/src/components/charts/horizon.tsx b/frontend/src/components/charts/horizon.tsx index 363d8b8..1cbc07c 100644 --- a/frontend/src/components/charts/horizon.tsx +++ b/frontend/src/components/charts/horizon.tsx @@ -337,6 +337,10 @@ function buildStopPath( 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, @@ -347,31 +351,56 @@ export function TradeChart({ 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 firstIdx = bars.findIndex((b) => b.date.slice(0, 10) >= openedDate); - // Fresh trades have few bars since entry — pad with context so the chart - // still reads (gray before entry, colored after). - const CONTEXT_BARS = 10; - const start = firstIdx === -1 - ? Math.max(0, bars.length - CONTEXT_BARS) - : Math.max(0, firstIdx - CONTEXT_BARS); - const window = bars.slice(start); - const series = window.map((b) => b.close); - const entryIdx = firstIdx === -1 ? series.length - 1 : firstIdx - start; + 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; + let start: number; + let entryIdx: number; + if (postCount <= MID + 1) { + start = Math.max(0, entryAbs - MID); + entryIdx = entryAbs - start; + } else { + // Enough history: keep the latest WINDOW bars; entry falls where it falls. + start = Math.max(0, bars.length - WINDOW); + entryIdx = 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( @@ -386,57 +415,87 @@ export function TradeChart({ ); const stopPath = stopPathFull.slice(start); const trailActive = trailMode !== 'fixed'; - // Target is a screening artifact under trailing policies — only draw when - // it is an actual exit. - const showTarget = exitMode === 'target'; + // 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'; - const vals = [...series, ...stopPath, entry, initialStop]; - if (showTarget) vals.push(target); + // 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 range = hi - lo || 1; - if (showTarget) { - 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; + 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 + (i / (series.length - 1)) * plotW; + const px = (i: number) => padL + (slotOf(i) / maxSlot) * plotW; const py = (v: number) => padT + plotH - ((v - lo) / (hi - lo)) * plotH; - const seg = (from: number, to: number) => - series - .slice(from, to + 1) - .map((v, i) => `${i === 0 ? 'M' : 'L'}${px(from + i).toFixed(1)},${py(v).toFixed(1)}`) - .join(' '); - // Step-style trail path so ratchet days read as jumps, not smoothed slopes. - const stopSeg = (from: number, to: number) => { - if (to < from) return ''; - let d = `M${px(from).toFixed(1)},${py(stopPath[from]).toFixed(1)}`; - for (let i = from + 1; i <= to; i++) { - d += `H${px(i).toFixed(1)}V${py(stopPath[i]).toFixed(1)}`; + + 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)}`; } - return d; - }; - const contextPath = entryIdx > 0 ? seg(0, entryIdx) : null; - const tradePath = seg(entryIdx, series.length - 1); - const trailPath = stopSeg(entryIdx, series.length - 1); - const last = series[series.length - 1]; - const liveStop = stopPath[stopPath.length - 1] ?? initialStop; - const trailMoved = Math.abs(liveStop - initialStop) > 1e-6; - const perShare = isShort ? entry - last : last - entry; - const col = perShare >= 0 ? 'var(--up)' : 'var(--down)'; + } + + 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); - let stopLabelY = py(liveStop); - if (Math.abs(stopLabelY - entryY) < 11) { - stopLabelY = entryY + (stopLabelY >= entryY ? 11 : -11); + 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 stopLabel = trailActive && trailMoved ? 'trail' : 'stop'; + const ly = Object.fromEntries(labelYs.map((l) => [l.key, l.y])); + if (ly.trail != null) trailLabelY = ly.trail; return ( - {/* Initial hard stop as a quiet dashed reference once the trail has lifted */} - {trailActive && trailMoved && ( - <> - - - )} - - entry {fmt(entry)} + {/* Full-width hard stop — always visible, including for day-0 trades. */} + + + stop {fmt(initialStop)} + - {/* Moving trail (or flat initial stop when no trailing policy) */} - {trailPath && ( + {/* 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 && ( )} - - {stopLabel} {fmt(liveStop)} - - - {showTarget && (() => { - const tFits = isShort - ? py(target) <= h - padB && py(target) >= padT - : py(target) <= h - padB && py(target) >= padT; - const inRange = isShort - ? lo - target <= (hi - lo) * 2 - : target - hi <= (hi - lo) * 2; - if (inRange && tFits) { - return ( - <> - - target {fmt(target)} - - ); - } - return ( - - target {fmt(target)} {isShort ? '↓' : '↑'} - - ); - })()} + {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 && ( + )} - - + ); } diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx index 7cd4063..8ade57b 100644 --- a/frontend/src/components/dashboard/OpenTradesPanel.tsx +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -66,11 +66,9 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o staleTime: 5 * 60_000, }); const opened = new Date(trade.opened_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - const chartHint = exitMode === 'target' - ? 'since entry · entry / stop / target' - : exitMode === 'time' - ? 'since entry · entry / stop' - : 'since entry · entry / initial stop / trail'; + const chartHint = trailMoved || exitMode === 'atr_trailing' || exitMode === 'trailing' + ? 'entry · now · stop · trail · gate' + : 'entry · now · stop · gate'; return (
@@ -144,6 +142,7 @@ function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, o exitMode={exitMode} atrMultiplier={atrMultiplier} trailingPct={trailingPct} + currentPrice={trade.current_price} />