diff --git a/.gradle/8.14.3/executionHistory/executionHistory.bin b/.gradle/8.14.3/executionHistory/executionHistory.bin index 16c706e..4299707 100644 Binary files a/.gradle/8.14.3/executionHistory/executionHistory.bin and b/.gradle/8.14.3/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.14.3/executionHistory/executionHistory.lock b/.gradle/8.14.3/executionHistory/executionHistory.lock index f790f34..3da9944 100644 Binary files a/.gradle/8.14.3/executionHistory/executionHistory.lock and b/.gradle/8.14.3/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.14.3/fileHashes/fileHashes.bin b/.gradle/8.14.3/fileHashes/fileHashes.bin index 62a5f70..55a7f70 100644 Binary files a/.gradle/8.14.3/fileHashes/fileHashes.bin and b/.gradle/8.14.3/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.14.3/fileHashes/fileHashes.lock b/.gradle/8.14.3/fileHashes/fileHashes.lock index 5510219..e635740 100644 Binary files a/.gradle/8.14.3/fileHashes/fileHashes.lock and b/.gradle/8.14.3/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.14.3/fileHashes/resourceHashesCache.bin b/.gradle/8.14.3/fileHashes/resourceHashesCache.bin index 1bc0848..36a7658 100644 Binary files a/.gradle/8.14.3/fileHashes/resourceHashesCache.bin and b/.gradle/8.14.3/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 95f84cb..16f8713 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/build/tmp/compileJava/previous-compilation-data.bin b/build/tmp/compileJava/previous-compilation-data.bin index dec5468..8b24939 100644 Binary files a/build/tmp/compileJava/previous-compilation-data.bin and b/build/tmp/compileJava/previous-compilation-data.bin differ diff --git a/src/main/java/com/stockport/server/application/controller/backtest/BacktestController.java b/src/main/java/com/stockport/server/application/controller/backtest/BacktestController.java new file mode 100644 index 0000000..8b4f134 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/BacktestController.java @@ -0,0 +1,26 @@ +package com.stockport.server.application.controller.backtest; + +import com.stockport.server.application.controller.backtest.dto.request.BacktestRequest; +import com.stockport.server.application.controller.backtest.dto.response.BacktestResponse; +import com.stockport.server.application.service.backtest.BacktestService; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "Backtest", description = "백테스팅 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/backtest") +public class BacktestController { + private final BacktestService backtestService; + + @PostMapping + public ResponseEntity runBacktest(@RequestBody BacktestRequest request) { + backtestService.validateRequest(request); + return ResponseEntity.ok(backtestService.runBacktest(request)); + } +} diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/request/AssetRequest.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/AssetRequest.java new file mode 100644 index 0000000..7cc349d --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/AssetRequest.java @@ -0,0 +1,12 @@ +package com.stockport.server.application.controller.backtest.dto.request; + + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class AssetRequest { + private String stockCd; + private int weight; +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/request/BacktestRequest.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/BacktestRequest.java new file mode 100644 index 0000000..ccf97f4 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/BacktestRequest.java @@ -0,0 +1,18 @@ +package com.stockport.server.application.controller.backtest.dto.request; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Setter +public class BacktestRequest { + + private LocalDate startDate; + private LocalDate endDate; + private Long initialCapital; + private RebalanceCycle rebalanceCycle; + private List assets; +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/request/RebalanceCycle.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/RebalanceCycle.java new file mode 100644 index 0000000..cec32dd --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/request/RebalanceCycle.java @@ -0,0 +1,5 @@ +package com.stockport.server.application.controller.backtest.dto.request; + +public enum RebalanceCycle { + MONTHLY, QUARTERLY, YEARLY +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/response/BacktestResponse.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/BacktestResponse.java new file mode 100644 index 0000000..73c1da3 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/BacktestResponse.java @@ -0,0 +1,19 @@ +package com.stockport.server.application.controller.backtest.dto.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@Builder +public class BacktestResponse { + private SummaryReport kospiSummary; + private SummaryReport kosdaqSummary; + private SummaryReport portfolioSummary; + private List monthlyDrawdowns; + private List monthlyAssets; + private List monthlyReturns; +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyAsset.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyAsset.java new file mode 100644 index 0000000..27b1ce1 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyAsset.java @@ -0,0 +1,14 @@ +package com.stockport.server.application.controller.backtest.dto.response; + +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Setter +public class MonthlyAsset { + private LocalDate date; + private BigDecimal portfolioValue; +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyDrawdown.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyDrawdown.java new file mode 100644 index 0000000..a146c11 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/MonthlyDrawdown.java @@ -0,0 +1,14 @@ +package com.stockport.server.application.controller.backtest.dto.response; + +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Setter +public class MonthlyDrawdown { + private LocalDate date; + private BigDecimal drawdown; +} diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/response/PortfolioValue.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/PortfolioValue.java new file mode 100644 index 0000000..e728977 --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/PortfolioValue.java @@ -0,0 +1,23 @@ +package com.stockport.server.application.controller.backtest.dto.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Setter +@Builder +public class PortfolioValue { + private LocalDate date; + private BigDecimal value; + + public static PortfolioValue create(LocalDate date, BigDecimal returnRate) { + return PortfolioValue.builder() + .date(date) + .value(returnRate) + .build(); + } +} diff --git a/src/main/java/com/stockport/server/application/controller/backtest/dto/response/SummaryReport.java b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/SummaryReport.java new file mode 100644 index 0000000..b3fcafd --- /dev/null +++ b/src/main/java/com/stockport/server/application/controller/backtest/dto/response/SummaryReport.java @@ -0,0 +1,23 @@ +package com.stockport.server.application.controller.backtest.dto.response; + +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@Builder +public class SummaryReport { + + private String portfolioName; + private BigDecimal initialCapital; + private BigDecimal finalCapital; + + private BigDecimal cagr; + private BigDecimal maxDrawdown; + private BigDecimal volatility; + private BigDecimal sharpeRatio; + private BigDecimal sortinoRatio; +} \ No newline at end of file diff --git a/src/main/java/com/stockport/server/application/controller/stock/StockUpdateController.java b/src/main/java/com/stockport/server/application/controller/stock/StockUpdateController.java index 6cf8d6d..7fd8995 100644 --- a/src/main/java/com/stockport/server/application/controller/stock/StockUpdateController.java +++ b/src/main/java/com/stockport/server/application/controller/stock/StockUpdateController.java @@ -54,4 +54,14 @@ public ApiResponse updatePeriodicStockData( stockService.updatePeriodicStockData(startDate, endDate); return ApiResponse.onSuccess("주가 데이터 업데이트 성공"); } + + @GetMapping("/update/stock-data") + @Operation( + summary = "주식 데이터 강제 업데이트", + description = "주식 데이터를 강제 업데이트합니다." + ) + public ApiResponse updateErrorStockData(@RequestParam String stockCd) { + stockService.forceUpdateStockData(stockCd); + return ApiResponse.onSuccess("오류 주가 데이터 재업데이트 성공"); + } } diff --git a/src/main/java/com/stockport/server/application/scheduler/indexData/IndexDataScheduler.java b/src/main/java/com/stockport/server/application/scheduler/indexData/IndexDataScheduler.java index 9ae6404..435c249 100644 --- a/src/main/java/com/stockport/server/application/scheduler/indexData/IndexDataScheduler.java +++ b/src/main/java/com/stockport/server/application/scheduler/indexData/IndexDataScheduler.java @@ -13,13 +13,13 @@ public class IndexDataScheduler { private final IndexDataService indexDataService; - @Scheduled(cron = "0 0/5 9-18 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. + @Scheduled(cron = "0 0/5 9-17 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. public void updateKospi() { indexDataService.updateCurrentIndexData(MarketType.KOSPI); log.info("[Scheduler] 코스피 데이터 업데이트 완료"); } - @Scheduled(cron = "0 0/5 9-18 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. + @Scheduled(cron = "0 0/5 9-17 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. public void updateKosdaq() { indexDataService.updateCurrentIndexData(MarketType.KOSPI); log.info("[Scheduler] 코스닥 데이터 업데이트 완료"); diff --git a/src/main/java/com/stockport/server/application/scheduler/stock/StockScheduler.java b/src/main/java/com/stockport/server/application/scheduler/stock/StockScheduler.java index 25f53b8..e408115 100644 --- a/src/main/java/com/stockport/server/application/scheduler/stock/StockScheduler.java +++ b/src/main/java/com/stockport/server/application/scheduler/stock/StockScheduler.java @@ -18,7 +18,7 @@ public void saveDailyStockData() { log.info("[Scheduler] 일별 주가 데이터 업데이트 완료"); } - @Scheduled(cron = "0 0/5 9-18 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. + @Scheduled(cron = "0 0/5 9-17 * * MON-FRI", zone = "Asia/Seoul") // todo: 공휴일/휴장일 스케쥴러 처리 필요. public void updateStockData() { stockService.updateCurrentStockData(); log.info("[Scheduler] 주가 데이터 업데이트 완료"); diff --git a/src/main/java/com/stockport/server/application/service/backtest/BacktestService.java b/src/main/java/com/stockport/server/application/service/backtest/BacktestService.java new file mode 100644 index 0000000..25ce8a9 --- /dev/null +++ b/src/main/java/com/stockport/server/application/service/backtest/BacktestService.java @@ -0,0 +1,9 @@ +package com.stockport.server.application.service.backtest; + +import com.stockport.server.application.controller.backtest.dto.request.BacktestRequest; +import com.stockport.server.application.controller.backtest.dto.response.BacktestResponse; + +public interface BacktestService { + BacktestResponse runBacktest(BacktestRequest request); + void validateRequest(BacktestRequest request); +} diff --git a/src/main/java/com/stockport/server/application/service/backtest/BacktestServiceImpl.java b/src/main/java/com/stockport/server/application/service/backtest/BacktestServiceImpl.java new file mode 100644 index 0000000..f62ec03 --- /dev/null +++ b/src/main/java/com/stockport/server/application/service/backtest/BacktestServiceImpl.java @@ -0,0 +1,429 @@ +package com.stockport.server.application.service.backtest; + +import com.stockport.server.application.controller.backtest.dto.request.AssetRequest; +import com.stockport.server.application.controller.backtest.dto.request.BacktestRequest; +import com.stockport.server.application.controller.backtest.dto.request.RebalanceCycle; +import com.stockport.server.application.controller.backtest.dto.response.BacktestResponse; +import com.stockport.server.application.controller.backtest.dto.response.PortfolioValue; +import com.stockport.server.application.controller.backtest.dto.response.SummaryReport; +import com.stockport.server.domain.indexData.constant.MarketType; +import com.stockport.server.domain.indexData.entity.IndexData; +import com.stockport.server.domain.indexData.repository.IndexDataRepository; +import com.stockport.server.domain.stock.entity.Stock; +import com.stockport.server.domain.stock.entity.StockPrice; +import com.stockport.server.domain.stock.repository.StockPriceRepository; +import com.stockport.server.domain.stock.repository.StockRepository; +import com.stockport.server.global.apipayload.code.status.ErrorStatus; +import com.stockport.server.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BacktestServiceImpl implements BacktestService { + private final StockRepository stockRepository; + private final StockPriceRepository stockPriceRepository; + private final IndexDataRepository indexDataRepository; + + @Override + public BacktestResponse runBacktest(BacktestRequest request) { + List kospiValues = calculateIndexReturns(request, MarketType.KOSPI); + List kosdaqValues = calculateIndexReturns(request, MarketType.KOSDAQ); + List portfolioValues = calculatePortfolioReturns(request); + + return BacktestResponse.builder() + .kospiSummary(calculateSummaryReport(kospiValues, "KOSPI")) + .kosdaqSummary(calculateSummaryReport(kosdaqValues, "KOSDAQ")) + .portfolioSummary(calculateSummaryReport(portfolioValues, "PORTFOLIO")) + .monthlyAssets(calcuateMonthlyAssets(portfolioValues)) + .monthlyDrawdowns(calculateMDD(portfolioValues)) + .monthlyReturns(calculateMonthlyReturns(portfolioValues)) + .build(); + } + + @Override + public void validateRequest(BacktestRequest request) { + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + + if (startDate.isAfter(endDate)) + throw new GeneralException(ErrorStatus.BACKTEST_INVALID_DATE_RANGE); + + List assets = request.getAssets(); + int totalWeight = assets.stream() + .mapToInt(AssetRequest::getWeight) + .sum(); + + if (totalWeight != 100) { + throw new GeneralException(ErrorStatus.BACKTEST_INVALID_WEIGHTS); + } + + for (AssetRequest asset : assets) { + Stock stock = stockRepository.findByStockCd(asset.getStockCd()).orElseThrow(() -> + new GeneralException(ErrorStatus.STOCK_NOT_FOUND)); + if (startDate.isBefore(stockPriceRepository.findTopByStockOrderByBaseDateAsc(stock).getBaseDate())) + throw new GeneralException(ErrorStatus.BACKTEST_INVALID_DATE_RANGE); + } + } + + private SummaryReport calculateSummaryReport(List values, String portfolioName) { + List dailyReturns = calculateDailyReturns(values); + BigDecimal avgDailyReturn = calculateAvgDailyReturn(dailyReturns); + + BigDecimal volatility = calculateVolatility(dailyReturns, avgDailyReturn, values); + BigDecimal sharpeRatio = calculateSharpeRatio(values, dailyReturns, avgDailyReturn, volatility); + BigDecimal sortinoRatio = calculateSortinoRatio(dailyReturns, avgDailyReturn); + return SummaryReport.builder() + .portfolioName(portfolioName) + .initialCapital(values.get(0).getValue()) + .finalCapital(values.get(values.size() - 1).getValue()) + .cagr(calculateCagr(values)) + .maxDrawdown(calculateTotalMDD(values)) + .volatility(volatility) + .sharpeRatio(sharpeRatio) + .sortinoRatio(sortinoRatio) + .build(); + } + + private BigDecimal calculateAvgDailyReturn(List dailyReturns) { + BigDecimal sum = dailyReturns.stream() + .reduce(BigDecimal.ZERO, BigDecimal::add); + + return sum.divide( + BigDecimal.valueOf(dailyReturns.size()), + 10, + RoundingMode.HALF_UP + ); + } + + private List calculateDailyReturns(List values) { + List dailyReturns = new ArrayList<>(); + + for (int i = 1; i < values.size(); i++) { + BigDecimal prev = values.get(i - 1).getValue(); + BigDecimal curr = values.get(i).getValue(); + + BigDecimal dailyReturn = curr.subtract(prev) + .divide(prev, 10, RoundingMode.HALF_UP); + + dailyReturns.add(dailyReturn); + } + return dailyReturns; + } + + private BigDecimal calculateSortinoRatio(List dailyReturns, BigDecimal avgDailyReturn) { + if (dailyReturns.isEmpty()) + return BigDecimal.ZERO; + + // Downside Deviation 계산 (음수 수익률만 사용) + BigDecimal downsideSum = BigDecimal.ZERO; + int downsideCount = 0; + + for (BigDecimal r : dailyReturns) { + if (r.compareTo(BigDecimal.ZERO) < 0) { // 음수 리턴만 + downsideSum = downsideSum.add(r.multiply(r)); + downsideCount++; + } + } + + if (downsideCount == 0) + return BigDecimal.ZERO; // 손실이 없으면 Sortino = 0 또는 매우 높음 (여기서는 0 반환) + + BigDecimal downsideVariance = downsideSum.divide( + BigDecimal.valueOf(downsideCount), + 10, + RoundingMode.HALF_UP + ); + + BigDecimal downsideDeviation = BigDecimal.valueOf( + Math.sqrt(downsideVariance.doubleValue()) + ).setScale(10, RoundingMode.HALF_UP); + + // 연율화된 Downside Deviation = × sqrt(252) + BigDecimal annualizedDownsideDeviation = downsideDeviation + .multiply(BigDecimal.valueOf(Math.sqrt(252))) + .setScale(10, RoundingMode.HALF_UP); + + if (annualizedDownsideDeviation.compareTo(BigDecimal.ZERO) == 0) + return BigDecimal.ZERO; + + // Sortino Ratio = (avgDailyReturn × 252) / annualizedDownsideDeviation + BigDecimal annualizedReturn = avgDailyReturn.multiply(BigDecimal.valueOf(252)); + + return annualizedReturn.divide(annualizedDownsideDeviation, 4, RoundingMode.HALF_UP); + } + + private BigDecimal calculateSharpeRatio(List values, List dailyReturns, BigDecimal avgDailyReturn, BigDecimal volatility) { + if (dailyReturns.isEmpty()) + return BigDecimal.ZERO; + + + // 연율화된 평균 수익률 = avgDailyReturn * 252 + BigDecimal annualizedReturn = avgDailyReturn + .multiply(BigDecimal.valueOf(252)); + + if (volatility.compareTo(BigDecimal.ZERO) == 0) + return BigDecimal.ZERO; + + // Sharpe Ratio = annualizedReturn / volatility + return annualizedReturn.divide(volatility, 4, RoundingMode.HALF_UP); + } + + private BigDecimal calculateVolatility(List dailyReturns, BigDecimal avgDailyReturns, List values) { + if (values.size() < 2) + return BigDecimal.ZERO; + + // 분산 계산 + BigDecimal varianceSum = BigDecimal.ZERO; + for (BigDecimal r : dailyReturns) { + BigDecimal diff = r.subtract(avgDailyReturns); + varianceSum = varianceSum.add(diff.multiply(diff)); + } + + BigDecimal variance = varianceSum.divide( + BigDecimal.valueOf(dailyReturns.size()), + 10, + RoundingMode.HALF_UP + ); + + // 표준편차 = sqrt(variance) + BigDecimal stdDev = BigDecimal.valueOf(Math.sqrt(variance.doubleValue())) + .setScale(10, RoundingMode.HALF_UP); + + // 연율화 변동성 = stdDev × sqrt(252) + return stdDev + .multiply(BigDecimal.valueOf(Math.sqrt(252))) + .setScale(4, RoundingMode.HALF_UP); + } + + private BigDecimal calculateTotalMDD(List values) { + BigDecimal peak = values.get(0).getValue(); + BigDecimal maxDrawdown = BigDecimal.ZERO; + + for (PortfolioValue pv : values) { + if (pv.getValue().compareTo(peak) > 0) { + peak = pv.getValue(); + continue; + } + + BigDecimal drawdown = peak.subtract(pv.getValue()) + .multiply(BigDecimal.valueOf(100)) + .divide(peak, 2, RoundingMode.HALF_EVEN); + + if (drawdown.compareTo(maxDrawdown) > 0) + maxDrawdown = drawdown; + } + return maxDrawdown.negate(); + } + + private BigDecimal calculateCagr(List values) { + + BigDecimal beginningValue = values.get(0).getValue(); + BigDecimal endingValue = values.get(values.size() - 1).getValue(); + + LocalDate startDate = values.get(0).getDate(); + LocalDate endDate = values.get(values.size() - 1).getDate(); + long days = ChronoUnit.DAYS.between(startDate, endDate); + + // 연수(years) = 일수 / 365 + BigDecimal years = BigDecimal.valueOf(days) + .divide(BigDecimal.valueOf(365), 10, RoundingMode.HALF_UP); + + // endingValue / beginningValue + BigDecimal ratio = endingValue.divide(beginningValue, 10, RoundingMode.HALF_UP); + + // CAGR = ratio ^ (1 / years) - 1 + double cagrDouble = Math.pow(ratio.doubleValue(), 1.0 / years.doubleValue()) - 1; + + return BigDecimal.valueOf(cagrDouble) + .multiply(BigDecimal.valueOf(100)) + .setScale(4, RoundingMode.HALF_UP); // 소수 4자리까지 예시 + } + + + private List calcuateMonthlyAssets(List protfolioReturns) { + LocalDate monthBoundaryDate = protfolioReturns.get(0).getDate().withDayOfMonth(1); + List monthlyAssetList = new ArrayList<>(); + + for (PortfolioValue pv : protfolioReturns) { + if (pv.getDate().isBefore(monthBoundaryDate)) continue; + + monthlyAssetList.add(PortfolioValue.create(pv.getDate(), pv.getValue())); + monthBoundaryDate = monthBoundaryDate.plusMonths(1).withDayOfMonth(1); + } + return monthlyAssetList; + } + + private List calculateMonthlyReturns(List portfolioReturns) { + LocalDate monthBoundaryDate = portfolioReturns.get(0).getDate().plusMonths(1).withDayOfMonth(1); + List monthlyReturnList = new ArrayList<>(); + + BigDecimal monthStartValue = portfolioReturns.get(0).getValue(); + for (PortfolioValue pv : portfolioReturns) { + if (pv.getDate().isBefore(monthBoundaryDate)) continue; + + BigDecimal monthEndValue = portfolioReturns.get(portfolioReturns.indexOf(pv) - 1).getValue(); + BigDecimal monthlyReturn = monthEndValue.subtract(monthStartValue) + .multiply(BigDecimal.valueOf(100)) + .divide(monthStartValue, 2, RoundingMode.HALF_EVEN); + monthlyReturnList.add(PortfolioValue.create(monthBoundaryDate, monthlyReturn)); + + monthBoundaryDate = monthBoundaryDate.plusMonths(1).withDayOfMonth(1); + monthStartValue = pv.getValue(); + } + return monthlyReturnList; + } + + private List calculateMDD(List portfolioReturns) { + LocalDate monthBoundaryDate = portfolioReturns.get(0).getDate().plusMonths(1).withDayOfMonth(1); + List mddList = new ArrayList<>(); + + BigDecimal peak = BigDecimal.ZERO, mdd = BigDecimal.ZERO; + for (PortfolioValue pv : portfolioReturns) { + if (pv.getDate().isAfter(monthBoundaryDate)) { + mddList.add(PortfolioValue.create(monthBoundaryDate, mdd)); + monthBoundaryDate = monthBoundaryDate.plusMonths(1).withDayOfMonth(1); + } + if (pv.getValue().compareTo(peak) > 0) { + peak = pv.getValue(); + mdd = BigDecimal.ZERO; + continue; + } + + BigDecimal drawdown = peak.subtract(pv.getValue()) + .multiply(BigDecimal.valueOf(100)) + .divide(peak, 2, RoundingMode.HALF_EVEN); + + if (drawdown.compareTo(mdd) > 0) + mdd = drawdown.negate(); + } + return mddList; + } + + private List calculateIndexReturns(BacktestRequest request, MarketType marketType) { + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + BigDecimal initialCapital = BigDecimal.valueOf(request.getInitialCapital()).setScale(2, RoundingMode.HALF_EVEN); + + List indexDataList = indexDataRepository.findAllByMarketTypeAndBaseDateBetweenOrderByBaseDateAsc( + marketType, startDate, endDate + ); + + List portfolioValueList = new ArrayList<>(); + BigDecimal stockQuantity = initialCapital.divide(indexDataList.get(0).getClosePrice(), RoundingMode.HALF_EVEN); + BigDecimal remainingCash = initialCapital.subtract(stockQuantity.multiply(indexDataList.get(0).getClosePrice())); + for (IndexData indexData : indexDataList) { + portfolioValueList.add(PortfolioValue.create( + indexData.getBaseDate(), + stockQuantity.multiply(indexData.getClosePrice()) + .add(remainingCash) + .setScale(2, RoundingMode.HALF_EVEN) + )); + } + + return portfolioValueList; + } + + private List calculatePortfolioReturns(BacktestRequest request) { + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + BigDecimal capital = BigDecimal.valueOf(request.getInitialCapital()).setScale(2, RoundingMode.HALF_EVEN); + List assets = request.getAssets(); + + List> dailyStockPriceLists = getDailyStockPriceList(assets, startDate, endDate); + LocalDate currentDate = startDate; + LocalDate lastRebalanceDate = startDate.minusYears(2); + List stockQuantityList = calculateStockQuantities(capital, assets, dailyStockPriceLists.get(0)); + BigDecimal remaingCash = calculateRemainingCash(capital, stockQuantityList, dailyStockPriceLists.get(0)); + List portfolioValueList = new ArrayList<>(); + + for (List dailyStockPriceList : dailyStockPriceLists) { + capital = calculatePortfolioValue(stockQuantityList, dailyStockPriceList) + .add(remaingCash) + .setScale(2, RoundingMode.HALF_EVEN); + currentDate = dailyStockPriceList.get(0).getBaseDate(); + + if (checkRebalance(currentDate, lastRebalanceDate, request.getRebalanceCycle())) { + stockQuantityList = calculateStockQuantities(capital, assets, dailyStockPriceList); + remaingCash = calculateRemainingCash(capital, stockQuantityList, dailyStockPriceList); + lastRebalanceDate = currentDate; + } + + portfolioValueList.add(PortfolioValue.create(currentDate, capital)); + } + + return portfolioValueList; + } + + private BigDecimal calculateRemainingCash(BigDecimal initialCapital, List stockQuantityList, List dailyStockPriceList) { + BigDecimal usedCapital = BigDecimal.ZERO; + for (int i = 0; i < stockQuantityList.size(); i++) { + BigDecimal stockQuantity = stockQuantityList.get(i); + StockPrice stockPrice = dailyStockPriceList.get(i); + usedCapital = usedCapital.add(stockQuantity.multiply(stockPrice.getClosePrice())); + } + return initialCapital.subtract(usedCapital); + } + + private BigDecimal calculatePortfolioValue(List stockQuantityList, List dailyStockPriceList) { + BigDecimal portfolioValue = BigDecimal.ZERO; + for (int i = 0; i < stockQuantityList.size(); i++) { + BigDecimal stockQuantity = stockQuantityList.get(i); + StockPrice stockPrice = dailyStockPriceList.get(i); + portfolioValue = portfolioValue.add(stockQuantity.multiply(stockPrice.getClosePrice())); + } + return portfolioValue; + } + + private List calculateStockQuantities(BigDecimal capital, List assets, List stockPriceListByStocks) { + List stockQuantityList = new ArrayList<>(); + for (int i = 0; i < assets.size(); i++) { + AssetRequest asset = assets.get(i); + StockPrice stockPrice = stockPriceListByStocks.get(i); + BigDecimal allocation = capital + .multiply(BigDecimal.valueOf(asset.getWeight())) + .divide(BigDecimal.valueOf(100), RoundingMode.DOWN); + BigDecimal stockQuantity = allocation.divide(stockPrice.getClosePrice(), RoundingMode.DOWN); + stockQuantityList.add(stockQuantity); + } + return stockQuantityList; + } + + private List> getDailyStockPriceList(List assets, LocalDate startDate, LocalDate endDate) { + List> stockPriceLists = new ArrayList<>(); + for (AssetRequest asset : assets) { + List stockPriceListByStock = stockPriceRepository.findByStockStockCdAndBaseDateBetweenOrderByBaseDateAsc( + asset.getStockCd(), startDate, endDate + ); + stockPriceLists.add(stockPriceListByStock); + } + + List> dailyStockPriceList = new ArrayList<>(); + for (int i = 0; i < stockPriceLists.get(0).size(); i++) { + List dailyPrices = new ArrayList<>(); + for (List stockPrices : stockPriceLists) { + dailyPrices.add(stockPrices.get(i)); + } + dailyStockPriceList.add(dailyPrices); + } + + return dailyStockPriceList; + } + + private boolean checkRebalance(LocalDate currentDate, LocalDate lastRebalanceDate, RebalanceCycle rebalanceCycle) { + return switch (rebalanceCycle) { + case MONTHLY -> currentDate.isAfter(lastRebalanceDate.plusMonths(1)); + case QUARTERLY -> currentDate.isAfter(lastRebalanceDate.plusMonths(3)); + case YEARLY -> currentDate.isAfter(lastRebalanceDate.plusYears(1)); + }; + } +} diff --git a/src/main/java/com/stockport/server/application/service/stock/StockService.java b/src/main/java/com/stockport/server/application/service/stock/StockService.java index 3fa9d40..dd98e32 100644 --- a/src/main/java/com/stockport/server/application/service/stock/StockService.java +++ b/src/main/java/com/stockport/server/application/service/stock/StockService.java @@ -20,5 +20,7 @@ public interface StockService { void updatePeriodicStockData(LocalDate startDate, LocalDate endDate); + void forceUpdateStockData(String stockCd); + void saveDailyStockData(); } diff --git a/src/main/java/com/stockport/server/application/service/stock/StockServiceImpl.java b/src/main/java/com/stockport/server/application/service/stock/StockServiceImpl.java index ad2b93d..9606b69 100644 --- a/src/main/java/com/stockport/server/application/service/stock/StockServiceImpl.java +++ b/src/main/java/com/stockport/server/application/service/stock/StockServiceImpl.java @@ -113,6 +113,20 @@ public void updatePeriodicStockData(LocalDate startDate, LocalDate endDate) { } } + @Override + @Transactional + public void forceUpdateStockData(String stockCd) { + Stock stock = stockRepository.findByStockCd(stockCd) + .orElseThrow(() -> new GeneralException(ErrorStatus.STOCK_NOT_FOUND)); + + stockPriceRepository.deleteAllByStock(stock); + for (LocalDate updateDate = LocalDate.now().minusYears(10).withMonth(1); updateDate.isBefore(LocalDate.now()); updateDate = updateDate.plusDays(140)) { + periodicSaver.saveOnePeriod(stock, updateDate, updateDate.plusDays(139)); + } + + log.info("[stock] 강제 주가 데이터 업데이트 완료: {}", stock.getStockCd()); + } + @Override @Transactional public void saveDailyStockData() { diff --git a/src/main/java/com/stockport/server/domain/stock/repository/StockPriceRepository.java b/src/main/java/com/stockport/server/domain/stock/repository/StockPriceRepository.java index 4a632c6..e9b76ba 100644 --- a/src/main/java/com/stockport/server/domain/stock/repository/StockPriceRepository.java +++ b/src/main/java/com/stockport/server/domain/stock/repository/StockPriceRepository.java @@ -12,6 +12,7 @@ public interface StockPriceRepository extends JpaRepository { List findByStockAndBaseDateBetweenOrderByBaseDateDesc(Stock stock, LocalDate startDate, LocalDate endDate); + List findByStockStockCdAndBaseDateBetweenOrderByBaseDateAsc(String stockCode, LocalDate startDate, LocalDate endDate); @Query(""" select sp.baseDate @@ -26,4 +27,7 @@ List findAllBaseDatesByStockAndDateRange( ); List findAllByBaseDate(LocalDate today); + + void deleteAllByStock(Stock stock); + StockPrice findTopByStockOrderByBaseDateAsc(Stock stock); } diff --git a/src/main/java/com/stockport/server/domain/stock/repository/StockRepository.java b/src/main/java/com/stockport/server/domain/stock/repository/StockRepository.java index f575c9c..4e0f3b6 100644 --- a/src/main/java/com/stockport/server/domain/stock/repository/StockRepository.java +++ b/src/main/java/com/stockport/server/domain/stock/repository/StockRepository.java @@ -13,4 +13,5 @@ public interface StockRepository extends JpaRepository { Optional findByStockCd(String stockCd); List findTop10ByStockNameContainingIgnoreCaseOrStockCdContainingIgnoreCaseOrIsinCdContainingIgnoreCaseOrderByMarketCapDesc( String name, String code, String isin - );} + ); +} diff --git a/src/main/java/com/stockport/server/global/apipayload/code/status/ErrorStatus.java b/src/main/java/com/stockport/server/global/apipayload/code/status/ErrorStatus.java index 42a90ba..cd098a8 100644 --- a/src/main/java/com/stockport/server/global/apipayload/code/status/ErrorStatus.java +++ b/src/main/java/com/stockport/server/global/apipayload/code/status/ErrorStatus.java @@ -19,7 +19,11 @@ public enum ErrorStatus implements BaseCode { INDEX_DATA_NOT_FOUND(HttpStatus.BAD_REQUEST, "INDEX4001", "지수 데이터가 존재하지 않습니다."), - STOCK_NOT_FOUND(HttpStatus.BAD_REQUEST, "STOCK4001", "해당 종목을 찾을 수 없습니다."),; + STOCK_NOT_FOUND(HttpStatus.BAD_REQUEST, "STOCK4001", "해당 종목을 찾을 수 없습니다."), + + BACKTEST_INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "BACKTEST4001", "백테스트 기간이 유효하지 않습니다."), + BACKTEST_INVALID_START_DATE(HttpStatus.BAD_REQUEST, "BACKTEST4002", "백테스트 시작 일자가 유효하지 않습니다."), + BACKTEST_INVALID_WEIGHTS(HttpStatus.BAD_REQUEST, "BACKTEST4004", "포트폴리오 가중치 합이 100%가 아닙니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/stockport/server/global/feign/adaptor/KisStockPriceAdaptor.java b/src/main/java/com/stockport/server/global/feign/adaptor/KisStockPriceAdaptor.java index 200d218..f3a81b2 100644 --- a/src/main/java/com/stockport/server/global/feign/adaptor/KisStockPriceAdaptor.java +++ b/src/main/java/com/stockport/server/global/feign/adaptor/KisStockPriceAdaptor.java @@ -74,7 +74,7 @@ public KisPeriodResponseWrapper getSt startDate.format(DateTimeFormatter.BASIC_ISO_DATE), endDate.format(DateTimeFormatter.BASIC_ISO_DATE), "D", - "1" + "0" ) );