전략평가 분석레포트 기능 추가

This commit is contained in:
Macbook
2026-06-16 23:28:23 +09:00
parent 092d181d0f
commit 5acaaabc76
3 changed files with 152 additions and 1 deletions
@@ -12,6 +12,8 @@ import type { VirtualIndicatorSnapshot } from '../hooks/useVirtualIndicatorSnaps
import { import {
loadStrategies, loadStrategies,
loadStrategy, loadStrategy,
loadBacktestSettings,
runBacktest,
type BacktestSignal, type BacktestSignal,
type StrategyDto, type StrategyDto,
} from '../utils/backendApi'; } from '../utils/backendApi';
@@ -41,6 +43,10 @@ import {
} from '../utils/backtestRunTimeframe'; } from '../utils/backtestRunTimeframe';
import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals'; import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals';
import type { OHLCVBar } from '../types'; import type { OHLCVBar } from '../types';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
import { buildChartBacktestReportModel } from '../utils/backtestReportModel';
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
const LEFT_KEY = 'seval-left-width'; const LEFT_KEY = 'seval-left-width';
const LEFT_OPEN_KEY = 'seval-left-open'; const LEFT_OPEN_KEY = 'seval-left-open';
@@ -80,6 +86,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [chartLoading, setChartLoading] = useState(false); const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]); const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
const [editorModalOpen, setEditorModalOpen] = useState(false); const [editorModalOpen, setEditorModalOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
const [reportLoading, setReportLoading] = useState(false);
const reportCacheKeyRef = useRef<string | null>(null);
const reportGenRef = useRef(0);
const evalSessionRef = useRef(0); const evalSessionRef = useRef(0);
const paramsRevisionRef = useRef(paramsRevision); const paramsRevisionRef = useRef(paramsRevision);
@@ -162,7 +173,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
[strategyPrimaryTimeframe], [strategyPrimaryTimeframe],
); );
/** 종목·시간봉·전략 변경 — 차트·평가 컨텍스트 초기화 */ /** 종목·시간봉·전략 변경 — 차트·평가·레포트 컨텍스트 초기화 */
useEffect(() => { useEffect(() => {
evalSessionRef.current += 1; evalSessionRef.current += 1;
setSnapshot(undefined); setSnapshot(undefined);
@@ -170,6 +181,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setEvalLoading(true); setEvalLoading(true);
setBars([]); setBars([]);
setSelectedBarIndex(0); setSelectedBarIndex(0);
reportCacheKeyRef.current = null;
setReportModel(null);
setReportOpen(false);
}, [market, chartTimeframe, selectedStrategyId]); }, [market, chartTimeframe, selectedStrategyId]);
/** 전략설정 파라미터 저장·초기화 */ /** 전략설정 파라미터 저장·초기화 */
@@ -181,6 +195,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setSnapshot(undefined); setSnapshot(undefined);
setEvalError(null); setEvalError(null);
setEvalLoading(true); setEvalLoading(true);
reportCacheKeyRef.current = null;
setReportModel(null);
setReportOpen(false);
}, [paramsRevision]); }, [paramsRevision]);
useEffect(() => { useEffect(() => {
@@ -331,6 +348,87 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
? repairUtf8Mojibake(selectedStrategy.name) ? repairUtf8Mojibake(selectedStrategy.name)
: '전략 미선택'; : '전략 미선택';
const reportCacheKey = useMemo(() => {
if (!selectedStrategyId || bars.length < 10) return null;
const lastBarTime = bars[bars.length - 1]?.time ?? 0;
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}`;
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars]);
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
const handleOpenReport = useCallback(async () => {
if (reportDisabled || !selectedStrategyId || !selectedStrategy) return;
if (reportCacheKey && reportCacheKeyRef.current === reportCacheKey && reportModel) {
setReportOpen(true);
return;
}
const gen = ++reportGenRef.current;
setReportLoading(true);
try {
const [btSettings] = await Promise.all([loadBacktestSettings()]);
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
const evaluationBarCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length);
const res = await runBacktest({
strategyId: selectedStrategyId,
bars: bars.map(b => ({
time: b.time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
volume: b.volume,
})),
timeframe: chartTimeframe,
symbol: market,
strategyName: selectedStrategy.name,
settings: btSettings,
indicatorParams,
evaluationBarCount,
});
if (gen !== reportGenRef.current) return;
if (!res) {
window.alert('분석 레포트 생성에 실패했습니다.');
return;
}
const model = buildChartBacktestReportModel({
analysis: res.analysis ?? null,
stats: res.stats ?? null,
signals: res.signals,
strategyName: selectedStrategy.name ?? strategyLabel,
symbol: market.replace(/^KRW-/, ''),
timeframe: chartTimeframe,
barCount: bars.length,
});
if (!model) {
window.alert('분석 데이터를 생성할 수 없습니다.');
return;
}
reportCacheKeyRef.current = reportCacheKey;
setReportModel(model);
setReportOpen(true);
} catch {
if (gen !== reportGenRef.current) return;
window.alert('상세분석 레포트 생성 중 오류가 발생했습니다.');
} finally {
if (gen === reportGenRef.current) setReportLoading(false);
}
}, [
reportDisabled,
selectedStrategyId,
selectedStrategy,
reportCacheKey,
reportModel,
getEvalParams,
bars,
chartTimeframe,
market,
strategyLabel,
]);
return ( return (
<div className={`btd-page se-page se-page--${theme}`}> <div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header"> <header className="btd-header">
@@ -485,6 +583,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
getVisualConfigOverride={getVisualConfig} getVisualConfigOverride={getVisualConfig}
evalLoading={evalLoading} evalLoading={evalLoading}
onSignalsChange={handleSignalsChange} onSignalsChange={handleSignalsChange}
onOpenReport={() => { void handleOpenReport(); }}
reportDisabled={reportDisabled}
reportLoading={reportLoading}
/> />
<StrategyEvaluationConditionPanel <StrategyEvaluationConditionPanel
strategy={selectedStrategy} strategy={selectedStrategy}
@@ -567,6 +668,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onStrategiesChanged={() => { void refreshStrategies(); }} onStrategiesChanged={() => { void refreshStrategies(); }}
/> />
)} )}
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</div> </div>
); );
} }
@@ -69,6 +69,10 @@ interface Props {
evalLoading?: boolean; evalLoading?: boolean;
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */ /** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
onSignalsChange?: (signals: BacktestSignal[]) => void; onSignalsChange?: (signals: BacktestSignal[]) => void;
/** 투자분석 상세 레포트 (인쇄·PDF) */
onOpenReport?: () => void;
reportDisabled?: boolean;
reportLoading?: boolean;
} }
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true; const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
@@ -102,6 +106,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
getVisualConfigOverride, getVisualConfigOverride,
evalLoading = false, evalLoading = false,
onSignalsChange, onSignalsChange,
onOpenReport,
reportDisabled = false,
reportLoading = false,
}) => { }) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings(); const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams; const getParams = getParamsOverride ?? baseGetParams;
@@ -462,6 +469,27 @@ const StrategyEvaluationChart: React.FC<Props> = ({
)} )}
</div> </div>
<div className="seval-chart-toolbar-actions"> <div className="seval-chart-toolbar-actions">
{onOpenReport && (
<button
type="button"
className="btd-analysis-tool btd-analysis-tool--report seval-chart-report-btn"
title={reportDisabled ? '전략과 차트 데이터를 선택하세요' : '투자분석 상세 레포트 (인쇄·PDF)'}
aria-label="분석 레포트"
disabled={reportDisabled || reportLoading}
onClick={onOpenReport}
>
{reportLoading ? (
<span className="btd-run-spinner seval-chart-report-spinner" aria-hidden />
) : (
<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>
)}
<button <button
type="button" type="button"
className={`seval-chart-magnifier-btn${magnifierActive ? ' seval-chart-magnifier-btn--active' : ''}`} className={`seval-chart-magnifier-btn${magnifierActive ? ' seval-chart-magnifier-btn--active' : ''}`}
@@ -335,9 +335,25 @@
.seval-chart-toolbar-actions { .seval-chart-toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px;
flex-shrink: 0; flex-shrink: 0;
} }
.seval-chart-report-btn {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 6px;
}
.seval-chart-report-spinner {
width: 14px;
height: 14px;
}
.seval-chart-magnifier-btn { .seval-chart-magnifier-btn {
display: flex; display: flex;
align-items: center; align-items: center;