From 0fe61e1a5cda6343a08090f1fb10b3b293434f78 Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:51:40 +0900 Subject: [PATCH 01/10] =?UTF-8?q?[BE]=20SISC1-56=20[FEAT]=20=EB=B0=B1?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4=ED=96=89=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 필요 --- backend/build.gradle | 3 + .../backend/backtest/dto/BacktestRequest.java | 19 +- .../backtest/dto/BacktestResponse.java | 18 +- .../backtest/dto/BacktestRunRequest.java | 39 +++ .../backtest/dto/StrategyCondition.java | 31 ++ .../backend/backtest/dto/StrategyOperand.java | 39 +++ .../backend/backtest/entity/BacktestRun.java | 17 +- .../backtest/entity/BacktestStatus.java | 8 + .../backtest/service/BacktestService.java | 70 ++++- .../backtest/service/BacktestingEngine.java | 149 +++++++++ .../backtest/service/Ta4jHelperService.java | 288 ++++++++++++++++++ .../CustomUserDetailsService.java | 4 +- .../backend/common/config/AsyncConfig.java | 9 + .../backend/common/exception/ErrorCode.java | 6 + 14 files changed, 685 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/dto/BacktestRunRequest.java create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyCondition.java create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/dto/StrategyOperand.java create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/entity/BacktestStatus.java create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/service/BacktestingEngine.java create mode 100644 backend/src/main/java/org/sejongisc/backend/backtest/service/Ta4jHelperService.java create mode 100644 backend/src/main/java/org/sejongisc/backend/common/config/AsyncConfig.java diff --git a/backend/build.gradle b/backend/build.gradle index 7f9909d7..bad5dd00 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/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/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/exception/ErrorCode.java b/backend/src/main/java/org/sejongisc/backend/common/exception/ErrorCode.java index 91ff86ff..dad58508 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, "백테스트 소유자가 아닙니다."), From 7df6f56e9b3bf5aad4b25d9232225ed7af099d1c Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:52:32 +0900 Subject: [PATCH 02/10] =?UTF-8?q?[BE]=20SISC1-56=20[FEAT]=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20DB=EC=9D=98=20=EC=A3=BC=EC=8B=9D=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=9D=B4=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EB=A9=80=ED=8B=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/PrimaryDataSourceConfig.java | 75 +++++++++++++++++++ .../common/config/StockDataSourceConfig.java | 60 +++++++++++++++ .../backend/stock/entity/PriceData.java | 29 +++++++ .../backend/stock/entity/PriceDataId.java | 36 +++++++++ .../stock/repository/PriceDataRepository.java | 14 ++++ 5 files changed, 214 insertions(+) create mode 100644 backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java create mode 100644 backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java create mode 100644 backend/src/main/java/org/sejongisc/backend/stock/entity/PriceData.java create mode 100644 backend/src/main/java/org/sejongisc/backend/stock/entity/PriceDataId.java create mode 100644 backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java 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/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 From 2652c9b4f8bb4e7506719bac4a22940b1f6b0546 Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Wed, 29 Oct 2025 20:52:58 +0900 Subject: [PATCH 03/10] =?UTF-8?q?[BE]=20SISC1-56=20[FIX]=20@Authentication?= =?UTF-8?q?Principal=20=EC=9E=91=EB=8F=99=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sejongisc/backend/common/auth/jwt/JwtParser.java | 6 +++++- .../common/auth/springsecurity/JwtAuthenticationFilter.java | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) 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/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", From 368f2ccb974a4916beaccdba6c6e5e8f0b33f260 Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Sun, 2 Nov 2025 02:33:14 +0900 Subject: [PATCH 04/10] =?UTF-8?q?[BE]=20SISC1-56=20[FEAT]=20=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B0=84=EB=8B=A8=ED=95=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9A=A9=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/stock/TestController.java | 23 +++++++++++++++++++ .../stock/repository/PriceDataRepository.java | 1 + 2 files changed, 24 insertions(+) create mode 100644 backend/src/main/java/org/sejongisc/backend/stock/TestController.java diff --git a/backend/src/main/java/org/sejongisc/backend/stock/TestController.java b/backend/src/main/java/org/sejongisc/backend/stock/TestController.java new file mode 100644 index 00000000..1b56b764 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/stock/TestController.java @@ -0,0 +1,23 @@ +package org.sejongisc.backend.stock; + + +import lombok.RequiredArgsConstructor; +import org.sejongisc.backend.stock.entity.PriceData; +import org.sejongisc.backend.stock.repository.PriceDataRepository; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class TestController { + + private final PriceDataRepository priceDataRepository; + + @PostMapping("/test") + public List test() { + return priceDataRepository.findByTicker("AAPL"); + } + +} 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 index fc8d4519..655022f3 100644 --- a/backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java +++ b/backend/src/main/java/org/sejongisc/backend/stock/repository/PriceDataRepository.java @@ -11,4 +11,5 @@ @Repository public interface PriceDataRepository extends JpaRepository { List findByTickerAndDateBetweenOrderByDateAsc(String ticker, LocalDate startDate, LocalDate endDate); + List findByTicker(String ticker); } \ No newline at end of file From 45fa9d347516d2923254ce0906cc55464a7280c4 Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Sun, 2 Nov 2025 02:34:24 +0900 Subject: [PATCH 05/10] =?UTF-8?q?[BE]=20SISC1-56=20[FEAT]=20=EC=A3=BC?= =?UTF-8?q?=EC=8B=9D=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=A9=80=ED=8B=B0=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/common/config/PrimaryDataSourceConfig.java | 9 +++++++++ .../backend/common/config/StockDataSourceConfig.java | 6 ++++++ 2 files changed, 15 insertions(+) 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 index 2405b2f4..40b643af 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/PrimaryDataSourceConfig.java @@ -16,6 +16,9 @@ import javax.sql.DataSource; import com.zaxxer.hikari.HikariDataSource; +import java.util.HashMap; +import java.util.Map; + @Configuration @EnableTransactionManagement @EnableJpaRepositories( @@ -48,6 +51,11 @@ public DataSource primaryDataSource(@Qualifier("primaryDataSourceProperties") Da public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( EntityManagerFactoryBuilder builder, @Qualifier("primaryDataSource") DataSource dataSource) { + + Map jpaProperties = new HashMap<>(); + jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"); + jpaProperties.put("hibernate.hbm2ddl.auto", "update"); + return builder .dataSource(dataSource) .packages( @@ -62,6 +70,7 @@ public LocalContainerEntityManagerFactoryBean primaryEntityManagerFactory( "org.sejongisc.backend.user.entity" ) .persistenceUnit("primary") + .properties(jpaProperties) .build(); } 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 index 0fe15a78..c234e8f5 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/config/StockDataSourceConfig.java @@ -45,10 +45,16 @@ public LocalContainerEntityManagerFactoryBean stockEntityManagerFactory( EntityManagerFactoryBuilder builder, @Qualifier("stockDataSource") DataSource dataSource) { + Map jpaProperties = new HashMap<>(); + jpaProperties.put("hibernate.dialect", "org.hibernate.dialect.PostgreSQLDialect"); + jpaProperties.put("hibernate.default_schema", "public"); + jpaProperties.put("hibernate.hbm2ddl.auto", "none"); + return builder .dataSource(dataSource) .packages("org.sejongisc.backend.stock.entity") // PriceData 엔티티가 있는 패키지 지정 .persistenceUnit("stock") // Persistence Unit 이름 + .properties(jpaProperties) .build(); } From ec45228abd1c91161c99e1e062d302c81eae006d Mon Sep 17 00:00:00 2001 From: Wi Seungjae <132977754+discipline24@users.noreply.github.com> Date: Sun, 2 Nov 2025 02:36:00 +0900 Subject: [PATCH 06/10] =?UTF-8?q?[BE]=20SISC1-56=20[DOCS]=20=EB=B0=B1?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8,=20=ED=85=9C=ED=94=8C=EB=A6=BF,=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=8A=A4?= =?UTF-8?q?=EC=9B=A8=EA=B1=B0=20=EC=9A=94=EC=B2=AD=20JSON=20=EC=98=88?= =?UTF-8?q?=EC=8B=9C,=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EB=93=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 - .../controller/BacktestController.java | 151 ++++++++++++++++++ .../backtest/dto/BacktestRunRequest.java | 6 +- .../backtest/dto/StrategyCondition.java | 13 +- .../backend/backtest/dto/StrategyOperand.java | 16 +- .../controller/PointHistoryController.java | 25 ++- .../controller/TemplateController.java | 29 +++- .../template/dto/TemplateResponse.java | 2 + 8 files changed, 212 insertions(+), 31 deletions(-) diff --git a/backend/build.gradle b/backend/build.gradle index bad5dd00..57fecef7 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -38,7 +38,6 @@ dependencies { runtimeOnly 'org.postgresql:postgresql' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - runtimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' diff --git a/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java b/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java index db99f09a..da3d7827 100644 --- a/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java +++ b/backend/src/main/java/org/sejongisc/backend/backtest/controller/BacktestController.java @@ -1,6 +1,11 @@ package org.sejongisc.backend.backtest.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.backtest.dto.BacktestRequest; import org.sejongisc.backend.backtest.dto.BacktestResponse; @@ -14,12 +19,20 @@ @RestController @RequestMapping("/api/backtest") +@Tag( + name = "백테스팅 API", + description = "백테스팅 관련 API 제공" +) @RequiredArgsConstructor public class BacktestController { private final BacktestService backtestService; // 백테스트 실행 상태 조회 @GetMapping("/runs/{backtestRunId}/status") + @Operation( + summary = "백테스트 실행 상태 조회", + description = "지정된 백테스트 실행 ID에 대한 현재 상태를 조회합니다." + ) public ResponseEntity getBacktestStatus(@PathVariable Long backtestRunId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(backtestService.getBacktestStatus(backtestRunId, customUserDetails.getUserId())); @@ -27,6 +40,10 @@ public ResponseEntity getBacktestStatus(@PathVariable Long bac // 백테스트 기록 상세 조회 @GetMapping("/runs/{backtestRunId}") + @Operation( + summary = "백테스트 실행 기록 상세 조회", + description = "지정된 백테스트 실행 ID에 대한 상세 결과를 조회합니다." + ) public ResponseEntity getBackTestResultDetails(@PathVariable Long backtestRunId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(backtestService.getBackTestDetails(backtestRunId, customUserDetails.getUserId())); @@ -34,6 +51,128 @@ public ResponseEntity getBackTestResultDetails(@PathVariable L // 백테스트 실행 @PostMapping("/runs") + @Operation( + summary = "백테스트 실행", + description = "사용자가 요청한 전략을 기반으로 백테스트를 실행합니다." + ) + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "백테스트 실행을 위한 기본 정보 및 전략", + required = true, + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BacktestRequest.class), + examples = { + @ExampleObject( + summary = "SMA 골든크로스 및 RSI 필터 전략 예시", + value = """ + { + "title": "골든크로스 + RSI 필터 (AAPL)", + "startDate": "2023-01-01", + "endDate": "2024-12-31", + "templateId": null, + "strategy": { + "initialCapital": 100000.00, + "ticker": "AAPL", + "buyConditions": [ + { + "leftOperand": { + "type": "indicator", + "indicatorCode": "RSI", + "output": "value", + "params": { + "length": 14 + } + }, + "operator": "LT", + "rightOperand": { + "type": "const", + "constantValue": 30 + }, + "isAbsolute": true + }, + { + "leftOperand": { + "type": "indicator", + "indicatorCode": "SMA", + "output": "value", + "params": { + "length": 50 + } + }, + "operator": "CROSSES_ABOVE", + "rightOperand": { + "type": "indicator", + "indicatorCode": "SMA", + "output": "value", + "params": { + "length": 200 + } + }, + "isAbsolute": false + }, + { + "leftOperand": { + "type": "price", + "priceField": "Close" + }, + "operator": "GTE", + "rightOperand": { + "type": "indicator", + "indicatorCode": "SMA", + "output": "value", + "params": { + "length": 200 + } + }, + "isAbsolute": false + } + ], + "sellConditions": [ + { + "leftOperand": { + "type": "indicator", + "indicatorCode": "RSI", + "output": "value", + "params": { + "length": 14 + } + }, + "operator": "GT", + "rightOperand": { + "type": "const", + "constantValue": 70 + }, + "isAbsolute": true + }, + { + "leftOperand": { + "type": "indicator", + "indicatorCode": "SMA", + "output": "value", + "params": { + "length": 50 + } + }, + "operator": "CROSSES_BELOW", + "rightOperand": { + "type": "indicator", + "indicatorCode": "SMA", + "output": "value", + "params": { + "length": 200 + } + }, + "isAbsolute": false + } + ], + "note": "간단한 골든크로스 전략 테스트. RSI 과매수/과매도 시 우선 청산/진입." + } + } + """ + ) + } + ) + ) public ResponseEntity runBacktest(@RequestBody BacktestRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { request.setUserId(customUserDetails.getUserId()); @@ -42,6 +181,10 @@ public ResponseEntity runBacktest(@RequestBody BacktestRequest // 백테스트 실행 정보 삭제 @DeleteMapping("/runs/{backtestRunId}") + @Operation( + summary = "백테스트 실행 정보 삭제", + description = "지정된 백테스트 실행 ID에 대한 기록을 삭제합니다." + ) public ResponseEntity deleteBacktest(@PathVariable Long backtestRunId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { backtestService.deleteBacktest(backtestRunId, customUserDetails.getUserId()); @@ -50,6 +193,10 @@ public ResponseEntity deleteBacktest(@PathVariable Long backtestRunId, // 백테스트를 특정 템플릿에 저장 @PatchMapping("/runs/{backtestRunId}") + @Operation( + summary = "템플릿에 백테스트 저장", + description = "지정된 백테스트를 특정 템플릿에 추가합니다." + ) public ResponseEntity postBacktestIntoTemplate(@RequestBody BacktestRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { request.setUserId(customUserDetails.getUserId()); @@ -59,6 +206,10 @@ public ResponseEntity postBacktestIntoTemplate(@RequestBody BacktestReques // 특정 템플릿의 백테스트 리스트 삭제 @DeleteMapping("/templates/{templateId}/runs") + @Operation( + summary = "템플릿의 백테스트 삭제", + description = "지정된 템플릿에서 특정 백테스트를 삭제합니다." + ) public ResponseEntity deleteBacktestFromTemplate(@RequestBody BacktestRequest request, @PathVariable UUID templateId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { 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 ff51e305..cd5caf21 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 @@ -17,10 +17,10 @@ @NoArgsConstructor @AllArgsConstructor public class BacktestRunRequest { - @Schema(description = "초기 자본금") + @Schema(description = "초기 자본금", defaultValue = "골든크로스 + RSI 필터 (AAPL)") private BigDecimal initialCapital; - @Schema(description = "대상 종목 티커") + @Schema(description = "대상 종목 티커", defaultValue = "AAPL") private String ticker; @Schema(description = "매수 조건 그룹") @@ -29,7 +29,7 @@ public class BacktestRunRequest { @Schema(description = "매도 조건 그룹") private List sellConditions; - @Schema(description = "노트") + @Schema(description = "노트", defaultValue = "골든크로스 + RSI 필터 전략 테스트") private String note; /* 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 5ba624e3..be2ccc6f 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,5 +1,6 @@ package org.sejongisc.backend.backtest.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,19 +14,15 @@ @AllArgsConstructor public class StrategyCondition { - // 좌항 + @Schema(description = "좌향") private StrategyOperand leftOperand; - // 연산자 (예: "GT", "LT", "CROSSES_ABOVE") + @Schema(description = "연산자 (예: \"GT\", \"LT\", \"CROSSES_ABOVE\")") private String operator; - // 우항 + @Schema(description = "우향") private StrategyOperand rightOperand; - /** - * "무조건 행동" 조건인지 여부 - * true = 이 조건이 맞으면 다른 '일반' 조건 무시 - * false = '일반' 조건 - */ + @Schema(description = "\"무조건 행동\" 조건인지 여부 (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 index 73aae8d6..68c31188 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,5 +1,6 @@ package org.sejongisc.backend.backtest.dto; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -16,24 +17,21 @@ @AllArgsConstructor public class StrategyOperand { - // 항의 타입: "indicator", "price", "const" + @Schema(description = "항의 타입: \"indicator\", \"price\", \"const\"") private String type; - // type == "indicator" 일 때 - // 지표 코드 (예: "SMA", "RSI", "MACD") + @Schema(description = "type == \"indicator\" 일 때의 지표 코드 (예: \"SMA\", \"RSI\", \"MACD\")") private String indicatorCode; - // type == "price" 일 때 - // 가격 필드 (예: "Close", "Open", "High", "Low", "Volume") + @Schema(description = "type == \"price\" 일 때의 가격 필드 (예: \"Close\", \"Open\", \"High\", \"Low\", \"Volume\")") private String priceField; - // type == "const" 일 때 - // 상수 값 (예: 30, 0.02) + @Schema(description = "type == \"const\" 일 때의 상수 값 (예: 30, 0.02)") private BigDecimal constantValue; - // 지표의 출력값 (예: "value", "macd", "signal", "hist") + @Schema(description = "지표의 출력값 (예: \"value\", \"macd\", \"signal\", \"hist\")") private String output; - // 지표의 파라미터 맵 (예: {"length": 20}) + @Schema(description = "지표의 파라미터 맵 (예: {\"length\": 20})") private Map params; } \ No newline at end of file diff --git a/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java b/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java index bd42a436..bdc106a9 100644 --- a/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java +++ b/backend/src/main/java/org/sejongisc/backend/point/controller/PointHistoryController.java @@ -1,6 +1,8 @@ package org.sejongisc.backend.point.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.point.dto.PointHistoryResponse; @@ -16,25 +18,32 @@ @RestController @RequestMapping("/api/points") @RequiredArgsConstructor +@Tag( + name = "포인트 내역 및 리더보드 API", + description = "포인트 내역 조회 및 리더보드 관련 API 제공" +) public class PointHistoryController { private final PointHistoryService pointHistoryService; @GetMapping("/history") - public ResponseEntity getPointHistory( - @RequestParam int pageNumber, - @RequestParam int pageSize, - @AuthenticationPrincipal CustomUserDetails customUserDetails - ) { + @Operation( + summary = "포인트 내역 조회", + description = "인증된 사용자의 포인트 내역을 페이지네이션하여 조회합니다." + ) + public ResponseEntity getPointHistory(@RequestParam int pageNumber, @RequestParam int pageSize, + @AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(pointHistoryService.getPointHistoryListByUserId( customUserDetails.getUserId(), PageRequest.of(pageNumber, pageSize)) ); } @GetMapping("/leaderboard") - public ResponseEntity getPointLeaderboard( - @RequestParam int period - ) { + @Operation( + summary = "포인트 리더보드 조회", + description = "지정된 기간 동안의 포인트 리더보드를 조회합니다. 기간은 일간, 주간, 월간 단위의 요청이 가능합니다. ex) /leaderboard?period=1 or 7 or 30" + ) + public ResponseEntity getPointLeaderboard(@RequestParam int period) { return ResponseEntity.ok(pointHistoryService.getPointLeaderboard(period)); } } diff --git a/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java b/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java index 90f9ab06..371a380b 100644 --- a/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java +++ b/backend/src/main/java/org/sejongisc/backend/template/controller/TemplateController.java @@ -1,5 +1,7 @@ package org.sejongisc.backend.template.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.sejongisc.backend.common.auth.springsecurity.CustomUserDetails; import org.sejongisc.backend.template.dto.TemplateRequest; @@ -16,25 +18,40 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/backtest/templates") +@Tag( + name = "템플릿 API", + description = "백테스트 템플릿 관련 API 제공" +) public class TemplateController { private final TemplateService templateService; - // 템플릿 목록 조회 @GetMapping + @Operation( + summary = "템플릿 목록 조회", + description = "사용자의 백테스트 템플릿 목록을 조회합니다." + ) public ResponseEntity getTemplateList(@AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(templateService.findAllByUserId(customUserDetails.getUserId())); } // 템플릿 상세 조회 @GetMapping("/{templateId}") + @Operation( + summary = "템플릿 상세 조회", + description = "지정된 템플릿 ID에 대한 상세 정보 및 템플릿에 저장된 백테스트 실행 기록들을 조회합니다." + ) public ResponseEntity getTemplateById(@PathVariable UUID templateId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { - return ResponseEntity.ok(templateService.findById(templateId)); + return ResponseEntity.ok(templateService.findById(templateId, customUserDetails.getUserId())); } // 템플릿 생성 @PostMapping + @Operation( + summary = "템플릿 생성", + description = "새로운 백테스트 템플릿을 생성합니다." + ) public ResponseEntity createTemplate(@RequestBody TemplateRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetail) { request.setUserId(customUserDetail.getUserId()); @@ -43,6 +60,10 @@ public ResponseEntity createTemplate(@RequestBody TemplateRequ // 템플릿 수정 @PatchMapping("/{templateId}") + @Operation( + summary = "템플릿 수정", + description = "기존의 백테스트 템플릿을 수정합니다." + ) public ResponseEntity updateTemplate(@RequestBody TemplateRequest request, @AuthenticationPrincipal CustomUserDetails customUserDetails) { return ResponseEntity.ok(templateService.updateTemplate(request)); @@ -50,6 +71,10 @@ public ResponseEntity updateTemplate(@RequestBody TemplateRequ // 템플릿 삭제 @DeleteMapping("/{templateId}") + @Operation( + summary = "템플릿 삭제", + description = "지정된 템플릿 ID에 대한 템플릿을 삭제합니다." + ) public ResponseEntity deleteTemplate(@PathVariable UUID templateId, @AuthenticationPrincipal CustomUserDetails customUserDetails) { templateService.deleteTemplate(templateId, customUserDetails.getUserId()); diff --git a/backend/src/main/java/org/sejongisc/backend/template/dto/TemplateResponse.java b/backend/src/main/java/org/sejongisc/backend/template/dto/TemplateResponse.java index ca70c63b..0be5bc73 100644 --- a/backend/src/main/java/org/sejongisc/backend/template/dto/TemplateResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/template/dto/TemplateResponse.java @@ -2,6 +2,7 @@ import lombok.Builder; import lombok.Getter; +import org.sejongisc.backend.backtest.entity.BacktestRun; import org.sejongisc.backend.template.entity.Template; import java.util.List; @@ -12,4 +13,5 @@ public class TemplateResponse { private List