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
Expand Up @@ -6,11 +6,14 @@
import com.cleanengine.coin.order.domain.spi.ActiveOrders;
import com.cleanengine.coin.order.domain.spi.ActiveOrdersManager;
import com.cleanengine.coin.orderbook.domain.OrderBookDomainService;
import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
import com.cleanengine.coin.orderbook.dto.OrderBookInfo;
import com.cleanengine.coin.orderbook.dto.OrderBookUnitInfo;
import com.cleanengine.coin.orderbook.infra.TradeQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.List;
import java.util.Optional;

Expand All @@ -22,6 +25,7 @@ public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUs
private final ActiveOrdersManager activeOrdersManager;
private final OrderBookDomainService orderBookDomainService;
private final OrderBookUpdatedNotifierPort orderBookUpdatedNotifierPort;
private final TradeQueryService tradeQueryService;

@Override
public void updateOrderBookOnNewOrder(Order order) {
Expand Down Expand Up @@ -67,15 +71,33 @@ private void updateOrderBookOnTradeExecuted(String ticker, Long orderId, boolean
}

private OrderBookInfo extractOrderBookInfo(String ticker){
ClosingPriceDto finalClosingPriceDto = getYesterdayClosingPrice(ticker);

List<OrderBookUnitInfo> buyOrderBookUnitInfos =
orderBookDomainService.getBuyOrderBookList(ticker, 10)
.stream().map(OrderBookUnitInfo::new).toList();
.stream()
.map(orderBookUnit -> new OrderBookUnitInfo(orderBookUnit, finalClosingPriceDto.closingPrice()))
.toList();
List<OrderBookUnitInfo> sellOrderBookUnitInfos =
orderBookDomainService.getSellOrderBookList(ticker, 10)
.stream().map(OrderBookUnitInfo::new).toList();
.stream()
.map(orderBookUnit -> new OrderBookUnitInfo(orderBookUnit, finalClosingPriceDto.closingPrice()))
.toList();

return new OrderBookInfo(ticker, buyOrderBookUnitInfos, sellOrderBookUnitInfos);
}

private ClosingPriceDto getYesterdayClosingPrice(String ticker){
LocalDate yesterday = LocalDate.now().minusDays(1);
ClosingPriceDto closingPriceDto = tradeQueryService.getYesterdayClosingPrice(ticker, yesterday);

if(closingPriceDto == null) {
closingPriceDto = new ClosingPriceDto(ticker, yesterday, 0.0);
}

return closingPriceDto;
}

private void sendOrderBookUpdated(String ticker){
orderBookUpdatedNotifierPort.sendOrderBooks(extractOrderBookInfo(ticker));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.cleanengine.coin.orderbook.application.service;

import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
import com.cleanengine.coin.orderbook.infra.TradeQueryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TradeQueryService {
private final TradeQueryRepository tradeQueryRepository;

@Transactional(isolation = Isolation.READ_COMMITTED)
public ClosingPriceDto getYesterdayClosingPrice(String ticker, LocalDate yesterdayDate) {
return tradeQueryRepository.getYesterdayClosingPrice(ticker, yesterdayDate);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.cleanengine.coin.orderbook.dto;

import com.querydsl.core.annotations.QueryProjection;

import java.time.LocalDate;

public record ClosingPriceDto(String ticker, LocalDate baseDate, Double closingPrice) {
@QueryProjection
public ClosingPriceDto {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,27 @@

public record OrderBookUnitInfo(
Double price,
Double size
Double size,
Double priceChangePercent
){
public OrderBookUnitInfo(OrderBookUnit orderBookUnit) {
this(orderBookUnit.getPrice(), orderBookUnit.getSize());
public OrderBookUnitInfo{
if(price == null || size == null || priceChangePercent == null){
throw new IllegalArgumentException("price, size, priceChangePercent cannot be null.");
}
}

public OrderBookUnitInfo(OrderBookUnit orderBookUnit, Double yesterdayClosingPrice) {
this(orderBookUnit.getPrice(),
orderBookUnit.getSize(),
calculateChangePercent(orderBookUnit.getPrice(), yesterdayClosingPrice));
}

private static Double calculateChangePercent(Double price, Double yesterdayClosingPrice) {
if(price == null || yesterdayClosingPrice == null){
throw new IllegalArgumentException("price, yesterdayClosingPrice cannot be null.");
}

return (yesterdayClosingPrice <= 0) ?
0.0 : (price - yesterdayClosingPrice) / yesterdayClosingPrice * 100;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.cleanengine.coin.orderbook.infra;

import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
import com.cleanengine.coin.orderbook.dto.QClosingPriceDto;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.LocalDateTime;

import static com.cleanengine.coin.trade.entity.QTrade.trade;

@Component
public class TradeQueryRepository {
private final JPAQueryFactory queryFactory;

public TradeQueryRepository(EntityManager entityManager){
this.queryFactory = new JPAQueryFactory(entityManager);
}

public ClosingPriceDto getYesterdayClosingPrice(String ticker, LocalDate yesterdayDate) {
LocalDateTime yesterday = yesterdayDate.atStartOfDay();

return queryFactory
.select(new QClosingPriceDto(
trade.ticker,
Expressions.asDate(yesterdayDate).as("baseDate"),
trade.price))
.from(trade)
.where(
trade.ticker.eq(ticker)
.and(trade.tradeTime.goe(yesterday))
.and(trade.tradeTime.lt(yesterday.plusDays(1))))
.orderBy(trade.tradeTime.desc(), trade.id.desc())
.fetchFirst();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,22 @@ static void initStatic(){
@Test
void eachOrderBookHasOnePrice_serializeIt_resultEqualsAsExpected() throws JsonProcessingException {
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
List.of(new OrderBookUnitInfo(1.0, 1.0)),
List.of(new OrderBookUnitInfo( 2.0, 2.0)));
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
List.of(new OrderBookUnitInfo( 2.0, 2.0, 0.0)));

String json = objectMapper.writeValueAsString(orderBookInfo);
System.out.println(json);
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0}],\"sellOrderBookUnits\":[{\"price\":2.0,\"size\":2.0}]}", json);
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0,\"priceChangePercent\":0.0}],\"sellOrderBookUnits\":[{\"price\":2.0,\"size\":2.0,\"priceChangePercent\":0.0}]}", json);
}

@Test
void oneOfOrderBookIsEmpty_serializeIt_resultEqualsAsExpected() throws JsonProcessingException {
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
List.of(new OrderBookUnitInfo(1.0, 1.0)),
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
List.of());

String json = objectMapper.writeValueAsString(orderBookInfo);
System.out.println(json);
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0}],\"sellOrderBookUnits\":[]}", json);
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0,\"priceChangePercent\":0.0}],\"sellOrderBookUnits\":[]}", json);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.cleanengine.coin.orderbook.dto;

import com.cleanengine.coin.orderbook.domain.BuyOrderBookUnit;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public class OrderBookUnitInfoTest {

@Nested
@DisplayName("기본 생성 유효성 테스트")
class CreateOrderBookUnitInfoTest {
@DisplayName("price가 null이라면 생성시 IllegalArgumentException을 반환한다.")
@Test
public void createOrderBookUnitInfoWithNullPrice_throwsIllegalArgumentException() {
Double nullPrice = null;

assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(nullPrice, 1.0, 1.0));
}

@DisplayName("size가 null이라면 생성시 IllegalArgumentException을 반환한다.")
@Test
public void createOrderBookUnitInfoWithNullSize_throwsIllegalArgumentException() {
Double nullSize = null;

assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(1.0, nullSize, 1.0));
}

@DisplayName("priceChangePercent가 null이라면 생성시 IllegalArgumentException을 반환한다.")
@Test
public void createOrderBookUnitInfoWithNullPriceChangePercent_throwsIllegalArgumentException() {
Double nullPriceChangePercent = null;

assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(1.0, 1.0, nullPriceChangePercent));
}
}

@Nested
@DisplayName("priceChangePercent 반영 테스트")
class ChangePercentTest {
@DisplayName("closingPrice가 0이하일 경우 percentChange도 0이다.")
@Test
public void createOrderBookUnitInfoWithZeroClosingPrice_percentChangeIsZero() {
Double closingPrice = 0.0;

OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(1.0, 1.0, closingPrice);

assertEquals(0.0, orderBookUnitInfo.priceChangePercent());
}

@DisplayName("closingPrice가 null이라면 IllegalArgumentException을 반환한다.")
@Test
public void createOrderBookUnitInfoWithNullClosingPrice_throwsIllegalArgumentException() {
Double nullClosingPrice = null;
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(1.0, 1.0);

assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(buyOrderBookUnit, nullClosingPrice));
}

@DisplayName("price가 null이라면 IllegalArgumentException을 반환다.")
@Test
public void createOrderBookUnitInfoWithNullPrice_throwsIllegalArgumentException() {
Double nullPrice = null;
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(nullPrice, 1.0);

assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(buyOrderBookUnit, 1.0));
}

@DisplayName("closingPrice가 비교대상 price의 절반이라면, percentChange는 +100.0이다.")
@Test
public void createOrderBookUnitInfoWithHalfClosingPrice_percentChangeIs100() {
Double closingPrice = 1.0;
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(closingPrice * 2.0, 1.0);

OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(buyOrderBookUnit, closingPrice);

assertEquals(100.0, orderBookUnitInfo.priceChangePercent());
}

@DisplayName("closingPrice가 비교대상 price의 두배라면, percentChange는 -50.0이다.")
@Test
public void createOrderBookUnitInfoWithDoubleClosingPrice_percentChangeIsMinus50() {
Double closingPrice = 1.0;
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(closingPrice / 2.0, 1.0);

OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(buyOrderBookUnit, closingPrice);

assertEquals(-50.0, orderBookUnitInfo.priceChangePercent());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public class OrderBookUpdatedNotifierAdapterTest extends WebSocketTest {
@Test
public void getOrderBooks() throws Exception {
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
List.of(new OrderBookUnitInfo(1.0, 1.0)),
List.of(new OrderBookUnitInfo( 2.0, 2.0)));
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
List.of(new OrderBookUnitInfo( 2.0, 2.0, 0.0)));

session.subscribe("/topic/orderbook/BTC",
new GenericStompFrameHandler<>(OrderBookInfo.class, responseQueue));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package com.cleanengine.coin.orderbook.infra;

import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.SqlGroup;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

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

@ActiveProfiles("dev, it, h2-mem")
@DataJpaTest
@Import({TradeQueryRepository.class})
public class TradeQueryRepositoryH2Test {
@Autowired
private TradeQueryRepository tradeQueryRepository;

@DisplayName("어제 trade가 있었을 경우, 정상적으로 yesterdayClosingPrice를 조회한다.")
@Test
@Transactional
@SqlGroup({
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertYesterdayTrade.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
})
public void queryYesterdayClosingPriceWithTradeExecutedYesterday_shouldReturnSuccessfully() {
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);

ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);

assertNotNull(closingPriceDto);
assertEquals("BTC", closingPriceDto.ticker());
assertEquals(yesterdayDate, closingPriceDto.baseDate());
assertEquals(400.0, closingPriceDto.closingPrice());
}

@DisplayName("어제 trade가 없었을 경우, null을 조회한다.")
@Test
@Transactional
public void queryYesterdayClosingPriceWithoutYesterdayTrade_shouldReturnNull() {
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);

ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);

assertNull(closingPriceDto);
}

@DisplayName("어제 trade가 없고, 오늘 00시 00분 00초의 trade가 있었을 때, null을 조회한다.")
@Test
@Transactional
@SqlGroup({
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertStartOfTodayTrade.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
})
public void queryYesterdayClosingPriceWithTradeExecutedToday_shouldReturnNull() {
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);

ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);

assertNull(closingPriceDto);
}

@DisplayName("어제 같은 시간에 여러건의 trade가 있었을 때, id가 가장 큰 ClosingPrice를 조회한다.")
@Test
@Transactional
@SqlGroup({
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertDuplicateTimeYesterdayTrade.sql",
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
})
public void queryYesterdayClosingPriceWithDuplicateTimeTrades_shouldReturnBiggestIdDto() {
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);

ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);

assertNotNull(closingPriceDto);
assertEquals("BTC", closingPriceDto.ticker());
assertEquals(yesterdayDate, closingPriceDto.baseDate());
assertEquals(600.0, closingPriceDto.closingPrice());
}
}
Loading
Loading