Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
package com.cleanengine.coin.chart.controller;

import com.cleanengine.coin.chart.dto.RealTimeOhlcDto;
import com.cleanengine.coin.chart.service.minute.MinuteOhlcDataService;
import com.cleanengine.coin.chart.service.minute.PagingMinuteOhlcDataService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.List;

@RestController
@RequestMapping("/api/minute-ohlc")
@RequiredArgsConstructor
public class MinuteOhlcDataController {

private final MinuteOhlcDataService service;
private final PagingMinuteOhlcDataService service;

/**
* GET /api/minute-ohlc?ticker=BTC
* DB에 있는 과거 거래를 1분 단위로 묶어 OHLC+volume을 계산한 리스트 반환
* GET /api/minute-ohlc?ticker=BTC&count=100&interval=1&from=2025-06-19T10:30
* DB에 있는 과거 거래를 interval 단위로 묶어 OHLC+volume을 계산한 리스트 반환
*/
@GetMapping
public ResponseEntity<List<RealTimeOhlcDto>> getMinuteOhlc(
@RequestParam("ticker") String ticker
@RequestParam("ticker") String ticker,
@RequestParam(value = "count", defaultValue = "100") int count,
@RequestParam(value = "interval", defaultValue = "1") int interval,
@RequestParam(value = "from", required = false) LocalDateTime from
) {
List<RealTimeOhlcDto> data = service.getMinuteOhlcData(ticker);
if (from == null) {
from = LocalDateTime.now();
}

List<RealTimeOhlcDto> data = service.getMinuteOhlcData(ticker, count, interval, from.minusMinutes(1));
return ResponseEntity.ok(data);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cleanengine.coin.chart.service.minute;

import com.cleanengine.coin.chart.dto.RealTimeOhlcDto;

import java.time.LocalDateTime;
import java.util.List;

public interface PagingMinuteOhlcDataService {

List<RealTimeOhlcDto> getMinuteOhlcData(String ticker, int count, int interval, LocalDateTime from);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.cleanengine.coin.chart.service.minute;

import com.cleanengine.coin.chart.dto.RealTimeOhlcDto;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

@RequiredArgsConstructor
@Service
public class PagingMinuteOhlcDataServiceImpl implements PagingMinuteOhlcDataService {

private final EntityManager em;

@Override
public List<RealTimeOhlcDto> getMinuteOhlcData(String ticker, int count, int interval, LocalDateTime from) {
validateTicker(ticker);

String timeSlotQuery = """
SELECT DISTINCT DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00') AS time_slot
FROM trade
WHERE ticker = :ticker AND trade_time <= :from
GROUP BY DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00')
ORDER BY time_slot DESC
LIMIT :count
""";

Query nativeQuery = em.createNativeQuery(timeSlotQuery);
nativeQuery.setParameter("ticker", ticker);
nativeQuery.setParameter("from", from);
nativeQuery.setParameter("interval", interval);
nativeQuery.setParameter("count", count);

@SuppressWarnings("unchecked")
List<String> timeSlotResults = nativeQuery.getResultList();
List<LocalDateTime> timeSlots = timeSlotResults.stream()
.map(str -> LocalDateTime.parse(str, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
.sorted(Comparator.naturalOrder())
.toList();

if (timeSlots.isEmpty()) {
return Collections.emptyList();
}

String ohlcQuery = """
SELECT
:ticker AS ticker,
t.time_slot AS timestamp,
MAX(CASE WHEN rn_open = 1 THEN price END) AS open,
MAX(price) AS high,
MIN(price) AS low,
MAX(CASE WHEN rn_close = 1 THEN price END) AS close,
SUM(size) AS volume
FROM (
SELECT
trade_time,
price,
size,
DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00') AS time_slot,
ROW_NUMBER() OVER (PARTITION BY DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00') ORDER BY trade_time) AS rn_open,
ROW_NUMBER() OVER (PARTITION BY DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00') ORDER BY trade_time DESC) AS rn_close
FROM trade
WHERE ticker = :ticker
AND DATE_FORMAT(
DATE_SUB(trade_time, INTERVAL MOD(MINUTE(trade_time), :interval) MINUTE),
'%Y-%m-%d %H:%i:00') IN (:timeSlots)
) t
GROUP BY t.time_slot
ORDER BY t.time_slot
""";

Query ohlcNativeQuery = em.createNativeQuery(ohlcQuery);
ohlcNativeQuery.setParameter("ticker", ticker);
ohlcNativeQuery.setParameter("interval", interval);
ohlcNativeQuery.setParameter("timeSlots", timeSlotResults);

@SuppressWarnings("unchecked")
List<Object[]> results = ohlcNativeQuery.getResultList();
return results.stream()
.map(row -> new RealTimeOhlcDto(
(String) row[0], // ticker
LocalDateTime.parse((String) row[1], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
((Number) row[2]).doubleValue(), // open
((Number) row[3]).doubleValue(), // high
((Number) row[4]).doubleValue(), // low
((Number) row[5]).doubleValue(), // close
((Number) row[6]).doubleValue() // volume
))
.collect(Collectors.toList());
}

static void validateTicker(String ticker) {
if (ticker == null || ticker.trim().isEmpty()) {
throw new IllegalArgumentException("티커는 비어있을 수 없습니다");
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package com.cleanengine.coin.chart.service.minute;

import com.cleanengine.coin.base.MariaDBAdapterTest;
import com.cleanengine.coin.chart.dto.RealTimeOhlcDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

// 테스트 실행 전 MariaDBAdapterTest 클래스의 @DataJpaTest, @Disabled 어노테이션 주석처리 필요
@DisplayName("차트 페이징 통합테스트")
@Transactional
@SpringBootTest
public class PagingMinuteOhlcDataServiceImplTest extends MariaDBAdapterTest {

@Autowired
private PagingMinuteOhlcDataService pagingMinuteOhlcDataService;

@DisplayName("페이징을 통해 차트 OHLC를 정상적으로 가져온다.")
@Test
@Sql("classpath:db/chart/paging_minute_ohlc_data.sql")
public void getMinuteOhlcData() {
// given
String ticker = "TRUMP";
int count = 1;
int interval = 1;
LocalDateTime from = LocalDateTime.of(2025, 6, 20, 12, 3, 0);

// when
List<RealTimeOhlcDto> ohlcData = pagingMinuteOhlcDataService.getMinuteOhlcData(ticker, count, interval, from.minusMinutes(1));

// then
assertNotNull(ohlcData);
assertEquals(1, ohlcData.size());

RealTimeOhlcDto resultDto = ohlcData.getFirst();
assertEquals("TRUMP", resultDto.getTicker());
assertEquals(LocalDateTime.of(2025, 6, 20, 12, 2, 0), resultDto.getTimestamp());
assertEquals(109500.0, resultDto.getOpen());
assertEquals(110500.0, resultDto.getHigh());
assertEquals(109300.0, resultDto.getLow());
assertEquals(110000.0, resultDto.getClose());
assertEquals(12.541453, resultDto.getVolume(), 0.000001);
}

}
5 changes: 5 additions & 0 deletions src/test/resources/db/chart/paging_minute_ohlc_data.sql

Large diffs are not rendered by default.