Files
auto-stock/frontend/src/components/TradingChart.tsx
shuim 028a1c352b feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:37:08 +09:00

535 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<HTMLDivElement>(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<HTMLDivElement>(null);
const volumeChartRef = useRef<HTMLDivElement>(null);
const rsiChartRef = useRef<HTMLDivElement>(null);
const macdChartRef = useRef<HTMLDivElement>(null);
// Refs for chart instances
const mainChartInstanceRef = useRef<IChartApi | null>(null);
const volumeChartInstanceRef = useRef<IChartApi | null>(null);
const rsiChartInstanceRef = useRef<IChartApi | null>(null);
const macdChartInstanceRef = useRef<IChartApi | null>(null);
// Refs for series
const candlestickSeriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
const volumeSeriesRef = useRef<ISeriesApi<'Histogram'> | 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>((): 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<number>(len);
// 날짜 변환을 1회만 수행 → 이후 모든 시리즈에서 재사용
const times = new Array<any>(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<string, any> => ({
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<IChartApi | null>) => {
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<ISeriesApi<'Line'>[]>([]);
const rsiLineSeriesRef = useRef<ISeriesApi<'Line'>[]>([]);
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<number | null>(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 (
<div ref={containerRef} className="w-full h-full relative bg-[#0b0e14] rounded-lg overflow-hidden">
{/* 범례 */}
<div className="absolute top-1 left-1 z-10 flex gap-1.5 text-[9px] bg-[#0b0e14]/80 px-1.5 py-0.5 rounded">
<span className="text-[#fbbf24]">MA5</span>
<span className="text-[#f6ad55]">MA20</span>
<span className="text-[#10b981]">MA60</span>
<span className="text-[#4a5568]">BB</span>
<span className="text-gray-600">|</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 bg-[#ef4444] inline-block rounded-sm" />상승</span>
<span className="flex items-center gap-0.5"><span className="w-1.5 h-1.5 bg-[#3b82f6] inline-block rounded-sm" />하락</span>
</div>
{/* 메인 차트 (캔들 + BB + MA) */}
<div ref={mainChartRef} style={{ height: `${mainH}px` }} />
{/* 구분선 + 라벨 */}
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
<span className="text-[8px] text-gray-600">거래량</span>
</div>
{/* 거래량 */}
<div ref={volumeChartRef} style={{ height: `${volH}px` }} />
{/* 구분선 + 라벨 */}
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
<span className="text-[8px] text-[#a78bfa]">RSI(14)</span>
<span className="ml-2 text-[8px] text-gray-600">30 / 50 / 70</span>
</div>
{/* RSI */}
<div ref={rsiChartRef} style={{ height: `${rsiH}px` }} />
{/* 구분선 + 라벨 */}
<div className="flex items-center px-1 bg-[#0b0e14]" style={{ height: `${LABEL_H}px` }}>
<span className="text-[8px] text-[#3b82f6]">MACD</span>
<span className="ml-1.5 text-[8px] text-[#f97316]">Signal</span>
</div>
{/* MACD */}
<div ref={macdChartRef} style={{ height: `${macdH}px` }} />
</div>
);
}, (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';