전략평가 분석레포트 기능 추가
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user