goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
@@ -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();
}
}