diff --git a/backend/build.gradle b/backend/build.gradle index 750da5dd..400b8140 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -57,6 +57,9 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.3' implementation 'org.springframework.boot:spring-boot-starter-validation' + + // backtesting library + implementation 'org.ta4j:ta4j-core:0.15' } jacoco { 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 d52babe7..28853d65 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 @@ -1,5 +1,7 @@ package org.sejongisc.backend.backtest.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; import org.sejongisc.backend.user.entity.User; @@ -11,16 +13,29 @@ @Getter @Setter public class BacktestRequest { - // hidden 설정하기 + @Schema(hidden = true, description = "회원") + @JsonIgnore private UUID userId; + @Schema(description = "템플릿 ID") private UUID templateId; + + @Schema(description = "백테스트 ID") private Long backtestRunId; + @Schema(description = "백테스트 제목") private String title; - private String paramsJson; + + @Schema(description = "백테스트 시작일") private LocalDate startDate; + + @Schema(description = "백테스트 종료일") private LocalDate endDate; + + @Schema(description = "백테스트 실행 요청 JSON") + private BacktestRunRequest strategy; + // 백테스트 리스트 삭제 + @Schema(description = "삭제할 백테스트 실행 리스트") private List backtestRunIds; } 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 6a04d955..6250e9e1 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 @@ -1,11 +1,19 @@ package org.sejongisc.backend.backtest.dto; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import org.sejongisc.backend.backtest.entity.BacktestRun; import org.sejongisc.backend.backtest.entity.BacktestRunMetrics; +import org.sejongisc.backend.backtest.entity.BacktestStatus; +import org.sejongisc.backend.template.entity.Template; +import org.sejongisc.backend.user.entity.User; + +import java.time.LocalDate; @Getter @@ -13,7 +21,13 @@ @AllArgsConstructor @NoArgsConstructor public class BacktestResponse { - private BacktestRun backtestRun; - private BacktestRunMetrics backtestRunMetrics; + private Long id; + private Template template; + private String title; + private BacktestStatus status; + private String paramsJson; + private LocalDate startDate; + private LocalDate endDate; + private BacktestRunMetrics backtestRunMetrics; } \ 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 new file mode 100644 index 00000000..ff51e305 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java @@ -0,0 +1,39 @@ +package org.sejongisc.backend.backtest.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 백테스트 실행 요청 시 Body에 담길 메인 DTO + * (이 객체가 BacktestRun.paramsJson에 직렬화되어 저장됨) + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BacktestRunRequest { + @Schema(description = "초기 자본금") + private BigDecimal initialCapital; + + @Schema(description = "대상 종목 티커") + private String ticker; + + @Schema(description = "매수 조건 그룹") + private List buyConditions; + + @Schema(description = "매도 조건 그룹") + private List sellConditions; + + @Schema(description = "노트") + private String note; + + /* + * 타임 프레임 (예: "D", "W", "M") + private String timeFrame; + */ +} \ No newline at end of file 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 new file mode 100644 index 00000000..5ba624e3 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java @@ -0,0 +1,31 @@ +package org.sejongisc.backend.backtest.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 전략 조건 한 줄 (Operand + Operator + Operand) + * 예: [SMA(20)] [GT] [Close] + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StrategyCondition { + + // 좌항 + private StrategyOperand leftOperand; + + // 연산자 (예: "GT", "LT", "CROSSES_ABOVE") + private String operator; + + // 우항 + private StrategyOperand rightOperand; + + /** + * "무조건 행동" 조건인지 여부 + * true = 이 조건이 맞으면 다른 '일반' 조건 무시 + * false = '일반' 조건 + */ + private 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 new file mode 100644 index 00000000..73aae8d6 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java @@ -0,0 +1,39 @@ +package org.sejongisc.backend.backtest.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * 전략 조건의 개별 항 (Operand) + * 예: SMA(20), 종가(Close), 30(상수) + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class StrategyOperand { + + // 항의 타입: "indicator", "price", "const" + private String type; + + // type == "indicator" 일 때 + // 지표 코드 (예: "SMA", "RSI", "MACD") + private String indicatorCode; + + // type == "price" 일 때 + // 가격 필드 (예: "Close", "Open", "High", "Low", "Volume") + private String priceField; + + // type == "const" 일 때 + // 상수 값 (예: 30, 0.02) + private BigDecimal constantValue; + + // 지표의 출력값 (예: "value", "macd", "signal", "hist") + private String output; + + // 지표의 파라미터 맵 (예: {"length": 20}) + private Map params; +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestRun.java b/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestRun.java index 60461791..67c2198c 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestRun.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestRun.java @@ -2,10 +2,9 @@ import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import org.sejongisc.backend.template.entity.Template; import org.sejongisc.backend.user.entity.User; @@ -15,6 +14,7 @@ @Entity @Getter +@Setter @Builder @NoArgsConstructor @AllArgsConstructor @@ -33,7 +33,12 @@ public class BacktestRun { private String title; + @Enumerated(EnumType.STRING) + private BacktestStatus status; + // 조건/종목 등 파라미터(JSONB). 가장 단순하게 String으로 보관 + // 기록 (불변성 목적) : 생성된 순간의 상태 박제 목적 + @JdbcTypeCode(SqlTypes.JSON) @Column(name = "params", columnDefinition = "jsonb") private String paramsJson; @@ -48,6 +53,10 @@ public class BacktestRun { private LocalDateTime startedAt; private LocalDateTime finishedAt; + // 오류 발생 시 기록 + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + public void updateTemplate(Template template) { this.template = template; } diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestStatus.java b/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestStatus.java new file mode 100644 index 00000000..11c89e54 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestStatus.java @@ -0,0 +1,8 @@ +package org.sejongisc.backend.backtest.entity; + +public enum BacktestStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED +} diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java index 848918b8..77dfed38 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestService.java @@ -1,17 +1,21 @@ package org.sejongisc.backend.backtest.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.sejongisc.backend.backtest.dto.BacktestRequest; import org.sejongisc.backend.backtest.dto.BacktestResponse; import org.sejongisc.backend.backtest.entity.BacktestRun; import org.sejongisc.backend.backtest.entity.BacktestRunMetrics; +import org.sejongisc.backend.backtest.entity.BacktestStatus; import org.sejongisc.backend.backtest.repository.BacktestRunMetricsRepository; import org.sejongisc.backend.backtest.repository.BacktestRunRepository; import org.sejongisc.backend.common.exception.CustomException; import org.sejongisc.backend.common.exception.ErrorCode; import org.sejongisc.backend.template.entity.Template; import org.sejongisc.backend.template.repository.TemplateRepository; +import org.sejongisc.backend.user.entity.User; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,22 +31,37 @@ public class BacktestService { private final BacktestRunRepository backtestRunRepository; private final BacktestRunMetricsRepository backtestRunMetricsRepository; private final TemplateRepository templateRepository; + private final BacktestingEngine backtestingEngine; + private final ObjectMapper objectMapper; + private final EntityManager em; public BacktestResponse getBacktestStatus(Long backtestRunId, UUID userId) { // TODO : 백테스트 상태 조회 로직 구현 (진행 중, 완료, 실패 등) BacktestRun backtestRun = findBacktestRunByIdAndVerifyUser(backtestRunId, userId); return BacktestResponse.builder() - .backtestRun(backtestRun) + .id(backtestRun.getId()) + .paramsJson(backtestRun.getParamsJson()) + .title(backtestRun.getTitle()) + .status(backtestRun.getStatus()) + .startDate(backtestRun.getStartDate()) + .endDate(backtestRun.getEndDate()) + .template(backtestRun.getTemplate()) .build(); } @Transactional public BacktestResponse getBackTestDetails(Long backtestRunId, UUID userId) { - BacktestRun backtestRun = findBacktestRunByIdAndVerifyUser(backtestRunId, userId); BacktestRunMetrics backtestRunMetrics = backtestRunMetricsRepository.findByBacktestRunId(backtestRunId) - .orElseThrow(() -> new CustomException(ErrorCode.BACKTEST_METRICS_NOT_FOUND)); + .orElse(null); + BacktestRun backtestRun = findBacktestRunByIdAndVerifyUser(backtestRunId, userId); return BacktestResponse.builder() - .backtestRun(backtestRun) + .id(backtestRun.getId()) + .paramsJson(backtestRun.getParamsJson()) + .title(backtestRun.getTitle()) + .status(backtestRun.getStatus()) + .startDate(backtestRun.getStartDate()) + .endDate(backtestRun.getEndDate()) + .template(backtestRun.getTemplate()) .backtestRunMetrics(backtestRunMetrics) .build(); } @@ -62,8 +81,47 @@ public void addBacktestTemplate(BacktestRequest request) { } public BacktestResponse runBacktest(BacktestRequest request) { - // TODO : 백테스트 실행 로직 구현 (비동기 처리) - return null; + User userRef = em.getReference(User.class, request.getUserId()); + + Template templateRef = null; + if (request.getTemplateId() != null) + templateRef = em.getReference(Template.class, request.getTemplateId()); + + String paramsJson; + try { + paramsJson = objectMapper.writeValueAsString(request.getStrategy()); + } catch (Exception e) { + log.error("paramsJson 변환 중 오류 발생", e); + throw new CustomException(ErrorCode.INVALID_BACKTEST_JSON_PARAMS); + } + + // BacktestRun 엔티티를 "PENDING" 상태로 생성 + BacktestRun backtestRun = BacktestRun.builder() + .user(userRef) + .template(templateRef) + .title(request.getTitle()) + .paramsJson(paramsJson) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .status(BacktestStatus.PENDING) + .build(); + + BacktestRun savedRun = backtestRunRepository.save(backtestRun); + log.info("백테스팅 실행 요청이 성공적으로 처리되었습니다. ID: {}", savedRun.getId()); + + // 비동기로 백테스팅 실행 시작 + backtestingEngine.execute(savedRun.getId()); + + // 사용자에게 실행 중 응답 반환 + return BacktestResponse.builder() + .id(savedRun.getId()) + .paramsJson(savedRun.getParamsJson()) + .title(savedRun.getTitle()) + .status(savedRun.getStatus()) + .startDate(savedRun.getStartDate()) + .endDate(savedRun.getEndDate()) + .template(templateRef) + .build(); } @Transactional 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 new file mode 100644 index 00000000..2230cc8d --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java @@ -0,0 +1,149 @@ +package org.sejongisc.backend.backtest.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.backtest.dto.BacktestRunRequest; +import org.sejongisc.backend.backtest.entity.BacktestRun; +import org.sejongisc.backend.backtest.entity.BacktestRunMetrics; +import org.sejongisc.backend.backtest.entity.BacktestStatus; +import org.sejongisc.backend.backtest.repository.BacktestRunMetricsRepository; +import org.sejongisc.backend.backtest.repository.BacktestRunRepository; +import org.sejongisc.backend.common.exception.CustomException; +import org.sejongisc.backend.common.exception.ErrorCode; +import org.sejongisc.backend.stock.entity.PriceData; +import org.sejongisc.backend.stock.repository.PriceDataRepository; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.ta4j.core.BarSeries; +import org.ta4j.core.Indicator; +import org.ta4j.core.Rule; +import org.ta4j.core.num.Num; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BacktestingEngine { + + private final BacktestRunRepository backtestRunRepository; + private final BacktestRunMetricsRepository backtestRunMetricsRepository; + private final PriceDataRepository priceDataRepository; + private final Ta4jHelperService ta4jHelper; + private final ObjectMapper objectMapper; + + @Async + @Transactional + public void execute(Long backtestRunId) { + log.info("백테스팅 실행이 시작됩니다. 실행 ID : {}", backtestRunId); + BacktestRun backtestRun = backtestRunRepository.findById(backtestRunId) + .orElseThrow(() -> new CustomException(ErrorCode.BACKTEST_NOT_FOUND)); + try { + backtestRun.setStatus(BacktestStatus.RUNNING); + backtestRun.setStartedAt(LocalDateTime.now()); + backtestRunRepository.save(backtestRun); + log.debug("백테스팅 상태 RUNNING 으로 변경됨. ID : {}", backtestRunId); + + // 전략(JSON)을 DTO로 파싱 + log.debug("paramsJson: {}", backtestRun.getParamsJson()); + BacktestRunRequest strategyDto = objectMapper.readValue(backtestRun.getParamsJson(), BacktestRunRequest.class); + String ticker = strategyDto.getTicker(); + log.debug("백테스팅 대상 티커: {}", ticker); + + // DB에서 가격 데이터 로드 + List priceDataList = priceDataRepository.findByTickerAndDateBetweenOrderByDateAsc( + ticker, backtestRun.getStartDate(), backtestRun.getEndDate()); + log.debug("가격 데이터 로드 완료. 데이터 개수: {}", priceDataList.size()); + if (priceDataList.isEmpty()) { + throw new CustomException(ErrorCode.PRICE_DATA_NOT_FOUND); + } + + // Ta4j BarSeries 생성 + BarSeries series = ta4jHelper.createBarSeries(priceDataList); + Map> indicatorCache = new HashMap<>(); + log.debug("BarSeries 생성 완료. 바 개수: {}", series.getBarCount()); + + // 매수/매도 룰 생성 + Rule buyRule = ta4jHelper.buildCombinedRule(strategyDto.getBuyConditions(), series, indicatorCache); + Rule sellRule = ta4jHelper.buildCombinedRule(strategyDto.getSellConditions(), series, indicatorCache); + + // 포트폴리오 초기화 + BigDecimal initialCapital = strategyDto.getInitialCapital(); + BigDecimal cash = initialCapital; + BigDecimal shares = BigDecimal.ZERO; + int tradesCount = 0; + + // MDD 및 수익률 추적용 리스트 + List dailyPortfolioValue = new ArrayList<>(); + BigDecimal peakValue = initialCapital; + BigDecimal maxDrawdown = BigDecimal.ZERO; + + for (int i = 0; i < series.getBarCount(); i++) { + // 오늘 날짜의 종가 가져오기 (Num -> BigDecimal) + Num numClosePrice = series.getBar(i).getClosePrice(); // Num 객체 반환 + BigDecimal currentClosePrice = new BigDecimal(numClosePrice.toString()); + + // 전략 평가 + boolean shouldBuy = buyRule.isSatisfied(i); + boolean shouldSell = sellRule.isSatisfied(i); + + // 거래 실행 및 포트폴리오 관리 + // "매수" + if (shares.compareTo(BigDecimal.ZERO) == 0 && shouldBuy) { + shares = cash.divide(currentClosePrice, 8, RoundingMode.HALF_UP); + cash = BigDecimal.ZERO; + tradesCount++; + log.debug("[{}] BUY at {}", series.getBar(i).getEndTime().toLocalDate(), currentClosePrice); + + } + // "매도" + else if (shares.compareTo(BigDecimal.ZERO) > 0 && shouldSell) { + cash = shares.multiply(currentClosePrice); + shares = BigDecimal.ZERO; + log.debug("[{}] SELL at {}", series.getBar(i).getEndTime().toLocalDate(), currentClosePrice); + } + // 일일 포트폴리오 가치 계산 + BigDecimal currentTotalValue = cash.add(shares.multiply(currentClosePrice)); + dailyPortfolioValue.add(currentTotalValue); + if (currentTotalValue.compareTo(peakValue) > 0) peakValue = currentTotalValue; + BigDecimal drawdown = peakValue.subtract(currentTotalValue).divide(peakValue, 4, RoundingMode.HALF_UP); + // MDD 갱신 + if (drawdown.compareTo(maxDrawdown) > 0) maxDrawdown = drawdown; + } + // 최종 지표 계산 + BigDecimal finalPortfolioValue = dailyPortfolioValue.getLast(); + // 총수익률 = (최종자산 / 초기자본) - 1 + BigDecimal totalReturnPct = finalPortfolioValue.divide(initialCapital, 4, RoundingMode.HALF_UP) + .subtract(BigDecimal.ONE); + // MDD (백분율로 변환) + BigDecimal maxDrawdownPct = maxDrawdown.multiply(BigDecimal.valueOf(-100)); + + BacktestRunMetrics metrics = BacktestRunMetrics.builder() + .backtestRun(backtestRun) + .totalReturn(totalReturnPct) + .maxDrawdown(maxDrawdownPct) + .sharpeRatio(BigDecimal.ZERO) // TODO: Sharpe 계산 (일일 수익률 표준편차 필요) + .avgHoldDays(BigDecimal.ZERO) // TODO: 평균 보유일 계산 (거래 로그 필요) + .tradesCount(tradesCount) + .build(); + + backtestRunMetricsRepository.save(metrics); + backtestRun.setStatus(BacktestStatus.COMPLETED); + } catch (Exception e) { + log.error("Backtest execution failed for run ID: {}", backtestRunId, e); + backtestRun.setStatus(BacktestStatus.FAILED); + backtestRun.setErrorMessage(e.getMessage()); + } finally { + backtestRun.setFinishedAt(LocalDateTime.now()); + backtestRunRepository.save(backtestRun); + } + } +} 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 new file mode 100644 index 00000000..c3373ad0 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java @@ -0,0 +1,288 @@ +package org.sejongisc.backend.backtest.service; + +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.backtest.dto.StrategyCondition; +import org.sejongisc.backend.backtest.dto.StrategyOperand; +import org.sejongisc.backend.stock.entity.PriceData; +import org.springframework.stereotype.Service; +import org.ta4j.core.BarSeries; +import org.ta4j.core.BaseBarSeries; +import org.ta4j.core.Indicator; +import org.ta4j.core.Rule; +import org.ta4j.core.indicators.CachedIndicator; // ⭐️ MACD Hist 구현용 +import org.ta4j.core.indicators.EMAIndicator; +import org.ta4j.core.indicators.MACDIndicator; +import org.ta4j.core.indicators.RSIIndicator; +import org.ta4j.core.indicators.SMAIndicator; +import org.ta4j.core.indicators.helpers.*; +import org.ta4j.core.num.Num; +import org.ta4j.core.rules.*; // IsEqualRule, AndRule, OrRule, OverIndicatorRule 등 + +import java.math.BigDecimal; +import java.time.ZoneId; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class Ta4jHelperService { + + /** + * PriceData 리스트를 ta4j의 BarSeries로 변환합니다. + */ + public BarSeries createBarSeries(List priceDataList) { + // ⭐️ (수정) BarSeries 이름에 Ticker 추가 + BarSeries series = new BaseBarSeries(priceDataList.get(0).getTicker()); + for (PriceData p : priceDataList) { + series.addBar( + p.getDate().atStartOfDay(ZoneId.systemDefault()), + p.getOpen(), p.getHigh(), p.getLow(), p.getClosePrice(), p.getVolume() + ); + } + return series; + } + + /** + * DTO 조건(List)을 ta4j의 Rule 객체로 빌드합니다. + * "isAbsolute" 로직(✳️무조건 OR ⚪️일반)을 포함합니다. + */ + public Rule buildCombinedRule(List conditions, BarSeries series, + Map> indicatorCache) { + + if (series.isEmpty()) { + throw new IllegalArgumentException("Cannot build rules on an empty series."); + } + + // ⭐️ (수정) "1"과 "0"에 해당하는 Num 객체를 시리즈에서 가져옴 + Num sampleNum = series.getBar(0).getClosePrice(); + Num one = sampleNum.numOf(1); + Num zero = sampleNum.numOf(0); + + // ⭐️ (수정) "1"과 "0"에 해당하는 Indicator를 '먼저' 생성 + Indicator indicatorOne = new ConstantIndicator<>(series, one); + Indicator indicatorZero = new ConstantIndicator<>(series, zero); + + // "FalseRule" 대체: "1 == 0" 규칙 (항상 false) + Rule falseRule = new IsEqualRule(indicatorOne, indicatorZero); + // "TrueRule" 대체: "1 == 1" 규칙 (항상 true) + Rule trueRule = new IsEqualRule(indicatorOne, indicatorOne); + + if (conditions == null || conditions.isEmpty()) { + return falseRule; // 조건이 없으면 항상 false + } + + // 1. ✳️ '무조건' 조건과 ⚪️ '일반' 조건으로 분리 + Map> partitioned = conditions.stream() + .collect(Collectors.partitioningBy(StrategyCondition::isAbsolute)); + + List absoluteConditions = partitioned.get(true); + List standardConditions = partitioned.get(false); + + Rule absoluteRule; + Rule standardRule; + + // 2. ✳️ '무조건' 조건들을 OR로 묶음 + if (absoluteConditions.isEmpty()) { + absoluteRule = falseRule; // ✳️ 조건 없음 + } else { + Rule combinedOrRule = buildSingleRule(absoluteConditions.get(0), series, indicatorCache); + for (int i = 1; i < absoluteConditions.size(); i++) { + combinedOrRule = new OrRule( + buildSingleRule(absoluteConditions.get(i), series, indicatorCache), + combinedOrRule + ); + } + absoluteRule = combinedOrRule; + } + + // 3. ⚪️ '일반' 조건들을 AND로 묶음 + if (standardConditions.isEmpty()) { + standardRule = falseRule; // ⚪️ 조건 없음 + } else { + Rule combinedAndRule = buildSingleRule(standardConditions.get(0), series, indicatorCache); + for (int i = 1; i < standardConditions.size(); i++) { + combinedAndRule = new AndRule( + buildSingleRule(standardConditions.get(i), series, indicatorCache), + combinedAndRule + ); + } + standardRule = combinedAndRule; + } + + // 4. 최종 결합: (✳️무조건 OR ⚪️일반) + return new OrRule(absoluteRule, standardRule); + } + + /** + * 개별 조건(StrategyCondition)을 ta4j Rule 객체로 변환 + */ + private Rule buildSingleRule(StrategyCondition condition, BarSeries series, + Map> indicatorCache) { + + Indicator left = resolveOperand(condition.getLeftOperand(), series, indicatorCache); + Indicator right = resolveOperand(condition.getRightOperand(), series, indicatorCache); + + // ⭐️ (스크린샷 0.13 버전 규칙 기준) + switch (condition.getOperator()) { + case "GT": + return new OverIndicatorRule(left, right); + case "GTE": + return new IsEqualRule(left, right).or(new OverIndicatorRule(left, right)); + case "LT": + return new UnderIndicatorRule(left, right); + case "LTE": + return new IsEqualRule(left, right).or(new UnderIndicatorRule(left, right)); + case "EQ": + return new IsEqualRule(left, right); + case "CROSSES_ABOVE": + return new CrossedUpIndicatorRule(left, right); + case "CROSSES_BELOW": + return new CrossedDownIndicatorRule(left, right); + default: + throw new IllegalArgumentException("Unknown operator: " + condition.getOperator()); + } + } + + /** + * StrategyOperand DTO를 ta4j Indicator 객체로 "번역" + */ + private Indicator resolveOperand(StrategyOperand operand, BarSeries series, + Map> indicatorCache) { + if (operand == null) return null; + + String key = generateIndicatorKey(operand); + if (indicatorCache.containsKey(key)) { + return indicatorCache.get(key); + } + + Indicator indicator; + switch (operand.getType()) { + case "price": + indicator = createPriceIndicator(operand.getPriceField(), series); + break; + case "indicator": + indicator = createIndicator(operand, series, indicatorCache); + break; + case "const": + Num constValue = series.getBar(0).getClosePrice().numOf(operand.getConstantValue()); + indicator = new ConstantIndicator<>(series, constValue); + break; + default: + throw new IllegalArgumentException("Unknown operand type: " + operand.getType()); + } + + indicatorCache.put(key, indicator); + return indicator; + } + + // 팩토리 헬퍼 1: 원본 가격 지표 생성 + private Indicator createPriceIndicator(String field, BarSeries series) { + switch (field) { + case "Open": + return new OpenPriceIndicator(series); + case "High": + return new HighPriceIndicator(series); + case "Low": + return new LowPriceIndicator(series); + case "Volume": + return new VolumeIndicator(series, 0); + case "Close": + default: + return new ClosePriceIndicator(series); + } + } + + // 팩토리 헬퍼 2: 보조 지표 생성 + private Indicator createIndicator(StrategyOperand operand, BarSeries series, + Map> cache) { + String code = operand.getIndicatorCode(); + Map params = operand.getParams(); + + Indicator baseIndicator = resolveOperand( + new StrategyOperand("price", null, null, null, "Close", null), + series, cache + ); + + switch (code) { + case "SMA": + int smaLength = ((Number) params.get("length")).intValue(); + return new SMAIndicator(baseIndicator, smaLength); + case "EMA": + int emaLength = ((Number) params.get("length")).intValue(); + return new EMAIndicator(baseIndicator, emaLength); + case "RSI": + int rsiLength = ((Number) params.get("length")).intValue(); + return new RSIIndicator(baseIndicator, rsiLength); + case "MACD": + int fast = ((Number) params.get("fast")).intValue(); + int slow = ((Number) params.get("slow")).intValue(); + int signal = ((Number) params.get("signal")).intValue(); + + MACDIndicator macd = new MACDIndicator(baseIndicator, fast, slow); + Indicator signalLine = new EMAIndicator(macd, signal); // Signal 라인 생성 + + switch (operand.getOutput()) { + case "macd": + return macd; + case "signal": + return signalLine; + case "hist": + // ⭐️ (변경) MACDHistogramIndicator -> 수동 계산 클래스 + return new ManualMACDHistogramIndicator(macd, signalLine); + default: + return macd; + } + // TODO: ATR, 볼린저 밴드 등 다른 지표 추가... + default: + throw new IllegalArgumentException("Unknown indicator code: " + code); + } + } + + // Operand DTO로부터 Map의 키를 생성 + private String generateIndicatorKey(StrategyOperand operand) { + if (operand == null) return "null_operand"; + switch (operand.getType()) { + case "price": + return operand.getPriceField(); + case "const": + return "const_" + operand.getConstantValue().toString(); + case "indicator": + String params = operand.getParams().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(); + } + return key; + default: + return "unknown_operand"; + } + } + + /** + * ⭐️ (신규) MACD 히스토그램 수동 계산 클래스 + * (MACDIndicator - EMAIndicator(MACDIndicator, signalLength)) + */ + private static class ManualMACDHistogramIndicator extends CachedIndicator { + private final Indicator macd; + private final Indicator signal; + + public ManualMACDHistogramIndicator(Indicator macd, Indicator signal) { + // 부모 클래스에 BarSeries를 전달해야 함 (macd에서 가져옴) + super(macd); + this.macd = macd; + this.signal = signal; + } + + @Override + protected Num calculate(int index) { + // MACD 값 - Signal 값 + return macd.getValue(index).minus(signal.getValue(index)); + } + } +} + diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java index d03614df..bc23d9fb 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/jwt/JwtParser.java @@ -10,6 +10,8 @@ import java.util.*; import javax.crypto.SecretKey; +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetailsService; import org.sejongisc.backend.user.entity.Role; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,7 +22,9 @@ import org.springframework.stereotype.Component; @Component +@RequiredArgsConstructor public class JwtParser { + private final CustomUserDetailsService customUserDetailsService; @Value("${jwt.secret}") private String rawSecretKey; @@ -87,7 +91,7 @@ public UsernamePasswordAuthenticationToken getAuthentication(String token) { Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role.name())); // "ROLE_TEAM_MEMBER" - UserDetails userDetails = new User(claims.getSubject(), "", authorities); + UserDetails userDetails = customUserDetailsService.loadUserByUsername(claims.getSubject()); return new UsernamePasswordAuthenticationToken(userDetails, "", authorities); } diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java index 722b4b0e..d07187a2 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/CustomUserDetailsService.java @@ -10,6 +10,8 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import java.util.UUID; + @RequiredArgsConstructor @Service @@ -19,7 +21,7 @@ public class CustomUserDetailsService implements UserDetailsService { @Override public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { - User findUser = userRepository.findUserByEmail(email).orElseThrow( + User findUser = userRepository.findById(UUID.fromString(email)).orElseThrow( () -> new CustomException(ErrorCode.USER_NOT_FOUND) ); diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java index 7ce532ba..8a89fae1 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/springsecurity/JwtAuthenticationFilter.java @@ -34,6 +34,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AntPathMatcher pathMatcher = new AntPathMatcher(); private static final List EXCLUDE_PATTERNS = List.of( + "/user/signup", "/auth/login", "/auth/login/kakao", "/auth/login/google", diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/AsyncConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/AsyncConfig.java new file mode 100644 index 00000000..df00bc8b --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package org.sejongisc.backend.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java new file mode 100644 index 00000000..2405b2f4 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java @@ -0,0 +1,75 @@ +package org.sejongisc.backend.common.config; + +import jakarta.persistence.EntityManagerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.*; + +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; +import com.zaxxer.hikari.HikariDataSource; + +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories( + basePackages = "org.sejongisc.backend", // 전체 패키지 스캔 + entityManagerFactoryRef = "primaryEntityManagerFactory", // 아래 Bean 이름과 일치 + transactionManagerRef = "primaryTransactionManager", // 아래 Bean 이름과 일치 + excludeFilters = @ComponentScan.Filter( // 특정 패키지 제외 (Stock 관련) + type = FilterType.REGEX, + pattern = "org\\.sejongisc\\.backend\\.stock\\.repository\\..*" + ) +) +public class PrimaryDataSourceConfig { + + @Primary + @Bean(name = "primaryDataSourceProperties") + @ConfigurationProperties("spring.datasource") // 표준 경로 사용 + public DataSourceProperties primaryDataSourceProperties() { + return new DataSourceProperties(); + } + + @Primary + @Bean(name = "primaryDataSource") + @ConfigurationProperties("spring.datasource.hikari") + public DataSource primaryDataSource(@Qualifier("primaryDataSourceProperties") DataSourceProperties properties) { + return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Primary + @Bean(name = "primaryEntityManagerFactory") + public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( + EntityManagerFactoryBuilder builder, + @Qualifier("primaryDataSource") DataSource dataSource) { + return builder + .dataSource(dataSource) + .packages( + "org.sejongisc.backend.attendance.entity", + "org.sejongisc.backend.auth.entity", + "org.sejongisc.backend.backtest.entity", + "org.sejongisc.backend.betting.entity", + "org.sejongisc.backend.common.entity.postgres", + "org.sejongisc.backend.point.entity", + "org.sejongisc.backend.stock.entity", + "org.sejongisc.backend.template.entity", + "org.sejongisc.backend.user.entity" + ) + .persistenceUnit("primary") + .build(); + } + + @Primary + @Bean(name = "primaryTransactionManager") + public PlatformTransactionManager primaryTransactionManager( + @Qualifier("primaryEntityManagerFactory") EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } +} + diff --git a/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java b/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java new file mode 100644 index 00000000..0fe15a78 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java @@ -0,0 +1,60 @@ +package org.sejongisc.backend.common.config; + +import jakarta.persistence.EntityManagerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import javax.sql.DataSource; +import com.zaxxer.hikari.HikariDataSource; + +import java.util.HashMap; +import java.util.Map; + +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories( + basePackages = "org.sejongisc.backend.stock.repository", // PriceDataRepository 가 있는 패키지만 스캔하도록 지정 + entityManagerFactoryRef = "stockEntityManagerFactory", // 아래 Bean 이름과 일치 + transactionManagerRef = "stockTransactionManager" // 아래 Bean 이름과 일치 +) +public class StockDataSourceConfig { + + @Bean(name = "stockDataSourceProperties") + @ConfigurationProperties("spring.stock.datasource") // yml의 'stock.datasource' 참조 + public DataSourceProperties stockDataSourceProperties() { + return new DataSourceProperties(); + } + + @Bean(name = "stockDataSource") + @ConfigurationProperties("spring.stock.datasource.hikari") + public DataSource stockDataSource(@Qualifier("stockDataSourceProperties") DataSourceProperties properties) { + return properties.initializeDataSourceBuilder().type(HikariDataSource.class).build(); + } + + @Bean(name = "stockEntityManagerFactory") + public LocalContainerEntityManagerFactoryBean stockEntityManagerFactory( + EntityManagerFactoryBuilder builder, + @Qualifier("stockDataSource") DataSource dataSource) { + + return builder + .dataSource(dataSource) + .packages("org.sejongisc.backend.stock.entity") // PriceData 엔티티가 있는 패키지 지정 + .persistenceUnit("stock") // Persistence Unit 이름 + .build(); + } + + @Bean(name = "stockTransactionManager") + public PlatformTransactionManager stockTransactionManager( + @Qualifier("stockEntityManagerFactory") EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } +} 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 86bf4f51..0d00f552 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 @@ -10,8 +10,14 @@ public enum ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 문제가 발생했습니다."), + // PRICE DATA + + PRICE_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 주식의 가격 데이터가 존재하지 않습니다."), + // BACKTEST + INVALID_BACKTEST_JSON_PARAMS(HttpStatus.BAD_REQUEST, "유효하지 않은 paramsJson 요청값 입니다."), + BACKTEST_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 백테스트가 존재하지 않습니다."), BACKTEST_OWNER_MISMATCH(HttpStatus.FORBIDDEN, "백테스트 소유자가 아닙니다."), 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 new file mode 100644 index 00000000..0519f9ee --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java @@ -0,0 +1,29 @@ +package org.sejongisc.backend.stock.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import java.math.BigDecimal; +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "price_data") +@IdClass(PriceDataId.class) +public class PriceData { + + @Id + private String ticker; + + @Id + private LocalDate date; + + private BigDecimal open; + private BigDecimal high; + private BigDecimal low; + @Column(name = "close") + private BigDecimal closePrice; // 'close'는 예약어일 수 있어 필드명 변경 + private Long volume; + private BigDecimal adjustedClose; +} diff --git a/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceDataId.java b/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceDataId.java new file mode 100644 index 00000000..61314bf2 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/stock/entity/PriceDataId.java @@ -0,0 +1,36 @@ +package org.sejongisc.backend.stock.entity; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Objects; + + +public class PriceDataId implements Serializable { + private String ticker; + private LocalDate date; + + /** + * JPA 는 프록시 객체 생성 등을 위해 기본 생성자가 필요함 + */ + public PriceDataId() { + } + + public PriceDataId(String ticker, LocalDate date) { + this.ticker = ticker; + this.date = date; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PriceDataId that = (PriceDataId) o; + return Objects.equals(ticker, that.ticker) && + Objects.equals(date, that.date); + } + + @Override + public int hashCode() { + return Objects.hash(ticker, date); + } +} \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java b/backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java new file mode 100644 index 00000000..fc8d4519 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java @@ -0,0 +1,14 @@ +package org.sejongisc.backend.stock.repository; + +import org.sejongisc.backend.stock.entity.PriceData; +import org.sejongisc.backend.stock.entity.PriceDataId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +public interface PriceDataRepository extends JpaRepository { + List findByTickerAndDateBetweenOrderByDateAsc(String ticker, LocalDate startDate, LocalDate endDate); +} \ No newline at end of file