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

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 {
loadStrategies,
loadStrategy,
loadBacktestSettings,
runBacktest,
type BacktestSignal,
type StrategyDto,
} from '../utils/backendApi';
@@ -41,6 +43,10 @@ import {
} from '../utils/backtestRunTimeframe';
import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals';
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_OPEN_KEY = 'seval-left-open';
@@ -80,6 +86,11 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
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 paramsRevisionRef = useRef(paramsRevision);
@@ -162,7 +173,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
[strategyPrimaryTimeframe],
);
/** 종목·시간봉·전략 변경 — 차트·평가 컨텍스트 초기화 */
/** 종목·시간봉·전략 변경 — 차트·평가·레포트 컨텍스트 초기화 */
useEffect(() => {
evalSessionRef.current += 1;
setSnapshot(undefined);
@@ -170,6 +181,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setEvalLoading(true);
setBars([]);
setSelectedBarIndex(0);
reportCacheKeyRef.current = null;
setReportModel(null);
setReportOpen(false);
}, [market, chartTimeframe, selectedStrategyId]);
/** 전략설정 파라미터 저장·초기화 */
@@ -181,6 +195,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setSnapshot(undefined);
setEvalError(null);
setEvalLoading(true);
reportCacheKeyRef.current = null;
setReportModel(null);
setReportOpen(false);
}, [paramsRevision]);
useEffect(() => {
@@ -331,6 +348,87 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
? 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 (
<div className={`btd-page se-page se-page--${theme}`}>
<header className="btd-header">
@@ -485,6 +583,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
getVisualConfigOverride={getVisualConfig}
evalLoading={evalLoading}
onSignalsChange={handleSignalsChange}
onOpenReport={() => { void handleOpenReport(); }}
reportDisabled={reportDisabled}
reportLoading={reportLoading}
/>
<StrategyEvaluationConditionPanel
strategy={selectedStrategy}
@@ -567,6 +668,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
onStrategiesChanged={() => { void refreshStrategies(); }}
/>
)}
<BacktestAnalysisReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
model={reportModel}
/>
</div>
);
}
@@ -69,6 +69,10 @@ interface Props {
evalLoading?: boolean;
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
onSignalsChange?: (signals: BacktestSignal[]) => void;
/** 투자분석 상세 레포트 (인쇄·PDF) */
onOpenReport?: () => void;
reportDisabled?: boolean;
reportLoading?: boolean;
}
const isOverlayType = (type: string) => getIndicatorDef(type)?.overlay === true;
@@ -102,6 +106,9 @@ const StrategyEvaluationChart: React.FC<Props> = ({
getVisualConfigOverride,
evalLoading = false,
onSignalsChange,
onOpenReport,
reportDisabled = false,
reportLoading = false,
}) => {
const { getParams: baseGetParams, getVisualConfig: baseGetVisual, settingsRevision } = useIndicatorSettings();
const getParams = getParamsOverride ?? baseGetParams;
@@ -462,6 +469,27 @@ const StrategyEvaluationChart: React.FC<Props> = ({
)}
</div>
<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
type="button"
className={`seval-chart-magnifier-btn${magnifierActive ? ' seval-chart-magnifier-btn--active' : ''}`}