diff --git a/frontend/src/components/charts/horizon.tsx b/frontend/src/components/charts/horizon.tsx index f093336..363d8b8 100644 --- a/frontend/src/components/charts/horizon.tsx +++ b/frontend/src/components/charts/horizon.tsx @@ -247,40 +247,161 @@ export function RadarChart({ } /* ------------------------------------------------------------------ */ -/* TradeChart — close path since entry + entry / stop / target levels */ +/* 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; +} + export function TradeChart({ - direction, entry, stop, target, bars, openedAt, + direction, + entry, + initialStop, + target, + bars, + openedAt, + exitMode = 'atr_trailing', + atrMultiplier = 3, + trailingPct = 12, }: { direction: string; entry: number; - /** Current stop level (trailing stop if active, else the initial stop). */ - stop: number; + /** Hard stop from the setup (floor for the trail). */ + initialStop: number; target: number; bars: OHLCVBar[]; openedAt: string; + exitMode?: 'time' | 'trailing' | 'atr_trailing' | 'target'; + atrMultiplier?: number; + trailingPct?: number; }) { 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 series = bars.slice(start).map((b) => b.close); + 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; if (series.length < 2) return null; + 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'; + // Target is a screening artifact under trailing policies — only draw when + // it is an actual exit. + const showTarget = exitMode === 'target'; + 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]; + const vals = [...series, ...stopPath, entry, initialStop]; + if (showTarget) vals.push(target); 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); } + 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; lo -= pad; hi += pad; const plotW = w - padL - padR; @@ -292,36 +413,97 @@ export function TradeChart({ .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)}`; + } + 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 labelX = w - padR + 10; const entryY = py(entry); - let stopY = py(stop); - if (Math.abs(stopY - entryY) < 11) stopY = entryY + (stopY >= entryY ? 11 : -11); + let stopLabelY = py(liveStop); + if (Math.abs(stopLabelY - entryY) < 11) { + stopLabelY = entryY + (stopLabelY >= entryY ? 11 : -11); + } + const stopLabel = trailActive && trailMoved ? 'trail' : 'stop'; + return ( + {/* Initial hard stop as a quiet dashed reference once the trail has lifted */} + {trailActive && trailMoved && ( + <> + + + )} entry {fmt(entry)} - - stop {fmt(stop)} - {targetFits ? ( - <> - - target {fmt(target)} - - ) : ( - - target {fmt(target)} {isShort ? '↓' : '↑'} - + + {/* Moving trail (or flat initial stop when no trailing policy) */} + {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 ? '↓' : '↑'} + + ); + })()} + {contextPath && ( )} diff --git a/frontend/src/components/dashboard/OpenTradesPanel.tsx b/frontend/src/components/dashboard/OpenTradesPanel.tsx index a70ccb8..7cd4063 100644 --- a/frontend/src/components/dashboard/OpenTradesPanel.tsx +++ b/frontend/src/components/dashboard/OpenTradesPanel.tsx @@ -46,21 +46,31 @@ function Detail({ label, value, valueClass = 'text-gray-100' }: { ); } -/** Expanded row: full trade detail + price chart with entry/stop/target levels. */ -function TradeDetail({ trade, exitLabel, onClose, closing }: { +/** Expanded row: full trade detail + price chart with entry / trail path. */ +function TradeDetail({ trade, exitLabel, exitMode, atrMultiplier, trailingPct, onClose, closing }: { trade: PaperTrade; exitLabel: string | null; + exitMode: 'time' | 'trailing' | 'atr_trailing' | 'target'; + atrMultiplier: number; + trailingPct: number; onClose: () => void; closing: boolean; }) { const p = tradePnl(trade); const stopLevel = trade.trailing_stop ?? trade.stop_loss; + const trailMoved = trade.trailing_stop != null + && Math.abs(trade.trailing_stop - trade.stop_loss) > 1e-6; 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' }); + const chartHint = exitMode === 'target' + ? 'since entry · entry / stop / target' + : exitMode === 'time' + ? 'since entry · entry / stop' + : 'since entry · entry / initial stop / trail'; return (
@@ -83,19 +93,30 @@ function TradeDetail({ trade, exitLabel, onClose, closing }: { valueClass={trade.alpha_pct != null ? pnlColor(trade.alpha_pct) : 'text-gray-500'} /> {formatPrice(stopLevel)} {trade.trailing_stop != null && ( - trailing{trade.trailing_distance_pct != null ? ` · ${Math.abs(trade.trailing_distance_pct).toFixed(1)}% away` : ''} + {trailMoved ? 'trailing' : 'at initial'} + {trade.trailing_distance_pct != null ? ` · ${Math.abs(trade.trailing_distance_pct).toFixed(1)}% away` : ''} )} } /> - + + {formatPrice(trade.target)} + {exitMode !== 'target' && ( + screening only + )} + + } + />
@@ -145,11 +169,14 @@ export function OpenTradesPanel() { } }, [trades]); + const exitMode = policy?.mode ?? 'atr_trailing'; + const atrMultiplier = policy?.atr_multiplier ?? 3; + const trailingPct = policy?.trailing_pct ?? 12; const exitLabel = policy ? policy.mode === 'atr_trailing' - ? `${(policy.atr_multiplier ?? 3).toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max` + ? `${atrMultiplier.toFixed(1)}x ATR trailing stop / ${policy.hold_days}d max` : policy.mode === 'trailing' - ? `trailing ${Math.round(policy.trailing_pct)}%` + ? `trailing ${Math.round(trailingPct)}%` : policy.mode === 'time' ? `${policy.hold_days}d hold` : 'target/stop' @@ -231,6 +258,9 @@ export function OpenTradesPanel() { { if (window.confirm(`Close ${t.shares} ${t.symbol} at the current price?`)) {