diff --git a/frontend/src/components/charts/CandlestickChart.tsx b/frontend/src/components/charts/CandlestickChart.tsx index 7226a45..c984c30 100644 --- a/frontend/src/components/charts/CandlestickChart.tsx +++ b/frontend/src/components/charts/CandlestickChart.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useCallback, useState } from 'react'; import type { OHLCVBar, SRLevel, SRZone, TradeSetup } from '../../lib/types'; -import { formatPrice, formatDate } from '../../lib/format'; +import { formatPrice, formatDate, formatLargeNumber } from '../../lib/format'; interface CandlestickChartProps { data: OHLCVBar[]; @@ -50,6 +50,9 @@ interface TooltipState { } const MIN_VISIBLE_BARS = 10; +const CHART_HEIGHT = 440; +const VOLUME_PANE_HEIGHT = 72; +const PANE_GAP = 18; type RangePreset = '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' | 'All'; const RANGE_PRESETS: RangePreset[] = ['1M', '3M', '6M', 'YTD', '1Y', '3Y', '5Y', 'All']; @@ -109,7 +112,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, const dpr = window.devicePixelRatio || 1; const rect = container.getBoundingClientRect(); const W = rect.width; - const H = 400; + const H = CHART_HEIGHT; canvas.width = W * dpr; canvas.height = H * dpr; @@ -124,7 +127,11 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, // Margins const ml = 12, mr = 70, mt = 12, mb = 32; const cw = W - ml - mr; - const ch = H - mt - mb; + const volumeH = VOLUME_PANE_HEIGHT; + const ch = H - mt - mb - volumeH - PANE_GAP; + const priceBottom = mt + ch; + const volumeTop = priceBottom + PANE_GAP; + const volumeBottom = volumeTop + volumeH; // Current price = explicit prop, else latest close const livePrice = currentPrice ?? visibleData[visibleData.length - 1].close; @@ -145,6 +152,9 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, const yScale = (v: number) => mt + ch - ((v - lo) / (hi - lo)) * ch; const barW = cw / visibleData.length; const candleW = Math.max(barW * 0.65, 1); + const volumeW = Math.max(barW * 0.65, 1); + const maxVolume = Math.max(...visibleData.map((b) => Math.max(0, b.volume)), 1); + const volumeScale = (v: number) => volumeTop + volumeH - (Math.max(0, v) / maxVolume) * volumeH; // Grid lines (horizontal) const nTicks = 6; @@ -172,6 +182,34 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, ctx.fillText(formatDate(visibleData[i].date), x, H - 6); } + // Volume pane + ctx.strokeStyle = 'rgba(255,255,255,0.06)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(ml, volumeTop - 9); + ctx.lineTo(ml + cw, volumeTop - 9); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(ml, volumeBottom); + ctx.lineTo(ml + cw, volumeBottom); + ctx.stroke(); + + ctx.font = '10px "IBM Plex Mono", ui-monospace, monospace'; + ctx.fillStyle = '#6b7280'; + ctx.textAlign = 'left'; + ctx.fillText('Volume', ml, volumeTop - 13); + ctx.textAlign = 'right'; + ctx.fillText(formatLargeNumber(maxVolume), W - 8, volumeTop + 4); + + visibleData.forEach((bar, i) => { + const x = ml + i * barW + barW / 2; + const bullish = bar.close >= bar.open; + const yVolume = volumeScale(bar.volume); + const hVolume = Math.max(volumeBottom - yVolume, bar.volume > 0 ? 1 : 0); + ctx.fillStyle = bullish ? 'rgba(16, 185, 129, 0.32)' : 'rgba(239, 68, 68, 0.28)'; + ctx.fillRect(x - volumeW / 2, yVolume, volumeW, hVolume); + }); + // Nearest support/resistance only (band if it came from a zone) markers.forEach((m) => { const isSupport = m.role === 'support'; @@ -312,7 +350,22 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, }); // Store geometry for hit testing (includes visibleRange offset) - (canvas as any).__chartMeta = { ml, mr, mt, mb, cw, ch, barW, lo, hi, yScale, visibleStart: start }; + (canvas as any).__chartMeta = { + ml, + mr, + mt, + mb, + cw, + ch, + barW, + lo, + hi, + yScale, + visibleStart: start, + volumeTop, + volumeH, + volumeBottom, + }; // Size the overlay canvas to match const overlay = overlayCanvasRef.current; @@ -342,12 +395,14 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, const meta = (canvas as any).__chartMeta; if (!meta) return; - const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart } = meta; + const { ml, mt, mb, cw, ch, barW, lo, hi, visibleStart, volumeBottom } = meta; const H = overlay.height / dpr; + const priceBottom = mt + ch; + const chartBottom = volumeBottom ?? priceBottom; // Clamp crosshair to chart area const cx = Math.max(ml, Math.min(ml + cw, pos.x)); - const cy = Math.max(mt, Math.min(mt + ch, pos.y)); + const cy = Math.max(mt, Math.min(chartBottom, pos.y)); // Dashed crosshair lines ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; @@ -357,37 +412,44 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, // Vertical line ctx.beginPath(); ctx.moveTo(cx, mt); - ctx.lineTo(cx, mt + ch); - ctx.stroke(); - - // Horizontal line - ctx.beginPath(); - ctx.moveTo(ml, cy); - ctx.lineTo(ml + cw, cy); + ctx.lineTo(cx, chartBottom); ctx.stroke(); ctx.setLineDash([]); - // Price label on y-axis (right side) - const price = hi - ((cy - mt) / ch) * (hi - lo); - const priceText = formatPrice(price); ctx.font = '11px "IBM Plex Mono", ui-monospace, monospace'; - const priceMetrics = ctx.measureText(priceText); const labelPadX = 5; const labelPadY = 3; - const labelW = priceMetrics.width + labelPadX * 2; - const labelH = 16 + labelPadY * 2; - const labelX = ml + cw + 2; - const labelY = cy - labelH / 2; - ctx.fillStyle = 'rgba(55, 65, 81, 0.9)'; - ctx.beginPath(); - ctx.roundRect(labelX, labelY, labelW, labelH, 3); - ctx.fill(); - ctx.fillStyle = '#e5e7eb'; - ctx.textAlign = 'left'; - ctx.textBaseline = 'middle'; - ctx.fillText(priceText, labelX + labelPadX, cy); + if (cy <= priceBottom) { + // Horizontal price crosshair only belongs in the price pane. + ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)'; + ctx.lineWidth = 0.75; + ctx.setLineDash([4, 3]); + ctx.beginPath(); + ctx.moveTo(ml, cy); + ctx.lineTo(ml + cw, cy); + ctx.stroke(); + ctx.setLineDash([]); + + // Price label on y-axis (right side) + const price = hi - ((cy - mt) / ch) * (hi - lo); + const priceText = formatPrice(price); + const priceMetrics = ctx.measureText(priceText); + const labelW = priceMetrics.width + labelPadX * 2; + const labelH = 16 + labelPadY * 2; + const labelX = ml + cw + 2; + const labelY = cy - labelH / 2; + + ctx.fillStyle = 'rgba(55, 65, 81, 0.9)'; + ctx.beginPath(); + ctx.roundRect(labelX, labelY, labelW, labelH, 3); + ctx.fill(); + ctx.fillStyle = '#e5e7eb'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillText(priceText, labelX + labelPadX, cy); + } // Date label on x-axis (bottom) const localIdx = Math.floor((cx - ml) / barW); @@ -619,7 +681,7 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, High${formatPrice(bar.high)} Low${formatPrice(bar.low)} Close${formatPrice(bar.close)} - Vol${bar.volume.toLocaleString()} + Vol${formatLargeNumber(bar.volume)} ${tradeTooltipHtml}`; } else { tip.style.display = 'none'; @@ -670,16 +732,16 @@ export function CandlestickChart({ data, srLevels = [], zones = [], tradeSetup, ))} scroll to zoom · drag to pan -
+
{/* Chart — always visible */} -
- {ohlcv.isLoading && } +
+ {ohlcv.isLoading && } {ohlcv.isError && (