goldenChat base source add
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
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("유효한 캔들 데이터가 없습니다.");
|
||||
|
||||
Rule entryRule = adapter.toRule(buyDsl, series, params);
|
||||
Rule baseExitRule = (sellDsl != null && !sellDsl.isNull())
|
||||
? adapter.toRule(sellDsl, series, params)
|
||||
: 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;
|
||||
|
||||
double equity = initCap;
|
||||
|
||||
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 enterOk = entryRule.isSatisfied(i);
|
||||
boolean exitOk = exitRule.isSatisfied(i);
|
||||
|
||||
if (enterOk) {
|
||||
double effEntry = applySlippage(closePrice, cfg, true);
|
||||
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||
signals.add(Signal.builder()
|
||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
||||
// 실제 포지션 추적은 LONG_ONLY 모드와 동일하게 유지 (수익 계산용)
|
||||
if (!inPosition) {
|
||||
double shares = (equity * tradeSizePct) / effEntry;
|
||||
record.enter(i, series.numFactory().numOf(effEntry), series.numFactory().numOf(shares));
|
||||
entryPrice = effEntry;
|
||||
entryBarIdx = i;
|
||||
inPosition = true;
|
||||
partialDone = false;
|
||||
}
|
||||
} else if (exitOk) {
|
||||
double effExit = applySlippage(exitPrice, cfg, false);
|
||||
String sigType = "SHORT".equals(direction) ? "SHORT_EXIT" : "SELL";
|
||||
signals.add(Signal.builder()
|
||||
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
||||
// 수익 계산: 실제 포지션이 있을 때만
|
||||
if (inPosition) {
|
||||
double commission = calcCommissionRate(cfg) * 2;
|
||||
double rawReturn = "SHORT".equals(direction)
|
||||
? (entryPrice - effExit) / entryPrice
|
||||
: (effExit - entryPrice) / entryPrice;
|
||||
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||
equity += equity * tradeSizePct * size * (rawReturn - commission);
|
||||
Num numExitPrice = series.numFactory().numOf(effExit);
|
||||
Num numShares = record.getCurrentPosition().getEntry().getAmount();
|
||||
record.exit(i, numExitPrice, numShares);
|
||||
inPosition = false;
|
||||
lastExitBar = i;
|
||||
partialDone = false;
|
||||
}
|
||||
}
|
||||
continue; // SIGNAL_ONLY 처리 완료, 다음 봉으로
|
||||
}
|
||||
|
||||
// ── 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 shares = (equity * tradeSizePct) / effEntry;
|
||||
|
||||
Num numPrice = series.numFactory().numOf(effEntry);
|
||||
Num numShares = series.numFactory().numOf(shares);
|
||||
record.enter(i, numPrice, numShares);
|
||||
|
||||
String sigType = "SHORT".equals(direction) ? "SHORT_ENTRY" : "BUY";
|
||||
signals.add(Signal.builder()
|
||||
.time(time).type(sigType).price(effEntry).barIndex(i).build());
|
||||
|
||||
entryPrice = effEntry;
|
||||
entryBarIdx = i;
|
||||
inPosition = true;
|
||||
partialDone = false;
|
||||
}
|
||||
} else {
|
||||
// 분할 청산: exit 조건 처음 충족 시 일부만 청산
|
||||
if (partialExit && !partialDone && exitRule.isSatisfied(i, record)) {
|
||||
double effExit = applySlippage(exitPrice, cfg, false);
|
||||
double partShares = record.getCurrentPosition().getEntry().getAmount().doubleValue() * partialPct;
|
||||
double partReturn = "SHORT".equals(direction)
|
||||
? (entryPrice - effExit) / entryPrice
|
||||
: (effExit - entryPrice) / entryPrice;
|
||||
double commission = calcCommissionRate(cfg) * 2;
|
||||
equity += equity * tradeSizePct * partialPct * (partReturn - commission);
|
||||
|
||||
signals.add(Signal.builder()
|
||||
.time(time).type("PARTIAL_SELL").price(effExit).barIndex(i).build());
|
||||
partialDone = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exitRule.isSatisfied(i, record)) {
|
||||
double effExit = applySlippage(exitPrice, cfg, false);
|
||||
double commission = calcCommissionRate(cfg) * 2;
|
||||
double rawReturn = "SHORT".equals(direction)
|
||||
? (entryPrice - effExit) / entryPrice
|
||||
: (effExit - entryPrice) / entryPrice;
|
||||
double netReturn = rawReturn - commission;
|
||||
|
||||
double size = partialDone ? (1.0 - partialPct) : 1.0;
|
||||
equity += equity * tradeSizePct * size * netReturn;
|
||||
|
||||
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(Signal.builder()
|
||||
.time(time).type(sigType).price(effExit).barIndex(i).build());
|
||||
|
||||
inPosition = false;
|
||||
lastExitBar = i;
|
||||
partialDone = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── AnalysisCriterion 전체 계산 ───────────────────────────────────────
|
||||
BacktestAnalysisDto analysis = calcAnalysis(series, record, cfg, initCap, equity);
|
||||
|
||||
// ── 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) {
|
||||
BacktestAnalysisDto.BacktestAnalysisDtoBuilder b = BacktestAnalysisDto.builder()
|
||||
.initialCapital(initCap)
|
||||
.finalEquity(finalEquity);
|
||||
|
||||
double totalReturnPct = safeCalc(() -> calcTotalReturnPct(series, record));
|
||||
double grossProfit = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossProfitCriterion", series, record));
|
||||
double grossLoss = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.GrossLossCriterion", series, record));
|
||||
double avgReturnPct = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.AverageProfitLossCriterion", series, record));
|
||||
double profitLossRatio = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.pnl.ProfitLossRatioCriterion", series, record));
|
||||
|
||||
int positions = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfPositionsCriterion", series, record));
|
||||
int winning = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfWinningPositionsCriterion", series, record));
|
||||
int losing = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfLosingPositionsCriterion", series, record));
|
||||
int breakEven = safeCalcInt(() -> calcCriterionInt("org.ta4j.core.criteria.NumberOfBreakEvenPositionsCriterion", series, record));
|
||||
double winRate = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.WinningPositionsRatioCriterion", series, record));
|
||||
|
||||
double maxDrawdown = safeCalc(() -> calcMaxDrawdown(series, record));
|
||||
double maxRunup = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.MaximumRunupCriterion", series, record));
|
||||
double sharpe = safeCalc(() -> calcSharpeRatio(series, record));
|
||||
double sortino = safeCalc(() -> calcCriterion("org.ta4j.core.criteria.SortinoRatioCriterion", series, record));
|
||||
double calmar = (maxDrawdown != 0) ? totalReturnPct / Math.abs(maxDrawdown) : 0.0;
|
||||
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;
|
||||
|
||||
// positions = 0 이면 record에서 직접 추출 시도
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// ── 개별 Criterion 계산 헬퍼 ─────────────────────────────────────────────
|
||||
|
||||
private double calcTotalReturnPct(BarSeries series, TradingRecord record) throws Exception {
|
||||
// 총 수익률 = (finalEquity - initialCapital) / initialCapital
|
||||
// TotalProfitLossPercentageCriterion 또는 ReturnCriterion 시도
|
||||
try {
|
||||
Class<?> cls = Class.forName("org.ta4j.core.criteria.pnl.TotalProfitLossPercentageCriterion");
|
||||
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
|
||||
return criterion.calculate(series, record).doubleValue();
|
||||
} catch (Exception ignored) {}
|
||||
try {
|
||||
Class<?> cls = Class.forName("org.ta4j.core.criteria.ReturnCriterion");
|
||||
AnalysisCriterion criterion = (AnalysisCriterion) cls.getDeclaredConstructor().newInstance();
|
||||
return criterion.calculate(series, record).doubleValue() - 1.0;
|
||||
} catch (Exception ignored) {}
|
||||
// fallback: 거래 수익 직접 계산
|
||||
return record.getPositions().stream()
|
||||
.filter(Position::isClosed)
|
||||
.mapToDouble(p -> p.getProfit().doubleValue() / p.getEntry().getNetPrice().doubleValue())
|
||||
.sum();
|
||||
}
|
||||
|
||||
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 "15m" -> Duration.ofMinutes(15);
|
||||
case "30m" -> Duration.ofMinutes(30);
|
||||
case "1h" -> Duration.ofHours(1);
|
||||
case "4h" -> Duration.ofHours(4);
|
||||
case "1D" -> Duration.ofDays(1);
|
||||
case "1W" -> Duration.ofDays(7);
|
||||
case "1M" -> Duration.ofDays(30);
|
||||
default -> Duration.ofMinutes(1);
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user