Skip to content

Commit ae2a616

Browse files
authored
Merge pull request #184 from CleanEngine/feat/orderbook-pricediff
Feat/orderbook pricediff
2 parents 3df89c2 + 2eadf07 commit ae2a616

File tree

15 files changed

+412
-12
lines changed

15 files changed

+412
-12
lines changed

src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import com.cleanengine.coin.order.domain.spi.ActiveOrders;
77
import com.cleanengine.coin.order.domain.spi.ActiveOrdersManager;
88
import com.cleanengine.coin.orderbook.domain.OrderBookDomainService;
9+
import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
910
import com.cleanengine.coin.orderbook.dto.OrderBookInfo;
1011
import com.cleanengine.coin.orderbook.dto.OrderBookUnitInfo;
12+
import com.cleanengine.coin.orderbook.infra.TradeQueryRepository;
1113
import lombok.RequiredArgsConstructor;
1214
import org.springframework.stereotype.Component;
1315

16+
import java.time.LocalDate;
1417
import java.util.List;
1518
import java.util.Optional;
1619

@@ -22,6 +25,7 @@ public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUs
2225
private final ActiveOrdersManager activeOrdersManager;
2326
private final OrderBookDomainService orderBookDomainService;
2427
private final OrderBookUpdatedNotifierPort orderBookUpdatedNotifierPort;
28+
private final TradeQueryService tradeQueryService;
2529

2630
@Override
2731
public void updateOrderBookOnNewOrder(Order order) {
@@ -67,15 +71,33 @@ private void updateOrderBookOnTradeExecuted(String ticker, Long orderId, boolean
6771
}
6872

6973
private OrderBookInfo extractOrderBookInfo(String ticker){
74+
ClosingPriceDto finalClosingPriceDto = getYesterdayClosingPrice(ticker);
75+
7076
List<OrderBookUnitInfo> buyOrderBookUnitInfos =
7177
orderBookDomainService.getBuyOrderBookList(ticker, 10)
72-
.stream().map(OrderBookUnitInfo::new).toList();
78+
.stream()
79+
.map(orderBookUnit -> new OrderBookUnitInfo(orderBookUnit, finalClosingPriceDto.closingPrice()))
80+
.toList();
7381
List<OrderBookUnitInfo> sellOrderBookUnitInfos =
7482
orderBookDomainService.getSellOrderBookList(ticker, 10)
75-
.stream().map(OrderBookUnitInfo::new).toList();
83+
.stream()
84+
.map(orderBookUnit -> new OrderBookUnitInfo(orderBookUnit, finalClosingPriceDto.closingPrice()))
85+
.toList();
86+
7687
return new OrderBookInfo(ticker, buyOrderBookUnitInfos, sellOrderBookUnitInfos);
7788
}
7889

90+
private ClosingPriceDto getYesterdayClosingPrice(String ticker){
91+
LocalDate yesterday = LocalDate.now().minusDays(1);
92+
ClosingPriceDto closingPriceDto = tradeQueryService.getYesterdayClosingPrice(ticker, yesterday);
93+
94+
if(closingPriceDto == null) {
95+
closingPriceDto = new ClosingPriceDto(ticker, yesterday, 0.0);
96+
}
97+
98+
return closingPriceDto;
99+
}
100+
79101
private void sendOrderBookUpdated(String ticker){
80102
orderBookUpdatedNotifierPort.sendOrderBooks(extractOrderBookInfo(ticker));
81103
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.cleanengine.coin.orderbook.application.service;
2+
3+
import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
4+
import com.cleanengine.coin.orderbook.infra.TradeQueryRepository;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.stereotype.Service;
7+
import org.springframework.transaction.annotation.Isolation;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import java.time.LocalDate;
11+
12+
@Service
13+
@Transactional(readOnly = true)
14+
@RequiredArgsConstructor
15+
public class TradeQueryService {
16+
private final TradeQueryRepository tradeQueryRepository;
17+
18+
@Transactional(isolation = Isolation.READ_COMMITTED)
19+
public ClosingPriceDto getYesterdayClosingPrice(String ticker, LocalDate yesterdayDate) {
20+
return tradeQueryRepository.getYesterdayClosingPrice(ticker, yesterdayDate);
21+
}
22+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.cleanengine.coin.orderbook.dto;
2+
3+
import com.querydsl.core.annotations.QueryProjection;
4+
5+
import java.time.LocalDate;
6+
7+
public record ClosingPriceDto(String ticker, LocalDate baseDate, Double closingPrice) {
8+
@QueryProjection
9+
public ClosingPriceDto {}
10+
}

src/main/java/com/cleanengine/coin/orderbook/dto/OrderBookUnitInfo.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,27 @@
44

55
public record OrderBookUnitInfo(
66
Double price,
7-
Double size
7+
Double size,
8+
Double priceChangePercent
89
){
9-
public OrderBookUnitInfo(OrderBookUnit orderBookUnit) {
10-
this(orderBookUnit.getPrice(), orderBookUnit.getSize());
10+
public OrderBookUnitInfo{
11+
if(price == null || size == null || priceChangePercent == null){
12+
throw new IllegalArgumentException("price, size, priceChangePercent cannot be null.");
13+
}
14+
}
15+
16+
public OrderBookUnitInfo(OrderBookUnit orderBookUnit, Double yesterdayClosingPrice) {
17+
this(orderBookUnit.getPrice(),
18+
orderBookUnit.getSize(),
19+
calculateChangePercent(orderBookUnit.getPrice(), yesterdayClosingPrice));
20+
}
21+
22+
private static Double calculateChangePercent(Double price, Double yesterdayClosingPrice) {
23+
if(price == null || yesterdayClosingPrice == null){
24+
throw new IllegalArgumentException("price, yesterdayClosingPrice cannot be null.");
25+
}
26+
27+
return (yesterdayClosingPrice <= 0) ?
28+
0.0 : (price - yesterdayClosingPrice) / yesterdayClosingPrice * 100;
1129
}
1230
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.cleanengine.coin.orderbook.infra;
2+
3+
import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
4+
import com.cleanengine.coin.orderbook.dto.QClosingPriceDto;
5+
import com.querydsl.core.types.dsl.Expressions;
6+
import com.querydsl.jpa.impl.JPAQueryFactory;
7+
import jakarta.persistence.EntityManager;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.time.LocalDate;
11+
import java.time.LocalDateTime;
12+
13+
import static com.cleanengine.coin.trade.entity.QTrade.trade;
14+
15+
@Component
16+
public class TradeQueryRepository {
17+
private final JPAQueryFactory queryFactory;
18+
19+
public TradeQueryRepository(EntityManager entityManager){
20+
this.queryFactory = new JPAQueryFactory(entityManager);
21+
}
22+
23+
public ClosingPriceDto getYesterdayClosingPrice(String ticker, LocalDate yesterdayDate) {
24+
LocalDateTime yesterday = yesterdayDate.atStartOfDay();
25+
26+
return queryFactory
27+
.select(new QClosingPriceDto(
28+
trade.ticker,
29+
Expressions.asDate(yesterdayDate).as("baseDate"),
30+
trade.price))
31+
.from(trade)
32+
.where(
33+
trade.ticker.eq(ticker)
34+
.and(trade.tradeTime.goe(yesterday))
35+
.and(trade.tradeTime.lt(yesterday.plusDays(1))))
36+
.orderBy(trade.tradeTime.desc(), trade.id.desc())
37+
.fetchFirst();
38+
}
39+
}

src/test/java/com/cleanengine/coin/orderbook/dto/OrderBookInfoSerializationTest.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,22 @@ static void initStatic(){
2121
@Test
2222
void eachOrderBookHasOnePrice_serializeIt_resultEqualsAsExpected() throws JsonProcessingException {
2323
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
24-
List.of(new OrderBookUnitInfo(1.0, 1.0)),
25-
List.of(new OrderBookUnitInfo( 2.0, 2.0)));
24+
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
25+
List.of(new OrderBookUnitInfo( 2.0, 2.0, 0.0)));
2626

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

3232
@Test
3333
void oneOfOrderBookIsEmpty_serializeIt_resultEqualsAsExpected() throws JsonProcessingException {
3434
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
35-
List.of(new OrderBookUnitInfo(1.0, 1.0)),
35+
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
3636
List.of());
3737

3838
String json = objectMapper.writeValueAsString(orderBookInfo);
3939
System.out.println(json);
40-
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0}],\"sellOrderBookUnits\":[]}", json);
40+
assertEquals("{\"ticker\":\"BTC\",\"buyOrderBookUnits\":[{\"price\":1.0,\"size\":1.0,\"priceChangePercent\":0.0}],\"sellOrderBookUnits\":[]}", json);
4141
}
4242
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.cleanengine.coin.orderbook.dto;
2+
3+
import com.cleanengine.coin.orderbook.domain.BuyOrderBookUnit;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Nested;
6+
import org.junit.jupiter.api.Test;
7+
8+
import static org.junit.jupiter.api.Assertions.assertEquals;
9+
import static org.junit.jupiter.api.Assertions.assertThrows;
10+
11+
public class OrderBookUnitInfoTest {
12+
13+
@Nested
14+
@DisplayName("기본 생성 유효성 테스트")
15+
class CreateOrderBookUnitInfoTest {
16+
@DisplayName("price가 null이라면 생성시 IllegalArgumentException을 반환한다.")
17+
@Test
18+
public void createOrderBookUnitInfoWithNullPrice_throwsIllegalArgumentException() {
19+
Double nullPrice = null;
20+
21+
assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(nullPrice, 1.0, 1.0));
22+
}
23+
24+
@DisplayName("size가 null이라면 생성시 IllegalArgumentException을 반환한다.")
25+
@Test
26+
public void createOrderBookUnitInfoWithNullSize_throwsIllegalArgumentException() {
27+
Double nullSize = null;
28+
29+
assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(1.0, nullSize, 1.0));
30+
}
31+
32+
@DisplayName("priceChangePercent가 null이라면 생성시 IllegalArgumentException을 반환한다.")
33+
@Test
34+
public void createOrderBookUnitInfoWithNullPriceChangePercent_throwsIllegalArgumentException() {
35+
Double nullPriceChangePercent = null;
36+
37+
assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(1.0, 1.0, nullPriceChangePercent));
38+
}
39+
}
40+
41+
@Nested
42+
@DisplayName("priceChangePercent 반영 테스트")
43+
class ChangePercentTest {
44+
@DisplayName("closingPrice가 0이하일 경우 percentChange도 0이다.")
45+
@Test
46+
public void createOrderBookUnitInfoWithZeroClosingPrice_percentChangeIsZero() {
47+
Double closingPrice = 0.0;
48+
49+
OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(1.0, 1.0, closingPrice);
50+
51+
assertEquals(0.0, orderBookUnitInfo.priceChangePercent());
52+
}
53+
54+
@DisplayName("closingPrice가 null이라면 IllegalArgumentException을 반환한다.")
55+
@Test
56+
public void createOrderBookUnitInfoWithNullClosingPrice_throwsIllegalArgumentException() {
57+
Double nullClosingPrice = null;
58+
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(1.0, 1.0);
59+
60+
assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(buyOrderBookUnit, nullClosingPrice));
61+
}
62+
63+
@DisplayName("price가 null이라면 IllegalArgumentException을 반환다.")
64+
@Test
65+
public void createOrderBookUnitInfoWithNullPrice_throwsIllegalArgumentException() {
66+
Double nullPrice = null;
67+
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(nullPrice, 1.0);
68+
69+
assertThrows(IllegalArgumentException.class, () -> new OrderBookUnitInfo(buyOrderBookUnit, 1.0));
70+
}
71+
72+
@DisplayName("closingPrice가 비교대상 price의 절반이라면, percentChange는 +100.0이다.")
73+
@Test
74+
public void createOrderBookUnitInfoWithHalfClosingPrice_percentChangeIs100() {
75+
Double closingPrice = 1.0;
76+
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(closingPrice * 2.0, 1.0);
77+
78+
OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(buyOrderBookUnit, closingPrice);
79+
80+
assertEquals(100.0, orderBookUnitInfo.priceChangePercent());
81+
}
82+
83+
@DisplayName("closingPrice가 비교대상 price의 두배라면, percentChange는 -50.0이다.")
84+
@Test
85+
public void createOrderBookUnitInfoWithDoubleClosingPrice_percentChangeIsMinus50() {
86+
Double closingPrice = 1.0;
87+
BuyOrderBookUnit buyOrderBookUnit = new BuyOrderBookUnit(closingPrice / 2.0, 1.0);
88+
89+
OrderBookUnitInfo orderBookUnitInfo = new OrderBookUnitInfo(buyOrderBookUnit, closingPrice);
90+
91+
assertEquals(-50.0, orderBookUnitInfo.priceChangePercent());
92+
}
93+
}
94+
}

src/test/java/com/cleanengine/coin/orderbook/infra/OrderBookUpdatedNotifierAdapterTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ public class OrderBookUpdatedNotifierAdapterTest extends WebSocketTest {
2222
@Test
2323
public void getOrderBooks() throws Exception {
2424
OrderBookInfo orderBookInfo = new OrderBookInfo("BTC",
25-
List.of(new OrderBookUnitInfo(1.0, 1.0)),
26-
List.of(new OrderBookUnitInfo( 2.0, 2.0)));
25+
List.of(new OrderBookUnitInfo(1.0, 1.0, 0.0)),
26+
List.of(new OrderBookUnitInfo( 2.0, 2.0, 0.0)));
2727

2828
session.subscribe("/topic/orderbook/BTC",
2929
new GenericStompFrameHandler<>(OrderBookInfo.class, responseQueue));
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package com.cleanengine.coin.orderbook.infra;
2+
3+
import com.cleanengine.coin.orderbook.dto.ClosingPriceDto;
4+
import org.junit.jupiter.api.DisplayName;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.beans.factory.annotation.Autowired;
7+
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
8+
import org.springframework.context.annotation.Import;
9+
import org.springframework.test.context.ActiveProfiles;
10+
import org.springframework.test.context.jdbc.Sql;
11+
import org.springframework.test.context.jdbc.SqlGroup;
12+
import org.springframework.transaction.annotation.Transactional;
13+
14+
import java.time.LocalDate;
15+
16+
import static org.junit.jupiter.api.Assertions.*;
17+
18+
@ActiveProfiles("dev, it, h2-mem")
19+
@DataJpaTest
20+
@Import({TradeQueryRepository.class})
21+
public class TradeQueryRepositoryH2Test {
22+
@Autowired
23+
private TradeQueryRepository tradeQueryRepository;
24+
25+
@DisplayName("어제 trade가 있었을 경우, 정상적으로 yesterdayClosingPrice를 조회한다.")
26+
@Test
27+
@Transactional
28+
@SqlGroup({
29+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertYesterdayTrade.sql",
30+
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
31+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
32+
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
33+
})
34+
public void queryYesterdayClosingPriceWithTradeExecutedYesterday_shouldReturnSuccessfully() {
35+
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);
36+
37+
ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);
38+
39+
assertNotNull(closingPriceDto);
40+
assertEquals("BTC", closingPriceDto.ticker());
41+
assertEquals(yesterdayDate, closingPriceDto.baseDate());
42+
assertEquals(400.0, closingPriceDto.closingPrice());
43+
}
44+
45+
@DisplayName("어제 trade가 없었을 경우, null을 조회한다.")
46+
@Test
47+
@Transactional
48+
public void queryYesterdayClosingPriceWithoutYesterdayTrade_shouldReturnNull() {
49+
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);
50+
51+
ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);
52+
53+
assertNull(closingPriceDto);
54+
}
55+
56+
@DisplayName("어제 trade가 없고, 오늘 00시 00분 00초의 trade가 있었을 때, null을 조회한다.")
57+
@Test
58+
@Transactional
59+
@SqlGroup({
60+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertStartOfTodayTrade.sql",
61+
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
62+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
63+
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
64+
})
65+
public void queryYesterdayClosingPriceWithTradeExecutedToday_shouldReturnNull() {
66+
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);
67+
68+
ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);
69+
70+
assertNull(closingPriceDto);
71+
}
72+
73+
@DisplayName("어제 같은 시간에 여러건의 trade가 있었을 때, id가 가장 큰 ClosingPrice를 조회한다.")
74+
@Test
75+
@Transactional
76+
@SqlGroup({
77+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/insertDuplicateTimeYesterdayTrade.sql",
78+
executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD),
79+
@Sql(scripts = "classpath:com/cleanengine/coin/orderbook/infra/TradeQueryRepository/clearTrade.sql",
80+
executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
81+
})
82+
public void queryYesterdayClosingPriceWithDuplicateTimeTrades_shouldReturnBiggestIdDto() {
83+
LocalDate yesterdayDate = LocalDate.of(2025, 7, 1);
84+
85+
ClosingPriceDto closingPriceDto = tradeQueryRepository.getYesterdayClosingPrice("BTC", yesterdayDate);
86+
87+
assertNotNull(closingPriceDto);
88+
assertEquals("BTC", closingPriceDto.ticker());
89+
assertEquals(yesterdayDate, closingPriceDto.baseDate());
90+
assertEquals(600.0, closingPriceDto.closingPrice());
91+
}
92+
}

0 commit comments

Comments
 (0)