Files
goldenChart/frontend/src/components/backtest/BacktestAnalysisChart.tsx
T
Macbook b34bdda073 fix: 백테스트 시간봉·캔들 수 불일치로 시그널 누락 문제 해결
- 전략 DSL 시간봉(5m)과 실행 시간봉(3m) 불일치 시 자동 동기화
- 업비트 캔들 200봉 제한 → 페이지네이션으로 800봉까지 조회
- 백테스트 차트는 실행 타임프레임 기준으로 표시 (시그널·지표 정렬)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 18:16:53 +09:00

497 lines
19 KiB
TypeScript

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<string, number | string | boolean>) => Record<string, number | string | boolean>,
getVisualConfig: ReturnType<typeof useIndicatorSettings>['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<string, number | string | boolean>) => Record<string, number | string | boolean>,
getVisualConfig: ReturnType<typeof useIndicatorSettings>['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<Props> = ({
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<OHLCVBar[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [chartType] = useState<ChartType>('candlestick');
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const [strategyLoading, setStrategyLoading] = useState(false);
/** 개별 지표 숨김 ID 집합 (실시간 차트와 동일한 per-indicator 숨김 지원) */
const [hiddenIndicatorIds, setHiddenIndicatorIds] = useState<ReadonlySet<string>>(new Set());
/** 백테스트·분석 레포트 — 세션 전용 오버레이 on/off (투자관리 탭은 부모 props 사용) */
const [internalOverlayVisibility, setInternalOverlayVisibility] = useState<PaperOverlayVisibility>(
() => ({ ...DEFAULT_PAPER_OVERLAY_VISIBILITY }),
);
const effectiveOverlayVisibility = overlayVisibility ?? internalOverlayVisibility;
const managerRef = useRef<ChartManager | null>(null);
const overlayVisibilityRef = useRef(effectiveOverlayVisibility);
const logicalRangeUnsubRef = useRef<(() => void) | null>(null);
const viewportUnsubRef = useRef<(() => void) | null>(null);
const loadMoreRefRef = useRef<MutableRefObject<() => void>>({ current: () => {} });
overlayVisibilityRef.current = effectiveOverlayVisibility;
const signalsRef = useRef(signals);
signalsRef.current = signals;
const onHistoryBarsLoadedRef = useRef(onHistoryBarsLoaded);
onHistoryBarsLoadedRef.current = onHistoryBarsLoaded;
const [loadedBarCount, setLoadedBarCount] = useState<number | null>(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 (
<div className="btd-analysis-chart">
<div className="btd-analysis-toolbar">
<div className="btd-analysis-toolbar-left">
<span className="btd-analysis-select btd-analysis-select--static">{koName}</span>
<span className="btd-analysis-select btd-analysis-select--static">{tfKo}</span>
<span className="btd-analysis-select btd-analysis-select--static">{chartTypeKo}</span>
</div>
<div className="btd-analysis-toolbar-right">
{onOpenReport && (
<button
type="button"
className="btd-analysis-tool btd-analysis-tool--report"
title={reportDisabled ? '분석 결과를 선택하세요' : '투자분석 상세 레포트 (인쇄·PDF)'}
aria-label="분석 레포트"
disabled={reportDisabled}
onClick={onOpenReport}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</button>
)}
<span className="btd-analysis-tool" title="자석 모드"></span>
<span className="btd-analysis-tool" title="드로잉"></span>
<span className="btd-analysis-tool" title="삭제"></span>
<span className="btd-analysis-count">
{(loadedBarCount ?? effectiveBarCount).toLocaleString()}
{isLoadingMore && ' · 과거 로드…'}
</span>
</div>
</div>
<div className="btd-analysis-main">
<div className="btd-analysis-canvas-wrap btd-analysis-canvas-wrap--live">
{loading && <div className="btd-analysis-loading">차트 로딩…</div>}
{error && !loading && <div className="btd-analysis-error">{error}</div>}
{!loading && !error && bars.length > 0 && (
<TradingChart
key={`${market}-${chartTimeframe}-${toTimeSec}-${effectiveBarCount}-${indicators.map(i => 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 && (
<div className="btd-analysis-error">표시할 캔들 데이터가 없습니다.</div>
)}
</div>
{/* 오실레이터 pane 은 TradingChart 내부 sub-pane 으로 통합 (시간 축 동기화) */}
</div>
</div>
);
};
export default BacktestAnalysisChart;