diff --git a/frontend/src/components/TradeSignalLabelOverlay.tsx b/frontend/src/components/TradeSignalLabelOverlay.tsx new file mode 100644 index 0000000..138b959 --- /dev/null +++ b/frontend/src/components/TradeSignalLabelOverlay.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import type { ChartManager } from '../utils/ChartManager'; +import { + layoutStackedSignalLabels, + resolveSignalLabelAnchors, + type DrawableSignalLabel, + type TradeSignalLabelItem, +} from '../utils/tradeSignalLabels'; + +interface Props { + manager: ChartManager; +} + +const ARROW_OFFSET = 16; +const FONT = '600 11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'; + +function resolveArrowY( + mgr: ChartManager, + item: TradeSignalLabelItem, + mainTopY: number, +): number | null { + const bar = mgr.getBarAtTime(item.time); + let paneY: number | null; + if (bar) { + paneY = item.isBuy ? mgr.priceToY(bar.low) : mgr.priceToY(bar.high); + } else { + paneY = mgr.priceToY(item.price); + } + if (paneY == null) return null; + const chartY = paneY + mainTopY; + return item.isBuy ? chartY + ARROW_OFFSET : chartY - ARROW_OFFSET; +} + +function drawLabel( + ctx: CanvasRenderingContext2D, + x: number, + textY: number, + text: string, + color: string, + baseline: 'top' | 'bottom', +): void { + ctx.font = FONT; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = baseline; + ctx.fillText(text, x, textY); +} + +function renderTradeSignalLabels( + ctx: CanvasRenderingContext2D, + mgr: ChartManager, + cssW: number, + labels: TradeSignalLabelItem[], +): void { + const main = mgr.getPaneLayouts().find(l => l.paneIndex === 0); + if (!main || labels.length === 0) return; + + const { buyBaseBottomY, sellBaseTopY } = resolveSignalLabelAnchors(mgr, main); + + const drawable: DrawableSignalLabel[] = []; + for (const item of labels) { + const x = mgr.timeToX(item.time); + if (x == null || x < -40 || x > cssW + 40) continue; + const arrowY = resolveArrowY(mgr, item, main.topY); + if (arrowY == null) continue; + drawable.push({ item, x, arrowY }); + } + if (drawable.length === 0) return; + + const buys = drawable.filter(d => d.item.isBuy); + const sells = drawable.filter(d => !d.item.isBuy); + + const buyPlaced = layoutStackedSignalLabels( + ctx, buys, mgr, buyBaseBottomY, 'bottom', FONT, + ); + const sellPlaced = layoutStackedSignalLabels( + ctx, sells, mgr, sellBaseTopY, 'top', FONT, + ); + + ctx.setLineDash([3, 4]); + ctx.lineWidth = 1; + + for (const p of [...buyPlaced, ...sellPlaced]) { + ctx.strokeStyle = p.item.color; + ctx.beginPath(); + ctx.moveTo(p.x, p.arrowY); + ctx.lineTo(p.x, p.textY); + ctx.stroke(); + + drawLabel(ctx, p.x, p.textY, p.item.text, p.item.color, p.baseline); + } + + ctx.setLineDash([]); +} + +const TradeSignalLabelOverlay: React.FC = ({ manager }) => { + const canvasRef = useRef(null); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const container = manager.getContainer(); + if (!canvas || !container) return; + + const cssW = container.clientWidth; + const cssH = container.clientHeight; + if (cssW <= 0 || cssH <= 0) return; + + const dpr = window.devicePixelRatio || 1; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cssW, cssH); + + renderTradeSignalLabels(ctx, manager, cssW, manager.getTradeSignalLabels()); + }, [manager]); + + useEffect(() => { + redraw(); + let layoutRaf = 0; + const scheduleRedraw = () => { + cancelAnimationFrame(layoutRaf); + layoutRaf = requestAnimationFrame(redraw); + }; + + const unsubs = [ + manager.subscribePaneLayout(scheduleRedraw), + manager.subscribeViewport(scheduleRedraw), + manager.subscribeTradeSignalLabels(scheduleRedraw), + ]; + + const container = manager.getContainer(); + const ro = new ResizeObserver(scheduleRedraw); + if (container) ro.observe(container); + + const raf1 = requestAnimationFrame(redraw); + const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw)); + + return () => { + unsubs.forEach(u => u()); + ro.disconnect(); + cancelAnimationFrame(layoutRaf); + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [manager, redraw]); + + return ( + + ); +}; + +export default TradeSignalLabelOverlay; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index 2b93e21..6c39b3a 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -64,6 +64,7 @@ import { chartHasStaleIndicators, classifyIndicatorChartChange, singleIndParamKe import { sortIndicatorsForPaneLoad } from '../utils/indicatorPaneMerge'; import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator'; import PaneSeparatorOverlay from './PaneSeparatorOverlay'; +import TradeSignalLabelOverlay from './TradeSignalLabelOverlay'; import { centerChartOnSignalBar } from '../utils/tradeSignalChartMarkers'; interface TradingChartProps { @@ -1861,6 +1862,9 @@ const TradingChart: React.FC = ({ options={paneSeparatorOptions} /> )} + {chartMgr && chartPaintReady && ( + + )} {chartMgr && ( | null = null; private patternMarkers: Array<{ id: string; markers: MarkerData[] }> = []; /** 백테스팅 매수/매도 시그널 마커 */ - private backtestMarkers: MarkerData[] = []; + private backtestMarkers: TradeSignalMarkerWithPrice[] = []; /** 실시간 전략 체크 마커 */ - private liveStrategyMarkers: MarkerData[] = []; + private liveStrategyMarkers: TradeSignalMarkerWithPrice[] = []; + private _tradeSignalLabelListeners = new Set<() => void>(); private compareSeriesMap = new Map>(); /** 캔들 pane 오버레이 그룹 표시 (config.hidden 과 별도 — in-place 토글용) */ private _chartOverlayVisibility: ChartOverlayVisibility = { @@ -1762,14 +1768,14 @@ export class ChartManager { position: m.position, shape: m.shape, color: m.color, - text: m.text, + text: '', })); const liveAll = this.liveStrategyMarkers.map(m => ({ time: m.time as Time, position: m.position, shape: m.shape, color: m.color, - text: m.text, + text: '', })); const all = [...patternAll, ...backtestAll, ...liveAll]; if (this.mainMarkersPlugin) { @@ -1815,17 +1821,20 @@ export class ChartManager { shape: buy ? ('arrowUp' as const) : ('arrowDown' as const), color: s.type === 'PARTIAL_SELL' ? '#FF9800' : (buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR), text, + price: s.price, }; }); // 시리즈 교체·detach 이후 stale 플러그인 방지 — 백테스트 마커는 항상 재생성 this._disposeMainMarkersPlugin(); this._reapplyAllPatternMarkers(); + this._notifyTradeSignalLabels(); } /** 백테스팅 마커 제거 */ clearBacktestMarkers(): void { this.backtestMarkers = []; this._reapplyAllPatternMarkers(); + this._notifyTradeSignalLabels(); } /** @@ -1848,15 +1857,35 @@ export class ChartManager { shape: buy ? ('arrowUp' as const) : ('arrowDown' as const), color: buy ? TRADE_BUY_COLOR : TRADE_SELL_COLOR, text, + price: m.price, }; }); this._reapplyAllPatternMarkers(); + this._notifyTradeSignalLabels(); } /** 실시간 전략 마커 전체 제거 */ clearLiveStrategyMarkers(): void { this.liveStrategyMarkers = []; this._reapplyAllPatternMarkers(); + this._notifyTradeSignalLabels(); + } + + /** 매수·매도 시그널 라벨 (캔버스 오버레이용) */ + getTradeSignalLabels(): TradeSignalLabelItem[] { + return [ + ...this.backtestMarkers.map(toTradeSignalLabelItem), + ...this.liveStrategyMarkers.map(toTradeSignalLabelItem), + ]; + } + + subscribeTradeSignalLabels(cb: () => void): () => void { + this._tradeSignalLabelListeners.add(cb); + return () => { this._tradeSignalLabelListeners.delete(cb); }; + } + + private _notifyTradeSignalLabels(): void { + for (const cb of this._tradeSignalLabelListeners) cb(); } /** diff --git a/frontend/src/utils/tradeSignalLabels.ts b/frontend/src/utils/tradeSignalLabels.ts new file mode 100644 index 0000000..d437d55 --- /dev/null +++ b/frontend/src/utils/tradeSignalLabels.ts @@ -0,0 +1,250 @@ +/** 캔들 차트 매수·매도 시그널 라벨 (화살표와 분리 렌더) */ +export interface TradeSignalLabelItem { + time: number; + text: string; + color: string; + isBuy: boolean; + price: number; +} + +export interface TradeSignalMarkerWithPrice { + time: number; + position: 'aboveBar' | 'belowBar'; + shape: 'arrowUp' | 'arrowDown' | 'circle' | 'square'; + color: string; + text: string; + price: number; +} + +export interface SignalLabelAnchors { + /** 매수 라벨 baseline (bottom) — 시간축 바로 위 */ + buyBaseBottomY: number; + /** 매도 라벨 baseline (top) — 캔들 pane 상단 */ + sellBaseTopY: number; +} + +export interface DrawableSignalLabel { + item: TradeSignalLabelItem; + x: number; + arrowY: number; +} + +export interface PlacedSignalLabel { + item: TradeSignalLabelItem; + x: number; + arrowY: number; + textY: number; + baseline: 'top' | 'bottom'; +} + +const CHART_BOTTOM_TIME_SCALE_H = 26; +const GAP_ABOVE_TIME_AXIS = 4; +const TOP_INSET = 10; +export const SIGNAL_LABEL_LINE_HEIGHT = 15; +const X_CLUSTER_PX = 12; +const LABEL_COLLISION_PAD = 4; + +interface BarTimeResolver { + getBarAtTime(time: number): { time: number } | undefined; + getRawBars(): Array<{ time: number }>; + timeToX(time: number): number | null; +} + +export function toTradeSignalLabelItem(m: TradeSignalMarkerWithPrice): TradeSignalLabelItem { + return { + time: m.time, + text: m.text, + color: m.color, + isBuy: m.position === 'belowBar', + price: m.price, + }; +} + +/** 시그널 time → 차트 bar time (정확 일치 또는 최근접 봉) */ +export function resolveSignalBarTime(mgr: BarTimeResolver, timeSec: number): number { + const exact = mgr.getBarAtTime(timeSec); + if (exact) return exact.time; + + const bars = mgr.getRawBars(); + if (bars.length === 0) return timeSec; + + let best = bars[0].time; + let bestDist = Math.abs(best - timeSec); + for (let i = 1; i < bars.length; i++) { + const t = bars[i].time; + const dist = Math.abs(t - timeSec); + if (dist < bestDist) { + bestDist = dist; + best = t; + } + } + + const interval = bars.length >= 2 + ? Math.abs(bars[bars.length - 1].time - bars[bars.length - 2].time) + : 60; + const maxSnap = Math.max(interval * 2, 60); + return bestDist <= maxSnap ? best : timeSec; +} + +/** 같은 캔들·같은 X 클러스터로 묶기 */ +export function clusterSignalLabels( + items: DrawableSignalLabel[], + mgr: BarTimeResolver, + clusterPx = X_CLUSTER_PX, +): DrawableSignalLabel[][] { + if (items.length === 0) return []; + + const enriched = items.map(d => ({ + d, + barTime: resolveSignalBarTime(mgr, d.item.time), + })); + + const byBar = new Map(); + for (const { d, barTime } of enriched) { + const list = byBar.get(barTime) ?? []; + list.push(d); + byBar.set(barTime, list); + } + + const groups = [...byBar.values()].map(group => { + const barTime = resolveSignalBarTime(mgr, group[0].item.time); + const snappedX = mgr.timeToX(barTime) ?? group[0].x; + return group.map(d => ({ ...d, x: snappedX })); + }); + + groups.sort((a, b) => a[0].x - b[0].x); + + const merged: DrawableSignalLabel[][] = []; + for (const g of groups) { + const last = merged[merged.length - 1]; + if (last && Math.abs(g[0].x - last[0].x) <= clusterPx) { + last.push(...g); + } else { + merged.push([...g]); + } + } + return merged; +} + +interface LabelRect { + x: number; + textY: number; + width: number; + height: number; + baseline: 'top' | 'bottom'; +} + +function labelRect( + x: number, + textY: number, + width: number, + height: number, + baseline: 'top' | 'bottom', +): { left: number; right: number; top: number; bottom: number } { + const halfW = width / 2; + const top = baseline === 'top' ? textY : textY - height; + const bottom = baseline === 'top' ? textY + height : textY; + return { left: x - halfW, right: x + halfW, top, bottom }; +} + +function rectsOverlap(a: LabelRect, b: LabelRect): boolean { + const ra = labelRect(a.x, a.textY, a.width, a.height, a.baseline); + const rb = labelRect(b.x, b.textY, b.width, b.height, b.baseline); + const pad = LABEL_COLLISION_PAD; + return ra.left < rb.right + pad + && ra.right + pad > rb.left + && ra.top < rb.bottom + pad + && ra.bottom + pad > rb.top; +} + +function compareSignalOrder(a: TradeSignalLabelItem, b: TradeSignalLabelItem): number { + if (a.time !== b.time) return a.time - b.time; + if (a.price !== b.price) return a.price - b.price; + return a.text.localeCompare(b.text); +} + +/** + * 같은 캔들·겹치는 X 에 여러 시그널 — 세로 줄(lane) 분리 + 가로 겹침 시 다음 줄. + */ +export function layoutStackedSignalLabels( + ctx: CanvasRenderingContext2D, + items: DrawableSignalLabel[], + mgr: BarTimeResolver, + baseY: number, + baseline: 'top' | 'bottom', + font: string, +): PlacedSignalLabel[] { + if (items.length === 0) return []; + + ctx.font = font; + const placed: PlacedSignalLabel[] = []; + const occupied: LabelRect[] = []; + const lineHeight = SIGNAL_LABEL_LINE_HEIGHT; + + const clusters = clusterSignalLabels(items, mgr); + + for (const cluster of clusters) { + const anchorX = cluster[0].x; + const sorted = [...cluster].sort((a, b) => compareSignalOrder(a.item, b.item)); + + for (const d of sorted) { + const width = ctx.measureText(d.item.text).width; + let lane = 0; + + while (true) { + const textY = baseline === 'top' + ? baseY + lane * lineHeight + : baseY - lane * lineHeight; + + const candidate: LabelRect = { + x: anchorX, + textY, + width, + height: lineHeight, + baseline, + }; + + if (!occupied.some(o => rectsOverlap(o, candidate))) { + occupied.push(candidate); + placed.push({ + item: d.item, + x: anchorX, + arrowY: d.arrowY, + textY, + baseline, + }); + break; + } + lane += 1; + if (lane > 24) { + placed.push({ + item: d.item, + x: anchorX, + arrowY: d.arrowY, + textY, + baseline, + }); + break; + } + } + } + } + + return placed; +} + +/** 매수(하단·시간축 위) / 매도(상단) 라벨 기준 Y */ +export function resolveSignalLabelAnchors( + mgr: { getCandlePaneTimeAxisBand(): { topY: number; height: number; plotWidth: number } | null }, + main: { topY: number; height: number }, +): SignalLabelAnchors { + const band = mgr.getCandlePaneTimeAxisBand(); + const buyBaseBottomY = band + ? band.topY - GAP_ABOVE_TIME_AXIS + : main.topY + main.height - CHART_BOTTOM_TIME_SCALE_H - GAP_ABOVE_TIME_AXIS; + + return { + buyBaseBottomY, + sellBaseTopY: main.topY + TOP_INSET, + }; +}