/** * TradingChart.tsx — lightweight-charts 기반 종합 트레이딩 차트 컴포넌트 * * 역할: * - 4단 차트 구성: 캔들+볼린저+이평선 | 거래량 | RSI(14) | MACD * - 모든 차트의 시간축 동기화 (메인 차트 스크롤 시 하위 차트 연동) * - 실시간 현재가 반영 (500ms polling, ref 기반 — 리렌더 없음) * - 반응형 크기 조절 (ResizeObserver + window resize) * * 최적화 전략: * - React.memo + 커스텀 비교 함수: livePrice 변경 무시, data 첫/끝 캔들만 비교 * - 차트 인스턴스는 마운트 시 1회만 생성 (chartsCreatedRef) * - 데이터 변경 시 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지) * - 기술적 지표 계산은 useMemo로 캐싱 (O(n) 슬라이딩 윈도우) * * 차트 높이 비율: 메인 51.9% | 거래량 14.3% | RSI 20.1% | MACD 나머지 */ import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { createChart, CrosshairMode, IChartApi, ISeriesApi, CandlestickData, LineData, HistogramData } from 'lightweight-charts'; import { calculateSMA, calculateBollingerBands, calculateRSI, calculateMACD } from '../utils/technicalIndicators'; import { CandleData, ProcessedChartData } from '../types/trading'; /** TradingChart Props 정의 */ interface TradingChartProps { /** 캔들 데이터 배열 (OHLCV) */ data: CandleData[]; /** 실시간 현재가 (ref 기반 처리, 리렌더 트리거 안 함) */ livePrice?: number; } // 한국 시장 색상 규칙 (상승=빨강, 하락=파랑) const COLORS = { candleUp: '#ef4444', // 상승: 빨강 candleDown: '#3b82f6', // 하락: 파랑 ma5: '#fbbf24', // 5일선: 노란색 ma20: '#f6ad55', // 20일선: 오렌지색 ma60: '#10b981', // 60일선: 초록색 bbLine: '#4a5568', // 볼린저 밴드: 회색 rsi: '#a78bfa', // RSI: 보라색 macd: '#3b82f6', // MACD: 파란색 signal: '#f97316', // Signal: 주황색 background: '#0b0e14', grid: '#1a1f2e', text: '#9ca3af', border: '#374151', } as const; export const TradingChart = React.memo(({ data, livePrice: livePriceProp }: TradingChartProps) => { // livePrice를 ref로 유지 — 리렌더 없이 useEffect에서 최신 값 사용 const livePriceRef = useRef(livePriceProp); livePriceRef.current = livePriceProp; const containerRef = useRef(null); const [containerHeight, setContainerHeight] = useState(350); // containerHeight를 ref로도 유지 — ResizeObserver 클로저가 최신 값 참조 (Observer 마운트 1회만 생성) const containerHeightRef = useRef(containerHeight); containerHeightRef.current = containerHeight; // 컨테이너 크기 감지 — 부모 높이에 맞게 차트 자동 조절 // 미세한 높이 차이(±2px)는 무시하여 불필요한 리렌더 방지 // deps에 containerHeight 제거 → Observer는 마운트 1회만 생성 (ref로 최신 값 참조) useEffect(() => { if (!containerRef.current) return; const observer = new ResizeObserver((entries) => { const h = Math.floor(entries[0]?.contentRect.height || 0); if (h > 100 && Math.abs(h - containerHeightRef.current) > 2) setContainerHeight(h); }); observer.observe(containerRef.current); return () => observer.disconnect(); }, []); // eslint-disable-line react-hooks/exhaustive-deps // Refs for chart containers const mainChartRef = useRef(null); const volumeChartRef = useRef(null); const rsiChartRef = useRef(null); const macdChartRef = useRef(null); // Refs for chart instances const mainChartInstanceRef = useRef(null); const volumeChartInstanceRef = useRef(null); const rsiChartInstanceRef = useRef(null); const macdChartInstanceRef = useRef(null); // Refs for series const candlestickSeriesRef = useRef | null>(null); const volumeSeriesRef = useRef | null>(null); // 날짜를 차트 시간 형식으로 변환하는 헬퍼 함수 const toChartTime = (date: string | Date): string => { if (typeof date === 'string') { // YYYY-MM-DD 형식이면 그대로 반환 if (/^\d{4}-\d{2}-\d{2}$/.test(date)) { return date; } // YYYYMMDD 형식이면 YYYY-MM-DD로 변환 if (/^\d{8}$/.test(date)) { return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`; } } // 그 외의 경우 Date 객체로 변환 const d = new Date(date); return d.toISOString().split('T')[0]; // "YYYY-MM-DD" }; // 데이터 계산 (메모이제이션) // Note: lightweight-charts Time 타입(BusinessDay | string)이 ProcessedChartData의 string | number와 // 충돌하므로 useMemo 내부에서 as any 캐스팅을 사용하고, 반환 타입만 ProcessedChartData로 선언 // eslint-disable-next-line @typescript-eslint/no-explicit-any const processedData = useMemo((): ProcessedChartData | null => { if (!data || data.length === 0) return null; // 데이터 정렬 (시간 오름차순) const sortedData = [...data].sort((a, b) => a.date < b.date ? -1 : a.date > b.date ? 1 : 0 ); const len = sortedData.length; const closes = new Array(len); // 날짜 변환을 1회만 수행 → 이후 모든 시리즈에서 재사용 const times = new Array(len); for (let i = 0; i < len; i++) { closes[i] = sortedData[i].close; times[i] = toChartTime(sortedData[i].date); } // 기술적 지표 계산 (O(n) 슬라이딩 윈도우) const bollinger = calculateBollingerBands(closes, 20); const sma5 = calculateSMA(closes, 5); const sma60 = calculateSMA(closes, 60); const rsi = calculateRSI(closes, 14); const { macd, signal, histogram } = calculateMACD(closes); // 단일 루프로 모든 시리즈 데이터 구성 const candles: CandlestickData[] = new Array(len); const volumes: HistogramData[] = new Array(len); const bbUpper: LineData[] = []; const bbMiddle: LineData[] = []; const bbLower: LineData[] = []; const sma5Data: LineData[] = []; const sma60Data: LineData[] = []; const rsiData: LineData[] = []; const rsiLine30: LineData[] = new Array(len); const rsiLine50: LineData[] = new Array(len); const rsiLine70: LineData[] = new Array(len); const macdHist: HistogramData[] = []; const macdLine: LineData[] = []; const signalLine: LineData[] = []; for (let i = 0; i < len; i++) { const item = sortedData[i]; const t = times[i]; candles[i] = { time: t, open: item.open, high: item.high, low: item.low, close: item.close } as any; volumes[i] = { time: t, value: item.volume, color: item.close >= item.open ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)', } as any; if (!isNaN(bollinger.upper[i])) bbUpper.push({ time: t, value: bollinger.upper[i] } as any); if (!isNaN(bollinger.middle[i])) bbMiddle.push({ time: t, value: bollinger.middle[i] } as any); if (!isNaN(bollinger.lower[i])) bbLower.push({ time: t, value: bollinger.lower[i] } as any); if (!isNaN(sma5[i])) sma5Data.push({ time: t, value: sma5[i] } as any); if (!isNaN(sma60[i])) sma60Data.push({ time: t, value: sma60[i] } as any); if (!isNaN(rsi[i])) rsiData.push({ time: t, value: rsi[i] } as any); rsiLine30[i] = { time: t, value: 30 } as any; rsiLine50[i] = { time: t, value: 50 } as any; rsiLine70[i] = { time: t, value: 70 } as any; if (!isNaN(histogram[i])) macdHist.push({ time: t, value: histogram[i], color: histogram[i] >= 0 ? 'rgba(239, 68, 68, 0.6)' : 'rgba(59, 130, 246, 0.6)', } as any); if (!isNaN(macd[i])) macdLine.push({ time: t, value: macd[i] } as any); if (!isNaN(signal[i])) signalLine.push({ time: t, value: signal[i] } as any); } return { candles, volumes, bollinger: { upper: bbUpper, middle: bbMiddle, lower: bbLower }, sma5: sma5Data, sma60: sma60Data, rsi: { data: rsiData, line30: rsiLine30, line50: rsiLine50, line70: rsiLine70 }, macd: { histogram: macdHist, line: macdLine, signal: signalLine }, } as unknown as ProcessedChartData; }, [data]); // 공통 차트 옵션 const getChartOptions = (height: number, hideTimeScale = false): Record => ({ layout: { background: { color: COLORS.background }, textColor: COLORS.text, fontSize: 9, }, grid: { vertLines: { color: '#141820', style: 1 }, horzLines: { color: '#141820', style: 1 }, }, crosshair: { mode: CrosshairMode.Normal, vertLine: { color: '#4a5568', width: 1, style: 2 }, horzLine: { color: '#4a5568', width: 1, style: 2 }, }, timeScale: { borderColor: COLORS.border, timeVisible: true, secondsVisible: false, borderVisible: false, visible: !hideTimeScale, barSpacing: 3, }, rightPriceScale: { borderColor: COLORS.border, borderVisible: false, scaleMargins: { top: 0.05, bottom: 0.05 }, }, handleScroll: { mouseWheel: true, pressedMouseMove: true }, handleScale: { mouseWheel: true, pinch: true }, height, }); // 차트 초기화 및 데이터 설정 // 라벨 3개 × 14px = 42px, 나머지를 비율로 분배 const LABEL_H = 14; const chartArea = Math.max(200, containerHeight - LABEL_H * 3); const mainH = Math.round(chartArea * 0.519); const volH = Math.round(chartArea * 0.143); const rsiH = Math.round(chartArea * 0.201); const macdH = chartArea - mainH - volH - rsiH; // 높이 변경 시 차트 재생성 없이 applyOptions만 호출 useEffect(() => { if (mainChartInstanceRef.current) { mainChartInstanceRef.current.applyOptions({ height: mainH }); } if (volumeChartInstanceRef.current) { volumeChartInstanceRef.current.applyOptions({ height: volH }); } if (rsiChartInstanceRef.current) { rsiChartInstanceRef.current.applyOptions({ height: rsiH }); } if (macdChartInstanceRef.current) { macdChartInstanceRef.current.applyOptions({ height: macdH }); } }, [mainH, volH, rsiH, macdH]); // cleanup 헬퍼 const destroyChart = (ref: React.MutableRefObject) => { try { ref.current?.remove(); } catch (_) { /* already disposed */ } ref.current = null; }; const destroyAllCharts = () => { destroyChart(mainChartInstanceRef); destroyChart(volumeChartInstanceRef); destroyChart(rsiChartInstanceRef); destroyChart(macdChartInstanceRef); candlestickSeriesRef.current = null; volumeSeriesRef.current = null; }; // 보조 시리즈 refs (데이터 교체용) const mainLineSeriesRef = useRef[]>([]); const rsiLineSeriesRef = useRef[]>([]); const macdSeriesRef = useRef<{ histogram: ISeriesApi<'Histogram'> | null; line: ISeriesApi<'Line'> | null; signal: ISeriesApi<'Line'> | null; }>({ histogram: null, line: null, signal: null }); // processedData를 ref로도 유지 — 차트 생성 직후 즉시 데이터 적용 가능 const processedDataRef = useRef(processedData); processedDataRef.current = processedData; // 시리즈 데이터 적용 함수 (차트 생성 후 + processedData 변경 시 공유) const applySeriesData = useCallback((pd: any) => { if (!pd || !candlestickSeriesRef.current) return; try { const allCandles = pd.candles; const totalBars = allCandles.length; if (totalBars === 0) return; // 시간 기반 setVisibleRange는 차트가 아직 레이아웃되기 전(폭 0)이나 재마운트 직후 // "Value is null"을 던짐 → 인덱스 기반 logical range로 교체 + 개별 try/catch const logicalRange = { from: Math.max(0, totalBars - 100), to: totalBars - 1 }; const setRange = (chart: IChartApi | null) => { try { chart?.timeScale().setVisibleLogicalRange(logicalRange); } catch (_) { /* not laid out yet */ } }; // 메인 차트 데이터 교체 candlestickSeriesRef.current.setData(pd.candles); const mainLines = mainLineSeriesRef.current; if (mainLines.length === 5) { mainLines[0].setData(pd.bollinger.upper); mainLines[1].setData(pd.bollinger.middle); mainLines[2].setData(pd.bollinger.lower); mainLines[3].setData(pd.sma5); mainLines[4].setData(pd.sma60); } setRange(mainChartInstanceRef.current); // 거래량 데이터 교체 volumeSeriesRef.current?.setData(pd.volumes); setRange(volumeChartInstanceRef.current); // RSI 데이터 교체 const rsiLines = rsiLineSeriesRef.current; if (rsiLines.length === 4) { rsiLines[0].setData(pd.rsi.data); rsiLines[1].setData(pd.rsi.line30); rsiLines[2].setData(pd.rsi.line50); rsiLines[3].setData(pd.rsi.line70); } setRange(rsiChartInstanceRef.current); // MACD 데이터 교체 macdSeriesRef.current.histogram?.setData(pd.macd.histogram); macdSeriesRef.current.line?.setData(pd.macd.line); macdSeriesRef.current.signal?.setData(pd.macd.signal); setRange(macdChartInstanceRef.current); } catch (error: unknown) { const err = error as Error | null; console.error('차트 데이터 업데이트 오류:', err?.message); } }, []); // 차트 초기 생성 (마운트 시 1회만) — 생성 직후 대기 중인 데이터 즉시 적용 const chartsCreatedRef = useRef(false); useEffect(() => { if (!mainChartRef.current || !volumeChartRef.current || !rsiChartRef.current || !macdChartRef.current) { return; } // 이미 생성된 차트가 있으면 건너뛰기 if (chartsCreatedRef.current) return; chartsCreatedRef.current = true; const containerWidth = mainChartRef.current.clientWidth; try { // ─── 메인 캔들 차트 ─── const mainChart = createChart(mainChartRef.current, { ...getChartOptions(mainH, true), width: containerWidth, }); const candlestickSeries = mainChart.addCandlestickSeries({ upColor: COLORS.candleUp, downColor: COLORS.candleDown, borderVisible: false, wickUpColor: COLORS.candleUp, wickDownColor: COLORS.candleDown, }); const bbUpperSeries = mainChart.addLineSeries({ color: COLORS.bbLine, lineWidth: 1 }); const bbMiddleSeries = mainChart.addLineSeries({ color: COLORS.ma20, lineWidth: 1 }); const bbLowerSeries = mainChart.addLineSeries({ color: COLORS.bbLine, lineWidth: 1 }); const sma5Series = mainChart.addLineSeries({ color: COLORS.ma5, lineWidth: 2 }); const sma60Series = mainChart.addLineSeries({ color: COLORS.ma60, lineWidth: 2 }); mainChartInstanceRef.current = mainChart; candlestickSeriesRef.current = candlestickSeries; mainLineSeriesRef.current = [bbUpperSeries, bbMiddleSeries, bbLowerSeries, sma5Series, sma60Series]; // ─── 거래량 차트 ─── const volumeChart = createChart(volumeChartRef.current, { ...getChartOptions(volH, true), width: containerWidth, }); const volumeSeries = volumeChart.addHistogramSeries({ priceFormat: { type: 'volume' } }); volumeChartInstanceRef.current = volumeChart; volumeSeriesRef.current = volumeSeries; // ─── RSI 차트 ─── const rsiChart = createChart(rsiChartRef.current, { ...getChartOptions(rsiH, true), width: containerWidth, }); const rsiDataSeries = rsiChart.addLineSeries({ color: COLORS.rsi, lineWidth: 2 }); const rsiLine30Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false }); const rsiLine50Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false }); const rsiLine70Series = rsiChart.addLineSeries({ color: COLORS.border, lineWidth: 1, lineStyle: 2, crosshairMarkerVisible: false }); rsiChart.priceScale('right').applyOptions({ scaleMargins: { top: 0.1, bottom: 0.1 } }); rsiChartInstanceRef.current = rsiChart; rsiLineSeriesRef.current = [rsiDataSeries, rsiLine30Series, rsiLine50Series, rsiLine70Series]; // ─── MACD 차트 ─── const macdChart = createChart(macdChartRef.current, { ...getChartOptions(macdH, false), width: containerWidth, }); const macdHistSeries = macdChart.addHistogramSeries(); const macdLineSeries = macdChart.addLineSeries({ color: COLORS.macd, lineWidth: 2 }); const macdSignalSeries = macdChart.addLineSeries({ color: COLORS.signal, lineWidth: 2 }); macdChartInstanceRef.current = macdChart; macdSeriesRef.current = { histogram: macdHistSeries, line: macdLineSeries, signal: macdSignalSeries }; // 시간축 동기화 // logical range 동기화: 하위 차트 시리즈에 해당 time이 아직 없을 때(setData 직전) // setVisibleRange가 "Value is null"을 던지므로 인덱스 기반으로 맞춘다 mainChart.timeScale().subscribeVisibleLogicalRangeChange((range) => { if (!range) return; for (const c of [volumeChart, rsiChart, macdChart]) { try { c.timeScale().setVisibleLogicalRange(range); } catch (_) { /* series not ready */ } } }); // === 반응형 처리 === const handleResize = () => { const width = mainChartRef.current?.clientWidth || 0; if (width === 0) return; mainChartInstanceRef.current?.applyOptions({ width }); volumeChartInstanceRef.current?.applyOptions({ width }); rsiChartInstanceRef.current?.applyOptions({ width }); macdChartInstanceRef.current?.applyOptions({ width }); }; window.addEventListener('resize', handleResize); // 차트 생성 완료 — 이미 대기 중인 processedData가 있으면 즉시 적용 if (processedDataRef.current) { applySeriesData(processedDataRef.current); } // Cleanup (컴포넌트 언마운트 시에만) return () => { window.removeEventListener('resize', handleResize); chartsCreatedRef.current = false; destroyAllCharts(); }; } catch (error: unknown) { const err = error as Error | null; console.error('차트 생성 오류:', err?.message); chartsCreatedRef.current = false; } }, []); // eslint-disable-line react-hooks/exhaustive-deps // processedData 변경 시 — 시리즈 데이터만 교체 (차트 재생성 없음 → 깜빡임 방지) useEffect(() => { if (!processedData) return; // 차트가 아직 생성되지 않았으면 건너뛰기 (생성 완료 시 자동 적용됨) if (!chartsCreatedRef.current) return; applySeriesData(processedData); }, [processedData, applySeriesData]); // 실시간 현재가 — 마지막 캔들만 update() (차트 재생성 없음) // ref 기반 polling: 리렌더 없이 500ms마다 최신 livePrice 반영 const lastAppliedPriceRef = useRef(null); useEffect(() => { const id = setInterval(() => { const price = livePriceRef.current; if (!price || !candlestickSeriesRef.current || !processedData) return; if (price === lastAppliedPriceRef.current) return; // 같은 가격이면 skip const candles = processedData.candles; if (!candles || candles.length === 0) return; const last = candles[candles.length - 1]; candlestickSeriesRef.current.update({ time: last.time as any, open: last.open, high: Math.max(last.high, price), low: Math.min(last.low, price), close: price, }); lastAppliedPriceRef.current = price; }, 500); return () => clearInterval(id); }, [processedData]); return (
{/* 범례 */}
MA5 MA20 MA60 BB | 상승 하락
{/* 메인 차트 (캔들 + BB + MA) */}
{/* 구분선 + 라벨 */}
거래량
{/* 거래량 */}
{/* 구분선 + 라벨 */}
RSI(14) 30 / 50 / 70
{/* RSI */}
{/* 구분선 + 라벨 */}
MACD Signal
{/* MACD */}
); }, (prevProps, nextProps) => { // livePrice는 ref로 처리 — 리렌더 불필요 (비교 제외) // data 배열의 내용이 같으면 리렌더 건너뛰기 (참조가 달라도) if (prevProps.data === nextProps.data) return true; if (!prevProps.data || !nextProps.data) return false; if (prevProps.data.length !== nextProps.data.length) return false; // 첫/끝 캔들만 비교 (전체 deep compare 비용 절감) const pFirst = prevProps.data[0], nFirst = nextProps.data[0]; const pLast = prevProps.data[prevProps.data.length - 1], nLast = nextProps.data[nextProps.data.length - 1]; return pFirst.date === nFirst.date && pFirst.close === nFirst.close && pLast.date === nLast.date && pLast.close === nLast.close; }); TradingChart.displayName = 'TradingChart';