diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderService.java b/src/main/java/com/cleanengine/coin/order/application/OrderService.java index 3d723e7c..d84f9af0 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -2,7 +2,14 @@ import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; +import com.cleanengine.coin.common.error.BusinessException; +import com.cleanengine.coin.common.response.ErrorStatus; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; import com.cleanengine.coin.order.application.strategy.CreateOrderStrategy; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -39,4 +46,5 @@ public OrderInfo createOrderWithBot(String ticker, Boolean isBuyOrder, Double return createOrder(createOrder); } + } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java index 77a34382..f62745eb 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java @@ -1,12 +1,10 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.chart.dto.TradeEventDto; import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; -import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; import jakarta.annotation.PreDestroy; import lombok.Getter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; @@ -21,28 +19,21 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -@Service @Order(4) +@Slf4j +@RequiredArgsConstructor +@Service public class TradeBatchProcessor implements ApplicationRunner { - Logger logger = LoggerFactory.getLogger(TradeBatchProcessor.class); - private final WaitingOrdersManager waitingOrdersManager; - private final TradeService tradeService; + private final TradeFlowService tradeFlowService; private final List executors = new ArrayList<>(); @Getter private final Map tradeQueueManagers = new HashMap<>(); - private final UpdateOrderBookUsecase updateOrderBookUsecase; @Value("${order.tickers}") String[] tickers; - public TradeBatchProcessor(WaitingOrdersManager waitingOrdersManager, TradeService tradeService, UpdateOrderBookUsecase updateOrderBookUsecase) { - this.waitingOrdersManager = waitingOrdersManager; - this.tradeService = tradeService; - this.updateOrderBookUsecase = updateOrderBookUsecase; - } - @Override public void run(ApplicationArguments args) { processTrades(); @@ -51,8 +42,7 @@ public void run(ApplicationArguments args) { private void processTrades() { for (String ticker : tickers) { TradeQueueManager tradeQueueManager = new TradeQueueManager(waitingOrdersManager.getWaitingOrders(ticker), - updateOrderBookUsecase, - tradeService); + tradeFlowService); tradeQueueManagers.put(ticker, tradeQueueManager); // 정상 종료를 위해 저장 ExecutorService tradeExecutor = Executors.newSingleThreadExecutor(r -> { @@ -66,7 +56,7 @@ private void processTrades() { try { tradeQueueManager.run(); } catch (Exception e) { - logger.error("Error in trade loop for {}: {}",ticker, e.getMessage()); + log.error("Error in trade loop for {}: {}",ticker, e.getMessage()); } }); } @@ -87,7 +77,7 @@ public void shutdown() { executor.shutdownNow(); // 추가로 1초 더 대기 if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { - System.err.println("스레드풀이 완전히 종료되지 않았습니다"); + log.error("스레드풀이 완전히 종료되지 않았습니다"); } } } catch (InterruptedException e) { @@ -97,9 +87,4 @@ public void shutdown() { } } - @Deprecated - public TradeEventDto retrieveTradeEventDto(String ticker) { - return null; - } - } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java index 11f90ebb..31fd0df0 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java @@ -7,7 +7,21 @@ @Getter @Builder public class TradeExecutedEvent { + Trade trade; + Long buyOrderId; + Long sellOrderId; + + private TradeExecutedEvent(Trade trade, Long buyOrderId, Long sellOrderId) { + this.trade = trade; + this.buyOrderId = buyOrderId; + this.sellOrderId = sellOrderId; + } + + public static TradeExecutedEvent of(Trade trade, Long buyOrderId, Long sellOrderId) { + return new TradeExecutedEvent(trade, buyOrderId, sellOrderId); + } + } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java new file mode 100644 index 00000000..b59e7c9f --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -0,0 +1,199 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.common.error.BusinessException; +import com.cleanengine.coin.common.response.ErrorStatus; +import com.cleanengine.coin.order.application.OrderService; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.OrderStatus; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.WalletService; +import jakarta.transaction.Transactional; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +import static com.cleanengine.coin.common.CommonValues.approxEquals; + +@Slf4j +@RequiredArgsConstructor +@Component +public class TradeExecutor { + + private final WalletService walletService; + private final AccountService accountService; + @Getter + private final TradeExecutedEventPublisher tradeExecutedEventPublisher; + private final TradeService tradeService; + + @Transactional + public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { + BuyOrder buyOrder = tradePair.getBuyOrder(); + SellOrder sellOrder = tradePair.getSellOrder(); + + double tradedPrice; + double tradedSize; + double totalTradedPrice; + + // 체결 단가, 수량 확정 + TradeUnitPriceAndSize tradeUnitPriceAndSize = getTradeUnitPriceAndSize(buyOrder, sellOrder); + tradedSize = tradeUnitPriceAndSize.tradedSize(); + tradedPrice = tradeUnitPriceAndSize.tradedPrice(); + if (approxEquals(tradedSize, 0.0)) { + log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); + return; + } + this.writeTradingLog(buyOrder, sellOrder); + + totalTradedPrice = tradedPrice * tradedSize; + // 주문 잔여수량, 잔여금액 감소 + if (isMarketOrder(buyOrder)) + buyOrder.decreaseRemainingDeposit(totalTradedPrice); + else + buyOrder.decreaseRemainingSize(tradedSize); + sellOrder.decreaseRemainingSize(tradedSize); + + // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) + this.removeCompletedBuyOrder(waitingOrders, buyOrder); + this.removeCompletedSellOrder(waitingOrders, sellOrder); + + tradeService.updateOrder(buyOrder); + tradeService.updateOrder(sellOrder); + + // 예수금 처리 + // - 매수 잔여금액 반환 + if (!isMarketOrder(buyOrder) && buyOrder.getPrice() > tradedPrice) { // 매도 호가보다 높은 가격에 매수를 시도한 경우, 차액 반환 + double totalRefundAmount = (buyOrder.getPrice() - tradedPrice) * tradedSize; + this.increaseAccountCash(buyOrder, totalRefundAmount); + } + + // - 매도 예수금 처리 + this.increaseAccountCash(sellOrder, totalTradedPrice); + + // 지갑 누적계산 + this.updateWalletAfterTrade(buyOrder, ticker, tradedSize, totalTradedPrice); + this.updateWalletAfterTrade(sellOrder, ticker, tradedSize, totalTradedPrice); + + // 체결내역 저장 + Trade trade = this.insertNewTrade(ticker, buyOrder, sellOrder, tradedSize, tradedPrice); + + TradeExecutedEvent tradeExecutedEvent = TradeExecutedEvent.of(trade, buyOrder.getId(), sellOrder.getId()); + tradeExecutedEventPublisher.publish(tradeExecutedEvent); + } + + public void increaseAccountCash(Order order, Double amount) { + Account account = accountService.findAccountByUserId(order.getUserId()).orElseThrow(); + accountService.save(account.increaseCash(amount)); + } + + public void updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) { + if (order instanceof BuyOrder) { + Wallet buyerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker); + double updatedBuySize = buyerWallet.getSize() + tradedSize; + double currentBuyPrice = buyerWallet.getBuyPrice() == null ? 0.0 : buyerWallet.getBuyPrice(); + double updatedBuyPrice = ((currentBuyPrice * buyerWallet.getSize()) + totalTradedPrice) / updatedBuySize; + buyerWallet.setSize(updatedBuySize); + buyerWallet.setBuyPrice(updatedBuyPrice); + // TODO : ROI 계산 + walletService.save(buyerWallet); + } else if (order instanceof SellOrder) { + // 매도 시에는 평단가 변동 없음 + Wallet sellerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker); + walletService.save(sellerWallet); + } else { + throw new BusinessException("Unsupported order type: " + order.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR); + } + } + + public Trade insertNewTrade(String ticker, BuyOrder buyOrder, SellOrder sellOrder, double tradeSize, Double tradePrice) { + Trade newTrade = Trade.of(ticker, LocalDateTime.now(), buyOrder.getUserId(), sellOrder.getUserId(), tradePrice, tradeSize); + + return tradeService.save(newTrade); + } + + private static TradeUnitPriceAndSize getTradeUnitPriceAndSize(BuyOrder buyOrder, SellOrder sellOrder) { + double tradedPrice; + double tradedSize; + if (isMarketOrder(buyOrder)) { // 시장가매수-지정가매도 + tradedPrice = sellOrder.getPrice(); + if (buyOrder.getRemainingDeposit() >= tradedPrice * sellOrder.getRemainingSize()) { // 매수 잔여예수금이 매도 잔여량보다 크거나 같은 경우 (매수 부분체결 or 완전체결, 매도 완전체결) + tradedSize = sellOrder.getRemainingSize(); + } else { + tradedSize = buyOrder.getRemainingDeposit() / tradedPrice; + } + } else if (isMarketOrder(sellOrder)) { // 시장가매도-지정가매수 + tradedPrice = buyOrder.getPrice(); + tradedSize = Math.min(sellOrder.getRemainingSize(), buyOrder.getRemainingSize()); + } else { // 지정가매수-지정가매도 + tradedPrice = getTradedUnitPrice(buyOrder, sellOrder); + tradedSize = Math.min(buyOrder.getRemainingSize(), sellOrder.getRemainingSize()); + } + return new TradeUnitPriceAndSize(tradedSize, tradedPrice); + } + + private record TradeUnitPriceAndSize(double tradedSize, double tradedPrice) { + } + + private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) { + // 주문 시간을 비교하여 먼저 들어온 주문의 가격으로 거래 + if (buyOrder.getCreatedAt().isBefore(sellOrder.getCreatedAt())) { + return buyOrder.getPrice(); + } else { + return sellOrder.getPrice(); + } + } + + private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { + log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", + Thread.currentThread().threadId(), + buyOrder.getTicker(), + buyOrder.getId(), + buyOrder.getUserId(), + isMarketOrder(buyOrder) ? "시장가" : "지정가(" + buyOrder.getPrice() + "원)", + buyOrder.getRemainingSize() == null ? buyOrder.getRemainingDeposit() : buyOrder.getRemainingSize(), + sellOrder.getId(), + sellOrder.getUserId(), + isMarketOrder(sellOrder) ? "시장가" : "지정가(" + sellOrder.getPrice() + "원)", + sellOrder.getRemainingSize()); + } + + private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { + boolean isOrderCompleted = (isMarketOrder(order) && approxEquals(order.getRemainingDeposit(), 0.0)) || + (isLimitOrder(order) && approxEquals(order.getRemainingSize(), 0.0)); + + if (isOrderCompleted) { + waitingOrders.removeOrder(order); + this.updateCompletedOrderStatus(order); + } + } + + private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { + boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0); + + if (isOrderCompleted) { + waitingOrders.removeOrder(order); + this.updateCompletedOrderStatus(order); + } + } + + public void updateCompletedOrderStatus(Order order) { + order.setState(OrderStatus.DONE); + } + + private static boolean isMarketOrder(Order order) { + return order.getIsMarketOrder(); + } + + private static boolean isLimitOrder(Order order) { + return !order.getIsMarketOrder(); + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java new file mode 100644 index 00000000..30f2415e --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java @@ -0,0 +1,29 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Component +public class TradeFlowService { + + private final TradeMatcher tradeMatcher; + private final TradeExecutor tradeExecutor; + + public void execMatchAndTrade(String ticker) { + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + // TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지 + Optional> tradePair = tradeMatcher.matchOrders(waitingOrders); + + tradePair.ifPresent(orderOrderTradePair -> tradeExecutor.executeTrade(waitingOrders, orderOrderTradePair, ticker)); + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java b/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java new file mode 100644 index 00000000..11bfa6ba --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeMatcher.java @@ -0,0 +1,82 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.OrderType; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Optional; + +@Slf4j +@Component +public class TradeMatcher { + + private final WaitingOrdersManager waitingOrdersManager; + + // 1초마다 로깅 + private long lastLogTime = 0; + private static final long LOG_INTERVAL = 1000; + + public TradeMatcher(WaitingOrdersManager waitingOrdersManager) { + this.waitingOrdersManager = waitingOrdersManager; + } + + public WaitingOrders getWaitingOrders(String ticker) { + return waitingOrdersManager.getWaitingOrders(ticker); + } + + public Optional> matchOrders(WaitingOrders waitingOrders) { // 반환값 : 체결여부 + this.writeQueueLog(waitingOrders); + + TradePair targetTradePair; + + // 시장가 주문 우선처리 + SellOrder marketSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).peek(); + SellOrder limitSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).peek(); + BuyOrder marketBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek(); + BuyOrder limitBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).peek(); + + if (marketSellOrder != null && limitBuyOrder != null) { + // 1. 시장가 매도 주문, 지정가 매수 주문 + targetTradePair = new TradePair<>(marketSellOrder, limitBuyOrder); + } else if (marketBuyOrder != null && limitSellOrder != null) { + // 2. 시장가 매수 주문, 지정가 매도 주문 + targetTradePair = new TradePair<>(marketBuyOrder, limitSellOrder); + } else { + // 3. 지정가 주문 + targetTradePair = this.matchBetweenLimitOrders(limitBuyOrder, limitSellOrder); + } + return Optional.ofNullable(targetTradePair); + } + + private TradePair matchBetweenLimitOrders(BuyOrder limitBuyOrder, SellOrder limitSellOrder) { + if (limitSellOrder == null || limitBuyOrder == null) + return null; + + if (this.canMatch(limitBuyOrder, limitSellOrder)) + return new TradePair<>(limitBuyOrder, limitSellOrder); + else + return null; + } + + private boolean canMatch(BuyOrder buyOrder, SellOrder sellOrder) { + return buyOrder.getPrice() >= sellOrder.getPrice(); + } + + private void writeQueueLog(WaitingOrders waitingOrders) { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastLogTime > LOG_INTERVAL) { + log.debug("주문 큐 - 시장가매도[{}], 지정가매도[{}], 시장가매수[{}], 지정가매수[{}]", + waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), + waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size()); + lastLogTime = currentTime; + } + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java index a65af58d..83a79d67 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java @@ -1,7 +1,6 @@ package com.cleanengine.coin.trade.application; import com.cleanengine.coin.order.domain.spi.WaitingOrders; -import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -10,10 +9,10 @@ public class TradeQueueManager { private volatile boolean running = true; // 무한루프 종료 플래그 private final String ticker; - private final TradeService tradeService; + private final TradeFlowService tradeFlowService; - public TradeQueueManager(WaitingOrders waitingOrders, UpdateOrderBookUsecase updateOrderBookUsecase, TradeService tradeService) { - this.tradeService = tradeService; + public TradeQueueManager(WaitingOrders waitingOrders, TradeFlowService tradeFlowService) { + this.tradeFlowService = tradeFlowService; this.ticker = waitingOrders.getTicker(); } @@ -21,8 +20,9 @@ public void run() { // TODO : 주문 시 이벤트 기반으로 동작하도록 개선 while (running) { try { - tradeService.execMatchAndTrade(ticker); + tradeFlowService.execMatchAndTrade(ticker); } catch (Exception e) { + // TODO : 무한루프 방지 회복처리 log.error("Error processing trades for {}: {}", this.ticker, e.getMessage()); } } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeService.java index 5ffe6609..2dfc9930 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeService.java @@ -4,57 +4,29 @@ import com.cleanengine.coin.common.response.ErrorStatus; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; -import com.cleanengine.coin.order.domain.*; -import com.cleanengine.coin.order.domain.spi.WaitingOrders; -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; -import com.cleanengine.coin.user.info.infra.AccountRepository; -import com.cleanengine.coin.user.info.infra.WalletRepository; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - -import static com.cleanengine.coin.common.CommonValues.approxEquals; - -@Slf4j +@RequiredArgsConstructor @Service -@Transactional public class TradeService { private final TradeRepository tradeRepository; private final BuyOrderRepository buyOrderRepository; private final SellOrderRepository sellOrderRepository; - private final AccountRepository accountRepository; - private final WalletRepository walletRepository; - @Getter - private final WaitingOrdersManager waitingOrdersManager; - private final TradeExecutedEventPublisher tradeExecutedEventPublisher; - - // 1초마다 큐 로깅 - private long lastLogTime = 0; - private static final long LOG_INTERVAL = 1000; - public TradeService(TradeRepository tradeRepository, BuyOrderRepository buyOrderRepository, SellOrderRepository sellOrderRepository, AccountRepository accountRepository, WalletRepository walletRepository, WaitingOrdersManager waitingOrdersManager, TradeExecutedEventPublisher tradeExecutedEventPublisher) { - this.tradeRepository = tradeRepository; - this.buyOrderRepository = buyOrderRepository; - this.sellOrderRepository = sellOrderRepository; - this.accountRepository = accountRepository; - this.walletRepository = walletRepository; - this.waitingOrdersManager = waitingOrdersManager; - this.tradeExecutedEventPublisher = tradeExecutedEventPublisher; - } - - public Trade saveTrade(Trade trade) { + public Trade save(Trade trade) { return tradeRepository.save(trade); } - public Order saveOrder(Order order) { + @Transactional + public Order updateOrder(Order order){ if (order instanceof BuyOrder) { return buyOrderRepository.save((BuyOrder) order); } else if (order instanceof SellOrder) { @@ -64,264 +36,4 @@ public Order saveOrder(Order order) { } } - public void increaseAccountCash(Order order, Double amount) { - Account account = this.findAccountByUserId(order.getUserId()).orElseThrow(); - accountRepository.save(account.increaseCash(amount)); - } - - public Optional findAccountByUserId(Integer userId) { - return accountRepository.findByUserId(userId); - } - - public void updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) { - if (order instanceof BuyOrder) { - Wallet buyerWallet = this.findWalletByUserIdAndTicker(order.getUserId(), ticker); - double updatedBuySize = buyerWallet.getSize() + tradedSize; - double currentBuyPrice = buyerWallet.getBuyPrice() == null ? 0.0 : buyerWallet.getBuyPrice(); - double updatedBuyPrice = ((currentBuyPrice * buyerWallet.getSize()) + totalTradedPrice) / updatedBuySize; - buyerWallet.setSize(updatedBuySize); - buyerWallet.setBuyPrice(updatedBuyPrice); - // TODO : ROI 계산 - this.saveWallet(buyerWallet); - } else if (order instanceof SellOrder) { - // 매도 시에는 평단가 변동 없음 - Wallet sellerWallet = this.findWalletByUserIdAndTicker(order.getUserId(), ticker); - this.saveWallet(sellerWallet); - } else { - throw new BusinessException("Unsupported order type: " + order.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR); - } - } - - public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { - Account account = findAccountByUserId(userId).orElseThrow(); - return walletRepository.findByAccountIdAndTicker(account.getId(), ticker) - .orElseGet(() -> createNewWallet(account.getId(), ticker)); - } - - public Wallet createNewWallet(Integer accountId, String ticker) { - Wallet newWallet = new Wallet(); - newWallet.setAccountId(accountId); - newWallet.setTicker(ticker); - newWallet.setSize(0.0); - newWallet.setBuyPrice(0.0); - newWallet.setRoi(0.0); - return newWallet; - } - - public Wallet saveWallet(Wallet Wallet) { - return walletRepository.save(Wallet); - } - - public Trade insertNewTrade(String ticker, BuyOrder buyOrder, SellOrder sellOrder, double tradeSize, Double tradePrice) { - Trade newTrade = new Trade(); - newTrade.setTicker(ticker); - newTrade.setBuyUserId(buyOrder.getUserId()); - newTrade.setSellUserId(sellOrder.getUserId()); - newTrade.setPrice(tradePrice); - newTrade.setSize(tradeSize); - - return this.saveTrade(newTrade); - } - - public void updateCompletedOrderStatus(Order order) { - order.setState(OrderStatus.DONE); - } - - private void writeQueueLog(WaitingOrders waitingOrders) { - long currentTime = System.currentTimeMillis(); - if (currentTime - lastLogTime > LOG_INTERVAL) { - log.debug("주문 큐 - 시장가매도[{}], 지정가매도[{}], 시장가매수[{}], 지정가매수[{}]", - waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), - waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), - waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), - waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size()); - lastLogTime = currentTime; - } - } - - public void execMatchAndTrade(String ticker) { - WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); - // TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지 - this.matchOrders(waitingOrders) - .ifPresent(tradePair -> executeTrade(waitingOrders, tradePair, ticker)); - } - - private Optional> matchOrders(WaitingOrders waitingOrders) { // 반환값 : 체결여부 - this.writeQueueLog(waitingOrders); - - TradePair targetTradePair; - - // 시장가 주문 우선처리 - SellOrder marketSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).peek(); - SellOrder limitSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).peek(); - BuyOrder marketBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek(); - BuyOrder limitBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).peek(); - - if (marketSellOrder != null && limitBuyOrder != null) { - // 1. 시장가 매도 주문, 지정가 매수 주문 - targetTradePair = new TradePair<>(marketSellOrder, limitBuyOrder); - } else if (marketBuyOrder != null && limitSellOrder != null) { - // 2. 시장가 매수 주문, 지정가 매도 주문 - targetTradePair = new TradePair<>(marketBuyOrder, limitSellOrder); - } else { - // 3. 지정가 주문 - targetTradePair = this.matchBetweenLimitOrders(limitBuyOrder, limitSellOrder); - } - return Optional.ofNullable(targetTradePair); - } - - private TradePair matchBetweenLimitOrders(BuyOrder limitBuyOrder, SellOrder limitSellOrder) { - if (limitSellOrder == null || limitBuyOrder == null) - return null; - - if (this.canMatch(limitBuyOrder, limitSellOrder)) - return new TradePair<>(limitBuyOrder, limitSellOrder); - else - return null; - } - - private boolean canMatch(BuyOrder buyOrder, SellOrder sellOrder) { - return buyOrder.getPrice() >= sellOrder.getPrice(); - } - - private record TradeUnitPriceAndSize(double tradedSize, double tradedPrice) { - } - - public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { - BuyOrder buyOrder = tradePair.getBuyOrder(); - SellOrder sellOrder = tradePair.getSellOrder(); - - double tradedPrice; - double tradedSize; - double totalTradedPrice; - - // 체결 단가, 수량 확정 - TradeUnitPriceAndSize tradeUnitPriceAndSize = getTradeUnitPriceAndSize(buyOrder, sellOrder); - tradedSize = tradeUnitPriceAndSize.tradedSize(); - tradedPrice = tradeUnitPriceAndSize.tradedPrice(); - if (approxEquals(tradedSize, 0.0)) { - log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); - return; - } - this.writeTradingLog(buyOrder, sellOrder); - - totalTradedPrice = tradedPrice * tradedSize; - // 주문 잔여수량, 잔여금액 감소 - if (isMarketOrder(buyOrder)) - buyOrder.decreaseRemainingDeposit(totalTradedPrice); - else - buyOrder.decreaseRemainingSize(tradedSize); - sellOrder.decreaseRemainingSize(tradedSize); - - // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - this.removeCompletedBuyOrder(waitingOrders, buyOrder); - this.removeCompletedSellOrder(waitingOrders, sellOrder); - - // DB 테이블 저장에 걸리는 시간 측정용 - long beforeTime = System.currentTimeMillis(); - this.saveOrder(buyOrder); - this.saveOrder(sellOrder); - long afterTime = System.currentTimeMillis(); - log.debug("주문 테이블에 update하는 데 걸린 시간 : {}ms", afterTime - beforeTime); - - // 예수금 처리 - // - 매수 잔여금액 반환 - if (isMarketOrder(buyOrder)) { - ; // TODO : 시장가 거래 시 1원 단위 등 작은 금액이 남을 수도 있는데 처리방안 - } else { - if (buyOrder.getPrice() > tradedPrice) { // 매도 호가보다 높은 가격에 매수를 시도한 경우, 차액 반환 - double totalRefundAmount = (buyOrder.getPrice() - tradedPrice) * tradedSize; - this.increaseAccountCash(buyOrder, totalRefundAmount); - } - } - - // - 매도 예수금 처리 - this.increaseAccountCash(sellOrder, totalTradedPrice); - - // 지갑 누적계산 - this.updateWalletAfterTrade(buyOrder, ticker, tradedSize, totalTradedPrice); - this.updateWalletAfterTrade(sellOrder, ticker, tradedSize, totalTradedPrice); - - // 체결내역 저장 - Trade trade = this.insertNewTrade(ticker, buyOrder, sellOrder, tradedSize, tradedPrice); - - TradeExecutedEvent tradeExecutedEvent = - TradeExecutedEvent.builder() - .trade(trade) - .buyOrderId(buyOrder.getId()) - .sellOrderId(sellOrder.getId()) - .build(); - tradeExecutedEventPublisher.publish(tradeExecutedEvent); - } - - private static TradeUnitPriceAndSize getTradeUnitPriceAndSize(BuyOrder buyOrder, SellOrder sellOrder) { - double tradedPrice; - double tradedSize; - if (isMarketOrder(buyOrder)) { // 시장가매수-지정가매도 - tradedPrice = sellOrder.getPrice(); - if (buyOrder.getRemainingDeposit() >= tradedPrice * sellOrder.getRemainingSize()) { // 매수 잔여예수금이 매도 잔여량보다 크거나 같은 경우 (매수 부분체결 or 완전체결, 매도 완전체결) - tradedSize = sellOrder.getRemainingSize(); - } else { - tradedSize = buyOrder.getRemainingDeposit() / tradedPrice; - } - } else if (isMarketOrder(sellOrder)) { // 시장가매도-지정가매수 - tradedPrice = buyOrder.getPrice(); - tradedSize = Math.min(sellOrder.getRemainingSize(), buyOrder.getRemainingSize()); - } else { // 지정가매수-지정가매도 - tradedPrice = getTradedUnitPrice(buyOrder, sellOrder); - tradedSize = Math.min(buyOrder.getRemainingSize(), sellOrder.getRemainingSize()); - } - return new TradeUnitPriceAndSize(tradedSize, tradedPrice); - } - - private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) { - // 주문 시간을 비교하여 먼저 들어온 주문의 가격으로 거래 - if (buyOrder.getCreatedAt().isBefore(sellOrder.getCreatedAt())) { - return buyOrder.getPrice(); - } else { - return sellOrder.getPrice(); - } - } - - private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { - log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", - Thread.currentThread().threadId(), - buyOrder.getTicker(), - buyOrder.getId(), - buyOrder.getUserId(), - isMarketOrder(buyOrder) ? "시장가" : "지정가(" + buyOrder.getPrice() + "원)", - buyOrder.getRemainingSize() == null ? buyOrder.getRemainingDeposit() : buyOrder.getRemainingSize(), - sellOrder.getId(), - sellOrder.getUserId(), - isMarketOrder(sellOrder) ? "시장가" : "지정가(" + sellOrder.getPrice() + "원)", - sellOrder.getRemainingSize()); - } - - private static Boolean isMarketOrder(Order order) { - return order.getIsMarketOrder(); - } - - private static Boolean isLimitOrder(Order order) { - return !order.getIsMarketOrder(); - } - - private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { - boolean isOrderCompleted = (isMarketOrder(order) && approxEquals(order.getRemainingDeposit(), 0.0)) || - (isLimitOrder(order) && approxEquals(order.getRemainingSize(), 0.0)); - - if (isOrderCompleted) { - waitingOrders.removeOrder(order); - this.updateCompletedOrderStatus(order); - } - } - - private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { - boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0); - - if (isOrderCompleted) { - waitingOrders.removeOrder(order); - this.updateCompletedOrderStatus(order); - } - } - } diff --git a/src/main/java/com/cleanengine/coin/trade/entity/Trade.java b/src/main/java/com/cleanengine/coin/trade/entity/Trade.java index cc2dde6e..9f6e11de 100644 --- a/src/main/java/com/cleanengine/coin/trade/entity/Trade.java +++ b/src/main/java/com/cleanengine/coin/trade/entity/Trade.java @@ -37,4 +37,18 @@ public class Trade { @Column(name = "size", nullable = false) private Double size; + + public Trade(String ticker, LocalDateTime tradeTime, Integer buyUserId, Integer sellUserId, Double price, Double size) { + this.ticker = ticker; + this.tradeTime = tradeTime; + this.buyUserId = buyUserId; + this.sellUserId = sellUserId; + this.price = price; + this.size = size; + } + + public static Trade of(String ticker, LocalDateTime tradeTime, Integer buyUserId, Integer sellUserId, Double price, Double size) { + return new Trade(ticker, tradeTime, buyUserId, sellUserId, price, size); + } + } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java b/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java index 6e1535ff..cbc78928 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java @@ -4,6 +4,8 @@ import com.cleanengine.coin.user.info.infra.AccountRepository; import org.springframework.stereotype.Service; +import java.util.Optional; + @Service public class AccountService { @@ -17,6 +19,14 @@ public Account retrieveAccountByUserId(Integer userId) { return accountRepository.findByUserId(userId).orElse(null); } + public Optional findAccountByUserId(Integer userId) { + return accountRepository.findByUserId(userId); + } + + public Account save(Account account) { + return accountRepository.save(account); + } + public Account createNewAccount(Integer userId, double cash) { Account account = Account.of(userId, cash); return accountRepository.save(account); diff --git a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java index 7dfa22b5..dc33b957 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/WalletService.java @@ -1,5 +1,6 @@ package com.cleanengine.coin.user.info.application; +import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Service; @@ -10,13 +11,35 @@ public class WalletService { private final WalletRepository walletRepository; + private final AccountService accountService; - public WalletService(WalletRepository walletRepository) { + public WalletService(WalletRepository walletRepository, AccountService accountService) { this.walletRepository = walletRepository; + this.accountService = accountService; } public List retrieveWalletsByAccountId(Integer accountId) { return walletRepository.findByAccountId(accountId); } + public Wallet save(Wallet wallet) { + return walletRepository.save(wallet); + } + + public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { + Account account = accountService.findAccountByUserId(userId).orElseThrow(); + return walletRepository.findByAccountIdAndTicker(account.getId(), ticker) + .orElseGet(() -> createNewWallet(account.getId(), ticker)); + } + + public Wallet createNewWallet(Integer accountId, String ticker) { + Wallet newWallet = new Wallet(); + newWallet.setAccountId(accountId); + newWallet.setTicker(ticker); + newWallet.setSize(0.0); + newWallet.setBuyPrice(0.0); + newWallet.setRoi(0.0); + return newWallet; + } + } diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java new file mode 100644 index 00000000..dded4ca8 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java @@ -0,0 +1,89 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.common.domain.port.PriorityQueueStore; +import com.cleanengine.coin.order.application.OrderService; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.order.application.dto.OrderInfo; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.OrderType; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.LocalDateTime; + +@SpringBootTest +class TradeExecuteLoadTest { + + @Autowired + TradeBatchProcessor tradeBatchProcessor; + + @Autowired + ApplicationArguments applicationArguments; + + @Autowired + OrderService orderService; + + @Autowired + WaitingOrdersManager waitingOrdersManager; + + @Autowired + TradeRepository tradeRepository; + + private final String ticker = "BTC"; + + @BeforeEach + void setUp() { + tradeBatchProcessor.shutdown(); + waitingOrdersManager.getWaitingOrders(ticker); + // TODO : 티커마다 큐, DB 초기화 + } + + @DisplayName("1000건의 매수 매도 주문을 요청 후 처리 성능을 조회한다.") + @Test + void basicLoadTestWith1000OrdersEachSide() { + // given 1000건의 매수, 매도 주문 요청 + for (int i = 0; i < 1000; i++) { + OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder(ticker, 1, + false, false, 30.0, 40.0, LocalDateTime.now(),false); + orderService.createOrder(sellOrderCommand); + + OrderCommand.CreateOrder buyOrderCommand = new OrderCommand.CreateOrder(ticker, 2, + true, false, 30.0, 40.0, LocalDateTime.now(),false); + orderService.createOrder(buyOrderCommand); + } + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + PriorityQueueStore buyOrderPriorityQueueStore = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT); + PriorityQueueStore sellOrderPriorityQueueStore = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT); + System.out.println("buyOrderPriorityQueueStore.size() : " + buyOrderPriorityQueueStore.size()); + System.out.println("sellOrderPriorityQueueStore.size() : " + sellOrderPriorityQueueStore.size()); + long testStart = System.currentTimeMillis(); + + + // when + tradeBatchProcessor.run(applicationArguments); + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + + // then + tradeBatchProcessor.shutdown(); + long testEnd = System.currentTimeMillis(); + + System.out.println("trade table size : " + tradeRepository.findAll().size()); + + System.out.println("test time : " + (testEnd - testStart) + " ms"); + System.out.println("buyOrderPriorityQueueStore.size() : " + buyOrderPriorityQueueStore.size()); + System.out.println("sellOrderPriorityQueueStore.size() : " + sellOrderPriorityQueueStore.size()); + } + +} diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisherTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisherTest.java new file mode 100644 index 00000000..78ae4d82 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisherTest.java @@ -0,0 +1,34 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.trade.entity.Trade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.context.ApplicationEventPublisher; + +import java.time.LocalDateTime; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +class TradeExecutedEventPublisherTest { + + @DisplayName("체결 내역 이벤트를 발행한다.") + @Test + void simplePublish() { + // given + ApplicationEventPublisher mockPublisher = Mockito.mock(ApplicationEventPublisher.class); + TradeExecutedEventPublisher publisher = new TradeExecutedEventPublisher(mockPublisher); + Trade newTrade = Trade.of("BTC", LocalDateTime.now(), 2, 1, 1000.0, 10.0); + TradeExecutedEvent tradeExecutedEvent = TradeExecutedEvent.of(newTrade, 1L, 2L); + + // when + publisher.publish(tradeExecutedEvent); + + // then + verify(mockPublisher, times(1)) + .publishEvent(tradeExecutedEvent); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java new file mode 100644 index 00000000..04806fc7 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceTest.java @@ -0,0 +1,435 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.OrderType; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.trade.repository.TradeRepository; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +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.ActiveProfiles; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.*; + +@ActiveProfiles({"dev", "it", "h2-mem"}) +@SpringBootTest +@DisplayName("체결 처리 통합테스트") +class TradeFlowServiceTest { + + private static TradeBatchProcessor staticTradeBatchProcessor; + + private static final double MINIMUM_ORDER_SIZE = 0.00000001; + + @Autowired + BuyOrderRepository buyOrderRepository; + @Autowired + SellOrderRepository sellOrderRepository; + @Autowired + TradeRepository tradeRepository; + @Autowired + TradeBatchProcessor tradeBatchProcessor; + @Autowired + private WaitingOrdersManager waitingOrdersManager; + @Autowired + TradeMatcher tradeMatcher; + + private final String ticker = "BTC"; + + @BeforeEach + void setUp() { + if (staticTradeBatchProcessor == null) { + staticTradeBatchProcessor = tradeBatchProcessor; + } + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + waitingOrders.clearAllQueues(); + tradeRepository.deleteAll(); + buyOrderRepository.deleteAll(); + sellOrderRepository.deleteAll(); + } + + @AfterAll + static void cleanup() { + staticTradeBatchProcessor.shutdown(); + } + + // TODO : 모든 케이스에서 각 객체의 값까지 정합성이 맞는지 테스트 필요 + + @DisplayName("지정가매수-지정가매도 완전체결") + @Test + void testLimitToLimitCompleteTrade() { + double orderSize = 10.0; + double price = 130_000_000.0; + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, orderSize, price, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, orderSize, price, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List tradeOfBuy = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List tradeOfSell = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(tradeOfBuy, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, tradeOfBuy.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(tradeOfSell, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, tradeOfSell.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals( + tradeOfBuy.getFirst().getId(), + tradeOfSell.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(tradeOfBuy.getFirst().getSize() - orderSize < MINIMUM_ORDER_SIZE, "체결수량과 주문수량은 같아야 합니다."); + assertTrue(tradeOfBuy.getFirst().getPrice() - price < MINIMUM_ORDER_SIZE, "체결단가와 주문단가는 같아야 합니다."); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + + assertTrue(buyOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매수주문의 잔여수량은 없어야 합니다."); + assertTrue(sellOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매도주문의 잔여수량은 없어야 합니다."); + } + + @DisplayName("지정가매수-지정가매도 매도부분체결") + @Test + void testLimitToLimitPartialTrade1() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 5.0, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); + } + + @DisplayName("지정가매수-지정가매도 매수부분체결") + @Test + void testLimitToLimitPartialTrade2() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 5.0, 130_000_000.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + } + + @DisplayName("시장가매수-지정가매도 완전체결") + @Test + void testMarketToLimitCompleteTrade1() { + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 1_300_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + } + + @DisplayName("지정가매수-시장가매도 완전체결") + @Test + void testMarketToLimitCompleteTrade2() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 10.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); + } + + @DisplayName("시장가매수-지정가매도 매도부분체결") + @Test + void testMarketToLimitPartialTrade1() { + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); + } + + @DisplayName("시장가매수-지정가매도 매수부분체결") + @Test + void testMarketToLimitPartialTrade2() { + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 1_300_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 1.0, 130_000_000.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), "시장가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + + assert waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek() != null; + assertEquals(1_300_000_000.0 - 130_000_000.0, + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek().getRemainingDeposit(), + "잔여 예수금이 맞지 않습니다."); + } + + @DisplayName("지정가매수-시장가매도 매수부분체결") + @Test + void testMarketToLimitPartialTrade3() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 1.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); + } + + @DisplayName("지정가매수-시장가매도 매도부분체결") + @Test + void testMarketToLimitPartialTrade4() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 1.0, 130_000_000.0, LocalDateTime.now(), false); + SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 10.0, LocalDateTime.now(), false); + + buyOrderRepository.save(buyOrder); + sellOrderRepository.save(sellOrder); + + WaitingOrders waitingOrders = tradeMatcher.getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); + + // 체결이 완료될 때까지 대기 (최대 3초) + await() + .atMost(3, TimeUnit.SECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> { + List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + return !trades.isEmpty(); + }); + + List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); + List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); + assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + + assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); + assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + + assertEquals(byBuyUserIdAndTicker.getFirst().getId(), + bySellUserIdAndTicker.getFirst().getId(), + "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" + ); + + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), "남은 시장가 매도 주문이 없어야 합니다."); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradePairTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradePairTest.java new file mode 100644 index 00000000..04eb4dca --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradePairTest.java @@ -0,0 +1,58 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TradePairTest { + + @DisplayName("매수, 매도 주문 1쌍을 체결쌍으로 지정한다.") + @Test + void newTradePair() { + // given + SellOrder sellOrder = SellOrder.createLimitSellOrder("BTC", 3, 1.0, 1000.0, LocalDateTime.now(), false); + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 4, 1.0, 1000.0, LocalDateTime.now(), false); + + // when + TradePair tradePair = TradePair.of(buyOrder, sellOrder); + + // then + assertThat(tradePair).isNotNull(); + assertThat(tradePair.getBuyOrder()).isEqualTo(buyOrder); + assertThat(tradePair.getSellOrder()).isEqualTo(sellOrder); + } + + @DisplayName("매도 주문 2개로 체결쌍을 지정하면 예외가 발생한다.") + @Test + void newTradePairWIthTwoSellOrders() { + // given + SellOrder sellOrder1 = SellOrder.createLimitSellOrder("BTC", 3, 1.0, 1000.0, LocalDateTime.now(), false); + SellOrder sellOrder2 = SellOrder.createLimitSellOrder("BTC", 5, 1.0, 1000.0, LocalDateTime.now(), false); + + // when, then + assertThatThrownBy(() -> TradePair.of(sellOrder1, sellOrder2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("매수 주문과 매도 주문이 각각 하나씩 매칭되어야 합니다."); + } + + @DisplayName("매수 주문 2개로 체결쌍을 지정하면 예외가 발생한다.") + @Test + void newTradePairWIthTwoBuyOrders() { + // given + BuyOrder buyOrder1 = BuyOrder.createLimitBuyOrder("BTC", 4, 1.0, 1000.0, LocalDateTime.now(), false); + BuyOrder buyOrder2 = BuyOrder.createLimitBuyOrder("BTC", 6, 1.0, 1000.0, LocalDateTime.now(), false); + + // when, then + assertThatThrownBy(() -> TradePair.of(buyOrder1, buyOrder2)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("매수 주문과 매도 주문이 각각 하나씩 매칭되어야 합니다."); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java index 169b961e..8791c0ca 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java @@ -1,495 +1,81 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.OrderType; -import com.cleanengine.coin.order.domain.SellOrder; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.cleanengine.coin.order.domain.spi.WaitingOrders; -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; -import com.cleanengine.coin.trade.entity.Trade; -import com.cleanengine.coin.trade.repository.TradeRepository; -import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.Level; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; -import static org.awaitility.Awaitility.await; -import static org.junit.jupiter.api.Assertions.*; +class TradeQueueManagerTest { -@SpringBootTest -@ActiveProfiles({"dev", "it", "h2-mem"}) -@DisplayName("체결 처리 테스트") -public class TradeQueueManagerTest { - - private static final Logger logger = LoggerFactory.getLogger(TradeQueueManagerTest.class); - - private static TradeBatchProcessor staticTradeBatchProcessor; - - private final double MINIMUM_ORDER_SIZE = 0.00000001; - - @Autowired - BuyOrderRepository buyOrderRepository; - @Autowired - SellOrderRepository sellOrderRepository; - @Autowired - TradeRepository tradeRepository; - @Autowired - TradeBatchProcessor tradeBatchProcessor; - @Autowired - private WaitingOrdersManager waitingOrdersManager; - @Autowired - TradeService tradeService; - - private final String ticker = "BTC"; - - private void addBuyOrdersToQueueManager(List orders){ - if (orders.isEmpty()) return; - String ticker = orders.getFirst().getTicker(); - WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); - for(com.cleanengine.coin.order.domain.Order order : orders){ - waitingOrders.addOrder(order); - } - } - - private void addSellOrdersToQueueManager(List orders){ - if (orders.isEmpty()) return; - String ticker = orders.getFirst().getTicker(); - WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); - for(Order order : orders){ - waitingOrders.addOrder(order); - } - } + private ListAppender listAppender; + private Logger tradeQueueManagerLogger; @BeforeEach void setUp() { - if (staticTradeBatchProcessor == null) { - staticTradeBatchProcessor = tradeBatchProcessor; - } - WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); - waitingOrders.clearAllQueues(); - tradeRepository.deleteAll(); - buyOrderRepository.deleteAll(); - sellOrderRepository.deleteAll(); + // TradeQueueManager 클래스의 로거를 가져옵니다. + tradeQueueManagerLogger = (Logger) LoggerFactory.getLogger(TradeQueueManager.class); + + // 로그 이벤트를 캡처하기 위한 ListAppender를 설정합니다. + listAppender = new ListAppender<>(); + // ListAppender가 올바르게 동작하기 위해 LoggerContext를 설정하는 것이 중요합니다. + listAppender.setContext((ch.qos.logback.classic.LoggerContext) LoggerFactory.getILoggerFactory()); + listAppender.start(); + + // 설정한 Appender를 로거에 추가합니다. + tradeQueueManagerLogger.addAppender(listAppender); + // ERROR 레벨의 로그만 캡처하도록 설정합니다 (테스트 대상이 ERROR 로그이므로). + tradeQueueManagerLogger.setLevel(Level.ERROR); } - @AfterAll - static void cleanup() { - staticTradeBatchProcessor.shutdown(); - // 모든 스레드가 정리될 때까지 잠시 대기 - try { - Thread.sleep(100); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + @AfterEach + void tearDown() { + // 테스트 후 Appender를 정리하여 다른 테스트에 영향을 주지 않도록 합니다. + if (tradeQueueManagerLogger != null && listAppender != null) { + tradeQueueManagerLogger.detachAppender(listAppender); + listAppender.stop(); } } - // TODO : 모든 케이스에서 각 객체의 값까지 정합성이 맞는지 테스트 필요 - - @DisplayName("지정가매수-지정가매도 완전체결") - @Test - public void testLimitToLimitCompleteTrade() { - double orderSize = 10.0; - double price = 130_000_000.0; - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, orderSize, price, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, orderSize, price, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List tradeOfBuy = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List tradeOfSell = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(tradeOfBuy, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, tradeOfBuy.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(tradeOfSell, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, tradeOfSell.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals( - tradeOfBuy.getFirst().getId(), - tradeOfSell.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertTrue(tradeOfBuy.getFirst().getSize() - orderSize < MINIMUM_ORDER_SIZE, "체결수량과 주문수량은 같아야 합니다."); - assertTrue(tradeOfBuy.getFirst().getPrice() - price < MINIMUM_ORDER_SIZE, "체결단가와 주문단가는 같아야 합니다."); - - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); - - assertTrue(buyOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매수주문의 잔여수량은 없어야 합니다."); - assertTrue(sellOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매도주문의 잔여수량은 없어야 합니다."); - } - - @DisplayName("지정가매수-지정가매도 매도부분체결") - @Test - public void testLimitToLimitPartialTrade1() { - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 5.0, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); - } - - @DisplayName("지정가매수-지정가매도 매수부분체결") - @Test - public void testLimitToLimitPartialTrade2() { - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 5.0, 130_000_000.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); - } - - @DisplayName("시장가매수-지정가매도 완전체결") + @DisplayName("체결 엔진 동작 중 예외 발생 시 catch 후 로깅되어야 한다.") @Test - public void testMarketToLimitCompleteTrade1() { - BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 1_300_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); + void catchExceptionWhenExecMatchAndTrade() { + // given + String ticker = "BTC"; + String errorMessage = "예외 발생"; + TradeFlowService mockTradeFlowService = mock(TradeFlowService.class); + WaitingOrders mockWaitingOrders = mock(WaitingOrders.class); - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); - } - - @DisplayName("지정가매수-시장가매도 완전체결") - @Test - public void testMarketToLimitCompleteTrade2() { - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 10.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); - } - - @DisplayName("시장가매수-지정가매도 매도부분체결") - @Test - public void testMarketToLimitPartialTrade1() { - BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); - assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); - } - - @DisplayName("시장가매수-지정가매도 매수부분체결") - @Test - public void testMarketToLimitPartialTrade2() { - BuyOrder buyOrder = BuyOrder.createMarketBuyOrder(ticker, 1, 1_300_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createLimitSellOrder(ticker, 2, 1.0, 130_000_000.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), "시장가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); - - assert waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek() != null; - assertEquals(1_300_000_000.0 - 130_000_000.0, - waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek().getRemainingDeposit(), - "잔여 예수금이 맞지 않습니다."); - logger.debug("잔여 예수금 : {}", waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek().getRemainingDeposit()); - } - - @DisplayName("지정가매수-시장가매도 매수부분체결") - @Test - public void testMarketToLimitPartialTrade3() { - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 1.0, LocalDateTime.now(), false); - - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); - - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); - - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); - - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); - - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); - - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); - - assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); - } - - @DisplayName("지정가매수-시장가매도 매도부분체결") - @Test - public void testMarketToLimitPartialTrade4() { - BuyOrder buyOrder = BuyOrder.createLimitBuyOrder(ticker, 1, 1.0, 130_000_000.0, LocalDateTime.now(), false); - SellOrder sellOrder = SellOrder.createMarketSellOrder(ticker, 2, 10.0, LocalDateTime.now(), false); + when(mockWaitingOrders.getTicker()).thenReturn(ticker); - buyOrderRepository.save(buyOrder); - sellOrderRepository.save(sellOrder); + TradeQueueManager tradeQueueManager = new TradeQueueManager(mockWaitingOrders, mockTradeFlowService); - Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); - waitingOrders.addOrder(buyOrder); - waitingOrders.addOrder(sellOrder); + doAnswer(invocation -> { + tradeQueueManager.stop(); + throw new RuntimeException(errorMessage); + }).when(mockTradeFlowService).execMatchAndTrade(ticker); - // 체결이 완료될 때까지 대기 (최대 3초) - await() - .atMost(3, TimeUnit.SECONDS) - .pollInterval(100, TimeUnit.MILLISECONDS) - .until(() -> { - List trades = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - return !trades.isEmpty(); - }); + // when, then + tradeQueueManager.run(); - List byBuyUserIdAndTicker = tradeRepository.findByBuyUserIdAndTicker(1, ticker); - List bySellUserIdAndTicker = tradeRepository.findBySellUserIdAndTicker(2, ticker); - assertNotNull(byBuyUserIdAndTicker, "매수인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, byBuyUserIdAndTicker.size(), "매수인의 거래 내역이 정확히 1개여야 합니다"); + // then + verify(mockTradeFlowService, times(1)).execMatchAndTrade(ticker); - assertNotNull(bySellUserIdAndTicker, "매도인의 거래 내역이 null이면 안됩니다"); - assertEquals(1, bySellUserIdAndTicker.size(), "매도인의 거래 내역이 정확히 1개여야 합니다"); + assertThat(listAppender.list).hasSize(1); + ILoggingEvent loggingEvent = listAppender.list.get(0); - assertEquals(byBuyUserIdAndTicker.getFirst().getId(), - bySellUserIdAndTicker.getFirst().getId(), - "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" - ); + assertThat(loggingEvent.getLevel()).isEqualTo(Level.ERROR); + assertThat(loggingEvent.getFormattedMessage()) + .isEqualTo("Error processing trades for " + ticker + ": " + errorMessage); - assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), "남은 시장가 매도 주문이 없어야 합니다."); } -// @DisplayName("여러 지정가매수-지정가매도 완전체결") -// @Test -// public void testMultiLimitToLimitCompleteTrade1() { -// List limitBuyOrdersWithDifferentCreatedTimesAsc = createLimitBuyOrdersWithDifferentCreatedTimesAsc(); -// List marketBuyOrdersWithDifferentPricesAsc = createMarketBuyOrdersWithDifferentPricesAsc(); -// addBuyOrdersToQueueManager(limitBuyOrdersWithDifferentCreatedTimesAsc); -// addBuyOrdersToQueueManager(marketBuyOrdersWithDifferentPricesAsc); -// -// List limitSellOrdersWithDifferentCreatedTimesAsc = createLimitSellOrdersWithDifferentCreatedTimesAsc(); -// List marketSellOrdersWithDifferentCreatedTimesAsc = createMarketSellOrdersWithDifferentCreatedTimesAsc(); -// addSellOrdersToQueueManager(limitSellOrdersWithDifferentCreatedTimesAsc); -// addSellOrdersToQueueManager(marketSellOrdersWithDifferentCreatedTimesAsc); -// -// OrderQueueManager orderQueueManager = orderQueueManagerPool.getOrderQueueManager("BTC"); -// -// -// -// System.out.println(orderQueueManager); -// ; -// } } \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeServiceTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeServiceTest.java new file mode 100644 index 00000000..2c28f78e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeServiceTest.java @@ -0,0 +1,45 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.common.error.BusinessException; +import com.cleanengine.coin.common.response.ErrorStatus; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.trade.repository.TradeRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ExtendWith( MockitoExtension.class) +class TradeServiceTest { + + @Mock + TradeRepository tradeRepository; + @Mock + BuyOrderRepository buyOrderRepository; + @Mock + SellOrderRepository sellOrderRepository; + + @InjectMocks + TradeService tradeService; + + @DisplayName("매도/매수 주문이 아닌 주문 타입을 변경하려고 하면 예외를 발생시킨다.") + @Test + void unsupportedOrder() { + // given + class UnsupportedOrder extends Order {} + + // when + UnsupportedOrder unsupportedOrder = new UnsupportedOrder(); + + // then + assertThatThrownBy(() -> tradeService.updateOrder(unsupportedOrder)) + .isInstanceOf(BusinessException.class) + .hasMessage("Unsupported order type: " + unsupportedOrder.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR); + } +}