Skip to content

Commit a3abfab

Browse files
authored
Merge pull request #84 from SynergyX-AI-Pattern/feat/#83_backtest_candle_api
Feat/#83 backtest candle api
2 parents 5cda59d + 724a71b commit a3abfab

File tree

12 files changed

+230
-13
lines changed

12 files changed

+230
-13
lines changed

src/main/java/com/synergyx/trading/apiPayload/code/status/ErrorStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public enum ErrorStatus implements BaseErrorCode {
4343
BACKTEST_NOT_FOUND(HttpStatus.NOT_FOUND, "BACKTEST404", "해당 백테스트 결과가 존재하지 않습니다."),
4444
BACKTEST_PERIOD_EXCEEDS_LIMIT(HttpStatus.BAD_REQUEST, "BACKTEST400", "백테스트 기간은 최대 5년까지 가능합니다."),
4545
FASTAPI_BACKTEST_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "BACKTEST502", "FastAPI 백테스트 서버 호출에 실패했습니다."),
46+
HIGHLIGHT_RANGE_NOT_FOUND(HttpStatus.NOT_FOUND, "BACKTEST4041", "해당 백테스트 결과에 하이라이트 구간이 존재하지 않습니다."),
4647

4748
// 캔들 데이터 관련 예외 처리
4849
CANDLE_DATA_NOT_FOUND(HttpStatus.NOT_FOUND, "CANDLE404", "캔들 데이터가 존재하지 않습니다."),

src/main/java/com/synergyx/trading/controller/BacktestController.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.synergyx.trading.controller;
22

33
import com.synergyx.trading.apiPayload.ApiResponse;
4+
import com.synergyx.trading.apiPayload.code.status.ErrorStatus;
45
import com.synergyx.trading.apiPayload.code.status.SuccessStatus;
6+
import com.synergyx.trading.apiPayload.exception.GeneralException;
57
import com.synergyx.trading.dto.backtest.BacktestRequestDTO;
68
import com.synergyx.trading.dto.backtest.BacktestResponseDTO;
79
import com.synergyx.trading.service.backtestService.BacktestService;
@@ -42,7 +44,7 @@ public ResponseEntity<?> runBacktest(
4244
// 과거 백테스팅 결과 상세 조회
4345
@Operation(summary = "백테스팅 결과 상세 조회", description = "해당 백테스팅 결과의 상세 정보를 조회합니다.")
4446
@GetMapping("/results/{backtestId}")
45-
public ResponseEntity<?> getBacktestResultDetail(
47+
public ResponseEntity<?> getBacktestResultDetail(
4648
@Parameter
4749
@PathVariable Long backtestId) {
4850
BacktestResponseDTO.BacktestResultDetailDTO dto = backtestService.getBacktestResultDetail(TEMP_USER_ID, backtestId);
@@ -52,7 +54,7 @@ public ResponseEntity<?> getBacktestResultDetail(
5254
// 최근 백테스팅 결과 목록 조회
5355
@Operation(summary = "백테스팅 결과 목록 조회", description = "최근 백테스팅 결과 목록을 조회합니다.")
5456
@GetMapping("/results")
55-
public ResponseEntity<?> getBacktestResultList(
57+
public ResponseEntity<?> getBacktestResultList(
5658
@RequestParam(defaultValue = "1") int page,
5759
@RequestParam(defaultValue = "10") int size) {
5860

@@ -70,4 +72,28 @@ public ResponseEntity<?> getBacktestResultList(
7072

7173
return ResponseEntity.ok(ApiResponse.onSuccess(dto));
7274
}
75+
76+
// 백테스트 결과 차트 조회
77+
@Operation(summary = "백테스팅 결과 차트 조회", description = "해당 백테스팅 결과의 상세 화면에 나타낼 캔들 데이터를 조회합니다. (조회 구간: 최대 수익률 구간, 마진은 선택 사항)")
78+
@GetMapping("/results/{backtestId}/candles")
79+
public ResponseEntity<?> getBacktestCandles(
80+
@Parameter(description = "백테스트 ID")
81+
@PathVariable Long backtestId,
82+
@Parameter(
83+
description = "기간의 앞뒤 여유 간격(기본값: 20, 최소: 0)"
84+
)
85+
@RequestParam(defaultValue = "20") int margin
86+
) {
87+
// 마진 입력값 검증
88+
if (margin < 0) {
89+
throw new GeneralException(ErrorStatus._BAD_REQUEST);
90+
}
91+
var candles = backtestService.getBacktestResultCandles(TEMP_USER_ID, backtestId, margin);
92+
93+
return ResponseEntity.ok(ApiResponse.onSuccess(
94+
candles,
95+
SuccessStatus.SUCCESS_CHART_DATA.getCode(),
96+
SuccessStatus.SUCCESS_CHART_DATA.getMessage()
97+
));
98+
}
7399
}

src/main/java/com/synergyx/trading/dto/backtest/BacktestResponseDTO.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.synergyx.trading.dto.backtest;
22
import com.fasterxml.jackson.annotation.JsonProperty;
3+
import com.synergyx.trading.enums.PeriodUnit;
34
import lombok.AllArgsConstructor;
45
import lombok.Builder;
56
import lombok.Getter;
67
import lombok.NoArgsConstructor;
78
import java.time.LocalDate;
9+
import java.time.LocalDateTime;
810
import java.util.List;
911

1012
public class BacktestResponseDTO {
@@ -46,7 +48,20 @@ public static class BacktestExecutionDTO {
4648
private Double totalReturn; // 모든 매칭 결과 누적 수익률
4749
private LocalDate lastMatchedDate; // 마지막 패턴 발생일
4850
private Double lastMatchedReturn; // 마지막 패턴 발생시 수익률
51+
private HighlightRangeDTO highlightRange; // 최대 수익률 하이라이트 범위
52+
private PeriodUnit periodUnit; // 단위
4953
}
54+
55+
// 백테스팅 최대 수익률 하이라이트 부분
56+
@Getter
57+
@Builder
58+
@NoArgsConstructor
59+
@AllArgsConstructor
60+
public static class HighlightRangeDTO {
61+
private LocalDateTime fromDate;
62+
private LocalDateTime toDate;
63+
}
64+
5065
// 백테스팅 결과 상세 조회
5166
@Getter
5267
@Builder
@@ -69,6 +84,8 @@ public static class BacktestResultDetailDTO {
6984
private Double totalReturn; // 모든 매칭 결과 누적 수익률
7085
private LocalDate lastMatchedDate; // 마지막 패턴 발생일
7186
private Double lastMatchedReturn; // 마지막 패턴 발생시 수익률
87+
private HighlightRangeDTO highlightRange; // 최대 수익률 하이라이트 범위
88+
private PeriodUnit periodUnit; // 단위
7289
}
7390

7491
// 백테스팅 결과 목록 조회용 content
@@ -106,5 +123,4 @@ public static class PageInfoDTO {
106123
private int totalPages; // 전체 페이지 수
107124
}
108125
}
109-
110126
}

src/main/java/com/synergyx/trading/model/Backtest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.synergyx.trading.model;
22

3+
import com.synergyx.trading.enums.PeriodUnit;
34
import com.synergyx.trading.model.common.BaseEntity;
45
import jakarta.persistence.*;
56
import lombok.*;
67
import java.time.LocalDate;
8+
import java.time.LocalDateTime;
79

810
@Entity
911
@Table(
@@ -77,4 +79,14 @@ public class Backtest extends BaseEntity {
7779

7880
@Column(nullable = false)
7981
private Double lastMatchedReturn; // 마지막 패턴 발생시 수익률
82+
83+
@Column(name = "highlight_from_date")
84+
private LocalDateTime highlightFromDate; // 최대 수익률 발생 시 매칭 시작일
85+
86+
@Column(name = "highlight_to_date")
87+
private LocalDateTime highlightToDate; // 최대 수익률 발생 시 매칭 종료일
88+
89+
@Column(name = "period_unit", nullable = false)
90+
@Enumerated(EnumType.STRING)
91+
private PeriodUnit periodUnit; // 기간 단위
8092
}

src/main/java/com/synergyx/trading/model/StockOhlcv1h.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.synergyx.trading.model;
22

33
import com.synergyx.trading.model.common.BaseEntity;
4+
import com.synergyx.trading.model.common.Ohlcv;
45
import jakarta.persistence.*;
56
import lombok.*;
67

@@ -18,7 +19,7 @@
1819
@NoArgsConstructor
1920
@AllArgsConstructor
2021
@Builder
21-
public class StockOhlcv1h extends BaseEntity {
22+
public class StockOhlcv1h extends BaseEntity implements Ohlcv {
2223

2324
@Id
2425
@GeneratedValue(strategy = GenerationType.IDENTITY)

src/main/java/com/synergyx/trading/repository/BacktestRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,7 @@ Optional<Backtest> findTop1ByPatternIdAndStockIdAndUserIdOrderByExecutedAtDesc(
3232

3333
// 특정 패턴에 대한 최근 3개 백테스트 조회 (패턴 목록 조회용)
3434
List<Backtest> findTop3ByPatternIdAndUserIdOrderByExecutedAtDescIdDesc(Long patternId, Long userId);
35+
36+
// 사용자의 백테스팅 정보 조회
37+
Optional<Backtest> findByIdAndUserId(Long backtestId, Long userId);
3538
}

src/main/java/com/synergyx/trading/repository/StockOhlcv1dRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,11 @@ public interface StockOhlcv1dRepository extends JpaRepository<StockOhlcv1d, Long
1717

1818
// 상위 63개 ohlcv 조회 (약 3개월)
1919
List<StockOhlcv1d> findTop63ByStockIdOrderByTimestampDesc(Long stockId);
20+
21+
// 기간별 일봉 조회 (오름차순 정렬)
22+
List<StockOhlcv1d> findByStockIdAndTimestampBetweenOrderByTimestampAsc(
23+
Long stockId,
24+
LocalDateTime startDate,
25+
LocalDateTime endDate
26+
);
2027
}

src/main/java/com/synergyx/trading/repository/StockOhlcv1hRepository.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@
44
import org.springframework.data.jpa.repository.JpaRepository;
55

66
import java.time.LocalDateTime;
7+
import java.util.List;
78
import java.util.Optional;
89

910
public interface StockOhlcv1hRepository extends JpaRepository<StockOhlcv1h, Long> {
1011

1112
// 중복 방지용
1213
Optional<StockOhlcv1h> findByStock_IdAndTimestamp(Long stockId, LocalDateTime timestamp);
14+
15+
// 기간별 시간봉 조회 (오름차순 정렬)
16+
List<StockOhlcv1h> findByStockIdAndTimestampBetweenOrderByTimestampAsc(
17+
Long stockId, LocalDateTime start, LocalDateTime end
18+
);
1319
}
1420

src/main/java/com/synergyx/trading/service/backtestService/BacktestService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import com.synergyx.trading.dto.backtest.BacktestRequestDTO;
44
import com.synergyx.trading.dto.backtest.BacktestResponseDTO;
5+
import com.synergyx.trading.dto.stockDetail.StockCandleResponseDTO;
56
import org.springframework.data.domain.Page;
67

8+
import java.util.List;
9+
710
public interface BacktestService {
811
// 백테스팅 실행
912
BacktestResponseDTO.BacktestExecutionDTO runBacktest(Long userId, Long patternId, Long stockId, BacktestRequestDTO request);
@@ -14,4 +17,6 @@ public interface BacktestService {
1417
// 백테스팅 결과 목록 조회
1518
Page<BacktestResponseDTO.BacktestSummaryDTO> getBacktestResultList(Long userId, int page, int size);
1619

20+
// 백테스팅 결과 차트 조회
21+
List<StockCandleResponseDTO> getBacktestResultCandles(Long userId, Long backtestId, int margin);
1722
}

src/main/java/com/synergyx/trading/service/backtestService/BacktestServiceImpl.java

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.synergyx.trading.apiPayload.exception.GeneralException;
44
import com.synergyx.trading.dto.backtest.BacktestRequestDTO;
55
import com.synergyx.trading.dto.backtest.BacktestResponseDTO;
6+
import com.synergyx.trading.dto.stockDetail.StockCandleResponseDTO;
67
import com.synergyx.trading.model.Backtest;
78
import com.synergyx.trading.model.Pattern;
89
import com.synergyx.trading.model.Stock;
@@ -11,6 +12,7 @@
1112
import com.synergyx.trading.repository.PatternRepository;
1213
import com.synergyx.trading.repository.StockRepository;
1314
import com.synergyx.trading.repository.UserRepository;
15+
import com.synergyx.trading.service.stockService.candle.StockCandleQueryService;
1416
import lombok.RequiredArgsConstructor;
1517
import org.springframework.data.domain.Page;
1618
import org.springframework.data.domain.PageRequest;
@@ -21,6 +23,8 @@
2123
import com.synergyx.trading.service.backtestService.client.BacktestClientService;
2224

2325
import java.time.LocalDate;
26+
import java.time.LocalDateTime;
27+
import java.util.List;
2428

2529
@Service
2630
@RequiredArgsConstructor
@@ -30,6 +34,7 @@ public class BacktestServiceImpl implements BacktestService {
3034
private final StockRepository stockRepository;
3135
private final UserRepository userRepository;
3236
private final BacktestClientService backtestClientService;
37+
private final StockCandleQueryService stockCandleQueryService;
3338

3439
// 백테스팅 실행
3540
@Override
@@ -75,6 +80,14 @@ public BacktestResponseDTO.BacktestExecutionDTO runBacktest(Long userId, Long pa
7580
.lastMatchedDate(result.getLastMatchedDate())
7681
.lastMatchedReturn(result.getLastMatchedReturn())
7782
.totalReturn(result.getTotalReturn())
83+
// null 허용
84+
.highlightFromDate(
85+
result.getHighlightRange() != null ? result.getHighlightRange().getFromDate() : null
86+
)
87+
.highlightToDate(
88+
result.getHighlightRange() != null ? result.getHighlightRange().getToDate() : null
89+
)
90+
.periodUnit(pattern.getPeriodUnit())
7891
.build());
7992

8093
return BacktestResponseDTO.BacktestExecutionDTO.builder()
@@ -93,6 +106,15 @@ public BacktestResponseDTO.BacktestExecutionDTO runBacktest(Long userId, Long pa
93106
.totalReturn(saved.getTotalReturn())
94107
.lastMatchedDate(saved.getLastMatchedDate())
95108
.lastMatchedReturn(saved.getLastMatchedReturn())
109+
.highlightRange(
110+
(saved.getHighlightFromDate() != null && saved.getHighlightToDate() != null)
111+
? BacktestResponseDTO.HighlightRangeDTO.builder()
112+
.fromDate(saved.getHighlightFromDate())
113+
.toDate(saved.getHighlightToDate())
114+
.build()
115+
: null
116+
)
117+
.periodUnit(saved.getPeriodUnit())
96118
.build();
97119
}
98120

@@ -101,12 +123,13 @@ public BacktestResponseDTO.BacktestExecutionDTO runBacktest(Long userId, Long pa
101123
@Transactional(readOnly = true)
102124
public BacktestResponseDTO.BacktestResultDetailDTO getBacktestResultDetail(Long userId, Long backtestId) {
103125

104-
// 사용자 조회
105-
User user = userRepository.findById(userId)
106-
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
126+
// 유저 존재 여부 확인
127+
if (!userRepository.existsById(userId)) {
128+
throw new GeneralException(ErrorStatus.USER_NOT_FOUND);
129+
}
107130

108131
// 백테스팅 조회
109-
Backtest backtest = backtestRepository.findById(backtestId)
132+
Backtest backtest = backtestRepository.findByIdAndUserId(backtestId, userId)
110133
.orElseThrow(() -> new GeneralException(ErrorStatus.BACKTEST_NOT_FOUND));
111134

112135
return BacktestResponseDTO.BacktestResultDetailDTO.builder()
@@ -126,6 +149,15 @@ public BacktestResponseDTO.BacktestResultDetailDTO getBacktestResultDetail(Long
126149
.lastMatchedDate(backtest.getLastMatchedDate())
127150
.lastMatchedReturn(backtest.getLastMatchedReturn())
128151
.totalReturn(backtest.getTotalReturn())
152+
.highlightRange(
153+
(backtest.getHighlightFromDate() != null && backtest.getHighlightToDate() != null)
154+
? BacktestResponseDTO.HighlightRangeDTO.builder()
155+
.fromDate(backtest.getHighlightFromDate())
156+
.toDate(backtest.getHighlightToDate())
157+
.build()
158+
: null
159+
)
160+
.periodUnit(backtest.getPeriodUnit())
129161
.build();
130162
}
131163

@@ -134,9 +166,10 @@ public BacktestResponseDTO.BacktestResultDetailDTO getBacktestResultDetail(Long
134166
@Transactional(readOnly = true)
135167
public Page<BacktestResponseDTO.BacktestSummaryDTO> getBacktestResultList(Long userId, int page, int size) {
136168

137-
// 사용자 조회
138-
User user = userRepository.findById(userId)
139-
.orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND));
169+
// 유저 존재 여부 확인
170+
if (!userRepository.existsById(userId)) {
171+
throw new GeneralException(ErrorStatus.USER_NOT_FOUND);
172+
}
140173

141174
Pageable pageable = PageRequest.of(page, size, Sort.by("executedAt").descending());
142175
Page<Backtest> resultPage = backtestRepository.findByUserId(userId, pageable);
@@ -151,4 +184,38 @@ public Page<BacktestResponseDTO.BacktestSummaryDTO> getBacktestResultList(Long u
151184
.build());
152185
}
153186

154-
}
187+
// 백테스팅 결과 차트 조회
188+
@Override
189+
@Transactional(readOnly = true)
190+
public List<StockCandleResponseDTO> getBacktestResultCandles(Long userId, Long backtestId, int margin) {
191+
192+
// 유저 존재 여부 확인
193+
if (!userRepository.existsById(userId)) {
194+
throw new GeneralException(ErrorStatus.USER_NOT_FOUND);
195+
}
196+
197+
Backtest backtest = backtestRepository.findByIdAndUserId(backtestId, userId)
198+
.orElseThrow(() -> new GeneralException(ErrorStatus.BACKTEST_NOT_FOUND));
199+
200+
if (backtest.getHighlightFromDate() == null || backtest.getHighlightToDate() == null) {
201+
throw new GeneralException(ErrorStatus.HIGHLIGHT_RANGE_NOT_FOUND);
202+
}
203+
204+
Long stockId = backtest.getStock().getId();
205+
LocalDateTime from = backtest.getHighlightFromDate();
206+
LocalDateTime to = backtest.getHighlightToDate();
207+
208+
return switch (backtest.getPeriodUnit()) {
209+
case DAY -> stockCandleQueryService.getBacktestDailyCandles(
210+
stockId,
211+
from.toLocalDate().minusDays(margin),
212+
to.toLocalDate().plusDays(margin)
213+
);
214+
case HOUR -> stockCandleQueryService.getBacktestHourlyCandles(
215+
stockId,
216+
from.minusHours(margin),
217+
to.plusHours(margin)
218+
);
219+
};
220+
}
221+
}

0 commit comments

Comments
 (0)