import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import BacktestAnalysisChart from '../backtest/BacktestAnalysisChart'; import { loadBacktestSettings, loadStrategy, runBacktest, type BacktestSignal, type StrategyDto, } from '../../utils/backendApi'; import { loadAnalysisCandles } from '../../utils/analysisChartData'; import { normalizeChartTimeframe } from '../../utils/backtestUiUtils'; import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators'; import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup'; import type { OHLCVBar, Theme } from '../../types'; import { useIndicatorSettings } from '../../hooks/useIndicatorSettings'; import { DEFAULT_PAPER_OVERLAY_VISIBILITY, type PaperOverlayToggleKey, type PaperOverlayVisibility, } from '../../utils/paperChartOverlayVisibility'; const BAR_COUNT = 300; const HISTORY_BACKTEST_DEBOUNCE_MS = 450; interface Props { market: string; strategyId?: number | null; theme?: Theme; /** 위젯 임베드 시 하단 줌/패닝 툴바 숨김 */ showHoverToolbar?: boolean; } /** * 모의투자 차트 탭 — 전략 백테스트 시그널 + 캔들·보조지표 (백테스팅 분석 차트와 동일) * 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산. */ const PaperAnalysisChart: React.FC = ({ market, strategyId, theme = 'dark', showHoverToolbar = true, }) => { const { getParams } = useIndicatorSettings(); const [signals, setSignals] = useState([]); const [strategy, setStrategy] = useState(); const [timeframe, setTimeframe] = useState('1h'); const [running, setRunning] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [error, setError] = useState(null); /** 종목 변경해도 유지 — 투자관리 차트 탭 세션 전용 (DB·설정 저장 없음) */ const [overlayVisibility, setOverlayVisibility] = useState( () => ({ ...DEFAULT_PAPER_OVERLAY_VISIBILITY }), ); const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]); const backtestGenRef = useRef(0); const historyDebounceRef = useRef | null>(null); const strategyRef = useRef(strategy); const strategyIdRef = useRef(strategyId); strategyRef.current = strategy; strategyIdRef.current = strategyId; useEffect(() => { if (!strategyId) { setStrategy(undefined); setSignals([]); setError('가상매매 투자대상에 종목 전략 또는 기본 전략이 설정되어 있지 않습니다.'); return; } let cancelled = false; setError(null); void loadStrategy(strategyId).then(s => { if (!cancelled) setStrategy(s ?? undefined); }); return () => { cancelled = true; }; }, [strategyId]); const runSignalsForBars = useCallback(async ( bars: OHLCVBar[], tf: string, strat: StrategyDto, sid: number, ) => { const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; if (bars.length < 10) { setSignals([]); setError('캔들 데이터가 부족합니다.'); return; } const gen = ++backtestGenRef.current; setRunning(true); setError(null); try { const btSettings = await loadBacktestSettings(); const indicatorParams = Object.fromEntries( ['RSI', 'MACD', 'CCI', 'SMA', 'EMA', 'IchimokuCloud', 'Stochastic', 'ADX', 'MFI', 'NewPsychological'].map(t => [ t, getParams(t) as Record, ]), ); const { evaluationBarCount } = resolveEvaluationFromLoadedBars( bars.length, strat.buyCondition, strat.sellCondition, indicatorParams, ); const res = await runBacktest({ strategyId: sid, bars: bars.map(b => ({ time: b.time, open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume, })), timeframe: tf, symbol: sym, strategyName: strat.name, settings: btSettings, indicatorParams, evaluationBarCount, }); if (gen !== backtestGenRef.current) return; if (!res) { setSignals([]); setError('시그널 계산에 실패했습니다.'); return; } setSignals(res.signals); } catch (e) { if (gen !== backtestGenRef.current) return; setSignals([]); setError(e instanceof Error ? e.message : '차트 분석 실패'); } finally { if (gen === backtestGenRef.current) setRunning(false); } }, [market, getParams]); useEffect(() => { if (!strategyId || !strategy) { setSignals([]); return; } let cancelled = false; const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy)); setTimeframe(tf); setError(null); void (async () => { try { const sym = market.startsWith('KRW-') ? market : `KRW-${market}`; const bars = await loadAnalysisCandles(sym, tf, toTimeSec, BAR_COUNT); if (cancelled) return; await runSignalsForBars(bars, tf, strategy, strategyId); } catch (e) { if (!cancelled) { setSignals([]); setError(e instanceof Error ? e.message : '차트 분석 실패'); setRunning(false); } } })(); return () => { cancelled = true; ++backtestGenRef.current; }; }, [market, strategyId, strategy, toTimeSec, runSignalsForBars]); const handleToggleOverlay = useCallback((key: PaperOverlayToggleKey) => { setOverlayVisibility(prev => ({ ...prev, [key]: !prev[key] })); }, []); const handleHistoryBarsLoaded = useCallback((bars: OHLCVBar[]) => { const strat = strategyRef.current; const sid = strategyIdRef.current; if (!strat || !sid) return; const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strat)); if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); historyDebounceRef.current = setTimeout(() => { historyDebounceRef.current = null; void runSignalsForBars(bars, tf, strat, sid); }, HISTORY_BACKTEST_DEBOUNCE_MS); }, [runSignalsForBars]); useEffect(() => () => { if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current); }, []); if (!strategyId) { return (

가상매매 투자대상 목록에서 종목별 전략 또는 기본 전략을 설정하면 차트에 매매 시그널이 표시됩니다.

); } const statusBusy = running || historyLoading; return (
{statusBusy && (
{historyLoading && !running ? '과거 캔들 로드 중…' : '전략 시그널 계산 중…'}
)} {error && !statusBusy && (
{error}
)}
); }; export default PaperAnalysisChart;