From 4fa004e29215471b1fd322e64432a49f6e94cfa3 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Sun, 22 Jun 2025 22:15:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=201?= =?UTF-8?q?=EB=B6=84=EB=B4=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/service/RealTimeOhlcService.java | 130 ++++++++---------- 1 file changed, 58 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java index 19298eb1..4e45b1ae 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -20,101 +21,86 @@ public class RealTimeOhlcService { private final TradeRepository tradeRepository; - // 티커별 마지막 처리 시간 - private final Map lastProcessedTimeMap = new ConcurrentHashMap<>(); - // 티커별 마지막 OHLC 데이터 캐싱 - private final Map lastOhlcDataMap = new ConcurrentHashMap<>(); + private final Map currentMinuteOhlcCache = new ConcurrentHashMap<>(); - /** - * 특정 티커의 최신 1초 OHLC 데이터 생성 - */ - public RealTimeOhlcDto getRealTimeOhlc(String ticker) { - try { - LocalDateTime now = LocalDateTime.now(); - // 시간 범위 계산 - TimeRange timeRange = calculateTimeRange(ticker, now); + public RealTimeOhlcDto getAndUpdateCumulative1mOhlc(String ticker, LocalDateTime now ) { + try { + LocalDateTime currentMinuteStart = now.truncatedTo(ChronoUnit.MINUTES); - // 거래 데이터 조회 및 전처리 - List recentTrades = getProcessedTradeData(ticker, timeRange); + RealTimeOhlcDto cachedOhlc = currentMinuteOhlcCache.get(ticker); - // 거래 데이터가 없으면 캐시된 데이터 반환 - if (recentTrades.isEmpty()) { - return getCachedData(ticker); + if (cachedOhlc == null || cachedOhlc.getTimestamp().isBefore(currentMinuteStart)) { + return handleNewMinute(ticker, now, currentMinuteStart); } + else { + return handleExistingMinute(ticker, now, cachedOhlc); + } + } catch (Exception e) { + log.error("티커 {}의 누적 OHLC 데이터 생성 중 오류 발생: {}", ticker, e.getMessage(), e); + // 오류 발생 시 캐시된 마지막 데이터라도 반환 + return currentMinuteOhlcCache.get(ticker); + } + } - calculateOhlcv ohlcv = getCalculateOhlcv(recentTrades); + private RealTimeOhlcDto handleNewMinute(String ticker, LocalDateTime now, LocalDateTime minuteStart) { + log.debug("티커 {}: 새로운 1분봉 시작 ({}).", ticker, minuteStart); + List trades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(ticker, minuteStart, now); - RealTimeOhlcDto ohlcData = createOhlcDto(ticker, now, ohlcv); + if (trades.isEmpty()) { + return null; + } - // 캐시 업데이트 - updateCache(ticker, now, ohlcData); + // 새 거래내역으로 OHLCV 계산 + CalculateOhlcv ohlcv = getCalculateOhlcv(trades); + RealTimeOhlcDto newOhlc = createOhlcDto(ticker, now, ohlcv); - return ohlcData; - } catch (Exception e) { - log.error("실시간 OHLC 데이터 생성 중 오류: {}", e.getMessage(), e); - return getCachedData(ticker); - } + // 캐시를 새로운 1분봉 데이터로 교체 + currentMinuteOhlcCache.put(ticker, newOhlc); + return newOhlc; } - // 시간 범위 계산 - TimeRange calculateTimeRange(String ticker, LocalDateTime now) { - LocalDateTime lastProcessedTime = lastProcessedTimeMap.getOrDefault( - ticker, now.minusSeconds(1)); - return new TimeRange(lastProcessedTime, now); - } - // 거래 데이터 조회 및 전처리 - List getProcessedTradeData(String ticker, TimeRange timeRange) { - return tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - ticker, - timeRange.start(), - timeRange.end() - ); - } + private RealTimeOhlcDto handleExistingMinute(String ticker, LocalDateTime now, RealTimeOhlcDto cachedOhlc) { + LocalDateTime lastProcessedTime = cachedOhlc.getTimestamp(); + List newTrades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(ticker, lastProcessedTime, now); - // 캐시 업데이트 - void updateCache(String ticker, LocalDateTime now, RealTimeOhlcDto ohlcData) { - lastProcessedTimeMap.put(ticker, now); - lastOhlcDataMap.put(ticker, ohlcData); - } + // 새로운 거래가 없다면, 타임스탬프만 최신으로 업데이트하여 "살아있음"을 알림 + if (newTrades.isEmpty()) { + cachedOhlc.setTimestamp(now); + return cachedOhlc; + } - // 캐시된 데이터 조회 - RealTimeOhlcDto getCachedData(String ticker) { - return lastOhlcDataMap.getOrDefault(ticker, null); - } + log.trace("티커 {}: 기존 1분봉 업데이트. 신규 거래 {}건", ticker, newTrades.size()); - // DTO 생성 - RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime timestamp, calculateOhlcv ohlcv) { - return new RealTimeOhlcDto( - ticker, - timestamp, - ohlcv.open(), - ohlcv.high(), - ohlcv.low(), - ohlcv.close(), - ohlcv.volume() - ); + // Open(시가)는 분이 끝날때까지 고정 + cachedOhlc.setHigh(Math.max(cachedOhlc.getHigh(), newTrades.stream().mapToDouble(Trade::getPrice).max().orElse(cachedOhlc.getHigh()))); + cachedOhlc.setLow(Math.min(cachedOhlc.getLow(), newTrades.stream().mapToDouble(Trade::getPrice).min().orElse(cachedOhlc.getLow()))); + cachedOhlc.setClose(newTrades.getLast().getPrice()); // 종가는 항상 마지막 거래 가격 + cachedOhlc.setVolume(cachedOhlc.getVolume() + newTrades.stream().mapToDouble(Trade::getSize).sum()); + cachedOhlc.setTimestamp(now); // 마지막 처리 시간 갱신 + + return cachedOhlc; } - // OHLCV 계산 메서드 + + @NotNull - static calculateOhlcv getCalculateOhlcv(List trades) { - // trades는 시간 오름차순 정렬되어 있음 - Double open = trades.getFirst().getPrice(); // 첫 번째(가장 오래된) = Open ✅ - Double close = trades.getLast().getPrice(); // 마지막(가장 최근) = Close ✅ + private CalculateOhlcv getCalculateOhlcv(List trades) { + Double open = trades.getFirst().getPrice(); + Double close = trades.getLast().getPrice(); Double high = trades.stream().mapToDouble(Trade::getPrice).max().orElse(0.0); Double low = trades.stream().mapToDouble(Trade::getPrice).min().orElse(0.0); Double volume = trades.stream().mapToDouble(Trade::getSize).sum(); - - return new calculateOhlcv(open, high, low, close, volume); + return new CalculateOhlcv(open, high, low, close, volume); } + private RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime timestamp, CalculateOhlcv ohlcv) { + return new RealTimeOhlcDto( + ticker, timestamp, ohlcv.open(), ohlcv.high(), ohlcv.low(), ohlcv.close(), ohlcv.volume() + ); + } - - - record TimeRange(LocalDateTime start, LocalDateTime end) {} - - record calculateOhlcv(Double open, Double high, Double low, Double close, Double volume) {} + record CalculateOhlcv(Double open, Double high, Double low, Double close, Double volume) {} } \ No newline at end of file From c2a68f279cc79bc0ab6f9965bbfb4664a22f7fe2 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Sun, 22 Jun 2025 22:20:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20R?= =?UTF-8?q?ealTimeOhlcService=20=EB=A9=94=EC=86=8C=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=B0=A9=EC=8B=9D=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chart/controller/ChartDataController.java | 33 ++++--------------- 1 file changed, 7 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java b/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java index b13f113c..de5789cb 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java @@ -37,51 +37,35 @@ public void publishRealTimeOhlc() { try { log.debug("△ 실시간 OHLC 데이터 스케줄러 실행"); - // 구독된 티커가 없으면 조기 종료 if (subscriptionService.getAllRealTimeOhlcSubscribedTickers().isEmpty()) { log.debug("실시간 OHLC 구독된 티커 없음, 전송 생략"); return; } + final LocalDateTime now = LocalDateTime.now(); - // 모든 구독된 티커에 대해 데이터 전송 for (String ticker : subscriptionService.getAllRealTimeOhlcSubscribedTickers()) { try { log.debug("티커 {} 실시간 OHLC 데이터 전송 중...", ticker); - // 티커별 최신 OHLC 데이터 조회 및 전송 - RealTimeOhlcDto ohlcData = realTimeOhlcService.getRealTimeOhlc(ticker); + RealTimeOhlcDto ohlcData = realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, now); if (ohlcData == null) { - // 이전에 전송한 데이터가 있는지 확인 RealTimeOhlcDto lastSentData = lastSentOhlcDataMap.get(ticker); - if (lastSentData != null) { - // 이전 데이터가 있으면 타임스탬프만 업데이트하여 재사용 log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 이전 데이터 재사용", ticker); - RealTimeOhlcDto updatedData = new RealTimeOhlcDto(lastSentData.getTicker(), LocalDateTime.now(), // 현재 시간으로 업데이트 + RealTimeOhlcDto updatedData = new RealTimeOhlcDto(lastSentData.getTicker(), now, lastSentData.getOpen(), lastSentData.getHigh(), lastSentData.getLow(), lastSentData.getClose(), lastSentData.getVolume()); - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, updatedData); - lastSentOhlcDataMap.put(ticker, updatedData); // 캐시 업데이트 + lastSentOhlcDataMap.put(ticker, updatedData); } else { - // 이전 데이터도 없는 경우 빈 데이터 전송 (첫 구독 시) log.debug("티커 {}의 이전 OHLC 데이터도 없습니다. 빈 데이터 전송", ticker); - RealTimeOhlcDto emptyData = new RealTimeOhlcDto(); - emptyData.setTicker(ticker); - emptyData.setTimestamp(LocalDateTime.now()); - emptyData.setOpen(0.0); - emptyData.setHigh(0.0); - emptyData.setLow(0.0); - emptyData.setClose(0.0); - emptyData.setVolume(0.0); - + RealTimeOhlcDto emptyData = new RealTimeOhlcDto(ticker, now, 0.0, 0.0, 0.0, 0.0, 0.0); messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); - lastSentOhlcDataMap.put(ticker, emptyData); // 캐시 업데이트 + lastSentOhlcDataMap.put(ticker, emptyData); } } else { - // 조회된 실시간 OHLC 데이터 전송 messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, ohlcData); - lastSentOhlcDataMap.put(ticker, ohlcData); // 캐시 업데이트 + lastSentOhlcDataMap.put(ticker, ohlcData); log.debug("실시간 OHLC 데이터 전송: {}", ohlcData); } } catch (Exception e) { @@ -91,8 +75,5 @@ public void publishRealTimeOhlc() { } catch (Exception e) { log.error("△ 실시간 OHLC 데이터 발행 중 오류: {}", e.getMessage(), e); } - } - - } \ No newline at end of file From 00597d8658c8dc1d7ce92cefb48ecb3f5e1e09d7 Mon Sep 17 00:00:00 2001 From: bongj9 Date: Sun, 22 Jun 2025 22:21:06 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=8B=A0=EA=B7=9C=20=EA=B5=AC?= =?UTF-8?q?=EB=8F=85=20=EC=8B=9C=20=EC=BA=90=EC=8B=9C=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebSocketMessageController.java | 34 +++++-------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java b/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java index be07435d..f77af8e6 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java @@ -2,7 +2,7 @@ import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; import com.cleanengine.coin.chart.service.ChartSubscriptionService; -import com.cleanengine.coin.chart.service.RealTimeOhlcService; +import com.cleanengine.coin.chart.service.RealTimeOhlcService; // RealTimeOhlcService 의존성은 이제 불필요 import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.Setter; @@ -19,9 +19,8 @@ public class WebSocketMessageController { private final ChartSubscriptionService subscriptionService; - private final RealTimeOhlcService realTimeOhlcService; private final SimpMessagingTemplate messagingTemplate; - private final ChartDataController chartDataController; + private final ChartDataController chartDataController; // 이미 계산된 데이터를 가진 컨트롤러를 활용 /** * 실시간 OHLC 데이터 구독 처리 @@ -34,30 +33,16 @@ public void subscribeRealTimeOhlc(RealTimeTradeMappingDto request) { // 구독 목록에 추가 subscriptionService.subscribeRealTimeOhlc(ticker); - // 구독 즉시 최근 실시간 OHLC 데이터 전송 - RealTimeOhlcDto latestOhlcData = realTimeOhlcService.getRealTimeOhlc(ticker); - RealTimeOhlcDto lastSentData = chartDataController.getLastSentOhlcDataMap().get(ticker); - if (latestOhlcData == null) { - if (lastSentData != null) { - // 이전에 전송한 데이터가 있으면 재사용 - log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 이전 데이터 재사용", ticker); - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, lastSentData); - } else { - // 이전 데이터도 없는 경우 빈 데이터 전송 - log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 빈 데이터 전송", ticker); - RealTimeOhlcDto emptyData = createEmptyRealTimeOhlcDto(ticker); - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); - // 빈 데이터도 캐시에 저장 - chartDataController.getLastSentOhlcDataMap().put(ticker, emptyData); - } + if (lastSentData == null) { + log.debug("티커 {}의 캐시된 OHLC 데이터가 없습니다. 빈 데이터 전송", ticker); + RealTimeOhlcDto emptyData = createEmptyRealTimeOhlcDto(ticker); + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); } else { - log.debug("티커 {}의 실시간 OHLC 데이터 전송: {}", ticker, latestOhlcData); - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, latestOhlcData); - // 데이터 캐시에 저장 - chartDataController.getLastSentOhlcDataMap().put(ticker, latestOhlcData); - + // 캐시된 데이터가 있으면 즉시 전송 + log.debug("티커 {}의 캐시된 OHLC 데이터 즉시 전송: {}", ticker, lastSentData); + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, lastSentData); } } @@ -80,6 +65,5 @@ private RealTimeOhlcDto createEmptyRealTimeOhlcDto(String ticker) { @Getter public static class RealTimeTradeMappingDto { private String ticker; - } } \ No newline at end of file From 31e0695af7b549d31904d50a444565f2278242ab Mon Sep 17 00:00:00 2001 From: bongj9 Date: Mon, 23 Jun 2025 09:02:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?test:=EC=83=88=EB=A1=9C=EC=9A=B4=20Ohlcv=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RealTimeOhlcServiceTest.java | 428 +++--------------- 1 file changed, 70 insertions(+), 358 deletions(-) diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java index b516bed8..a9fb8c9e 100644 --- a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java @@ -3,420 +3,132 @@ import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("RealTimeOhlcService 단위 테스트") class RealTimeOhlcServiceTest { @Mock private TradeRepository tradeRepository; @InjectMocks - private RealTimeOhlcService service; + private RealTimeOhlcService realTimeOhlcService; - private String validTicker; - private LocalDateTime fixedNow; - private List mockTrades; - - @BeforeEach - void setUp() { - validTicker = "BTC"; - fixedNow = LocalDateTime.of(2024, 1, 15, 10, 30, 0); - mockTrades = createMockTrades(); - } - - // ===== getRealTimeOhlc 통합 테스트 ===== - @Test - @DisplayName("정상적인 거래 데이터로 실시간 OHLC를 생성한다") - void getRealTimeOhlc_WithValidTrades_ReturnsOhlcData() { - // given - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) - .thenReturn(mockTrades); - - // when - RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); - - // then - assertThat(result).isNotNull(); - assertThat(result.getTicker()).isEqualTo("BTC"); - assertThat(result.getHigh()).isEqualTo(200.0); - assertThat(result.getLow()).isEqualTo(100.0); - assertThat(result.getVolume()).isEqualTo(6.0); // 1+2+3 - } + private final String ticker = "KRW-BTC"; + private final Integer dummyBuyUserId = 100; + private final Integer dummySellUserId = 200; @Test - @DisplayName("거래 데이터가 없으면 캐시된 데이터를 반환한다") - void getRealTimeOhlc_NoTrades_ReturnsCachedData() { + @DisplayName("새로운 1분봉 - 첫 거래 발생 시 OHLCV가 정상적으로 생성되어야 한다") + void should_create_new_ohlc_when_new_minute_starts() { // given - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // 캐시에 데이터 미리 저장 - RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); - service.updateCache(validTicker, fixedNow, cachedData); - - // when - RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + LocalDateTime now = LocalDateTime.of(2025, 6, 22, 10, 1, 15); + LocalDateTime minuteStart = now.truncatedTo(ChronoUnit.MINUTES); - // then - assertThat(result).isEqualTo(cachedData); - } - - @Test - @DisplayName("거래 데이터도 캐시도 없으면 null을 반환한다") - void getRealTimeOhlc_NoTradesNoCache_ReturnsNull() { - // given - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) - .thenReturn(List.of()); - - // when - RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); - - // then - assertThat(result).isNull(); - } - - @Test - @DisplayName("예외 발생 시 캐시된 데이터를 반환한다") - void getRealTimeOhlc_ExceptionOccurs_ReturnsCachedData() { - // given - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) - .thenThrow(new RuntimeException("DB 연결 오류")); - - RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); - service.updateCache(validTicker, fixedNow, cachedData); - - // when - RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); - - // then - assertThat(result).isEqualTo(cachedData); - } - - // ===== calculateTimeRange 테스트 ===== - @Test - @DisplayName("첫 번째 호출 시 1초 전부터 현재까지의 범위를 계산한다") - void calculateTimeRange_FirstCall_ReturnsOneSecondRange() { - // when - RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); - - // then - assertThat(result.start()).isEqualTo(fixedNow.minusSeconds(1)); - assertThat(result.end()).isEqualTo(fixedNow); - } - - @Test - @DisplayName("이전 처리 시간이 있으면 그 시간부터 현재까지의 범위를 계산한다") - void calculateTimeRange_WithPreviousTime_ReturnsCustomRange() { - // given - LocalDateTime previousTime = fixedNow.minusSeconds(5); - - // null 대신 더미 데이터 사용 - RealTimeOhlcDto dummyData = new RealTimeOhlcDto( - validTicker, previousTime, 100.0, 100.0, 100.0, 100.0, 1.0 + List trades = List.of( + new Trade(ticker, now.minusSeconds(10), dummyBuyUserId, dummySellUserId, 50000.0, 10.0) // 10:01:05 ); - service.updateCache(validTicker, previousTime, dummyData); // ✅ 유효한 객체 - - // when - RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); - - // then - assertThat(result.start()).isEqualTo(previousTime); - assertThat(result.end()).isEqualTo(fixedNow); - } - - // ===== getProcessedTradeData 테스트 ===== - @Test - @DisplayName("빈 거래 데이터는 빈 리스트를 반환한다") - void getProcessedTradeData_EmptyTrades_ReturnsEmptyList() { - // given - RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange( - fixedNow.minusSeconds(1), fixedNow); - - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - validTicker, timeRange.start(), timeRange.end())) - .thenReturn(List.of()); - // when - List result = service.getProcessedTradeData(validTicker, timeRange); - - // then - assertThat(result).isEmpty(); - } - - // ===== updateCache 테스트 ===== - @Test - @DisplayName("캐시를 정상적으로 업데이트한다") - void updateCache_ValidData_UpdatesCorrectly() { - // given - RealTimeOhlcDto ohlcData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(eq(ticker), eq(minuteStart), eq(now))) + .thenReturn(trades); // when - service.updateCache(validTicker, fixedNow, ohlcData); + RealTimeOhlcDto result = realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, now); // then - RealTimeOhlcDto cachedData = service.getCachedData(validTicker); - assertThat(cachedData).isEqualTo(ohlcData); - - // 시간 범위 계산 시 업데이트된 시간이 사용되는지 확인 - RealTimeOhlcService.TimeRange timeRange = service.calculateTimeRange(validTicker, fixedNow.plusSeconds(5)); - assertThat(timeRange.start()).isEqualTo(fixedNow); - } - - // ===== getCachedData 테스트 ===== - @Test - @DisplayName("캐시된 데이터를 정상적으로 조회한다") - void getCachedData_ExistingData_ReturnsData() { - // given - RealTimeOhlcDto expectedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); - service.updateCache(validTicker, fixedNow, expectedData); - - // when - RealTimeOhlcDto result = service.getCachedData(validTicker); - - // then - assertThat(result).isEqualTo(expectedData); - } - - @Test - @DisplayName("캐시에 데이터가 없으면 null을 반환한다") - void getCachedData_NoData_ReturnsNull() { - // when - RealTimeOhlcDto result = service.getCachedData("NONEXISTENT"); - - // then - assertThat(result).isNull(); - } - - // ===== createOhlcDto 테스트 ===== - @Test - @DisplayName("OHLCV 데이터로 DTO를 생성한다") - void createOhlcDto_ValidData_CreatesCorrectDto() { - // given - RealTimeOhlcService.calculateOhlcv ohlcv = - new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); - - // when - RealTimeOhlcDto result = service.createOhlcDto(validTicker, fixedNow, ohlcv); - - // then - assertThat(result.getTicker()).isEqualTo(validTicker); - assertThat(result.getTimestamp()).isEqualTo(fixedNow); - assertThat(result.getOpen()).isEqualTo(100.0); - assertThat(result.getHigh()).isEqualTo(200.0); - assertThat(result.getLow()).isEqualTo(50.0); - assertThat(result.getClose()).isEqualTo(150.0); + assertThat(result).isNotNull(); + assertThat(result.getOpen()).isEqualTo(50000.0); + assertThat(result.getHigh()).isEqualTo(50000.0); + assertThat(result.getLow()).isEqualTo(50000.0); + assertThat(result.getClose()).isEqualTo(50000.0); assertThat(result.getVolume()).isEqualTo(10.0); } - // ===== getCalculateOhlcv 정적 메서드 테스트 ===== - @Test - @DisplayName("단일 거래로 OHLCV를 계산한다") - void getCalculateOhlcv_SingleTrade_CalculatesCorrectly() { - // given - List trades = List.of( - createTrade(fixedNow, 100.0, 5.0) - ); - - // when - RealTimeOhlcService.calculateOhlcv result = - RealTimeOhlcService.getCalculateOhlcv(trades); - - // then - assertThat(result.open()).isEqualTo(100.0); - assertThat(result.high()).isEqualTo(100.0); - assertThat(result.low()).isEqualTo(100.0); - assertThat(result.close()).isEqualTo(100.0); - assertThat(result.volume()).isEqualTo(5.0); - } - @Test - @DisplayName("여러 거래로 OHLCV를 계산한다") - void getCalculateOhlcv_MultipleTrades_CalculatesCorrectly() { + @DisplayName("기존 1분봉 - 새로운 거래 발생 시 OHLCV가 누적 업데이트되어야 한다") + void should_update_existing_ohlc_on_new_trades_in_same_minute() { // given - List trades = List.of( - createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), // open - createTrade(fixedNow.minusSeconds(2), 200.0, 2.0), // high - createTrade(fixedNow.minusSeconds(1), 50.0, 3.0), // low - createTrade(fixedNow, 150.0, 4.0) // close - ); - - // when - RealTimeOhlcService.calculateOhlcv result = - RealTimeOhlcService.getCalculateOhlcv(trades); + // 1. 첫 번째 거래 발생 (10:01:15) + LocalDateTime time1 = LocalDateTime.of(2025, 6, 22, 10, 1, 15); + LocalDateTime minuteStart = time1.truncatedTo(ChronoUnit.MINUTES); - // then - assertThat(result.open()).isEqualTo(100.0); - assertThat(result.high()).isEqualTo(200.0); - assertThat(result.low()).isEqualTo(50.0); - assertThat(result.close()).isEqualTo(150.0); - assertThat(result.volume()).isEqualTo(10.0); // 1+2+3+4 - } - - @Test - @DisplayName("동일한 가격의 거래들로 OHLCV를 계산한다") - void getCalculateOhlcv_SamePriceTrades_CalculatesCorrectly() { - // given - List trades = List.of( - createTrade(fixedNow.minusSeconds(2), 100.0, 1.0), - createTrade(fixedNow.minusSeconds(1), 100.0, 2.0), - createTrade(fixedNow, 100.0, 3.0) + List initialTrades = List.of( + new Trade(ticker, time1.minusSeconds(10), dummyBuyUserId, dummySellUserId, 50000.0, 10.0) // 10:01:05 ); + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(eq(ticker), eq(minuteStart), eq(time1))) + .thenReturn(initialTrades); - // when - RealTimeOhlcService.calculateOhlcv result = - RealTimeOhlcService.getCalculateOhlcv(trades); + realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, time1); // 첫 호출로 상태 초기화 - // then - assertThat(result.open()).isEqualTo(100.0); - assertThat(result.high()).isEqualTo(100.0); - assertThat(result.low()).isEqualTo(100.0); - assertThat(result.close()).isEqualTo(100.0); - assertThat(result.volume()).isEqualTo(6.0); - } + // 2. 두 번째 거래들 발생 (10:01:30) + LocalDateTime time2 = LocalDateTime.of(2025, 6, 22, 10, 1, 30); - @Test - @DisplayName("소수점 가격과 거래량으로 정확하게 계산한다") - void getCalculateOhlcv_DecimalValues_CalculatesCorrectly() { - // given - List trades = List.of( - createTrade(fixedNow.minusSeconds(1), 100.5, 1.5), - createTrade(fixedNow, 200.75, 2.25) + List newTrades = List.of( + new Trade(ticker, time2.minusSeconds(10), dummyBuyUserId, dummySellUserId, 52000.0, 5.0), // 10:01:20 (고가) + new Trade(ticker, time2.minusSeconds(5), dummyBuyUserId, dummySellUserId, 49000.0, 8.0) // 10:01:25 (저가, 종가) ); + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(eq(ticker), eq(time1), eq(time2))) + .thenReturn(newTrades); // when - RealTimeOhlcService.calculateOhlcv result = - RealTimeOhlcService.getCalculateOhlcv(trades); + RealTimeOhlcDto result = realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, time2); // then - assertThat(result.open()).isEqualTo(100.5); - assertThat(result.high()).isEqualTo(200.75); - assertThat(result.low()).isEqualTo(100.5); - assertThat(result.close()).isEqualTo(200.75); - assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 - } - - // ===== 레코드 객체 테스트 ===== - @Test - @DisplayName("TimeRange 레코드가 올바르게 동작한다") - void timeRangeRecord_WorksCorrectly() { - // given - LocalDateTime start = fixedNow.minusSeconds(1); - LocalDateTime end = fixedNow; - - // when - RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange(start, end); - - // then - assertThat(timeRange.start()).isEqualTo(start); - assertThat(timeRange.end()).isEqualTo(end); - assertThat(timeRange.toString()).contains(start.toString(), end.toString()); - } - - @Test - @DisplayName("calculateOhlcv 레코드가 올바르게 동작한다") - void calculateOhlcvRecord_WorksCorrectly() { - // given - RealTimeOhlcService.calculateOhlcv ohlcv = - new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); - - // then - assertThat(ohlcv.open()).isEqualTo(100.0); - assertThat(ohlcv.high()).isEqualTo(200.0); - assertThat(ohlcv.low()).isEqualTo(50.0); - assertThat(ohlcv.close()).isEqualTo(150.0); - assertThat(ohlcv.volume()).isEqualTo(10.0); - assertThat(ohlcv.toString()).contains("100.0", "200.0", "50.0", "150.0", "10.0"); + assertThat(result).isNotNull(); + assertThat(result.getOpen()).isEqualTo(50000.0); // 시가 불변 + assertThat(result.getHigh()).isEqualTo(52000.0); // 고가 갱신 + assertThat(result.getLow()).isEqualTo(49000.0); // 저가 갱신 + assertThat(result.getClose()).isEqualTo(49000.0); // 종가 갱신 + assertThat(result.getVolume()).isEqualTo(23.0); // 거래량 누적 (10 + 5 + 8) } - // ===== 동시성 테스트 ===== @Test - @DisplayName("여러 티커를 동시에 처리해도 캐시가 올바르게 동작한다") - void concurrentTickers_CacheWorksCorrectly() { + @DisplayName("시간 경과 - 다음 '분'으로 넘어갈 시 새로운 1분봉이 시작되어야 한다") + void should_start_new_ohlc_when_minute_rolls_over() { // given - String ticker1 = "BTC"; - String ticker2 = "ETH"; - RealTimeOhlcDto data1 = new RealTimeOhlcDto(ticker1, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); - RealTimeOhlcDto data2 = new RealTimeOhlcDto(ticker2, fixedNow, 200.0, 200.0, 200.0, 200.0, 10.0); + // 1. 10:01분대의 마지막 상태 설정 + LocalDateTime time1 = LocalDateTime.of(2025, 6, 22, 10, 1, 50); + LocalDateTime minute1Start = time1.truncatedTo(ChronoUnit.MINUTES); - // when - service.updateCache(ticker1, fixedNow, data1); - service.updateCache(ticker2, fixedNow, data2); + List tradesInMin1 = List.of( + new Trade(ticker, time1.minusSeconds(10), dummyBuyUserId, dummySellUserId, 50000.0, 10.0) + ); + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(eq(ticker), eq(minute1Start), eq(time1))) + .thenReturn(tradesInMin1); + realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, time1); - // then - assertThat(service.getCachedData(ticker1)).isEqualTo(data1); - assertThat(service.getCachedData(ticker2)).isEqualTo(data2); - assertThat(service.getCachedData(ticker1)).isNotEqualTo(data2); - } + // 2. 10:02분대의 첫 거래 발생 + LocalDateTime time2 = LocalDateTime.of(2025, 6, 22, 10, 2, 10); + LocalDateTime minute2Start = time2.truncatedTo(ChronoUnit.MINUTES); - // ===== Repository 호출 검증 테스트 ===== - @Test - @DisplayName("Repository가 올바른 파라미터로 호출된다") - void repository_CalledWithCorrectParameters() { - // given - when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - any(String.class), any(LocalDateTime.class), any(LocalDateTime.class))) - .thenReturn(mockTrades); + List tradesInMin2 = List.of( + new Trade(ticker, time2.minusSeconds(5), dummyBuyUserId, dummySellUserId, 51500.0, 20.0) + ); + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc(eq(ticker), eq(minute2Start), eq(time2))) + .thenReturn(tradesInMin2); // when - service.getRealTimeOhlc(validTicker); + RealTimeOhlcDto result = realTimeOhlcService.getAndUpdateCumulative1mOhlc(ticker, time2); // then - ArgumentCaptor tickerCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); - - verify(tradeRepository).findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - tickerCaptor.capture(), startCaptor.capture(), endCaptor.capture()); - - assertThat(tickerCaptor.getValue()).isEqualTo(validTicker); - assertThat(startCaptor.getValue()).isBefore(endCaptor.getValue()); - } - - // ===== 헬퍼 메서드들 ===== - private List createMockTrades() { - return List.of( - createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), - createTrade(fixedNow.minusSeconds(2), 150.0, 2.0), - createTrade(fixedNow.minusSeconds(1), 200.0, 3.0) - ); - } - - private Trade createTrade(LocalDateTime tradeTime, Double price, Double size) { - Trade trade = new Trade(); - try { - setField(trade, "tradeTime", tradeTime); - setField(trade, "price", price); - setField(trade, "size", size); - } catch (Exception e) { - throw new RuntimeException("Trade 객체 생성 실패", e); - } - return trade; - } - - private void setField(Object target, String fieldName, Object value) throws Exception { - java.lang.reflect.Field field = Trade.class.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(target, value); + assertThat(result).isNotNull(); + assertThat(result.getOpen()).isEqualTo(51500.0); // 시가 새로 설정 + assertThat(result.getHigh()).isEqualTo(51500.0); + assertThat(result.getLow()).isEqualTo(51500.0); + assertThat(result.getClose()).isEqualTo(51500.0); + assertThat(result.getVolume()).isEqualTo(20.0); // 거래량 새로 시작 } } \ No newline at end of file