Files
goldenChart/backend/src/main/java/com/goldenchart/service/BacktestingService.java
T
2026-06-08 09:38:01 +09:00

849 lines
43 KiB
Java

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 기반 백테스팅 실행 서비스.
*
* <ul>
* <li>TradingRecord 를 정상 populate → AnalysisCriterion 전체 활용</li>
* <li>StopLoss / StopGain / TrailingStop / Commission / Slippage 반영</li>
* <li>실행 결과를 gc_backtest_result 테이블에 저장</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class BacktestingService {
private final StrategyDslToTa4jAdapter adapter;
private final GcStrategyRepository strategyRepository;
private final GcBacktestResultRepository resultRepository;
private final ObjectMapper objectMapper;
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<GcStrategy> 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 파싱 오류");
}
}
Map<String, Map<String, Object>> params = req.getIndicatorParams() != null
? req.getIndicatorParams() : Map.of();
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<String, BarSeries> 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);
}
// ── 청산 규칙 합성 ────────────────────────────────────────────────────────
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) {
BaseTradingRecord record = new BaseTradingRecord();
List<Signal> 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);
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 모드: 지표 시그널은 포지션 무관, 체결·레포트는 LONG_ONLY와 동일 ──
if (signalOnly) {
if (!inPosition) {
if (i - lastExitBar <= reentryWait && lastExitBar >= 0) {
if (useLedger) ledger.markToMarket(closePrice);
continue;
}
if (entryRule.isSatisfied(i)) {
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 {
if (partialExit && !partialDone && exitRule.isSatisfied(i)) {
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);
}
} else if (exitRule.isSatisfied(i)) {
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);
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);
}
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);
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<Signal> signals, BacktestAnalysisDto analysis,
BarSeries series, String strategyName) {
try {
List<OhlcvBar> bars = req.getBars();
long fromTime = bars.isEmpty() ? 0 : bars.get(0).getTime();
long toTime = bars.isEmpty() ? 0 : bars.get(bars.size() - 1).getTime();
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))
.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<Signal> 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<OhlcvBar> 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<OhlcvBar> 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<String, BarSeries> buildSeriesOverrides(BarSeries primarySeries, String primaryTf,
JsonNode buyDsl, JsonNode sellDsl) {
Set<String> requiredTfs = new LinkedHashSet<>();
collectTimeframesFromDsl(buyDsl, requiredTfs);
collectTimeframesFromDsl(sellDsl, requiredTfs);
requiredTfs.remove(primaryTf);
Map<String, BarSeries> 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<String> 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<Long, List<Bar>> 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<Long, List<Bar>> entry : grouped.entrySet()) {
List<Bar> 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;
};
}
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();
}
}