diff --git a/backend/src/main/java/com/goldenchart/service/BacktestingService.java b/backend/src/main/java/com/goldenchart/service/BacktestingService.java index 4685191..b6784d4 100644 --- a/backend/src/main/java/com/goldenchart/service/BacktestingService.java +++ b/backend/src/main/java/com/goldenchart/service/BacktestingService.java @@ -46,6 +46,7 @@ public class BacktestingService { private final GcBacktestResultRepository resultRepository; private final ObjectMapper objectMapper; private final IndicatorSettingsService indicatorSettingsService; + private final StrategyDslTimeframeNormalizer timeframeNormalizer; private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto(); @@ -111,12 +112,20 @@ public class BacktestingService { // ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ────── String primaryTf = normalizeTf(req.getTimeframe()); + // scan-signals·전략평가 차트와 동일 — 차트 시간봉에 맞게 DSL 리매핑 + if (buyDsl != null && !buyDsl.isNull()) { + buyDsl = timeframeNormalizer.remapForChartTimeframe(buyDsl, primaryTf); + } + if (sellDsl != null && !sellDsl.isNull()) { + sellDsl = timeframeNormalizer.remapForChartTimeframe(sellDsl, primaryTf); + } Map seriesOverrides = buildSeriesOverrides( series, primaryTf, buyDsl, sellDsl); + String market = req.getSymbol() != null && !req.getSymbol().isBlank() ? req.getSymbol() : null; StrategyDslToTa4jAdapter.RuleBuildContext ruleCtx = new StrategyDslToTa4jAdapter.RuleBuildContext( - series, params, visual, null, null, false, seriesOverrides); + series, params, visual, market, null, false, seriesOverrides); Rule entryRule = adapter.toRule(buyDsl, ruleCtx); Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) ? adapter.toRule(sellDsl, ruleCtx) diff --git a/frontend/src/components/StrategyEvaluationPage.tsx b/frontend/src/components/StrategyEvaluationPage.tsx index e58dc74..2382529 100644 --- a/frontend/src/components/StrategyEvaluationPage.tsx +++ b/frontend/src/components/StrategyEvaluationPage.tsx @@ -14,7 +14,7 @@ import { loadStrategies, loadStrategy, loadBacktestSettings, - runBacktest, + type BacktestSettingsDto, type BacktestSignal, type StrategyDto, } from '../utils/backendApi'; @@ -55,6 +55,7 @@ import { formatEvaluationReturnPct, padEvaluationSignalCount, } from '../utils/strategyEvaluationReport'; +import { resolveEvaluationCommissionRate } from '../utils/strategyEvaluationSignals'; import { BACKTEST_DISPLAY_BAR_COUNT, resolveEvaluationBarSlice } from '../utils/backtestWarmup'; import type { StrategyEvaluationWindowMeta } from './strategyEvaluation/StrategyEvaluationChart'; import StrategyEvaluationAiVerifyPanel from './strategyEvaluation/StrategyEvaluationAiVerifyPanel'; @@ -152,6 +153,7 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { const [backtestSignals, setBacktestSignals] = useState([]); const [signalScanRunning, setSignalScanRunning] = useState(false); const [initialCapital, setInitialCapital] = useState(10_000_000); + const [backtestSettings, setBacktestSettings] = useState(null); const [editorModalOpen, setEditorModalOpen] = useState(false); const [reportOpen, setReportOpen] = useState(false); const [reportModel, setReportModel] = useState(null); @@ -440,17 +442,24 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { useEffect(() => { void loadBacktestSettings().then(s => { + setBacktestSettings(s); if (s?.initialCapital) setInitialCapital(s.initialCapital); }); }, []); + const evaluationCommissionRate = useMemo( + () => (backtestSettings ? resolveEvaluationCommissionRate(backtestSettings) : 0.0015), + [backtestSettings], + ); + const analysisSummary = useMemo(() => { if (!selectedStrategy) return null; return buildStrategyEvaluationToolbarSummary(backtestSignals, { initialCapital, symbol: market.replace(/^KRW-/, ''), + commissionRate: evaluationCommissionRate, }); - }, [selectedStrategy, backtestSignals, initialCapital, market]); + }, [selectedStrategy, backtestSignals, initialCapital, market, evaluationCommissionRate]); const bulkEval = useStrategyBulkEvaluation({ strategies, @@ -622,10 +631,14 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { if (!selectedStrategyId || bars.length < 10) return null; const lastBarTime = bars[bars.length - 1]?.time ?? 0; const windowKey = evalWindowMeta.windowEndTimeSec ?? 'latest'; - return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}`; - }, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec]); + const lastSig = backtestSignals[backtestSignals.length - 1]; + const sigKey = backtestSignals.length > 0 + ? `${backtestSignals.length}:${lastSig?.time}:${lastSig?.price}` + : '0'; + return `${selectedStrategyId}|${market}|${chartTimeframe}|${paramsRevision}|${bars.length}|${lastBarTime}|${windowKey}|${sigKey}`; + }, [selectedStrategyId, market, chartTimeframe, paramsRevision, bars, evalWindowMeta.windowEndTimeSec, backtestSignals]); - const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading; + const reportDisabled = !selectedStrategyId || !selectedStrategy || bars.length < 10 || chartLoading || signalScanRunning; const aiVerifyDisabled = reportDisabled || evalLoading @@ -724,36 +737,10 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { const gen = ++reportGenRef.current; setReportLoading(true); try { - const [btSettings] = await Promise.all([loadBacktestSettings()]); - const indicatorParams = buildEvalParamsFromStrategy(selectedStrategy, getEvalParams); const evaluationBarCount = evalWindowMeta.evaluationBarCount; const { evalBars } = resolveEvaluationBarSlice(bars, evaluationBarCount); - 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, positionMode: 'SIGNAL_ONLY' }, - indicatorParams, - evaluationBarCount, - }); - if (gen !== reportGenRef.current) return; - if (!res) { - window.alert('분석 레포트 생성에 실패했습니다.'); - return; - } - - const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000; const model = buildStrategyEvaluationReportModel({ - signals: res.signals, + signals: backtestSignals, initialCapital, strategyName: selectedStrategy.name ?? strategyLabel, symbol: market.replace(/^KRW-/, ''), @@ -761,7 +748,9 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { barCount: evaluationBarCount, firstBarClose: evalBars[0]?.close, lastBarClose: evalBars[evalBars.length - 1]?.close, + commissionRate: evaluationCommissionRate, }); + if (gen !== reportGenRef.current) return; if (!model) { window.alert('분석 데이터를 생성할 수 없습니다.'); return; @@ -782,12 +771,14 @@ function StrategyEvaluationPageInner({ theme = 'dark' }: Props) { selectedStrategy, reportCacheKey, reportModel, - getEvalParams, - bars, - chartTimeframe, - market, + backtestSignals, + initialCapital, strategyLabel, + market, + chartTimeframe, + bars, evalWindowMeta.evaluationBarCount, + evaluationCommissionRate, ]); return ( diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index 930487d..70d0a9f 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -15,7 +15,7 @@ import { getIndicatorDef } from '../../utils/indicatorRegistry'; import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone'; import { getKoreanName } from '../../utils/marketNameCache'; import type { BacktestSignal, StrategyDto } from '../../utils/backendApi'; -import { fetchLiveConditionScanSignals } from '../../utils/backendApi'; +import { fetchStrategyEvaluationSignals } from '../../utils/strategyEvaluationSignals'; import type { MarketInfo, TickerData } from '../../hooks/useMarketTicker'; import { BACKTEST_DISPLAY_BAR_COUNT, @@ -394,23 +394,17 @@ const StrategyEvaluationChart: React.FC = ({ setBacktestRunning(true); setBacktestError(null); try { - const res = await fetchLiveConditionScanSignals( + const { signals: nextSignals } = await fetchStrategyEvaluationSignals({ + strategyId: strategy.id, + strategy, market, - strategy.id, - barData.map(b => ({ - time: b.time, - open: b.open, - high: b.high, - low: b.low, - close: b.close, - volume: b.volume, - })), timeframe, - indicatorParams, - evaluationBarCountRef.current, - ); + bars: barData, + evaluationBarCount: evaluationBarCountRef.current, + getParams, + }); if (gen !== backtestGenRef.current) return; - setSignals(res); + setSignals(nextSignals); applyMarkers(); } catch (e) { if (gen !== backtestGenRef.current) return; @@ -420,7 +414,7 @@ const StrategyEvaluationChart: React.FC = ({ } finally { if (gen === backtestGenRef.current) setBacktestRunning(false); } - }, [strategy, market, timeframe, indicatorParams, applyMarkers]); + }, [strategy, market, timeframe, getParams, applyMarkers]); useEffect(() => { if (!strategy?.id || loading || bars.length < 10) { diff --git a/frontend/src/utils/strategyBulkEvaluation.ts b/frontend/src/utils/strategyBulkEvaluation.ts index 7958d16..175f626 100644 --- a/frontend/src/utils/strategyBulkEvaluation.ts +++ b/frontend/src/utils/strategyBulkEvaluation.ts @@ -1,7 +1,6 @@ import type { OHLCVBar } from '../types'; import type { StrategyDto } from './backendApi'; -import { fetchLiveConditionScanSignals } from './backendApi'; -import { buildEvalParamsFromStrategy } from './strategyEvaluationParams'; +import { fetchStrategyEvaluationSignals } from './strategyEvaluationSignals'; import { buildStrategyEvaluationToolbarSummary } from './strategyEvaluationReport'; export interface StrategyBulkEvalResult { @@ -49,26 +48,20 @@ export async function evaluateStrategyBulkReturns(opts: { throw new Error('평가 데이터 부족'); } - const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams); - const signals = await fetchLiveConditionScanSignals( - opts.market, + const { signals, initialCapital, commissionRate } = await fetchStrategyEvaluationSignals({ strategyId, - opts.bars.map(b => ({ - time: b.time, - open: b.open, - high: b.high, - low: b.low, - close: b.close, - volume: b.volume, - })), - opts.timeframe, - indicatorParams, - opts.evaluationBarCount, - ); + strategy: opts.strategy, + market: opts.market, + timeframe: opts.timeframe, + bars: opts.bars, + evaluationBarCount: opts.evaluationBarCount, + getParams: opts.getParams, + }); const summary = buildStrategyEvaluationToolbarSummary(signals, { - initialCapital: opts.initialCapital, + initialCapital: initialCapital ?? opts.initialCapital, symbol: opts.market.replace(/^KRW-/, ''), + commissionRate, }); return { diff --git a/frontend/src/utils/strategyEvaluationReport.ts b/frontend/src/utils/strategyEvaluationReport.ts index aa902ca..d157828 100644 --- a/frontend/src/utils/strategyEvaluationReport.ts +++ b/frontend/src/utils/strategyEvaluationReport.ts @@ -66,6 +66,7 @@ export function simulateEvaluationPortfolio( initialCapital: number, symbol: string, mode: BuyAllocationMode, + commissionRate = 0, ): EvaluationSimulationResult { const sorted = sortSignals(signals); const curve: EquityPoint[] = []; @@ -107,9 +108,10 @@ export function simulateEvaluationPortfolio( const frac = buyCashFraction(buyIndexInCycle, mode); if (frac != null && cash > 0) { const spend = cash * frac; - if (spend > 0 && s.price > 0) { + const commission = spend * commissionRate; + if (spend > 0 && s.price > 0 && cash >= spend + commission) { const buyQty = spend / s.price; - cash -= spend; + cash -= spend + commission; totalCost += spend; qty += buyQty; tradeId += 1; @@ -126,10 +128,12 @@ export function simulateEvaluationPortfolio( markEquity(s.time, s.price); } else if (SELL_TYPES.has(s.type) && qty > 0) { const proceeds = qty * s.price; - const pnl = proceeds - totalCost; + const commission = proceeds * commissionRate; + const netProceeds = proceeds - commission; + const pnl = netProceeds - totalCost; const avgEntry = totalCost / qty; - const pnlPct = avgEntry > 0 ? (s.price - avgEntry) / avgEntry : 0; - cash += proceeds; + const pnlPct = avgEntry > 0 ? (netProceeds / qty - avgEntry) / avgEntry : 0; + cash += netProceeds; tradeId += 1; trades.push({ id: tradeId, @@ -309,6 +313,7 @@ export function buildStrategyEvaluationToolbarSummary( opts?: { initialCapital?: number; symbol?: string; + commissionRate?: number; }, ): StrategyEvaluationToolbarSummary { const filtered = filterSignalsForEvaluationReport(signals); @@ -324,10 +329,11 @@ export function buildStrategyEvaluationToolbarSummary( const capital = opts?.initialCapital ?? 10_000_000; const symbol = opts?.symbol ?? ''; + const commissionRate = opts?.commissionRate ?? 0; const stillHolding = isStillHolding(filtered); - const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full'); - const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split'); + const fullSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'full', commissionRate); + const splitSim = simulateEvaluationPortfolio(filtered, capital, symbol, 'split', commissionRate); const fullAnalysis = buildAnalysisFromSimulation(fullSim, capital, 0, stillHolding); const splitAnalysis = buildAnalysisFromSimulation(splitSim, capital, 0, stillHolding); @@ -350,9 +356,11 @@ export function buildStrategyEvaluationReportModel(opts: { createdAt?: string; firstBarClose?: number; lastBarClose?: number; + commissionRate?: number; }): BacktestAnalysisReportModel | null { const filtered = filterSignalsForEvaluationReport(opts.signals); const stillHolding = isStillHolding(filtered); + const commissionRate = opts.commissionRate ?? 0; const firstClose = opts.firstBarClose ?? filtered[0]?.price ?? 0; const lastClose = opts.lastBarClose ?? filtered[filtered.length - 1]?.price ?? 0; @@ -360,7 +368,9 @@ export function buildStrategyEvaluationReportModel(opts: { const modes: BuyAllocationMode[] = ['full', 'split']; const evaluationAllocations: EvaluationAllocationReport[] = modes.map(mode => { - const sim = simulateEvaluationPortfolio(filtered, opts.initialCapital, opts.symbol, mode); + const sim = simulateEvaluationPortfolio( + filtered, opts.initialCapital, opts.symbol, mode, commissionRate, + ); return { mode, label: ALLOCATION_LABELS[mode], @@ -384,6 +394,8 @@ export function buildStrategyEvaluationReportModel(opts: { reportKind: 'backtest', evaluationAllocations, reportSignalFilterNote: - '분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. 매도는 보유 물량 전량 매도로 가정합니다.', + '분석 기간 전체 캔들 기준으로, 최초 매수 이전 매도·최종 매도 이후 매수 시그널은 제외했습니다. ' + + '매도는 보유 물량 전량 매도로 가정합니다. ' + + '체결가·청산 Rule은 백테스트 설정(슬리피지·손절/익절·진입/청산가)과 동일합니다.', }; } diff --git a/frontend/src/utils/strategyEvaluationSignals.ts b/frontend/src/utils/strategyEvaluationSignals.ts new file mode 100644 index 0000000..bf4faef --- /dev/null +++ b/frontend/src/utils/strategyEvaluationSignals.ts @@ -0,0 +1,72 @@ +/** + * 전략평가 — 차트·상단 요약·분석 레포트·일괄평가 공통 시그널 소스 + * + * runBacktest(SIGNAL_ONLY) + 백테스트 설정(슬리피지·손절/익절·진입/청산가) 기준. + * scan-signals(종가·이상적 체결)와 달리 실제 백테스트 엔진과 동일한 Rule·체결가를 사용한다. + */ +import type { OHLCVBar } from '../types'; +import { + loadBacktestSettings, + runBacktest, + type BacktestSettingsDto, + type BacktestSignal, + type StrategyDto, +} from './backendApi'; +import { buildEvalParamsFromStrategy } from './strategyEvaluationParams'; + +export interface StrategyEvaluationSignalsResult { + signals: BacktestSignal[]; + initialCapital: number; + settings: BacktestSettingsDto; + commissionRate: number; +} + +export function resolveEvaluationCommissionRate(settings: BacktestSettingsDto): number { + if (settings.commissionType === 'ZERO') return 0; + return settings.commissionRate ?? 0.0015; +} + +export async function fetchStrategyEvaluationSignals(opts: { + strategyId: number; + strategy: StrategyDto; + market: string; + timeframe: string; + bars: OHLCVBar[]; + evaluationBarCount: number; + getParams: (type: string) => Record; + backtestSettings?: BacktestSettingsDto | null; +}): Promise { + const btSettings = opts.backtestSettings ?? await loadBacktestSettings(); + const settings: BacktestSettingsDto = { ...btSettings, positionMode: 'SIGNAL_ONLY' }; + const indicatorParams = buildEvalParamsFromStrategy(opts.strategy, opts.getParams); + + const res = await runBacktest({ + strategyId: opts.strategyId, + bars: opts.bars.map(b => ({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + })), + timeframe: opts.timeframe, + symbol: opts.market, + strategyName: opts.strategy.name, + settings, + indicatorParams, + evaluationBarCount: opts.evaluationBarCount, + }); + + if (!res?.signals) { + throw new Error('시그널 계산 실패'); + } + + const initialCapital = btSettings.initialCapital ?? res.analysis?.initialCapital ?? 10_000_000; + return { + signals: res.signals, + initialCapital, + settings, + commissionRate: resolveEvaluationCommissionRate(settings), + }; +}