전략평가 분석레포트 기능 추가
This commit is contained in:
@@ -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' : ''}`}
|
||||
|
||||
@@ -335,9 +335,25 @@
|
||||
.seval-chart-toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user