849 lines
43 KiB
Java
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();
|
|
}
|
|
}
|