diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRequest.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRequest.java index 68258b9b..07d12e45 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRequest.java @@ -4,7 +4,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; -import org.sejongisc.backend.user.entity.User; import java.time.LocalDate; import java.util.List; diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestResponse.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestResponse.java index 54da487b..14ca7364 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestResponse.java @@ -5,10 +5,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.sejongisc.backend.backtest.entity.BacktestRun; -import org.sejongisc.backend.backtest.entity.BacktestStatus; -import org.sejongisc.backend.template.entity.Template; - -import java.time.LocalDate; import java.util.List; diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunMetricsResponse.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunMetricsResponse.java index 64723c1b..784bf3c7 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunMetricsResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunMetricsResponse.java @@ -1,37 +1,28 @@ package org.sejongisc.backend.backtest.dto; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; import org.sejongisc.backend.backtest.entity.BacktestRunMetrics; import java.math.BigDecimal; - -@Getter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class BacktestRunMetricsResponse { - private Long id; - private BigDecimal totalReturn; // 총 수익률 - private BigDecimal maxDrawdown; // 최대 낙폭 - private BigDecimal sharpeRatio; // 샤프 지수 - private BigDecimal avgHoldDays; // 평균 보유 기간 - private int tradesCount; // 총 거래 횟수 - private String assetCurveJson; - +public record BacktestRunMetricsResponse( + Long id, + BigDecimal totalReturn, // 총 수익률 + BigDecimal maxDrawdown, // 최대 낙폭 + BigDecimal sharpeRatio, // 샤프 지수 + BigDecimal avgHoldDays, // 평균 보유 기간 + int tradesCount, // 총 거래 횟수 + String assetCurveJson +) { public static BacktestRunMetricsResponse fromEntity(BacktestRunMetrics backtestRunMetrics) { - return BacktestRunMetricsResponse.builder() - .id(backtestRunMetrics.getId()) - .totalReturn(backtestRunMetrics.getTotalReturn()) - .maxDrawdown(backtestRunMetrics.getMaxDrawdown()) - .sharpeRatio(backtestRunMetrics.getSharpeRatio()) - .avgHoldDays(backtestRunMetrics.getAvgHoldDays()) - .tradesCount(backtestRunMetrics.getTradesCount()) - .assetCurveJson(backtestRunMetrics.getAssetCurveJson()) - .build(); + return new BacktestRunMetricsResponse( + backtestRunMetrics.getId(), + backtestRunMetrics.getTotalReturn(), + backtestRunMetrics.getMaxDrawdown(), + backtestRunMetrics.getSharpeRatio(), + backtestRunMetrics.getAvgHoldDays(), + backtestRunMetrics.getTradesCount(), + backtestRunMetrics.getAssetCurveJson() + ); } -} +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java index 059b9128..d2b75a63 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java @@ -6,7 +6,6 @@ import lombok.NoArgsConstructor; import java.math.BigDecimal; -import java.time.LocalDate; import java.util.List; /** diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java index be2ccc6f..503e08bf 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java @@ -1,28 +1,22 @@ package org.sejongisc.backend.backtest.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - /** * 전략 조건 한 줄 (Operand + Operator + Operand) * 예: [SMA(20)] [GT] [Close] */ -@Getter -@NoArgsConstructor -@AllArgsConstructor -public class StrategyCondition { - @Schema(description = "좌향") - private StrategyOperand leftOperand; +import io.swagger.v3.oas.annotations.media.Schema; + +public record StrategyCondition( + + @Schema(description = "좌향 (Operand)") + StrategyOperand leftOperand, - @Schema(description = "연산자 (예: \"GT\", \"LT\", \"CROSSES_ABOVE\")") - private String operator; + @Schema(description = "연산자 (예: \"GT\", \"LT\", \"CROSSES_ABOVE\")") + String operator, - @Schema(description = "우향") - private StrategyOperand rightOperand; + @Schema(description = "우향 (Operand)") + StrategyOperand rightOperand, - @Schema(description = "\"무조건 행동\" 조건인지 여부 (true = 이 조건이 맞으면 다른 '일반' 조건 무시, false = 이 조건은 일반 조건)") - private boolean isAbsolute; -} \ No newline at end of file + @Schema(description = "\"무조건 행동\" 조건인지 여부 (true = OR 조건, false = AND 조건)") + boolean isAbsolute +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java index 57053348..4ce3304c 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java @@ -1,41 +1,40 @@ package org.sejongisc.backend.backtest.dto; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import java.util.Map; -import lombok.Getter; -import lombok.Setter; -@Getter -@Setter -public class StrategyOperand { +public record StrategyOperand( - @Schema(description = "유형 (price: 가격데이터, const: 상수, indicator: 보조지표)", example = "indicator") - private String type; // 'price', 'const', 'indicator' + @Schema(description = "유형 (price: 가격데이터, const: 상수, indicator: 보조지표)", example = "indicator") + @NotNull(message = "피연산자 유형은 필수입니다.") + String type, // 'price', 'const', 'indicator' - @Schema(description = "지표 코드 (SMA, EMA, RSI, MACD, BB, STOCH, CCI, ATR, ADX)", example = "BB") - private String indicatorCode; + @Schema(description = "지표 코드 (SMA, EMA, RSI, MACD, BB, STOCH, CCI, ATR, ADX)", example = "BB") + String indicatorCode, - @Schema(description = "가격 기준 (Close, Open, High, Low, Volume)", example = "Close") - private String priceField; + @Schema(description = "가격 기준 (Close, Open, High, Low, Volume)", example = "Close") + String priceField, - @Schema(description = "상수 값 (type이 const일 때 사용)", example = "30") - private Double constantValue; + @Schema(description = "상수 값 (type이 const일 때 사용)", example = "30") + Double constantValue, - @Schema(description = """ + @Schema(description = """ 지표별 결과값 선택 (다중 출력 지표용): - BB (볼린저밴드): upper, middle, lower - MACD: macd, signal, hist - STOCH (스토캐스틱): k, d - 나머지 단일 지표는 null 혹은 생략 가능 """, example = "lower") - private String output; + String output, - @Schema(description = """ + @Schema(description = """ 지표별 필수 파라미터 (Map 형식): - SMA, EMA, RSI, CCI, ATR, ADX: { "length": 14 } - MACD: { "fast": 12, "slow": 26, "signal": 9 } - BB (볼린저밴드): { "length": 20, "k": 2.0 } - STOCH (스토캐스틱): { "kLength": 14, "dLength": 3 } """, example = "{\"length\": 20, \"k\": 2.0}") - private Map params; -} \ No newline at end of file + @NotNull(message = "파라미터 맵은 필수입니다. (비어있더라도 {} 전달)") + Map params +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeLog.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeLog.java index 556697c9..1daff4c4 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeLog.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeLog.java @@ -3,21 +3,12 @@ import java.math.BigDecimal; import java.time.LocalDateTime; -public class TradeLog { - public enum Type { - BUY, - SELL, - SELL_FORCED // 기본 청산 기간에 의한 강제 매도 구분용 추가 - } - public final Type type; - public final LocalDateTime time; - public final BigDecimal price; - public final BigDecimal shares; - - public TradeLog(Type type, LocalDateTime time, BigDecimal price, BigDecimal shares) { - this.type = type; - this.time = time; - this.price = price; - this.shares = shares; - } -} \ No newline at end of file +/** + * 백테스팅 거래 기록 (불변 객체) + */ +public record TradeLog( + TradeType type, // Enum 변경 적용 + LocalDateTime time, + BigDecimal price, + BigDecimal shares +) {} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeType.java b/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeType.java new file mode 100644 index 00000000..2b844eb3 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/TradeType.java @@ -0,0 +1,14 @@ +package org.sejongisc.backend.backtest.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TradeType { + BUY("매수"), + SELL("매도"), + SELL_FORCED("기간 만료 강제 청산"); + + private final String description; +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java index 27703e55..cf96d555 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.backtest.dto.BacktestRunRequest; import org.sejongisc.backend.backtest.dto.TradeLog; +import org.sejongisc.backend.backtest.dto.TradeType; import org.sejongisc.backend.backtest.entity.BacktestRun; import org.sejongisc.backend.backtest.entity.BacktestRunMetrics; import org.sejongisc.backend.backtest.entity.BacktestStatus; @@ -30,6 +31,8 @@ import java.util.List; import java.util.Map; +import static org.sejongisc.backend.backtest.dto.TradeType.*; + @Service @RequiredArgsConstructor @Slf4j @@ -115,7 +118,7 @@ public void execute(BacktestRun backtestRun) { // 거래 로그 기록 if (buyShares.compareTo(BigDecimal.ZERO) > 0) { BigDecimal transactionCost = buyShares.multiply(currentClosePrice); - tradeLogs.add(new TradeLog(TradeLog.Type.BUY, currentTime, currentClosePrice, buyShares)); + tradeLogs.add(new TradeLog(BUY, currentTime, currentClosePrice, buyShares)); shares = shares.add(buyShares); // 매수 주식 수 cash = cash.subtract(transactionCost); // 잔고에서 매수 대금 차감 tradesCount++; // 거래 횟수 증가 @@ -131,7 +134,7 @@ else if (shares.compareTo(BigDecimal.ZERO) > 0 && (shouldSell || shouldExitByDay BigDecimal sharesToSell = shares.multiply(sellRatio).setScale(8, RoundingMode.DOWN); // 매도 비중 적용 BigDecimal tradeValue = sharesToSell.multiply(currentClosePrice); // 거래 로그 기록 - TradeLog.Type logType = shouldExitByDays ? TradeLog.Type.SELL_FORCED : TradeLog.Type.SELL; // 강제 청산 여부에 따른 로그 타입 설정 + TradeType logType = shouldExitByDays ? SELL_FORCED : SELL;// 강제 청산 여부에 따른 로그 타입 설정 tradeLogs.add(new TradeLog(logType, currentTime, currentClosePrice, sharesToSell)); shares = shares.subtract(sharesToSell); // 매도 주식 수 차감 cash = cash.add(tradeValue); // 잔고에서 매도 대금 추가 @@ -227,10 +230,10 @@ private BigDecimal calculateAvgHoldDays(List tradeLogs) { // 매수-매도 쌍을 찾아 보유 기간 계산 for (TradeLog log : tradeLogs) { - if (log.type == TradeLog.Type.BUY) { - currentBuyTime = log.time; - } else if ((log.type == TradeLog.Type.SELL || log.type == TradeLog.Type.SELL_FORCED) && currentBuyTime != null) { - long days = java.time.temporal.ChronoUnit.DAYS.between(currentBuyTime.toLocalDate(), log.time.toLocalDate()); + if (log.type() == BUY) { + currentBuyTime = log.time(); + } else if ((log.type() == SELL || log.type() == SELL_FORCED) && currentBuyTime != null) { + long days = java.time.temporal.ChronoUnit.DAYS.between(currentBuyTime.toLocalDate(), log.time().toLocalDate()); holdDurations.add(days); currentBuyTime = null; } diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java b/backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java index ea8bdc55..5b33f2fb 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java @@ -4,6 +4,7 @@ import org.sejongisc.backend.backtest.dto.StrategyCondition; import org.sejongisc.backend.backtest.dto.StrategyOperand; import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.stock.entity.PriceData; import org.springframework.stereotype.Service; import org.ta4j.core.BarSeries; @@ -24,11 +25,8 @@ import org.ta4j.core.indicators.bollinger.BollingerBandsUpperIndicator; import org.ta4j.core.indicators.statistics.StandardDeviationIndicator; import org.ta4j.core.indicators.StochasticOscillatorKIndicator; -import org.ta4j.core.indicators.StochasticOscillatorDIndicator; import org.ta4j.core.indicators.CCIIndicator; -import org.ta4j.core.indicators.ATRIndicator; import org.ta4j.core.indicators.adx.ADXIndicator; -import org.ta4j.core.indicators.bollinger.BollingerBandsMiddleIndicator; import java.time.ZoneId; import java.util.List; @@ -115,10 +113,10 @@ public Rule buildCombinedRule(List conditions, BarSeries seri */ private Rule buildSingleRule(StrategyCondition condition, BarSeries series, Map> indicatorCache) { - Indicator left = resolveOperand(condition.getLeftOperand(), series, indicatorCache); - Indicator right = resolveOperand(condition.getRightOperand(), series, indicatorCache); + Indicator left = resolveOperand(condition.leftOperand(), series, indicatorCache); + Indicator right = resolveOperand(condition.rightOperand(), series, indicatorCache); - return switch (condition.getOperator()) { + return switch (condition.operator()) { case "GT" -> new OverIndicatorRule(left, right); case "GTE" -> new IsEqualRule(left, right).or(new OverIndicatorRule(left, right)); case "LT" -> new UnderIndicatorRule(left, right); @@ -126,7 +124,7 @@ private Rule buildSingleRule(StrategyCondition condition, BarSeries series, case "EQ" -> new IsEqualRule(left, right); case "CROSSES_ABOVE" -> new CrossedUpIndicatorRule(left, right); case "CROSSES_BELOW" -> new CrossedDownIndicatorRule(left, right); - default -> throw new IllegalArgumentException("Unknown operator: " + condition.getOperator()); + default -> throw new CustomException(ErrorCode.INVALID_BACKTEST_PARAMS); }; } @@ -142,10 +140,9 @@ private Indicator resolveOperand(StrategyOperand operand, BarSeries series, return indicatorCache.get(key); } - Num indicatorOne = series.getBar(0).getClosePrice().numOf(1); // 1.0에 해당하는 Num - Indicator indicator = switch (operand.getType()) { - case "price" -> createPriceIndicator(operand.getPriceField(), series); + Indicator indicator = switch (operand.type()) { + case "price" -> createPriceIndicator(operand.priceField(), series); case "indicator" -> { Indicator baseIndicator = createIndicator(operand, series, indicatorCache); // Transform 로직 추가 @@ -158,10 +155,10 @@ private Indicator resolveOperand(StrategyOperand operand, BarSeries series, yield baseIndicator; } case "const" -> { - Num constValue = series.getBar(0).getClosePrice().numOf(operand.getConstantValue()); + Num constValue = series.getBar(0).getClosePrice().numOf(operand.constantValue()); yield new ConstantIndicator<>(series, constValue); } - default -> throw new IllegalArgumentException("Unknown operand type: " + operand.getType()); + default -> throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); }; indicatorCache.put(key, indicator); @@ -176,15 +173,15 @@ private Indicator createPriceIndicator(String field, BarSeries series) { case "Low" -> new LowPriceIndicator(series); case "Volume" -> new VolumeIndicator(series, 0); case "Close" -> new ClosePriceIndicator(series); - default -> throw new IllegalArgumentException("Unknown priceField: " + field); + default -> throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); }; } // 팩토리 헬퍼 2: 보조 지표 생성 private Indicator createIndicator(StrategyOperand operand, BarSeries series, Map> cache) { - String code = operand.getIndicatorCode(); - Map params = operand.getParams(); + String code = operand.indicatorCode(); + Map params = operand.params(); Indicator baseIndicator = createPriceIndicator("Close", series); Indicator closePrice = createPriceIndicator("Close", series); @@ -207,7 +204,7 @@ private Indicator createIndicator(StrategyOperand operand, BarSeries series MACDIndicator macd = new MACDIndicator(baseIndicator, fast, slow); Indicator signalLine = new EMAIndicator(macd, signal); - return switch (operand.getOutput()) { + return switch (operand.output()) { case "macd" -> macd; case "signal" -> signalLine; case "hist" -> new ManualMACDHistogramIndicator(macd, signalLine); @@ -227,7 +224,7 @@ private Indicator createIndicator(StrategyOperand operand, BarSeries series // 표준편차 생성 StandardDeviationIndicator sd = new StandardDeviationIndicator(closePrice, bbLength); - return switch (operand.getOutput() != null ? operand.getOutput() : "middle") { + return switch (operand.output() != null ? operand.output() : "middle") { // bbSma 대신 bbMiddle을 전달해야 함 case "upper" -> new BollingerBandsUpperIndicator(bbMiddle, sd, series.numOf(bbK)); case "lower" -> new BollingerBandsLowerIndicator(bbMiddle, sd, series.numOf(bbK)); @@ -245,7 +242,7 @@ private Indicator createIndicator(StrategyOperand operand, BarSeries series StochasticOscillatorKIndicator stochK = new StochasticOscillatorKIndicator(series, kLength); - if ("d".equalsIgnoreCase(operand.getOutput())) { + if ("d".equalsIgnoreCase(operand.output())) { // D 라인은 보통 K 라인의 SMA 입니다. return new SMAIndicator(stochK, dLength); } @@ -266,26 +263,26 @@ private Indicator createIndicator(StrategyOperand operand, BarSeries series int adxLength = ((Number) params.get("length")).intValue(); // 보통 14 return new ADXIndicator(series, adxLength); default: - throw new IllegalArgumentException("Unknown indicator code: " + code); + throw new CustomException(ErrorCode.BACKTEST_INDICATOR_NOT_FOUND); } } // Operand DTO 로부터 Map의 키를 생성 private String generateIndicatorKey(StrategyOperand operand) { if (operand == null) return "null_operand"; - switch (operand.getType()) { + switch (operand.type()) { case "price": - return operand.getPriceField(); + return operand.priceField(); case "const": - return "const_" + operand.getConstantValue().toString(); + return "const_" + operand.constantValue().toString(); case "indicator": - String params = operand.getParams().entrySet().stream() + String params = operand.params().entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(e -> e.getValue().toString()) .collect(Collectors.joining(",")); - String key = String.format("%s(%s)", operand.getIndicatorCode(), params); - if (operand.getOutput() != null && !"value".equals(operand.getOutput())) { - key += "." + operand.getOutput(); + String key = String.format("%s(%s)", operand.indicatorCode(), params); + if (operand.output() != null && !"value".equals(operand.output())) { + key += "." + operand.output(); } // Transform 정보도 Key에 포함 //if (operand.getTransform() != null) { @@ -318,31 +315,29 @@ protected Num calculate(int index) { } private void validateOperand(StrategyOperand operand) { - if (operand.getType() == null) { - throw new IllegalArgumentException("Operand 'type' must not be null."); + if (operand.type() == null) { + throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); } - switch (operand.getType()) { + switch (operand.type()) { case "price": - if (operand.getPriceField() == null) { - throw new IllegalArgumentException("Operand of type 'price' must have a non-null 'priceField'."); + if (operand.priceField() == null) { + throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); } + break; case "indicator": - if (operand.getIndicatorCode() == null) { - throw new IllegalArgumentException("Operand of type 'indicator' must have a non-null 'indicatorCode'."); - } - if (operand.getParams() == null) { - throw new IllegalArgumentException("Operand of type 'indicator' must have non-null 'params'."); + if (operand.indicatorCode() == null || operand.params() == null) { + throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); } break; case "const": - if (operand.getConstantValue() == null) { - throw new IllegalArgumentException("Operand of type 'const' must have a non-null 'constantValue'."); + if (operand.constantValue() == null) { + throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); } break; default: - throw new IllegalArgumentException("Unknown operand type: " + operand.getType()); + throw new CustomException(ErrorCode.BACKTEST_OPERAND_INVALID); } } } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 538b09a4..fc668015 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java +++ b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java @@ -18,6 +18,14 @@ public enum ErrorCode { INVALID_BACKTEST_JSON_PARAMS(HttpStatus.BAD_REQUEST, "유효하지 않은 paramsJson 요청값 입니다."), + INVALID_BACKTEST_PARAMS(HttpStatus.BAD_REQUEST, "유효하지 않은 백테스트 파라미터입니다."), + + BACKTEST_INDICATOR_NOT_FOUND(HttpStatus.BAD_REQUEST, "지원하지 않는 보조지표입니다."), + + BACKTEST_OPERAND_INVALID(HttpStatus.BAD_REQUEST, "전략 피연산자(Operand) 설정이 올바르지 않습니다."), + + BACKTEST_EXECUTION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "백테스트 실행 중 오류가 발생했습니다."), + BACKTEST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 백테스트가 존재하지 않습니다."), BACKTEST_OWNER_MISMATCH(HttpStatus.FORBIDDEN, "백테스트 소유자가 아닙니다."), @@ -26,6 +34,8 @@ public enum ErrorCode { BACKTEST_METRICS_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 백테스트 결과 정보가 존재하지 않습니다."), + + // TEMPLATE TEMPLATE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 템플릿이 존재하지 않습니다."), diff --git a/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java b/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java index 0519f9ee..18d921c0 100644 --- a/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java +++ b/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java @@ -1,6 +1,8 @@ package org.sejongisc.backend.stock.entity; import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import java.math.BigDecimal; @@ -9,6 +11,8 @@ @Entity @Getter @NoArgsConstructor +@AllArgsConstructor +@Builder @Table(name = "price_data") @IdClass(PriceDataId.class) public class PriceData { diff --git a/backend/src/test/java/org/sejongisc/backend/backtest/service/BacktestServiceTest.java b/backend/src/test/java/org/sejongisc/backend/backtest/service/BacktestServiceTest.java index 0043d9c8..2d6dde79 100644 --- a/backend/src/test/java/org/sejongisc/backend/backtest/service/BacktestServiceTest.java +++ b/backend/src/test/java/org/sejongisc/backend/backtest/service/BacktestServiceTest.java @@ -119,7 +119,7 @@ void getBackTestDetails_success() { BacktestResponse response = backtestService.getBackTestDetails(1L, userId); - assertThat(response.getBacktestRunMetricsResponse().getSharpeRatio()).isEqualTo(BigDecimal.valueOf(1.5)); + assertThat(response.getBacktestRunMetricsResponse().sharpeRatio()).isEqualTo(BigDecimal.valueOf(1.5)); } @Test diff --git a/backend/src/test/java/org/sejongisc/backend/backtest/service/Ta4jHelperServiceTest.java b/backend/src/test/java/org/sejongisc/backend/backtest/service/Ta4jHelperServiceTest.java new file mode 100644 index 00000000..0abedc15 --- /dev/null +++ b/backend/src/test/java/org/sejongisc/backend/backtest/service/Ta4jHelperServiceTest.java @@ -0,0 +1,126 @@ +package org.sejongisc.backend.backtest.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sejongisc.backend.backtest.dto.StrategyCondition; +import org.sejongisc.backend.backtest.dto.StrategyOperand; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.stock.entity.PriceData; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Rule; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith(MockitoExtension.class) +class Ta4jHelperServiceTest { + + @InjectMocks + private Ta4jHelperService ta4jHelperService; + + // 테스트용 더미 데이터 생성 헬퍼 + private List createDummyPriceData() { + PriceData p1 = PriceData.builder() + .ticker("AAPL") + .date(LocalDate.now().minusDays(1)) + .open(BigDecimal.valueOf(100)) + .high(BigDecimal.valueOf(110)) + .low(BigDecimal.valueOf(90)) + .closePrice(BigDecimal.valueOf(105)) + .volume(1000L) + .build(); + return List.of(p1); + } + + @Test + @DisplayName("createBarSeries - PriceData 리스트를 BarSeries로 변환 성공") + void createBarSeries_success() { + // given + List priceDataList = createDummyPriceData(); + + // when + BarSeries series = ta4jHelperService.createBarSeries(priceDataList); + + // then + assertThat(series).isNotNull(); + assertThat(series.getName()).isEqualTo("AAPL"); + assertThat(series.getBarCount()).isEqualTo(1); + } + + @Test + @DisplayName("buildCombinedRule - 단순 조건(RSI > 30) 파싱 성공") + void buildCombinedRule_simpleCondition() { + // given + BarSeries series = ta4jHelperService.createBarSeries(createDummyPriceData()); + + // 조건: RSI(14) > 30 + StrategyOperand left = new StrategyOperand( + "indicator", "RSI", "Close", null, null, Map.of("length", 14) + ); + StrategyOperand right = new StrategyOperand( + "const", null, null, 30.0, null, null + ); + + StrategyCondition condition = new StrategyCondition(left, "GT", right, false); + + // when + Rule rule = ta4jHelperService.buildCombinedRule(List.of(condition), series, new HashMap<>()); + + // then + assertThat(rule).isNotNull(); + // 실제 로직 동작 여부(Exception 안 나는지) 확인 + } + + @Test + @DisplayName("validateOperand - 필수 값 누락 시 예외 발생 (ErrorCode 확인)") + void validateOperand_exception() { + // given + BarSeries series = ta4jHelperService.createBarSeries(createDummyPriceData()); + + // type이 없는 잘못된 피연산자 + StrategyOperand invalidOperand = new StrategyOperand( + null, "RSI", "Close", null, null, Map.of("length", 14) + ); + StrategyCondition condition = new StrategyCondition(invalidOperand, "GT", invalidOperand, false); + + // when & then + assertThatThrownBy(() -> + ta4jHelperService.buildCombinedRule(List.of(condition), series, new HashMap<>()) + ) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BACKTEST_OPERAND_INVALID.getMessage()); + } + + @Test + @DisplayName("createIndicator - 지원하지 않는 지표 코드 시 예외 발생") + void createIndicator_unknownCode() { + // given + BarSeries series = ta4jHelperService.createBarSeries(createDummyPriceData()); + + StrategyOperand unknownIndicator = new StrategyOperand( + "indicator", "UNKNOWN_CODE", "Close", null, null, Map.of("length", 14) + ); + StrategyOperand constOperand = new StrategyOperand( + "const", null, null, 10.0, null, null + ); + StrategyCondition condition = new StrategyCondition(unknownIndicator, "GT", constOperand, false); + + // when & then + assertThatThrownBy(() -> + ta4jHelperService.buildCombinedRule(List.of(condition), series, new HashMap<>()) + ) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.BACKTEST_INDICATOR_NOT_FOUND.getMessage()); + } +} \ No newline at end of file