- 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>
535 lines
25 KiB
TypeScript
535 lines
25 KiB
TypeScript
/**
|
||
* 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';
|