Files
goldenChart/frontend/src/components/paper/PaperAnalysisChart.tsx
T
2026-06-12 01:41:34 +09:00

223 lines
7.5 KiB
TypeScript

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;
}
/**
* 모의투자 차트 탭 — 전략 백테스트 시그널 + 캔들·보조지표 (백테스팅 분석 차트와 동일)
* 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산.
*/
const PaperAnalysisChart: React.FC<Props> = ({ market, strategyId, theme = 'dark' }) => {
const { getParams } = useIndicatorSettings();
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const [timeframe, setTimeframe] = useState('1h');
const [running, setRunning] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/** 종목 변경해도 유지 — 투자관리 차트 탭 세션 전용 (DB·설정 저장 없음) */
const [overlayVisibility, setOverlayVisibility] = useState<PaperOverlayVisibility>(
() => ({ ...DEFAULT_PAPER_OVERLAY_VISIBILITY }),
);
const toTimeSec = useMemo(() => Math.floor(Date.now() / 1000), [market, strategyId]);
const backtestGenRef = useRef(0);
const historyDebounceRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>,
]),
);
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 (
<div className="ptd-analysis-chart ptd-analysis-chart--empty">
<p className="ptd-muted">
가상매매 투자대상 목록에서 종목별 전략 또는 기본 전략을 설정하면 차트에 매매 시그널이 표시됩니다.
</p>
</div>
);
}
const statusBusy = running || historyLoading;
return (
<div className="ptd-analysis-chart ptd-analysis-chart--backtest">
{statusBusy && (
<div className="ptd-analysis-chart-status">
{historyLoading && !running ? '과거 캔들 로드 중…' : '전략 시그널 계산 중…'}
</div>
)}
{error && !statusBusy && (
<div className="ptd-analysis-chart-status ptd-analysis-chart-status--err">{error}</div>
)}
<BacktestAnalysisChart
symbol={market}
timeframe={timeframe}
toTimeSec={toTimeSec}
barCount={BAR_COUNT}
signals={signals}
strategyId={strategyId}
theme={theme}
onHistoryBarsLoaded={handleHistoryBarsLoaded}
onHistoryLoadingChange={setHistoryLoading}
overlayVisibility={overlayVisibility}
onToggleOverlay={handleToggleOverlay}
/>
</div>
);
};
export default PaperAnalysisChart;