전략평가 화면 수익률 요약표시

This commit is contained in:
Macbook
2026-06-19 07:44:14 +09:00
parent 2514e0adae
commit 994a2024c7
5 changed files with 415 additions and 51 deletions
@@ -48,8 +48,14 @@ import { resolveBarSignalHighlight } from '../utils/strategyEvaluationBarSignals
import type { OHLCVBar } from '../types';
import BacktestAnalysisReportModal from './backtest/BacktestAnalysisReportModal';
import type { BacktestAnalysisReportModel } from './backtest/BacktestAnalysisReportModal';
import { buildStrategyEvaluationReportModel } from '../utils/strategyEvaluationReport';
import { BACKTEST_DISPLAY_BAR_COUNT } from '../utils/backtestWarmup';
import {
buildStrategyEvaluationReportModel,
buildStrategyEvaluationToolbarSummary,
formatEvaluationReturnPct,
padEvaluationSignalCount,
} from '../utils/strategyEvaluationReport';
import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup';
import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart';
import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel';
import StrategyEvaluationChartSettingsTab from './strategyEvaluation/StrategyEvaluationChartSettingsTab';
import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay';
@@ -104,6 +110,12 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [timeframeChoice, setTimeframeChoice] = useState<BacktestRunTimeframeChoice>(BACKTEST_STRATEGY_TIMEFRAME);
const [bars, setBars] = useState<OHLCVBar[]>([]);
const [evalWindowMeta, setEvalWindowMeta] = useState<StrategyEvaluationWindowMeta>({
evaluationBarCount: BACKTEST_DISPLAY_BAR_COUNT,
warmupBarCount: 0,
isAtLatestWindow: true,
windowEndTimeSec: null,
});
const [selectedBarIndex, setSelectedBarIndex] = useState(0);
const [snapshot, setSnapshot] = useState<VirtualIndicatorSnapshot | undefined>();
const [evalLoading, setEvalLoading] = useState(false);
@@ -112,6 +124,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
const [paramsRevision, setParamsRevision] = useState(0);
const [chartLoading, setChartLoading] = useState(false);
const [backtestSignals, setBacktestSignals] = useState<BacktestSignal[]>([]);
const [signalScanRunning, setSignalScanRunning] = useState(false);
const [initialCapital, setInitialCapital] = useState(10_000_000);
const [editorModalOpen, setEditorModalOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const [reportModel, setReportModel] = useState<BacktestAnalysisReportModel | null>(null);
@@ -385,6 +399,20 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
setBacktestSignals(signals);
}, []);
useEffect(() => {
void loadBacktestSettings().then(s => {
if (s?.initialCapital) setInitialCapital(s.initialCapital);
});
}, []);
const analysisSummary = useMemo(() => {
if (!selectedStrategy) return null;
return buildStrategyEvaluationToolbarSummary(backtestSignals, {
initialCapital,
symbol: market.replace(/^KRW-/, ''),
});
}, [selectedStrategy, backtestSignals, initialCapital, market]);
const refreshEvaluation = useCallback(async () => {
if (!selectedStrategyId || !selectedStrategy) {
setSnapshot(undefined);
@@ -488,8 +516,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
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 windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest';
return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}`;
}, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec]);
const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading;
@@ -517,6 +546,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
try {
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
const context = buildStrategyEvaluationAiVerifyContext({
strategy: selectedStrategy,
strategyId: selectedStrategyId,
@@ -530,7 +560,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
chartSignals: backtestSignals,
barSignalHighlight,
indicatorParams,
evaluationBarCount: Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length),
evaluationBarCount,
});
setAiVerifyContext(context);
@@ -568,6 +598,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
backtestSignals,
barSignalHighlight,
getEvalParams,
evalWindowMeta.evaluationBarCount,
]);
const handleOpenAiVerify = useCallback(() => {
@@ -590,7 +621,8 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
try {
const [btSettings] = await Promise.all([loadBacktestSettings()]);
const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams);
const evaluationBarCount = Math.min(BACKTEST_DISPLAY_BAR_COUNT, bars.length);
const evaluationBarCount = evalWindowMeta.evaluationBarCount;
const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount);
const res = await runBacktest({
strategyId: selectedStrategyId,
bars: bars.map(b => ({
@@ -621,9 +653,9 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
strategyName: selectedStrategy.name ?? strategyLabel,
symbol: market.replace(/^KRW-/, ''),
timeframe: chartTimeframe,
barCount: bars.length,
firstBarClose: bars[0]?.close,
lastBarClose: bars[bars.length - 1]?.close,
barCount: evaluationBarCount,
firstBarClose: evalBars[0]?.close,
lastBarClose: evalBars[evalBars.length - 1]?.close,
});
if (!model) {
window.alert('분석 데이터를 생성할 수 없습니다.');
@@ -650,6 +682,7 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
chartTimeframe,
market,
strategyLabel,
evalWindowMeta.evaluationBarCount,
]);
return (
@@ -659,6 +692,45 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
<h1 className="btd-header-title"> </h1>
<span className="btd-header-sub">Strategy Match Analysis</span>
</div>
<div className="btd-header-actions seval-header-analysis">
{selectedStrategy && signalScanRunning && (
<span className="seval-header-analysis-busy"> </span>
)}
{selectedStrategy && analysisSummary && !signalScanRunning && (
<span
className="seval-analysis-summary"
title="일괄매수(최초 전액) · 분할매수(40/30/잔여) · 레포트와 동일 필터 기준"
>
<span className="seval-analysis-item">
:<strong>{padEvaluationSignalCount(analysisSummary.buyCount)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className="seval-analysis-item">
:<strong>{padEvaluationSignalCount(analysisSummary.sellCount)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className={`seval-analysis-item seval-analysis-return${
analysisSummary.fullBuyReturnPct > 0
? ' seval-analysis-return--pos'
: analysisSummary.fullBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(analysisSummary.fullBuyReturnPct)}</strong>
</span>
<span className="seval-analysis-sep" aria-hidden>·</span>
<span className={`seval-analysis-item seval-analysis-return${
analysisSummary.splitBuyReturnPct > 0
? ' seval-analysis-return--pos'
: analysisSummary.splitBuyReturnPct < 0
? ' seval-analysis-return--neg'
: ''
}`}>
:<strong>{formatEvaluationReturnPct(analysisSummary.splitBuyReturnPct)}</strong>
</span>
</span>
)}
</div>
</header>
<div className="btd-body">
@@ -819,12 +891,14 @@ export default function StrategyEvaluationPage({ theme = 'dark' }: Props) {
selectedBarIndex={selectedBarIndex}
onSelectedBarIndexChange={setSelectedBarIndex}
onBarsLoaded={setBars}
onEvalWindowMetaChange={setEvalWindowMeta}
paramsRevision={paramsRevision}
appliedIndicatorParams={appliedIndicatorParams}
getParamsOverride={getEvalParams}
getVisualConfigOverride={getVisualConfig}
evalLoading={evalLoading}
onSignalsChange={handleSignalsChange}
onBacktestRunningChange={setSignalScanRunning}
onOpenReport={() => { void handleOpenReport(); }}
reportDisabled={reportDisabled}
reportLoading={reportLoading}
@@ -19,6 +19,8 @@ import { fetchLiveConditionScanSignals } from '../../utils/backendApi';
import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker';
import {
BACKTEST_DISPLAY_BAR_COUNT,
computeNextWindowEndTime,
computePreviousWindowEndTime,
fetchBacktestCandleBundle,
} from '../../utils/backtestWarmup';
import type { EvalIndicatorParams } from '../../utils/strategyEvaluationParams';
@@ -52,6 +54,13 @@ import {
} from '../../utils/backtestRunTimeframe';
import { timeframeBarSeconds } from '../../utils/backtestUiUtils';
export interface StrategyEvaluationWindowMeta {
evaluationBarCount: number;
warmupBarCount: number;
isAtLatestWindow: boolean;
windowEndTimeSec: number | null;
}
interface Props {
market: string;
timeframe: Timeframe;
@@ -68,6 +77,7 @@ interface Props {
selectedBarIndex: number;
onSelectedBarIndexChange: (index: number) => void;
onBarsLoaded?: (bars: OHLCVBar[]) => void;
onEvalWindowMetaChange?: (meta: StrategyEvaluationWindowMeta) => void;
paramsRevision?: number;
/** 사용자가 저장한 지표 파라미터 — null 이면 백테스트도 서버 DB 설정 사용 (live-conditions 와 동일) */
appliedIndicatorParams?: EvalIndicatorParams | null;
@@ -88,6 +98,7 @@ interface Props {
evalLoading?: boolean;
/** 백테스트 시그널 목록 변경 시 (우측 패널 하이라이트 등) */
onSignalsChange?: (signals: BacktestSignal[]) => void;
onBacktestRunningChange?: (running: boolean) => void;
/** 투자분석 상세 레포트 (인쇄·PDF) */
onOpenReport?: () => void;
reportDisabled?: boolean;
@@ -132,12 +143,14 @@ const StrategyEvaluationChart: React.FC<Props> = ({
selectedBarIndex,
onSelectedBarIndexChange,
onBarsLoaded,
onEvalWindowMetaChange,
paramsRevision = 0,
appliedIndicatorParams = null,
getParamsOverride,
getVisualConfigOverride,
evalLoading = false,
onSignalsChange,
onBacktestRunningChange,
onOpenReport,
reportDisabled = false,
reportLoading = false,
@@ -181,6 +194,11 @@ const StrategyEvaluationChart: React.FC<Props> = ({
const backtestGenRef = useRef(0);
const evaluationBarCountRef = useRef(BACKTEST_DISPLAY_BAR_COUNT);
const warmupBarCountRef = useRef(0);
const latestBarTimeSecRef = useRef<number | null>(null);
type EvaluationWindowAnchor = 'latest' | number;
const [windowAnchor, setWindowAnchor] = useState<EvaluationWindowAnchor>('latest');
const [displayEvalBarCount, setDisplayEvalBarCount] = useState(BACKTEST_DISPLAY_BAR_COUNT);
const signalsRef = useRef(signals);
signalsRef.current = signals;
const chartType: ChartType = 'candlestick';
@@ -428,8 +446,25 @@ const StrategyEvaluationChart: React.FC<Props> = ({
}, [signals, onSignalsChange]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
onBacktestRunningChange?.(backtestRunning);
}, [backtestRunning, onBacktestRunningChange]);
const emitEvalWindowMeta = useCallback((
evalCount: number,
warmupCount: number,
anchor: EvaluationWindowAnchor,
) => {
onEvalWindowMetaChange?.({
evaluationBarCount: evalCount,
warmupBarCount: warmupCount,
isAtLatestWindow: anchor === 'latest',
windowEndTimeSec: anchor === 'latest' ? null : anchor,
});
}, [onEvalWindowMetaChange]);
useEffect(() => {
setWindowAnchor('latest');
}, [market, timeframe, strategy?.id, paramsRevision]);
useEffect(() => {
let cancelled = false;
@@ -440,6 +475,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
managerRef.current?.clearBacktestMarkers();
setLoading(true);
setError(null);
const toTimeSec = windowAnchor === 'latest' ? undefined : windowAnchor;
void fetchBacktestCandleBundle({
market,
timeframe,
@@ -447,12 +484,19 @@ const StrategyEvaluationChart: React.FC<Props> = ({
buyDsl: strategy?.buyCondition,
sellDsl: strategy?.sellCondition,
indicatorParams,
toTimeSec,
})
.then(({ bars: data, evaluationBarCount: evalCount }) => {
.then(({ bars: data, evaluationBarCount: evalCount, warmupBarCount }) => {
if (cancelled) return;
evaluationBarCountRef.current = evalCount;
warmupBarCountRef.current = warmupBarCount;
setDisplayEvalBarCount(evalCount);
if (windowAnchor === 'latest') {
latestBarTimeSecRef.current = data[data.length - 1]?.time ?? null;
}
setBars(data);
onBarsLoaded?.(data);
emitEvalWindowMeta(evalCount, warmupBarCount, windowAnchor);
if (data.length > 0) {
onSelectedBarIndexChange(data.length - 1);
}
@@ -466,7 +510,17 @@ const StrategyEvaluationChart: React.FC<Props> = ({
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [market, timeframe, strategy?.id, paramsRevision, onBarsLoaded, onSelectedBarIndexChange]);
}, [
market,
timeframe,
strategy?.id,
paramsRevision,
indicatorParamsKey,
windowAnchor,
onBarsLoaded,
onSelectedBarIndexChange,
emitEvalWindowMeta,
]);
const scrollToBar = useCallback((barIdx: number) => {
const mgr = managerRef.current;
@@ -523,6 +577,31 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onSelectedBarIndexChange(bars.length - 1);
}, [bars.length, onSelectedBarIndexChange]);
const isAtLatestWindow = windowAnchor === 'latest';
const shiftEvaluationWindow = useCallback((direction: 'prev' | 'next') => {
if (loading || bars.length === 0) return;
const evalCount = evaluationBarCountRef.current;
const barSec = timeframeBarSeconds(timeframe);
if (direction === 'next') {
if (isAtLatestWindow) return;
const next = computeNextWindowEndTime(
bars,
evalCount,
latestBarTimeSecRef.current,
barSec,
);
if (next == null) return;
setWindowAnchor(next === 'latest' ? 'latest' : next);
return;
}
const prevEnd = computePreviousWindowEndTime(bars, evalCount, barSec);
if (prevEnd == null) return;
setWindowAnchor(prevEnd);
}, [loading, bars, timeframe, isAtLatestWindow]);
useEffect(() => {
const mgr = chartMgr;
if (!mgr || bars.length === 0) return;
@@ -601,6 +680,10 @@ const StrategyEvaluationChart: React.FC<Props> = ({
onMarketSearchOpenChange?.(showMarketSearch);
}, [showMarketSearch, onMarketSearchOpenChange]);
useEffect(() => {
onChartLoadingChange?.(loading);
}, [loading, onChartLoadingChange]);
const selectedBar = bars[selectedBarIndex] ?? null;
return (
@@ -655,11 +738,6 @@ const StrategyEvaluationChart: React.FC<Props> = ({
{new Date(selectedBar.time * 1000).toLocaleString('ko-KR')}
</span>
)}
{strategy && signals.length > 0 && !backtestRunning && (
<span className="seval-chart-signal-count" title="조건 충족 매매 시그널">
{signals.length}
</span>
)}
{strategy && backtestRunning && (
<span className="seval-chart-backtest-busy"> </span>
)}
@@ -818,37 +896,67 @@ const StrategyEvaluationChart: React.FC<Props> = ({
</div>
<div className="seval-chart-bottom-toolbar">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 (←)"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 ()"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
<div className="seval-chart-bottom-side seval-chart-bottom-side--left">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--window"
title={`이전 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가 (워밍업 포함 재조회)`}
onClick={() => shiftEvaluationWindow('prev')}
disabled={loading || bars.length === 0}
>
</button>
</div>
<div className="seval-chart-bottom-center">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="이전 봉 ()"
onClick={() => stepBar(-1)}
disabled={selectedBarIndex <= 0 || bars.length === 0}
>
</button>
<span className="seval-chart-count">
{bars.length > 0 ? `${selectedBarIndex + 1} / ${bars.length}` : '0봉'}
{bars.length > 0 && (
<span className="seval-chart-eval-range">
{isAtLatestWindow ? ' · 최신 평가' : ' · 과거 평가'}
{` · ${Math.min(displayEvalBarCount, bars.length)}`}
</span>
)}
</span>
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--step"
title="다음 봉 (→)"
onClick={() => stepBar(1)}
disabled={selectedBarIndex >= bars.length - 1 || bars.length === 0}
>
</button>
<span className="seval-chart-bottom-divider" aria-hidden />
<button
type="button"
className="btd-btn btd-btn--ghost"
title="최신 봉"
onClick={goLatest}
disabled={bars.length === 0 || selectedBarIndex >= bars.length - 1}
>
</button>
</div>
<div className="seval-chart-bottom-side seval-chart-bottom-side--right">
<button
type="button"
className="btd-btn btd-btn--ghost seval-chart-nav--window"
title={`다음 ${BACKTEST_DISPLAY_BAR_COUNT}봉 구간 평가`}
onClick={() => shiftEvaluationWindow('next')}
disabled={loading || bars.length === 0 || isAtLatestWindow}
>
</button>
</div>
</div>
{showMarketSearch && marketBtnRef.current && ReactDOM.createPortal(