diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index 1fd79b8..28f9bb9 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import type { GateTargetLevel, OHLCVBar, + PaperTrade, SRLevel, SRZone, TradeSetup, @@ -20,6 +21,69 @@ interface CandlestickChartProps { onShowGateTrafficChange?: (visible: boolean) => void; tradeSetup?: TradeSetup; currentPrice?: number; + /** Paper trades for this ticker — entry/exit arrows on the matching bars. */ + paperTrades?: PaperTrade[]; +} + +/** YYYY-MM-DD from an ISO timestamp or bare date string. */ +function dayKey(value: string): string { + return value.length >= 10 ? value.slice(0, 10) : value; +} + +/** + * Index of the bar that best represents a paper-trade event time. + * Prefer the same calendar day; else the first session on/after that day. + */ +function barIndexForEvent(data: OHLCVBar[], timestamp: string): number { + if (!data.length) return -1; + const day = dayKey(timestamp); + const exact = data.findIndex((b) => dayKey(b.date) === day); + if (exact >= 0) return exact; + const after = data.findIndex((b) => dayKey(b.date) >= day); + if (after >= 0) return after; + // Event is after the last bar we have — pin to the last session. + if (dayKey(data[data.length - 1].date) < day) return data.length - 1; + return -1; +} + +/** Filled triangle pointing down; tip sits at (x, y). */ +function drawEntryArrow( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + color: string, + size = 7, +) { + ctx.fillStyle = color; + ctx.strokeStyle = 'rgba(10, 11, 17, 0.55)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - size, y - size * 1.35); + ctx.lineTo(x + size, y - size * 1.35); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); +} + +/** Filled triangle pointing up; tip sits at (x, y). */ +function drawExitArrow( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + color: string, + size = 7, +) { + ctx.fillStyle = color; + ctx.strokeStyle = 'rgba(10, 11, 17, 0.55)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x - size, y + size * 1.35); + ctx.lineTo(x + size, y + size * 1.35); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); } /** A horizontal price marker to draw, with an optional band (zone). */ @@ -98,6 +162,7 @@ export function CandlestickChart({ onShowGateTrafficChange, tradeSetup, currentPrice, + paperTrades = [], }: CandlestickChartProps) { const canvasRef = useRef(null); const overlayCanvasRef = useRef(null); @@ -153,10 +218,10 @@ export function CandlestickChart({ // Only the nearest support/resistance are drawn — keep the chart legible const markers = nearestSRMarkers(srLevels, zones, livePrice); - // Margins: line labels (Entry / Stop / Support…) on the LEFT, pure price - // ticks on the RIGHT. Packing both on the right made trade overlays unreadable. - const hasLineLabels = Boolean(tradeSetup) || markers.length > 0; - const ml = hasLineLabels ? 78 : 16; + // Margins: trade role labels (Now / Stop / Gate / Entry) outside on the + // LEFT; pure price ticks on the RIGHT. Support/Resist sit *inside* the + // plot on their lines, so they don't need exterior gutter space. + const ml = 56; const mr = 58; const mt = 12, mb = 32; const cw = W - ml - mr; @@ -289,22 +354,63 @@ export function CandlestickChart({ ctx.restore(); } - // Collect left-side line labels; prices live on the right axis only. - type LineLabel = { y: number; text: string; color: string; weight?: 'normal' | 'bold' }; + // Left-side role labels only; prices live on the right axis. + // Priority when levels crowd: Now > Stop > Gate > Entry > S/R. + // Best practice: hide Entry when it sits on top of Now — at market, Now + // *is* the fill; Entry only matters once price has drifted from the scan. + type LineLabel = { + y: number; + text: string; + color: string; + weight?: 'normal' | 'bold'; + priority: number; + }; const lineLabels: LineLabel[] = []; + const LABEL_MIN_GAP_PX = 14; + const nowY = yScale(livePrice); - // Nearest support/resistance only (band if it came from a zone) + // Entry is noise when price hasn't moved off the scan level. ~0.25R or + // ~12px, whichever is larger, keeps the chart honest without double lines. + const riskDist = tradeSetup + ? Math.abs(tradeSetup.entry_price - tradeSetup.stop_loss) + : 0; + const entryDriftPx = tradeSetup + ? Math.abs(yScale(tradeSetup.entry_price) - nowY) + : 0; + const entryDriftR = tradeSetup && riskDist > 0 + ? Math.abs(livePrice - tradeSetup.entry_price) / riskDist + : 0; + const showEntry = Boolean( + tradeSetup + && (entryDriftPx >= 12 || entryDriftR >= 0.25), + ); + + const tradeYs: number[] = [nowY]; + if (tradeSetup) { + tradeYs.push(yScale(tradeSetup.stop_loss), yScale(tradeSetup.target)); + if (showEntry) tradeYs.push(yScale(tradeSetup.entry_price)); + } + + // In-plot S/R labels (drawn after candles so they stay readable). Not + // exterior margin labels — they ride the line itself, left-aligned. + type InPlotLabel = { y: number; text: string; color: string; bg: string }; + const inPlotLabels: InPlotLabel[] = []; + + // Nearest support/resistance only (band if it came from a zone). + // Drop S/R entirely when it sits on a trade line — the trade label wins. markers.forEach((m) => { const isSupport = m.role === 'support'; - // Support = rim-cyan, resistance = neutral ink — rose stays reserved for - // the stop level so S/R never impersonates trade levels. - const color = isSupport ? '#2f9db2' : '#9aa0b0'; + // Support stays cyan; resistance is a soft coral (warmer than neutral ink, + // cooler/softer than the stop so the two never read as the same thing). + const color = isSupport ? '#2f9db2' : '#e89b8c'; const yMid = yScale(m.price); + const collidesTrade = tradeYs.some((ty) => Math.abs(ty - yMid) < LABEL_MIN_GAP_PX); + if (collidesTrade) return; if (m.high > m.low) { const yTop = yScale(m.high); const rectHeight = Math.max(yScale(m.low) - yTop, 2); - ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(154, 160, 176, 0.10)'; + ctx.fillStyle = isSupport ? 'rgba(47, 157, 178, 0.12)' : 'rgba(232, 155, 140, 0.12)'; ctx.fillRect(ml, yTop, cw, rectHeight); } @@ -319,10 +425,11 @@ export function CandlestickChart({ ctx.setLineDash([]); ctx.globalAlpha = 1; - lineLabels.push({ + inPlotLabels.push({ y: yMid, text: isSupport ? 'Support' : 'Resist', color, + bg: isSupport ? 'rgba(12, 18, 28, 0.78)' : 'rgba(28, 14, 14, 0.78)', }); }); @@ -358,68 +465,67 @@ export function CandlestickChart({ ctx.stroke(); ctx.setLineDash([]); - // Entry price: dashed horizontal line (neutral ink) - ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)'; - ctx.lineWidth = 1.5; - ctx.setLineDash([6, 4]); - ctx.beginPath(); - ctx.moveTo(ml, entryY); - ctx.lineTo(ml + cw, entryY); - ctx.stroke(); - ctx.setLineDash([]); + // Entry only when it has drifted from Now — otherwise the two lines + // stack and fight for the same label slot. + if (showEntry) { + ctx.strokeStyle = 'rgba(154, 160, 176, 0.9)'; + ctx.lineWidth = 1.5; + ctx.setLineDash([6, 4]); + ctx.beginPath(); + ctx.moveTo(ml, entryY); + ctx.lineTo(ml + cw, entryY); + ctx.stroke(); + ctx.setLineDash([]); + lineLabels.push({ + y: entryY, + text: 'Entry', + color: 'rgba(154, 160, 176, 0.95)', + weight: 'bold', + priority: 70, + }); + } lineLabels.push( - { y: entryY, text: 'Entry', color: 'rgba(154, 160, 176, 0.95)', weight: 'bold' }, - { y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold' }, - { y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold' }, + { y: stopY, text: 'Stop', color: 'rgba(239, 145, 130, 0.95)', weight: 'bold', priority: 90 }, + { y: targetY, text: 'Gate', color: 'rgba(196, 181, 253, 0.95)', weight: 'bold', priority: 80 }, ); } // Current price line — the anchor for everything else (drawn on top) { - const py = yScale(livePrice); ctx.strokeStyle = 'rgba(226, 232, 240, 0.9)'; ctx.lineWidth = 1.25; ctx.beginPath(); - ctx.moveTo(ml, py); - ctx.lineTo(ml + cw, py); + ctx.moveTo(ml, nowY); + ctx.lineTo(ml + cw, nowY); ctx.stroke(); lineLabels.push({ - y: py, + y: nowY, text: 'Now', color: 'rgba(226, 232, 240, 0.95)', weight: 'bold', + priority: 100, }); } - // Left-side role labels (no prices — those sit on the right axis). Spread - // stacked labels so Entry/Stop/Gate don't paint over each other when close. + // Left-side role labels. Prefer dropping lower-priority labels over shifting + // them off their lines (shifted labels look like wrong prices). if (lineLabels.length > 0) { - const ordered = [...lineLabels].sort((a, b) => a.y - b.y); - const minGap = 13; - for (let i = 1; i < ordered.length; i++) { - if (ordered[i].y - ordered[i - 1].y < minGap) { - ordered[i].y = ordered[i - 1].y + minGap; - } - } - // If we overflow the pane bottom, pull the stack back up. - const maxY = mt + ch - 4; - if (ordered.length > 0 && ordered[ordered.length - 1].y > maxY) { - let shift = ordered[ordered.length - 1].y - maxY; - for (let i = ordered.length - 1; i >= 0 && shift > 0; i--) { - const prevFloor = i === 0 ? mt + 4 : ordered[i - 1].y + minGap; - const room = ordered[i].y - prevFloor; - const pull = Math.min(shift, Math.max(0, room)); - ordered[i].y -= pull; - shift -= pull; - } + const byPriority = [...lineLabels].sort((a, b) => b.priority - a.priority); + const kept: LineLabel[] = []; + for (const candidate of byPriority) { + const collides = kept.some( + (k) => Math.abs(k.y - candidate.y) < LABEL_MIN_GAP_PX, + ); + if (!collides) kept.push(candidate); } + kept.sort((a, b) => a.y - b.y); ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; - for (const label of ordered) { + for (const label of kept) { ctx.fillStyle = label.color; if (label.weight === 'bold') { ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace'; @@ -466,6 +572,72 @@ export function CandlestickChart({ ctx.fillRect(x - candleW / 2, bodyTop, candleW, bodyH); }); + // Support / Resist: label rides the line inside the plot, left-aligned. + // Soft pill so text stays legible over candles without needing the gutter. + if (inPlotLabels.length > 0) { + ctx.font = '600 10px "IBM Plex Mono", ui-monospace, monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + const padX = 5; + const padY = 2; + for (const label of inPlotLabels) { + const tw = ctx.measureText(label.text).width; + const bx = ml + 6; + const by = label.y; + const bw = tw + padX * 2; + const bh = 14 + padY; + ctx.fillStyle = label.bg; + ctx.beginPath(); + ctx.roundRect(bx, by - bh / 2, bw, bh, 3); + ctx.fill(); + ctx.fillStyle = label.color; + ctx.fillText(label.text, bx + padX, by); + } + ctx.textBaseline = 'alphabetic'; + } + + // Paper-trade markers: green ↓ entry above the bar at entry price, red ↑ + // exit below the bar at close price (classic execution-chart convention). + if (paperTrades.length > 0) { + const ENTRY_GREEN = '#34d399'; + const EXIT_RED = '#f87171'; + for (const pt of paperTrades) { + const entryAbs = barIndexForEvent(data, pt.opened_at); + if (entryAbs >= start && entryAbs < end) { + const local = entryAbs - start; + const x = ml + local * barW + barW / 2; + const bar = data[entryAbs]; + // Sit the tip on the entry price, but keep the arrow above the high + // so it stays readable when entry is mid-body. + const yPrice = yScale(pt.entry_price); + const yHigh = yScale(bar.high); + const y = Math.min(yPrice, yHigh) - 2; + if (y >= mt - 2 && y <= priceBottom + 4) { + drawEntryArrow(ctx, x, y, ENTRY_GREEN); + } + } + + if ( + pt.status === 'closed' + && pt.closed_at + && pt.close_price != null + ) { + const closeAbs = barIndexForEvent(data, pt.closed_at); + if (closeAbs >= start && closeAbs < end) { + const local = closeAbs - start; + const x = ml + local * barW + barW / 2; + const bar = data[closeAbs]; + const yPrice = yScale(pt.close_price); + const yLow = yScale(bar.low); + const y = Math.max(yPrice, yLow) + 2; + if (y >= mt - 4 && y <= priceBottom + 6) { + drawExitArrow(ctx, x, y, EXIT_RED); + } + } + } + } + } + // Store geometry for hit testing (includes visibleRange offset) (canvas as any).__chartMeta = { ml, @@ -503,6 +675,7 @@ export function CandlestickChart({ currentPrice, data, gateTargetLevels, + paperTrades, showGateTraffic, srLevels, tradeSetup, diff --git a/frontend/src/pages/TickerDetailPage.tsx b/frontend/src/pages/TickerDetailPage.tsx index 86e0db0..8a0f078 100644 --- a/frontend/src/pages/TickerDetailPage.tsx +++ b/frontend/src/pages/TickerDetailPage.tsx @@ -145,12 +145,21 @@ export default function TickerDetailPage() { // Status labels: is there an open paper trade on this ticker, and is it the // current top pick (same ranking the dashboard highlights)? const openTrades = usePaperTrades('open'); + // Full history for chart entry/exit arrows (open + closed on this symbol). + const paperTradeHistory = usePaperTrades(); const allTrades = useTrades(); const activation = useActivation(); const hasOpenTrade = useMemo( () => (openTrades.data ?? []).some((t) => t.symbol.toUpperCase() === symbol.toUpperCase()), [openTrades.data, symbol], ); + const symbolPaperTrades = useMemo( + () => + (paperTradeHistory.data ?? []).filter( + (t) => t.symbol.toUpperCase() === symbol.toUpperCase(), + ), + [paperTradeHistory.data, symbol], + ); const isTopPick = useMemo( () => topPickSymbol(allTrades.data, activation.data)?.toUpperCase() === symbol.toUpperCase(), [allTrades.data, activation.data, symbol], @@ -456,11 +465,20 @@ export default function TickerDetailPage() { onShowGateTrafficChange={setShowGateTraffic} tradeSetup={overlayWithTarget} currentPrice={priceInfo?.price} + paperTrades={symbolPaperTrades} />

Only the nearest support & resistance are drawn. Full list in the S/R Levels tab. {srLevels.isError && ' S/R levels unavailable.'} {gateTargetLadder.isError && ' GTL diagnostic unavailable.'} + {symbolPaperTrades.length > 0 && ( + <> + {' '} + paper entry + {' · '} + paper exit + + )}