import React, { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'; import TradingChart from '../TradingChart'; import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; import { loadStrategyForNotification } from '../../utils/backendApi'; import { isStrategyMissingOnServer } from '../../utils/strategyMissingCache'; import type { ChartType, IndicatorConfig, OHLCVBar, Theme, Timeframe } from '../../types'; import { loadAnalysisCandles } from '../../utils/analysisChartData'; import type { ChartManager } from '../../utils/ChartManager'; import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../../hooks/useHistoryLoader'; import { isUpbitMarket } from '../../utils/upbitApi'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { formatChartTypeKo, formatTimeframeKo, normalizeChartTimeframe, timeframeBarSeconds, } from '../../utils/backtestUiUtils'; import { getKoreanName } from '../../utils/marketNameCache'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { useAppSettings } from '../../hooks/useAppSettings'; import { getIndicatorDef } from '../../utils/indicatorRegistry'; import { chartPaneFlexRatio, countNonOverlayIndicatorPanes, } from '../../utils/strategyOscillatorSeries'; import { buildChartIndicatorConfig } from '../../utils/indicatorPaneMerge'; import { applyPaperOverlayVisibility, DEFAULT_PAPER_OVERLAY_VISIBILITY, listPaperOverlayToggleItems, paperOverlayVisibilityToChart, type PaperOverlayToggleKey, type PaperOverlayVisibility, } from '../../utils/paperChartOverlayVisibility'; import { buildVirtualTradingChartIndicators, ensurePaperChartOverlays, resolveStrategyPrimaryTimeframe, } from '../../utils/strategyToChartIndicators'; import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility'; interface Props { symbol: string; timeframe: string; toTimeSec: number; fromTimeSec?: number; barCount: number; signals: BacktestSignal[]; strategyId?: number | null; theme?: Theme; /** 과거 캔들 추가 로드 후 (차트에 반영된 전체 봉) */ onHistoryBarsLoaded?: (bars: OHLCVBar[]) => void; /** 과거 로드·시그널 재계산 중 UI */ onHistoryLoadingChange?: (loading: boolean) => void; /** 투자관리 차트 탭 — 캔들 오버레이 표시/숨김 (세션만, DB 미저장) */ overlayVisibility?: PaperOverlayVisibility; onToggleOverlay?: (key: PaperOverlayToggleKey) => void; /** 분석 레포트 팝업 (백테스팅 상단 툴바) */ onOpenReport?: () => void; reportDisabled?: boolean; /** 거래 로그 클릭 시 해당 시점으로 차트 이동 */ focusTimeSec?: number | null; } const noop = () => {}; let _indSeq = 0; function newIndId() { _indSeq += 1; return `btd_${_indSeq}_${Date.now()}`; } const ALL_TF_VISIBLE = { '1m': true, '3m': true, '5m': true, '10m': true, '15m': true, '30m': true, '1h': true, '4h': true, '1D': true, '1W': true, '1M': true }; function defaultOverlayIndicators( getParams: (t: string, d?: Record) => Record, getVisualConfig: ReturnType['getVisualConfig'], ): IndicatorConfig[] { const cfg = buildChartIndicatorConfig('SMA', newIndId(), getParams, getVisualConfig, { timeframeVisibility: ALL_TF_VISIBLE, }); return cfg ? [cfg] : []; } /** 전략 미선택 시 기본 오실레이터(RSI, MACD)를 TradingChart sub-pane 으로 추가 */ function defaultOscillatorIndicators( getParams: (t: string, d?: Record) => Record, getVisualConfig: ReturnType['getVisualConfig'], ): IndicatorConfig[] { return ['RSI', 'MACD'] .map(type => buildChartIndicatorConfig(type, newIndId(), getParams, getVisualConfig, { timeframeVisibility: ALL_TF_VISIBLE, })) .filter((c): c is IndicatorConfig => c != null); } const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; /** * 백테스트·투자관리 차트 — TradingChart 단일 엔진(실시간 차트와 동일 크로스헤어·시간축·우측 가격) */ const BacktestAnalysisChart: React.FC = ({ symbol, timeframe: timeframeRaw, toTimeSec, fromTimeSec, barCount, signals, strategyId, theme = 'dark', onHistoryBarsLoaded, onHistoryLoadingChange, overlayVisibility, onToggleOverlay, onOpenReport, reportDisabled = false, focusTimeSec = null, }) => { const { getParams, getVisualConfig } = useIndicatorSettings(); const { defaults: appDefaults } = useAppSettings(); const [bars, setBars] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [chartType] = useState('candlestick'); const [strategy, setStrategy] = useState(); const [strategyLoading, setStrategyLoading] = useState(false); /** 개별 지표 숨김 ID 집합 (실시간 차트와 동일한 per-indicator 숨김 지원) */ const [hiddenIndicatorIds, setHiddenIndicatorIds] = useState>(new Set()); /** 백테스트·분석 레포트 — 세션 전용 오버레이 on/off (투자관리 탭은 부모 props 사용) */ const [internalOverlayVisibility, setInternalOverlayVisibility] = useState( () => ({ ...DEFAULT_PAPER_OVERLAY_VISIBILITY }), ); const effectiveOverlayVisibility = overlayVisibility ?? internalOverlayVisibility; const managerRef = useRef(null); const overlayVisibilityRef = useRef(effectiveOverlayVisibility); const logicalRangeUnsubRef = useRef<(() => void) | null>(null); const viewportUnsubRef = useRef<(() => void) | null>(null); const loadMoreRefRef = useRef void>>({ current: () => {} }); overlayVisibilityRef.current = effectiveOverlayVisibility; const signalsRef = useRef(signals); signalsRef.current = signals; const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded); onHistoryBarsLoadedRef.current = onHistoryBarsLoaded; const [loadedBarCount, setLoadedBarCount] = useState(null); const market = symbol.startsWith('KRW-') ? symbol : `KRW-${symbol}`; const chartTimeframe = useMemo((): Timeframe => { // 백테스트 결과: 실행 타임프레임 우선 (전략 DSL TF와 실행 TF 불일치 시 차트·시그널 어긋남 방지) const tf = timeframeRaw || (strategy ? resolveStrategyPrimaryTimeframe(strategy) : '1m'); return normalizeChartTimeframe(tf) as Timeframe; }, [strategy, timeframeRaw]); useEffect(() => { if (!strategyId || isStrategyMissingOnServer(strategyId)) { setStrategy(undefined); setStrategyLoading(false); return; } let cancelled = false; setStrategyLoading(true); void loadStrategyForNotification(strategyId).then(s => { if (!cancelled) { setStrategy(s ?? undefined); setStrategyLoading(false); } }); return () => { cancelled = true; }; }, [strategyId]); /** * 백테스트 화면(overlayVisibility 없음)에서 보조지표를 TradingChart sub-pane 으로 통합. * - 기존: 오실레이터를 SVG 기반 StrategyOscillatorPanes 에서 별도 렌더링 * → 캔들 차트와 시간 축(x축) 불일치, 우측 가격 라벨 없음 * - 변경: 모든 지표를 TradingChart sub-pane 으로 통합 * → 시간 축 완전 동기화, 우측 가격 라벨 표시 */ const showOscillatorPanel = !overlayVisibility; const baseIndicators = useMemo(() => { if (strategyId && strategyLoading) return []; let inds: IndicatorConfig[]; if (strategy) { inds = buildVirtualTradingChartIndicators(strategy, getParams, getVisualConfig); inds = inds.filter(i => i.timeframeVisibility?.[chartTimeframe] !== false); if (showOscillatorPanel && inds.every(i => isOverlayType(i.type))) { inds = [...inds, ...defaultOscillatorIndicators(getParams, getVisualConfig)]; } } else { inds = defaultOverlayIndicators(getParams, getVisualConfig); if (showOscillatorPanel) { inds = [...inds, ...defaultOscillatorIndicators(getParams, getVisualConfig)]; } } inds = ensurePaperChartOverlays(inds, getParams, getVisualConfig); return inds; }, [strategy, strategyId, strategyLoading, getParams, getVisualConfig, chartTimeframe, overlayVisibility, showOscillatorPanel]); const indicators = useMemo(() => { let inds = applyPaperOverlayVisibility(baseIndicators, effectiveOverlayVisibility); // 개별 지표 숨김 상태 반영 (per-indicator hidden toggle) if (hiddenIndicatorIds.size > 0) { inds = inds.map(ind => hiddenIndicatorIds.has(ind.id) ? { ...ind, hidden: true } : ind, ); } return inds; }, [baseIndicators, effectiveOverlayVisibility, hiddenIndicatorIds]); const candleOverlayToggles = useMemo( () => listPaperOverlayToggleItems(baseIndicators, effectiveOverlayVisibility), [baseIndicators, effectiveOverlayVisibility], ); /** * 캔들 오버레이 그룹 토글 — 실시간 차트와 동일하게 ChartManager 를 즉시 호출한 뒤 * 부모 state 도 업데이트한다. */ const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { const pKey = key as PaperOverlayToggleKey; const prev = overlayVisibilityRef.current; if (pKey in prev) { const newVisible = !prev[pKey]; managerRef.current?.setChartOverlayGroupVisible(key, newVisible); } if (onToggleOverlay) { onToggleOverlay(pKey); } else { setInternalOverlayVisibility(prev => ({ ...prev, [pKey]: !prev[pKey] })); } }, [onToggleOverlay]); /** * 개별 보조지표 숨김/표시 — 실시간 차트와 동일한 per-indicator 토글 */ const handleToggleIndicatorHidden = useCallback((id: string) => { setHiddenIndicatorIds(prev => { const next = new Set(prev); const willHide = !next.has(id); if (willHide) { next.add(id); } else { next.delete(id); } // ChartManager 즉시 반영 managerRef.current?.toggleIndicatorVisible(id, !willHide); return next; }); }, []); const auxPaneCount = useMemo( () => countNonOverlayIndicatorPanes(indicators, isOverlayType), [indicators], ); const paneAreaRatio = useMemo(() => { if (auxPaneCount <= 0) return null; const flex = chartPaneFlexRatio(auxPaneCount); return { candle: flex.candle, aux: flex.aux }; }, [auxPaneCount]); const effectiveBarCount = useMemo(() => { const barSec = timeframeBarSeconds(timeframeRaw); if (fromTimeSec && toTimeSec > fromTimeSec) { return Math.max(barCount, Math.min(500, Math.ceil((toTimeSec - fromTimeSec) / barSec) + 50)); } return barCount; }, [barCount, fromTimeSec, toTimeSec, timeframeRaw]); useEffect(() => { let cancelled = false; setLoading(true); setError(null); setLoadedBarCount(null); void loadAnalysisCandles(market, chartTimeframe, toTimeSec, effectiveBarCount) .then(data => { if (cancelled) return; setBars(data); }) .catch(e => { if (cancelled) return; setError(e instanceof Error ? e.message : '차트 로드 실패'); setBars([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [market, chartTimeframe, toTimeSec, effectiveBarCount]); const applyMarkers = useCallback((attempt = 0) => { const mgr = managerRef.current; if (!mgr?.hasMainSeries()) { if (attempt < 80) requestAnimationFrame(() => applyMarkers(attempt + 1)); return; } mgr.setBacktestMarkers(signalsRef.current, true); }, []); const applyPaneRatio = useCallback((mgr: ChartManager) => { if (paneAreaRatio && paneAreaRatio.aux > 0) { mgr.setPaneAreaRatio(paneAreaRatio.candle, paneAreaRatio.aux); } else { mgr.setPaneAreaRatio(0, 0); } }, [paneAreaRatio]); const handleBarsPrepended = useCallback((_added: number, total: number) => { setLoadedBarCount(total); const mgr = managerRef.current; if (!mgr) return; onHistoryBarsLoadedRef.current?.(mgr.getRawBars()); // 왼쪽 끝에 붙어 있으면 연속 로드 requestAnimationFrame(() => { const lr = mgr.getVisibleLogicalRange(); if (lr && lr.from < LOAD_MORE_TRIGGER) { loadMoreRefRef.current.current(); } }); }, []); const { isLoadingMore, loadMoreRef } = useHistoryLoader({ symbol: market, timeframe: chartTimeframe, isUpbit: isUpbitMarket(market), managerRef, onBarsPrepended: handleBarsPrepended, }); loadMoreRefRef.current = loadMoreRef; const setupHistoryScroll = useCallback((mgr: ChartManager) => { logicalRangeUnsubRef.current?.(); viewportUnsubRef.current?.(); const tryLoadMore = () => { if (!mgr.hasMainSeries()) return; const lr = mgr.getVisibleLogicalRange(); if (lr && lr.from < LOAD_MORE_TRIGGER) { loadMoreRefRef.current.current(); } }; logicalRangeUnsubRef.current = mgr.subscribeVisibleLogicalRange(r => { if (r && r.from < LOAD_MORE_TRIGGER) { loadMoreRefRef.current.current(); } }); // 커스텀 패닝은 LWC 논리범위 이벤트를 안 낼 수 있어 뷰포트 알림에도 연결 viewportUnsubRef.current = mgr.subscribeViewport(tryLoadMore); requestAnimationFrame(tryLoadMore); }, []); useEffect(() => { onHistoryLoadingChange?.(isLoadingMore); }, [isLoadingMore, onHistoryLoadingChange]); useEffect(() => () => { logicalRangeUnsubRef.current?.(); logicalRangeUnsubRef.current = null; viewportUnsubRef.current?.(); viewportUnsubRef.current = null; }, []); const onManagerReady = useCallback((mgr: ChartManager) => { managerRef.current = mgr; // 실시간 차트와 동일하게: 마운트 시 오버레이 가시성 ChartManager 에 동기화 if (overlayVisibilityRef.current) { mgr.setChartOverlayVisibility(paperOverlayVisibilityToChart(overlayVisibilityRef.current)); } setupHistoryScroll(mgr); setLoadedBarCount(mgr.getRawBarsLength()); applyPaneRatio(mgr); applyMarkers(); }, [applyMarkers, applyPaneRatio, setupHistoryScroll]); const onCandlesReady = useCallback(() => { const mgr = managerRef.current; if (!mgr) return; setupHistoryScroll(mgr); applyPaneRatio(mgr); applyMarkers(); setLoadedBarCount(mgr.getRawBarsLength()); }, [applyMarkers, applyPaneRatio, setupHistoryScroll]); useEffect(() => { applyMarkers(); }, [signals, bars, applyMarkers]); useEffect(() => { if (focusTimeSec == null) return; const mgr = managerRef.current; if (!mgr) return; const barSec = timeframeBarSeconds(timeframeRaw); const pad = Math.max(barSec * 48, 3600); mgr.applyVisibleTimeRange(focusTimeSec - pad, focusTimeSec + pad); }, [focusTimeSec, timeframeRaw]); useEffect(() => { const mgr = managerRef.current; if (mgr) applyPaneRatio(mgr); }, [paneAreaRatio, applyPaneRatio]); const koName = getKoreanName(market); const tfKo = formatTimeframeKo(chartTimeframe); const chartTypeKo = formatChartTypeKo(chartType); const stratTf = chartTimeframe; const priceLabels = appDefaults.chartSeriesPriceLabels !== false; return (
{koName} {tfKo} {chartTypeKo}
{onOpenReport && ( )} {(loadedBarCount ?? effectiveBarCount).toLocaleString()}봉 {isLoadingMore && ' · 과거 로드…'}
{loading &&
차트 로딩…
} {error && !loading &&
{error}
} {!loading && !error && bars.length > 0 && ( i.id).join(',')}`} bars={bars} barsMarket={market} market={market} timeframe={stratTf} chartType={chartType} theme={theme} mode="chart" indicators={indicators} drawingTool="cursor" drawings={[]} logScale={false} onCrosshair={noop} onManagerReady={onManagerReady} onCandlesReady={onCandlesReady} onAddDrawing={noop} displayTimezone={DEFAULT_DISPLAY_TIMEZONE} volumeVisible={false} paneSeparatorOptions={appDefaults.chartPaneSeparator} paneLayoutClamp showPaneResizeHandle={auxPaneCount > 0} paneAreaRatio={paneAreaRatio} showPaneLegend crosshairInfoVisible candleAreaPriceLabelsEnabled={priceLabels} indicatorAreaPriceLabelsEnabled={priceLabels} seriesPriceLabelsEnabled={priceLabels} showHoverToolbar candleOverlayToggles={candleOverlayToggles} onToggleCandleOverlay={handleToggleCandleOverlay} showCandleOverlayControls onToggleIndicatorHidden={handleToggleIndicatorHidden} /> )} {!loading && !error && bars.length === 0 && (
표시할 캔들 데이터가 없습니다.
)}
{/* 오실레이터 pane 은 TradingChart 내부 sub-pane 으로 통합 (시간 축 동기화) */}
); }; export default BacktestAnalysisChart;