package com.goldenchart.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.goldenchart.dto.*; import com.goldenchart.dto.BacktestResponse.Signal; import com.goldenchart.dto.BacktestResponse.Stats; import com.goldenchart.entity.GcBacktestResult; import com.goldenchart.entity.GcStrategy; import com.goldenchart.repository.GcBacktestResultRepository; import com.goldenchart.repository.GcStrategyRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.ta4j.core.*; import org.ta4j.core.bars.TimeBarBuilderFactory; import org.ta4j.core.indicators.helpers.ClosePriceIndicator; import org.ta4j.core.num.Num; import org.ta4j.core.rules.BooleanRule; import org.ta4j.core.rules.OrRule; import org.ta4j.core.rules.StopGainRule; import org.ta4j.core.rules.StopLossRule; import org.ta4j.core.rules.TrailingStopLossRule; import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; import java.util.*; /** * Ta4j 기반 백테스팅 실행 서비스. * * */ @Service @RequiredArgsConstructor @Slf4j public class BacktestingService { private final StrategyDslToTa4jAdapter adapter; private final GcStrategyRepository strategyRepository; private final GcBacktestResultRepository resultRepository; private final ObjectMapper objectMapper; private final IndicatorSettingsService indicatorSettingsService; private static final BacktestSettingsDto DEFAULT_SETTINGS = new BacktestSettingsDto(); // ── Public API ──────────────────────────────────────────────────────────── public BacktestResponse run(BacktestRequest req) { if (req.getBars() == null || req.getBars().isEmpty()) { return emptyResponse("캔들 데이터가 없습니다."); } JsonNode buyDsl = req.getBuyCondition(); JsonNode sellDsl = req.getSellCondition(); String strategyName = req.getStrategyName() != null ? req.getStrategyName() : "전략"; if (req.getStrategyId() != null) { Optional opt = strategyRepository.findById(req.getStrategyId()); if (opt.isEmpty()) return emptyResponse("전략을 찾을 수 없습니다: id=" + req.getStrategyId()); GcStrategy strat = opt.get(); strategyName = strat.getName() != null ? strat.getName() : strategyName; try { if (strat.getBuyConditionJson() != null) buyDsl = objectMapper.readTree(strat.getBuyConditionJson()); if (strat.getSellConditionJson() != null) sellDsl = objectMapper.readTree(strat.getSellConditionJson()); } catch (Exception e) { log.warn("전략 DSL JSON 파싱 실패: {}", e.getMessage()); return emptyResponse("전략 DSL 파싱 오류"); } } // DB 저장 인디케이터 설정을 기본으로 로드 (디바이스별 저장값 → 하드코딩 기본값 방지) Map> dbParams = Map.of(); if (req.getDeviceId() != null && !req.getDeviceId().isBlank()) { try { dbParams = indicatorSettingsService.getAll(null, req.getDeviceId()); } catch (Exception e) { log.warn("[Backtest] 인디케이터 설정 DB 로드 실패 (deviceId={}): {}", req.getDeviceId(), e.getMessage()); } } // 요청에 포함된 파라미터가 있으면 DB 값 위에 덮어씀 (요청값 우선) Map> params = mergeIndicatorParams(dbParams, req.getIndicatorParams()); log.info("[Backtest] 인디케이터 파라미터 로드 — deviceId={}, dbParams키={}, reqParams키={}, 최종키={}", req.getDeviceId(), dbParams.keySet(), req.getIndicatorParams() != null ? req.getIndicatorParams().keySet() : "null", params.keySet()); if (params.containsKey("Stochastic")) { log.info("[Backtest] Stochastic 파라미터: {}", params.get("Stochastic")); } if (params.isEmpty()) { log.warn("[Backtest] 인디케이터 파라미터 없음 — deviceId={}, 지표 기본값 사용 가능성 있음", req.getDeviceId()); } BacktestSettingsDto cfg = req.getSettings() != null ? req.getSettings() : DEFAULT_SETTINGS; BarSeries series = buildSeries(req.getBars(), req.getTimeframe()); int n = series.getBarCount(); if (n == 0) return emptyResponse("유효한 캔들 데이터가 없습니다."); // ── 멀티 타임프레임 지원: DSL에서 상위봉 타입 추출 후 집계 시리즈 빌드 ────── String primaryTf = normalizeTf(req.getTimeframe()); Map seriesOverrides = buildSeriesOverrides( series, primaryTf, buyDsl, sellDsl); Rule entryRule = adapter.toRule(buyDsl, series, params, seriesOverrides); Rule baseExitRule = (sellDsl != null && !sellDsl.isNull()) ? adapter.toRule(sellDsl, series, params, seriesOverrides) : new BooleanRule(false); Rule exitRule = buildExitRule(baseExitRule, series, cfg); return runBacktest(series, entryRule, exitRule, req, cfg, strategyName, buyDsl, sellDsl, params); } // ── 청산 규칙 합성 ──────────────────────────────────────────────────────── private Rule buildExitRule(Rule baseExit, BarSeries series, BacktestSettingsDto cfg) { Rule result = baseExit; ClosePriceIndicator close = new ClosePriceIndicator(series); if (Boolean.TRUE.equals(cfg.getStopLossEnabled())) { double pct = cfg.getStopLossPct() != null ? cfg.getStopLossPct().doubleValue() : 2.0; result = new OrRule(result, new StopLossRule(close, series.numFactory().numOf(pct))); } if (Boolean.TRUE.equals(cfg.getTakeProfitEnabled())) { double pct = cfg.getTakeProfitPct() != null ? cfg.getTakeProfitPct().doubleValue() : 5.0; result = new OrRule(result, new StopGainRule(close, series.numFactory().numOf(pct))); } if (Boolean.TRUE.equals(cfg.getTrailingStopEnabled())) { double pct = cfg.getTrailingStopPct() != null ? cfg.getTrailingStopPct().doubleValue() : 2.0; result = new OrRule(result, new TrailingStopLossRule(close, series.numFactory().numOf(pct))); } return result; } // ── 백테스팅 루프 ───────────────────────────────────────────────────────── private BacktestResponse runBacktest(BarSeries series, Rule entryRule, Rule exitRule, BacktestRequest req, BacktestSettingsDto cfg, String strategyName, JsonNode execBuyDsl, JsonNode execSellDsl, Map> execParams) { BaseTradingRecord record = new BaseTradingRecord(); List signals = new ArrayList<>(); boolean inPosition = false; double entryPrice = 0; int entryBarIdx = -1; int lastExitBar = -1; int reentryWait = cfg.getReentryWaitBars() != null ? cfg.getReentryWaitBars() : 0; String direction = cfg.getPositionDirection() != null ? cfg.getPositionDirection() : "LONG"; // positionMode: LONG_ONLY(기본) | SIGNAL_ONLY(포지션 락 우회) String posMode = cfg.getPositionMode() != null ? cfg.getPositionMode() : "LONG_ONLY"; boolean signalOnly = "SIGNAL_ONLY".equals(posMode); log.info("[Backtest] 실행 시작 — strategyId={}, bars={}, timeframe={}, positionMode={}, signalOnly={}", req.getStrategyId(), series.getBarCount(), req.getTimeframe(), posMode, signalOnly); double initCap = cfg.getInitialCapital() != null ? cfg.getInitialCapital().doubleValue() : 10_000_000.0; double tradeSizePct = cfg.getTradeSizeValue() != null ? cfg.getTradeSizeValue().doubleValue() / 100.0 : 1.0; boolean partialExit = Boolean.TRUE.equals(cfg.getPartialExitEnabled()); double partialPct = cfg.getPartialExitPct() != null ? cfg.getPartialExitPct().doubleValue() / 100.0 : 0.5; boolean partialDone = false; boolean useLedger = "LONG".equals(direction); PortfolioLedger ledger = useLedger ? new PortfolioLedger(initCap, cfg) : null; double equity = initCap; final double[] simGrossProfit = {0.0}; final double[] simGrossLoss = {0.0}; int barCount = series.getBarCount(); for (int i = 0; i < barCount; i++) { double closePrice = getPrice(series, req.getBars(), i, cfg.getEntryPriceType()); double exitPrice = getPrice(series, req.getBars(), i, cfg.getExitPriceType()); long time = series.getBar(i).getEndTime().getEpochSecond(); // ── SIGNAL_ONLY 모드: 포지션·자금 상태 무관, 조건 충족 시마다 전체 시그널 생성 ── if (signalOnly) { boolean entrySatisfied = entryRule.isSatisfied(i); boolean exitSatisfied = exitRule.isSatisfied(i); if (entrySatisfied) { double effEntry = applySlippage(closePrice, cfg, true); String buyType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; signals.add(buildFillSignal(time, buyType, effEntry, i, 1.0)); log.debug("[Backtest:SIGNAL_ONLY] BUY @ bar={} price={}", i, effEntry); } if (exitSatisfied) { double effExit = applySlippage(exitPrice, cfg, false); String sellType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; signals.add(buildFillSignal(time, sellType, effExit, i, 1.0)); log.debug("[Backtest:SIGNAL_ONLY] SELL @ bar={} price={}", i, effExit); } continue; } // ── LONG_ONLY 모드: 표준 포지션 제약 로직 ──────────────────────────── if (!inPosition) { if (i - lastExitBar <= reentryWait && lastExitBar >= 0) continue; boolean doEnter = entryRule.isSatisfied(i, record); if (!doEnter && "SHORT".equals(direction)) doEnter = exitRule.isSatisfied(i, record); if (doEnter) { double effEntry = applySlippage(closePrice, cfg, true); double qty = enterPosition(ledger, useLedger, equity, tradeSizePct, effEntry, closePrice, record, i, series, time); if (qty > 0) { String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY"; signals.add(buildFillSignal(time, sigType, effEntry, i, qty)); entryPrice = effEntry; entryBarIdx = i; inPosition = true; partialDone = false; if (useLedger) equity = ledger.portfolioValue(closePrice); } } } else { // 분할 청산: exit 조건 처음 충족 시 일부만 청산 if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) { double effExit = applySlippage(exitPrice, cfg, false); double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, partialPct, direction, cfg, simGrossProfit, simGrossLoss, time, i); if (qty > 0) { signals.add(buildFillSignal(time, "PARTIAL_SELL", effExit, i, qty)); partialDone = true; if (useLedger) equity = ledger.portfolioValue(closePrice); } if (useLedger) ledger.markToMarket(closePrice); continue; } if (exitRule.isSatisfied(i, record)) { double effExit = applySlippage(exitPrice, cfg, false); double size = partialDone ? (1.0 - partialPct) : 1.0; double qty = applyExit(ledger, useLedger, equity, tradeSizePct, entryPrice, effExit, size, direction, cfg, simGrossProfit, simGrossLoss, time, i); if (qty > 0) { Num numExitPrice = series.numFactory().numOf(effExit); Num numShares = record.getCurrentPosition().getEntry().getAmount(); record.exit(i, numExitPrice, numShares); String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL"; signals.add(buildFillSignal(time, sigType, effExit, i, qty)); inPosition = false; lastExitBar = i; partialDone = false; if (useLedger) equity = ledger.portfolioValue(closePrice); } } } if (useLedger) ledger.markToMarket(closePrice); } long buyCnt = signals.stream().filter(s -> "BUY".equals(s.getType()) || "SHORT_ENTRY".equals(s.getType())).count(); long sellCnt = signals.stream().filter(s -> "SELL".equals(s.getType()) || "SHORT_EXIT".equals(s.getType())).count(); log.info("[Backtest] 완료 — strategyId={}, bars={}, 생성시그널={}개 (매수={}, 매도={}), positionMode={}", req.getStrategyId(), barCount, signals.size(), buyCnt, sellCnt, posMode); double lastMarkPrice = barCount > 0 ? series.getBar(barCount - 1).getClosePrice().doubleValue() : 0.0; double finalEquity = resolveFinalEquity(ledger, useLedger, cfg, initCap, equity, inPosition, entryPrice, lastMarkPrice, tradeSizePct, partialDone, partialPct, direction, simGrossProfit, simGrossLoss); // 체결(ledger) 기준 거래 통계 — Ta4j TradingRecord와 분리 PortfolioLedger.TradeStats executedStats = ledger != null ? ledger.tradeStats() : null; if (executedStats != null && executedStats.closedCount > 0) { int ta4jWins = countTa4jWinningPositions(record); if (ta4jWins != executedStats.winning) { log.debug("[Backtest] 승률: ledger 순손익 {}승 (Ta4j 가격기준 {}승) — ledger 기준 적용", executedStats.winning, ta4jWins); } } // ── AnalysisCriterion 전체 계산 ─────────────────────────────────────── BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, finalEquity, ledger, executedStats, lastMarkPrice, simGrossProfit[0], simGrossLoss[0]); // ── Stats (하위 호환) ───────────────────────────────────────────────── Stats stats = toStats(analysis, signals); // ── DB 저장 ─────────────────────────────────────────────────────────── Long resultId = saveResult(req, cfg, signals, analysis, series, strategyName, execBuyDsl, execSellDsl, execParams); return BacktestResponse.builder() .signals(signals) .stats(stats) .analysis(analysis) .resultId(resultId) .build(); } // ── Ta4j AnalysisCriterion 전체 계산 ───────────────────────────────────── private BacktestAnalysisDto calcAnalysis(BarSeries series, TradingRecord record, BacktestSettingsDto cfg, double initCap, double finalEquity, PortfolioLedger ledger, PortfolioLedger.TradeStats executedStats, double lastMarkPrice, double simGrossProfit, double simGrossLoss) { String analysisMethod = ledger != null ? ledger.analysisMethod : PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod()); double cashBalance = ledger != null ? ledger.cash : 0.0; double holdingsValue = ledger != null ? ledger.shares * lastMarkPrice : 0.0; double realizedPnl = ledger != null ? ledger.realizedPnl : (simGrossProfit + simGrossLoss); double unrealizedPnl = ledger != null ? ledger.unrealizedPnl(lastMarkPrice) : 0.0; double grossProfit = ledger != null ? ledger.grossProfit : simGrossProfit; double grossLoss = ledger != null ? ledger.grossLoss : simGrossLoss; BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder() .initialCapital(initCap) .finalEquity(finalEquity) .analysisMethod(analysisMethod) .cashBalance(cashBalance) .holdingsValue(holdingsValue) .realizedPnl(realizedPnl) .unrealizedPnl(unrealizedPnl); double totalReturnPct = initCap > 0 ? (finalEquity - initCap) / initCap : 0.0; double profitLossRatio = 0.0; int positions = 0; int winning = 0; int losing = 0; int breakEven = 0; double winRate = 0.0; double avgReturnPct = 0.0; double maxDrawdown; double sharpe; double sortino; double calmar; if (executedStats != null && executedStats.closedCount > 0) { // LONG PortfolioLedger: 수수료·슬리피지 반영 실현손익 기준 (실매매와 동일) positions = executedStats.closedCount; winning = executedStats.winning; losing = executedStats.losing; breakEven = executedStats.breakEven; winRate = executedStats.winRate; avgReturnPct = executedStats.avgReturnPct; maxDrawdown = normalizeDrawdownPct(ledger.maxDrawdownPct()); sharpe = ledger.sharpeFromEquityCurve(); sortino = safeCalc(() -> calcCriterion( "org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; } else if (ledger != null) { // ledger 있으나 청산 0건 — Ta4j 승률 사용하지 않음 positions = 0; winning = 0; losing = 0; breakEven = 0; winRate = 0.0; avgReturnPct = 0.0; maxDrawdown = normalizeDrawdownPct(ledger.maxDrawdownPct()); sharpe = ledger.sharpeFromEquityCurve(); sortino = 0.0; calmar = 0.0; } else { positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record)); winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record)); losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record)); breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record)); winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record)); maxDrawdown = normalizeDrawdownPct(safeCalc(() -> calcMaxDrawdown(series, record))); sharpe = safeCalc(() -> calcSharpeRatio(series, record)); sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record)); calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0; if (positions == 0) positions = record.getPositionCount(); if (winning == 0 && positions > 0) { winning = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isPositive()).count(); losing = (int) record.getPositions().stream().filter(p -> p.isClosed() && p.getProfit().isNegative()).count(); breakEven = positions - winning - losing; } if (winRate == 0 && positions > 0) winRate = (double) winning / positions; if (positions > 0) { avgReturnPct = totalReturnPct / positions; } else { avgReturnPct = safeCalc(() -> calcCriterion( "org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record)); } } double maxRunup = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record)); double var95 = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ValueAtRiskCriterion", series, record)); double es = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.ExpectedShortfallCriterion", series, record)); double buyHoldPct = safeCalc(() -> calcBuyAndHold(series, record)); double vsBuyHold = (buyHoldPct != 0) ? (1 + totalReturnPct) / (1 + buyHoldPct) : 0.0; // 금액 기준 총 손익 재계산 double totalPnl = finalEquity - initCap; if (grossLoss != 0) { profitLossRatio = grossProfit / Math.abs(grossLoss); } else if (grossProfit > 0) { profitLossRatio = safeCalc(() -> calcCriterion( "org.ta4j.core.criteria.pnl.ProfitLossRatioCriterion", series, record)); } return b .totalReturnPct(totalReturnPct) .totalProfitLoss(totalPnl) .grossProfit(grossProfit) .grossLoss(grossLoss) .avgReturnPct(avgReturnPct) .profitLossRatio(profitLossRatio) .numberOfPositions(positions) .numberOfWinning(winning) .numberOfLosing(losing) .numberOfBreakEven(breakEven) .winRate(winRate) .maxDrawdownPct(maxDrawdown) .maxRunupPct(maxRunup) .sharpeRatio(sharpe) .sortinoRatio(sortino) .calmarRatio(calmar) .valueAtRisk95(var95) .expectedShortfall(es) .buyAndHoldReturnPct(buyHoldPct) .vsBuyAndHold(vsBuyHold) .build(); } /** MDD는 음수(낙폭)로 통일 */ private static double normalizeDrawdownPct(double dd) { if (dd == 0.0) return 0.0; return dd > 0 ? -dd : dd; } /** Ta4j 가격 기준 승수 (디버그·비교용, 레포트에는 미사용) */ private static int countTa4jWinningPositions(TradingRecord record) { if (record == null) return 0; return (int) record.getPositions().stream() .filter(p -> p.isClosed() && p.getProfit().isPositive()) .count(); } private static Signal buildFillSignal(long time, String type, double price, int barIndex, double quantity) { return Signal.builder() .time(time) .type(type) .price(price) .barIndex(barIndex) .quantity(quantity) .build(); } // ── 개별 Criterion 계산 헬퍼 ───────────────────────────────────────────── private double enterPosition(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct, double effEntry, double markPrice, BaseTradingRecord record, int barIndex, BarSeries series, long time) { if (useLedger) { double bought = ledger.executeBuy(effEntry, markPrice, time, barIndex); if (bought <= 1e-12) return 0.0; record.enter(barIndex, series.numFactory().numOf(effEntry), series.numFactory().numOf(bought)); return bought; } double shares = (equity * tradeSizePct) / effEntry; if (shares <= 1e-12) return 0.0; record.enter(barIndex, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares)); return shares; } /** @return 체결 수량 (ledger·LONG 경로), 레거시 비율 모델은 환산 수량 */ private double applyExit(PortfolioLedger ledger, boolean useLedger, double equity, double tradeSizePct, double entryPrice, double effExit, double sellFraction, String direction, BacktestSettingsDto cfg, double[] grossProfitAcc, double[] grossLossAcc, long time, int barIndex) { if (useLedger) { return ledger.executeSell(effExit, sellFraction, time, barIndex); } double commission = calcCommissionRate(cfg) * 2; double rawReturn = "SHORT".equals(direction) ? (entryPrice - effExit) / entryPrice : (effExit - entryPrice) / entryPrice; applyEquityPnl(equity, tradeSizePct, sellFraction, rawReturn - commission, grossProfitAcc, grossLossAcc); return sellFraction > 0 && entryPrice > 0 ? (equity * tradeSizePct * sellFraction) / entryPrice : 0.0; } private double resolveFinalEquity(PortfolioLedger ledger, boolean useLedger, BacktestSettingsDto cfg, double initCap, double equity, boolean inPosition, double entryPrice, double lastMarkPrice, double tradeSizePct, boolean partialDone, double partialPct, String direction, double[] grossProfitAcc, double[] grossLossAcc) { if (useLedger && ledger != null) { return ledger.resolveFinalEquity(lastMarkPrice); } String method = PortfolioLedger.normalizeMethod(cfg.getAnalysisMethod()); if (inPosition && lastMarkPrice > 0 && entryPrice > 0 && PortfolioLedger.MARK_TO_MARKET.equals(method)) { double commission = calcCommissionRate(cfg) * 2; double rawReturn = "SHORT".equals(direction) ? (entryPrice - lastMarkPrice) / entryPrice : (lastMarkPrice - entryPrice) / entryPrice; double size = partialDone ? (1.0 - partialPct) : 1.0; return applyEquityPnl(equity, tradeSizePct, size, rawReturn - commission, grossProfitAcc, grossLossAcc); } if (PortfolioLedger.REALIZED_ONLY.equals(method)) { return initCap + grossProfitAcc[0] + grossLossAcc[0]; } return equity; } /** SHORT/레거시: 비율 기반 equity 갱신 */ private double applyEquityPnl(double equity, double tradeSizePct, double size, double netReturn, double[] grossProfitAcc, double[] grossLossAcc) { double eqBefore = equity; double next = eqBefore + eqBefore * tradeSizePct * size * netReturn; double delta = next - eqBefore; if (delta >= 0) grossProfitAcc[0] += delta; else grossLossAcc[0] += delta; return next; } private double calcMaxDrawdown(BarSeries series, TradingRecord record) throws Exception { try { Class cls = Class.forName("org.ta4j.core.criteria.MaximumDrawdownCriterion"); AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); return criterion.calculate(series, record).doubleValue(); } catch (Exception ignored) {} return 0.0; } private double calcSharpeRatio(BarSeries series, TradingRecord record) throws Exception { try { Class cls = Class.forName("org.ta4j.core.criteria.SharpeRatioCriterion"); AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); return criterion.calculate(series, record).doubleValue(); } catch (Exception ignored) {} return 0.0; } private double calcBuyAndHold(BarSeries series, TradingRecord record) throws Exception { try { Class cls = Class.forName("org.ta4j.core.criteria.EnterAndHoldReturnCriterion"); AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); double v = criterion.calculate(series, record).doubleValue(); // 일부 버전은 비율 반환, 일부는 배수 반환 return v > 10 ? v / 100.0 : v; } catch (Exception ignored) {} // fallback: 첫봉~마지막봉 종가 변화율 if (series.getBarCount() >= 2) { double first = series.getBar(0).getClosePrice().doubleValue(); double last = series.getBar(series.getBarCount() - 1).getClosePrice().doubleValue(); return (last - first) / first; } return 0.0; } private double calcCriterion(String className, BarSeries series, TradingRecord record) throws Exception { Class cls = Class.forName(className); AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); return criterion.calculate(series, record).doubleValue(); } private int calcCriterionInt(String className, BarSeries series, TradingRecord record) throws Exception { Class cls = Class.forName(className); AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance(); return (int) Math.round(criterion.calculate(series, record).doubleValue()); } private double safeCalc(CriterionSupplier supplier) { try { return supplier.get(); } catch (Exception e) { log.debug("Criterion 계산 실패 (무시): {}", e.getMessage()); return 0.0; } } private int safeCalcInt(CriterionSupplier supplier) { try { return (int) Math.round(supplier.get()); } catch (Exception e) { return 0; } } @FunctionalInterface interface CriterionSupplier { double get() throws Exception; } // ── DB 저장 ─────────────────────────────────────────────────────────────── private Long saveResult(BacktestRequest req, BacktestSettingsDto cfg, List signals, BacktestAnalysisDto analysis, BarSeries series, String strategyName, JsonNode execBuyDsl, JsonNode execSellDsl, Map> execParams) { try { List bars = req.getBars(); long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime(); long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime(); Map snapshot = new LinkedHashMap<>(); snapshot.put("strategyId", req.getStrategyId()); snapshot.put("strategyName", strategyName); snapshot.put("symbol", req.getSymbol() != null ? req.getSymbol() : "UNKNOWN"); snapshot.put("timeframe", req.getTimeframe()); snapshot.put("barCount", bars.size()); if (execParams != null && !execParams.isEmpty()) snapshot.put("indicatorParams", execParams); if (execBuyDsl != null && !execBuyDsl.isNull()) snapshot.put("buyCondition", execBuyDsl); if (execSellDsl != null && !execSellDsl.isNull()) snapshot.put("sellCondition", execSellDsl); GcBacktestResult entity = GcBacktestResult.builder() .deviceId(req.getDeviceId()) .strategyId(req.getStrategyId()) .strategyName(strategyName) .symbol(req.getSymbol() != null ? req.getSymbol() : "UNKNOWN") .timeframe(req.getTimeframe()) .barCount(bars.size()) .fromTime(fromTime) .toTime(toTime) .settingsJson(objectMapper.writeValueAsString(cfg)) .executionSnapshotJson(objectMapper.writeValueAsString(snapshot)) .signalsJson(objectMapper.writeValueAsString(signals)) .analysisJson(objectMapper.writeValueAsString(analysis)) .totalReturn(BigDecimal.valueOf(analysis.getTotalReturnPct())) .winRate(BigDecimal.valueOf(analysis.getWinRate())) .totalTrades(analysis.getNumberOfPositions()) .maxDrawdown(BigDecimal.valueOf(analysis.getMaxDrawdownPct())) .sharpeRatio(BigDecimal.valueOf(analysis.getSharpeRatio())) .finalEquity(BigDecimal.valueOf(analysis.getFinalEquity())) .build(); return resultRepository.save(entity).getId(); } catch (Exception e) { log.warn("백테스팅 결과 DB 저장 실패: {}", e.getMessage()); return null; } } // ── Stats 변환 (하위 호환) ──────────────────────────────────────────────── private Stats toStats(BacktestAnalysisDto a, List signals) { int buySignals = (int) signals.stream().filter(s -> "BUY".equals(s.getType()) || "SHORT_ENTRY".equals(s.getType())).count(); int sellSignals = (int) signals.stream().filter(s -> "SELL".equals(s.getType()) || "SHORT_EXIT".equals(s.getType())).count(); return Stats.builder() .totalSignals(signals.size()) .buySignals(buySignals) .sellSignals(sellSignals) .totalTrades(a.getNumberOfPositions()) .winTrades(a.getNumberOfWinning()) .winRate(a.getWinRate()) .totalReturn(a.getTotalReturnPct()) .maxDrawdown(a.getMaxDrawdownPct()) .avgReturn(a.getAvgReturnPct()) .finalEquity(a.getFinalEquity()) .build(); } // ── 가격 결정 ───────────────────────────────────────────────────────────── private double getPrice(BarSeries series, List bars, int i, String priceType) { if ("NEXT_OPEN".equals(priceType) && i + 1 < bars.size()) return bars.get(i + 1).getOpen(); if ("OPEN".equals(priceType)) return series.getBar(i).getOpenPrice().doubleValue(); if ("HIGH".equals(priceType)) return series.getBar(i).getHighPrice().doubleValue(); if ("LOW".equals(priceType)) return series.getBar(i).getLowPrice().doubleValue(); return series.getBar(i).getClosePrice().doubleValue(); } private double applySlippage(double price, BacktestSettingsDto cfg, boolean isBuy) { double slip = cfg.getSlippageRate() != null ? cfg.getSlippageRate().doubleValue() : 0.0005; return isBuy ? price * (1 + slip) : price * (1 - slip); } private double calcCommissionRate(BacktestSettingsDto cfg) { if ("ZERO".equals(cfg.getCommissionType())) return 0.0; return cfg.getCommissionRate() != null ? cfg.getCommissionRate().doubleValue() : 0.0015; } // ── BarSeries 빌드 ──────────────────────────────────────────────────────── private BarSeries buildSeries(List bars, String timeframe) { BarSeries series = new BaseBarSeriesBuilder() .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) .build(); TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); Duration period = timeframeToDuration(timeframe); for (OhlcvBar b : bars) { // b.getTime() = 봉 시작 시각. ta4j Bar.endTime = 봉 종료 시각이므로 duration 가산. Instant endInst = Instant.ofEpochSecond(b.getTime()).plus(period); factory.createBarBuilder(series) .timePeriod(period).endTime(endInst) .openPrice(b.getOpen()).highPrice(b.getHigh()) .lowPrice(b.getLow()).closePrice(b.getClose()) .volume(b.getVolume()).add(); } return series; } private static Duration timeframeToDuration(String tf) { if (tf == null) return Duration.ofMinutes(1); return switch (tf) { case "1m" -> Duration.ofMinutes(1); case "3m" -> Duration.ofMinutes(3); case "5m" -> Duration.ofMinutes(5); case "10m" -> Duration.ofMinutes(10); case "15m" -> Duration.ofMinutes(15); case "30m" -> Duration.ofMinutes(30); case "1h" -> Duration.ofHours(1); case "4h" -> Duration.ofHours(4); case "1d", "1D" -> Duration.ofDays(1); case "1w", "1W" -> Duration.ofDays(7); case "1M" -> Duration.ofDays(30); default -> Duration.ofMinutes(1); }; } // ── 멀티 타임프레임 지원 ───────────────────────────────────────────────── /** * 기본봉 시리즈 + DSL 에서 추출한 상위봉 집계 시리즈로 seriesOverrides 맵을 구성한다. * 백테스트에서 라이브와 동일한 멀티 TF 시그널 평가를 보장한다. */ private Map buildSeriesOverrides(BarSeries primarySeries, String primaryTf, JsonNode buyDsl, JsonNode sellDsl) { Set requiredTfs = new LinkedHashSet<>(); collectTimeframesFromDsl(buyDsl, requiredTfs); collectTimeframesFromDsl(sellDsl, requiredTfs); requiredTfs.remove(primaryTf); Map overrides = new LinkedHashMap<>(); overrides.put(primaryTf, primarySeries); for (String tf : requiredTfs) { Duration targetDur = timeframeToDuration(tf); Duration primaryDur = timeframeToDuration(primaryTf); if (targetDur.compareTo(primaryDur) <= 0) continue; // 같거나 하위봉은 기본 시리즈 사용 BarSeries aggregated = aggregateSeries(primarySeries, primaryDur, targetDur, tf); if (aggregated.getBarCount() > 0) { overrides.put(tf, aggregated); log.info("[Backtest] 멀티 TF 집계: {} bars → {} ({}봉)", primaryTf, tf, aggregated.getBarCount()); } } return overrides; } /** * DSL 트리를 재귀 탐색하여 TIMEFRAME 노드의 candleType 값을 수집한다. */ private void collectTimeframesFromDsl(JsonNode node, Set result) { if (node == null || node.isNull()) return; String type = node.path("type").asText(""); if ("TIMEFRAME".equals(type)) { String ct = node.path("candleType").asText(""); if (!ct.isBlank()) result.add(normalizeTf(ct)); } JsonNode children = node.path("children"); if (children.isArray()) { for (JsonNode child : children) collectTimeframesFromDsl(child, result); } JsonNode child = node.path("child"); if (!child.isMissingNode() && !child.isNull()) collectTimeframesFromDsl(child, result); JsonNode cond = node.path("condition"); if (!cond.isMissingNode() && !cond.isNull()) { // 복합지표 leftCandleType / rightCandleType String leftCt = cond.path("leftCandleType").asText(""); String rightCt = cond.path("rightCandleType").asText(""); if (!leftCt.isBlank()) result.add(normalizeTf(leftCt)); if (!rightCt.isBlank()) result.add(normalizeTf(rightCt)); } } /** * primarySeries(하위봉) → targetDur(상위봉) 으로 OHLCV 집계. * 각 타깃 봉 버킷에 속하는 하위봉들을 그룹핑하여 새 BarSeries 를 생성한다. */ private BarSeries aggregateSeries(BarSeries primary, Duration primaryDur, Duration targetDur, String targetTf) { long primarySecs = primaryDur.getSeconds(); long targetSecs = targetDur.getSeconds(); BarSeries result = new BaseBarSeriesBuilder() .withNumFactory(org.ta4j.core.num.DoubleNumFactory.getInstance()) .build(); TimeBarBuilderFactory factory = new TimeBarBuilderFactory(); // 하위봉을 상위봉 버킷별로 그룹핑 (버킷 시작 epoch 초 → 봉 목록) Map> grouped = new LinkedHashMap<>(); for (int i = primary.getBeginIndex(); i <= primary.getEndIndex(); i++) { Bar bar = primary.getBar(i); // bar.endTime = 봉 종료 시각 → 봉 시작 = endTime - primaryDur long barStartSec = bar.getEndTime().getEpochSecond() - primarySecs; long bucketStart = (barStartSec / targetSecs) * targetSecs; grouped.computeIfAbsent(bucketStart, k -> new ArrayList<>()).add(bar); } for (Map.Entry> entry : grouped.entrySet()) { List bars = entry.getValue(); if (bars.isEmpty()) continue; Instant bucketEnd = Instant.ofEpochSecond(entry.getKey()).plus(targetDur); double open = bars.get(0).getOpenPrice().doubleValue(); double high = bars.stream().mapToDouble(b -> b.getHighPrice().doubleValue()).max().orElse(open); double low = bars.stream().mapToDouble(b -> b.getLowPrice().doubleValue()).min().orElse(open); double close = bars.get(bars.size() - 1).getClosePrice().doubleValue(); double volume = bars.stream().mapToDouble(b -> b.getVolume().doubleValue()).sum(); try { factory.createBarBuilder(result) .timePeriod(targetDur).endTime(bucketEnd) .openPrice(open).highPrice(high).lowPrice(low) .closePrice(close).volume(volume).add(); } catch (Exception e) { log.debug("[Backtest] 상위봉 집계 실패 (tf={} bucket={}): {}", targetTf, entry.getKey(), e.getMessage()); } } return result; } /** timeframe 문자열 정규화 (1m, 3m, 5m ... 1h, 4h, 1d, 1w) */ private static String normalizeTf(String tf) { if (tf == null) return "1m"; return switch (tf.toLowerCase()) { case "1", "1m" -> "1m"; case "3", "3m" -> "3m"; case "5", "5m" -> "5m"; case "10", "10m" -> "10m"; case "15", "15m" -> "15m"; case "30", "30m" -> "30m"; case "60", "1h" -> "1h"; case "240", "4h" -> "4h"; case "1d", "d" -> "1d"; case "1w", "w" -> "1w"; default -> tf; }; } /** * DB 기본 파라미터 위에 요청 파라미터를 병합한다 (요청값 우선). * *
    *
  • base: DB에서 로드한 사용자 저장 인디케이터 파라미터
  • *
  • override: 요청에 포함된 파라미터 (없으면 null)
  • *
* 같은 indicatorType 키가 있을 경우 override 내부 필드가 base를 덮어씌운다. */ private static Map> mergeIndicatorParams( Map> base, Map> override) { if (override == null || override.isEmpty()) return base; if (base == null || base.isEmpty()) return override; Map> merged = new java.util.LinkedHashMap<>(base); override.forEach((type, overrideParams) -> { Map baseParams = merged.get(type); if (baseParams == null || baseParams.isEmpty()) { merged.put(type, overrideParams); } else { Map inner = new java.util.LinkedHashMap<>(baseParams); inner.putAll(overrideParams); merged.put(type, inner); } }); return merged; } private BacktestResponse emptyResponse(String reason) { log.warn("[BacktestingService] 빈 결과: {}", reason); BacktestAnalysisDto emptyAnalysis = BacktestAnalysisDto.builder().build(); return BacktestResponse.builder() .signals(List.of()) .stats(Stats.builder().build()) .analysis(emptyAnalysis) .build(); } }