Skip to content

Commit d4516bb

Browse files
authored
Merge pull request #107 from CleanEngine/feat/trade-core
refactor: 체결 로직 리팩토링 및 테스트 코드 일부 작성
2 parents fcbb726 + cf06064 commit d4516bb

17 files changed

Lines changed: 1114 additions & 791 deletions

src/main/java/com/cleanengine/coin/order/application/OrderService.java

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

33
import com.cleanengine.coin.order.application.dto.OrderCommand;
44
import com.cleanengine.coin.order.application.dto.OrderInfo;
5+
import com.cleanengine.coin.common.error.BusinessException;
6+
import com.cleanengine.coin.common.response.ErrorStatus;
7+
import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository;
8+
import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository;
59
import com.cleanengine.coin.order.application.strategy.CreateOrderStrategy;
10+
import com.cleanengine.coin.order.domain.BuyOrder;
11+
import com.cleanengine.coin.order.domain.Order;
12+
import com.cleanengine.coin.order.domain.SellOrder;
613
import jakarta.validation.Valid;
714
import lombok.RequiredArgsConstructor;
815
import org.springframework.stereotype.Service;
@@ -39,4 +46,5 @@ public OrderInfo<?> createOrderWithBot(String ticker, Boolean isBuyOrder, Double
3946

4047
return createOrder(createOrder);
4148
}
49+
4250
}

src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
package com.cleanengine.coin.trade.application;
22

3-
import com.cleanengine.coin.chart.dto.TradeEventDto;
43
import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager;
5-
import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase;
64
import jakarta.annotation.PreDestroy;
75
import lombok.Getter;
8-
import org.slf4j.Logger;
9-
import org.slf4j.LoggerFactory;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
108
import org.springframework.beans.factory.annotation.Value;
119
import org.springframework.boot.ApplicationArguments;
1210
import org.springframework.boot.ApplicationRunner;
@@ -21,28 +19,21 @@
2119
import java.util.concurrent.Executors;
2220
import java.util.concurrent.TimeUnit;
2321

24-
@Service
2522
@Order(4)
23+
@Slf4j
24+
@RequiredArgsConstructor
25+
@Service
2626
public class TradeBatchProcessor implements ApplicationRunner {
2727

28-
Logger logger = LoggerFactory.getLogger(TradeBatchProcessor.class);
29-
3028
private final WaitingOrdersManager waitingOrdersManager;
31-
private final TradeService tradeService;
29+
private final TradeFlowService tradeFlowService;
3230
private final List<ExecutorService> executors = new ArrayList<>();
3331

3432
@Getter
3533
private final Map<String, TradeQueueManager> tradeQueueManagers = new HashMap<>();
36-
private final UpdateOrderBookUsecase updateOrderBookUsecase;
3734

3835
@Value("${order.tickers}") String[] tickers;
3936

40-
public TradeBatchProcessor(WaitingOrdersManager waitingOrdersManager, TradeService tradeService, UpdateOrderBookUsecase updateOrderBookUsecase) {
41-
this.waitingOrdersManager = waitingOrdersManager;
42-
this.tradeService = tradeService;
43-
this.updateOrderBookUsecase = updateOrderBookUsecase;
44-
}
45-
4637
@Override
4738
public void run(ApplicationArguments args) {
4839
processTrades();
@@ -51,8 +42,7 @@ public void run(ApplicationArguments args) {
5142
private void processTrades() {
5243
for (String ticker : tickers) {
5344
TradeQueueManager tradeQueueManager = new TradeQueueManager(waitingOrdersManager.getWaitingOrders(ticker),
54-
updateOrderBookUsecase,
55-
tradeService);
45+
tradeFlowService);
5646
tradeQueueManagers.put(ticker, tradeQueueManager); // 정상 종료를 위해 저장
5747

5848
ExecutorService tradeExecutor = Executors.newSingleThreadExecutor(r -> {
@@ -66,7 +56,7 @@ private void processTrades() {
6656
try {
6757
tradeQueueManager.run();
6858
} catch (Exception e) {
69-
logger.error("Error in trade loop for {}: {}",ticker, e.getMessage());
59+
log.error("Error in trade loop for {}: {}",ticker, e.getMessage());
7060
}
7161
});
7262
}
@@ -87,7 +77,7 @@ public void shutdown() {
8777
executor.shutdownNow();
8878
// 추가로 1초 더 대기
8979
if (!executor.awaitTermination(1, TimeUnit.SECONDS)) {
90-
System.err.println("스레드풀이 완전히 종료되지 않았습니다");
80+
log.error("스레드풀이 완전히 종료되지 않았습니다");
9181
}
9282
}
9383
} catch (InterruptedException e) {
@@ -97,9 +87,4 @@ public void shutdown() {
9787
}
9888
}
9989

100-
@Deprecated
101-
public TradeEventDto retrieveTradeEventDto(String ticker) {
102-
return null;
103-
}
104-
10590
}

src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,21 @@
77
@Getter
88
@Builder
99
public class TradeExecutedEvent {
10+
1011
Trade trade;
12+
1113
Long buyOrderId;
14+
1215
Long sellOrderId;
16+
17+
private TradeExecutedEvent(Trade trade, Long buyOrderId, Long sellOrderId) {
18+
this.trade = trade;
19+
this.buyOrderId = buyOrderId;
20+
this.sellOrderId = sellOrderId;
21+
}
22+
23+
public static TradeExecutedEvent of(Trade trade, Long buyOrderId, Long sellOrderId) {
24+
return new TradeExecutedEvent(trade, buyOrderId, sellOrderId);
25+
}
26+
1327
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package com.cleanengine.coin.trade.application;
2+
3+
import com.cleanengine.coin.common.error.BusinessException;
4+
import com.cleanengine.coin.common.response.ErrorStatus;
5+
import com.cleanengine.coin.order.application.OrderService;
6+
import com.cleanengine.coin.order.domain.BuyOrder;
7+
import com.cleanengine.coin.order.domain.Order;
8+
import com.cleanengine.coin.order.domain.OrderStatus;
9+
import com.cleanengine.coin.order.domain.SellOrder;
10+
import com.cleanengine.coin.order.domain.spi.WaitingOrders;
11+
import com.cleanengine.coin.trade.entity.Trade;
12+
import com.cleanengine.coin.user.domain.Account;
13+
import com.cleanengine.coin.user.domain.Wallet;
14+
import com.cleanengine.coin.user.info.application.AccountService;
15+
import com.cleanengine.coin.user.info.application.WalletService;
16+
import jakarta.transaction.Transactional;
17+
import lombok.Getter;
18+
import lombok.RequiredArgsConstructor;
19+
import lombok.extern.slf4j.Slf4j;
20+
import org.springframework.stereotype.Component;
21+
22+
import java.time.LocalDateTime;
23+
24+
import static com.cleanengine.coin.common.CommonValues.approxEquals;
25+
26+
@Slf4j
27+
@RequiredArgsConstructor
28+
@Component
29+
public class TradeExecutor {
30+
31+
private final WalletService walletService;
32+
private final AccountService accountService;
33+
@Getter
34+
private final TradeExecutedEventPublisher tradeExecutedEventPublisher;
35+
private final TradeService tradeService;
36+
37+
@Transactional
38+
public void executeTrade(WaitingOrders waitingOrders, TradePair<Order, Order> tradePair, String ticker) {
39+
BuyOrder buyOrder = tradePair.getBuyOrder();
40+
SellOrder sellOrder = tradePair.getSellOrder();
41+
42+
double tradedPrice;
43+
double tradedSize;
44+
double totalTradedPrice;
45+
46+
// 체결 단가, 수량 확정
47+
TradeUnitPriceAndSize tradeUnitPriceAndSize = getTradeUnitPriceAndSize(buyOrder, sellOrder);
48+
tradedSize = tradeUnitPriceAndSize.tradedSize();
49+
tradedPrice = tradeUnitPriceAndSize.tradedPrice();
50+
if (approxEquals(tradedSize, 0.0)) {
51+
log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice());
52+
return;
53+
}
54+
this.writeTradingLog(buyOrder, sellOrder);
55+
56+
totalTradedPrice = tradedPrice * tradedSize;
57+
// 주문 잔여수량, 잔여금액 감소
58+
if (isMarketOrder(buyOrder))
59+
buyOrder.decreaseRemainingDeposit(totalTradedPrice);
60+
else
61+
buyOrder.decreaseRemainingSize(tradedSize);
62+
sellOrder.decreaseRemainingSize(tradedSize);
63+
64+
// 주문 완전체결 처리(잔여금액 or 잔여수량이 0)
65+
this.removeCompletedBuyOrder(waitingOrders, buyOrder);
66+
this.removeCompletedSellOrder(waitingOrders, sellOrder);
67+
68+
tradeService.updateOrder(buyOrder);
69+
tradeService.updateOrder(sellOrder);
70+
71+
// 예수금 처리
72+
// - 매수 잔여금액 반환
73+
if (!isMarketOrder(buyOrder) && buyOrder.getPrice() > tradedPrice) { // 매도 호가보다 높은 가격에 매수를 시도한 경우, 차액 반환
74+
double totalRefundAmount = (buyOrder.getPrice() - tradedPrice) * tradedSize;
75+
this.increaseAccountCash(buyOrder, totalRefundAmount);
76+
}
77+
78+
// - 매도 예수금 처리
79+
this.increaseAccountCash(sellOrder, totalTradedPrice);
80+
81+
// 지갑 누적계산
82+
this.updateWalletAfterTrade(buyOrder, ticker, tradedSize, totalTradedPrice);
83+
this.updateWalletAfterTrade(sellOrder, ticker, tradedSize, totalTradedPrice);
84+
85+
// 체결내역 저장
86+
Trade trade = this.insertNewTrade(ticker, buyOrder, sellOrder, tradedSize, tradedPrice);
87+
88+
TradeExecutedEvent tradeExecutedEvent = TradeExecutedEvent.of(trade, buyOrder.getId(), sellOrder.getId());
89+
tradeExecutedEventPublisher.publish(tradeExecutedEvent);
90+
}
91+
92+
public void increaseAccountCash(Order order, Double amount) {
93+
Account account = accountService.findAccountByUserId(order.getUserId()).orElseThrow();
94+
accountService.save(account.increaseCash(amount));
95+
}
96+
97+
public void updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) {
98+
if (order instanceof BuyOrder) {
99+
Wallet buyerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker);
100+
double updatedBuySize = buyerWallet.getSize() + tradedSize;
101+
double currentBuyPrice = buyerWallet.getBuyPrice() == null ? 0.0 : buyerWallet.getBuyPrice();
102+
double updatedBuyPrice = ((currentBuyPrice * buyerWallet.getSize()) + totalTradedPrice) / updatedBuySize;
103+
buyerWallet.setSize(updatedBuySize);
104+
buyerWallet.setBuyPrice(updatedBuyPrice);
105+
// TODO : ROI 계산
106+
walletService.save(buyerWallet);
107+
} else if (order instanceof SellOrder) {
108+
// 매도 시에는 평단가 변동 없음
109+
Wallet sellerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker);
110+
walletService.save(sellerWallet);
111+
} else {
112+
throw new BusinessException("Unsupported order type: " + order.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR);
113+
}
114+
}
115+
116+
public Trade insertNewTrade(String ticker, BuyOrder buyOrder, SellOrder sellOrder, double tradeSize, Double tradePrice) {
117+
Trade newTrade = Trade.of(ticker, LocalDateTime.now(), buyOrder.getUserId(), sellOrder.getUserId(), tradePrice, tradeSize);
118+
119+
return tradeService.save(newTrade);
120+
}
121+
122+
private static TradeUnitPriceAndSize getTradeUnitPriceAndSize(BuyOrder buyOrder, SellOrder sellOrder) {
123+
double tradedPrice;
124+
double tradedSize;
125+
if (isMarketOrder(buyOrder)) { // 시장가매수-지정가매도
126+
tradedPrice = sellOrder.getPrice();
127+
if (buyOrder.getRemainingDeposit() >= tradedPrice * sellOrder.getRemainingSize()) { // 매수 잔여예수금이 매도 잔여량보다 크거나 같은 경우 (매수 부분체결 or 완전체결, 매도 완전체결)
128+
tradedSize = sellOrder.getRemainingSize();
129+
} else {
130+
tradedSize = buyOrder.getRemainingDeposit() / tradedPrice;
131+
}
132+
} else if (isMarketOrder(sellOrder)) { // 시장가매도-지정가매수
133+
tradedPrice = buyOrder.getPrice();
134+
tradedSize = Math.min(sellOrder.getRemainingSize(), buyOrder.getRemainingSize());
135+
} else { // 지정가매수-지정가매도
136+
tradedPrice = getTradedUnitPrice(buyOrder, sellOrder);
137+
tradedSize = Math.min(buyOrder.getRemainingSize(), sellOrder.getRemainingSize());
138+
}
139+
return new TradeUnitPriceAndSize(tradedSize, tradedPrice);
140+
}
141+
142+
private record TradeUnitPriceAndSize(double tradedSize, double tradedPrice) {
143+
}
144+
145+
private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) {
146+
// 주문 시간을 비교하여 먼저 들어온 주문의 가격으로 거래
147+
if (buyOrder.getCreatedAt().isBefore(sellOrder.getCreatedAt())) {
148+
return buyOrder.getPrice();
149+
} else {
150+
return sellOrder.getPrice();
151+
}
152+
}
153+
154+
private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) {
155+
log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)",
156+
Thread.currentThread().threadId(),
157+
buyOrder.getTicker(),
158+
buyOrder.getId(),
159+
buyOrder.getUserId(),
160+
isMarketOrder(buyOrder) ? "시장가" : "지정가(" + buyOrder.getPrice() + "원)",
161+
buyOrder.getRemainingSize() == null ? buyOrder.getRemainingDeposit() : buyOrder.getRemainingSize(),
162+
sellOrder.getId(),
163+
sellOrder.getUserId(),
164+
isMarketOrder(sellOrder) ? "시장가" : "지정가(" + sellOrder.getPrice() + "원)",
165+
sellOrder.getRemainingSize());
166+
}
167+
168+
private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) {
169+
boolean isOrderCompleted = (isMarketOrder(order) && approxEquals(order.getRemainingDeposit(), 0.0)) ||
170+
(isLimitOrder(order) && approxEquals(order.getRemainingSize(), 0.0));
171+
172+
if (isOrderCompleted) {
173+
waitingOrders.removeOrder(order);
174+
this.updateCompletedOrderStatus(order);
175+
}
176+
}
177+
178+
private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) {
179+
boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0);
180+
181+
if (isOrderCompleted) {
182+
waitingOrders.removeOrder(order);
183+
this.updateCompletedOrderStatus(order);
184+
}
185+
}
186+
187+
public void updateCompletedOrderStatus(Order order) {
188+
order.setState(OrderStatus.DONE);
189+
}
190+
191+
private static boolean isMarketOrder(Order order) {
192+
return order.getIsMarketOrder();
193+
}
194+
195+
private static boolean isLimitOrder(Order order) {
196+
return !order.getIsMarketOrder();
197+
}
198+
199+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.cleanengine.coin.trade.application;
2+
3+
import com.cleanengine.coin.order.domain.Order;
4+
import com.cleanengine.coin.order.domain.spi.WaitingOrders;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.stereotype.Component;
8+
import org.springframework.transaction.annotation.Transactional;
9+
10+
import java.util.Optional;
11+
12+
@Slf4j
13+
@RequiredArgsConstructor
14+
@Transactional
15+
@Component
16+
public class TradeFlowService {
17+
18+
private final TradeMatcher tradeMatcher;
19+
private final TradeExecutor tradeExecutor;
20+
21+
public void execMatchAndTrade(String ticker) {
22+
WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker);
23+
// TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지
24+
Optional<TradePair<Order, Order>> tradePair = tradeMatcher.matchOrders(waitingOrders);
25+
26+
tradePair.ifPresent(orderOrderTradePair -> tradeExecutor.executeTrade(waitingOrders, orderOrderTradePair, ticker));
27+
}
28+
29+
}

0 commit comments

Comments
 (0)