diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e6c01564..2f68f9e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,6 +1,6 @@ name: CI/CD Deploy and Start services -run-name: Deploy to ECR and Start services by ${{github.actor}} +run-name: Deploy and Start services by ${{github.actor}} on: push: diff --git a/src/main/java/com/cleanengine/coin/account/readme.md b/src/main/java/com/cleanengine/coin/account/readme.md deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java index 7177eb7d..d5529057 100644 --- a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java @@ -1,7 +1,7 @@ package com.cleanengine.coin.configuration.bootstrap; import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.User; @@ -24,7 +24,7 @@ public class DBInitRunner implements CommandLineRunner { private final AccountRepository accountRepository; private final UserRepository userRepository; - private final WalletExternalRepository walletExternalRepository; + private final OrderWalletRepository orderWalletRepository; private final AssetRepository assetRepository; @Transactional @@ -58,7 +58,7 @@ protected void initSellBotData(){ wallet2.setTicker("TRUMP"); wallet2.setAccountId(account.getId()); wallet2.setSize(500_000_000.0); - walletExternalRepository.saveAll(List.of(wallet, wallet2)); + orderWalletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional @@ -80,7 +80,7 @@ protected void initBuyBotData() { wallet2.setTicker("TRUMP"); wallet2.setAccountId(account.getId()); wallet2.setSize(0.0); - walletExternalRepository.saveAll(List.of(wallet, wallet2)); + orderWalletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional diff --git a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java index 4af03dbb..04c314da 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java @@ -16,7 +16,7 @@ public class SpringOrderCreatedAddQueueHandler { private final WaitingOrdersManager waitingOrdersManager; private final ApplicationEventPublisher applicationEventPublisher; - @TransactionalEventListener(OrderCreated.class) + @TransactionalEventListener public void handleOrderCreated(OrderCreated event) { Order order = event.order(); WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(order.getTicker()); diff --git a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java index ba4572e9..aef0fbd9 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java @@ -11,7 +11,7 @@ public class SpringOrderCreatedUpdateOrderBookHandler { private final UpdateOrderBookUsecase updateOrderBookUsecase; - @TransactionalEventListener(OrderCreated.class) + @TransactionalEventListener public void handleOrderCreated(OrderCreated event) { updateOrderBookUsecase.updateOrderBookOnNewOrder(event.order()); } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java deleted file mode 100644 index d70869ac..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.account; - -import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.port.out.AccountUpdatePort; -import com.cleanengine.coin.user.domain.Account; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.FieldError; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class AccountExternalService implements AccountUpdatePort { - private final AccountExternalRepository accountRepository; - private final LockDepositService lockDepositService; - - // TODO 동시성 문제 고려해야 - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void lockDepositForBuyOrder(Integer userId, Double orderAmount) throws RuntimeException { - if(orderAmount <= 0){ - throw new DomainValidationException("orderAmount must be greater than 0", - List.of(new FieldError("BuyOrder", "lockedDeposit", "orderAmount must be greater than 0"))); - } - Account account = accountRepository - .findByUserId(userId) - .orElseThrow(()-> - new DomainValidationException("Account not found", - List.of(new FieldError("account", "userId", "user might not exist")))); - lockDepositService.lockDeposit(account, orderAmount); - } -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java deleted file mode 100644 index 1e83ed90..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.account; - -import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.user.domain.Account; -import org.springframework.stereotype.Component; -import org.springframework.validation.FieldError; - -import java.util.List; - -@Component -public class LockDepositService { - public void lockDeposit(Account account, Double orderAmount){ - // TODO 원래라면 이 로직은 Account 내에 있어야 함. 각 엔티티가 자신의 invariant를 보장해야 하므로 - if(account.getCash() < orderAmount){ - throw new DomainValidationException("not enough cash", - List.of(new FieldError("account", "cash", "not enough cash"))); - } - account.setCash(account.getCash() - orderAmount); - } -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java similarity index 69% rename from src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java index d5e70575..0ad71a03 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java @@ -5,7 +5,6 @@ import java.util.Optional; -public interface AccountExternalRepository extends CrudRepository { - // TODO null 대처 해야 +public interface OrderAccountRepository extends CrudRepository { Optional findByUserId(Integer userId); } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java deleted file mode 100644 index 9d0a142c..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; - -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.SellOrder; -import lombok.Getter; - -import java.util.concurrent.PriorityBlockingQueue; - -@Deprecated -@Getter -public class OrderQueueManager { - private final String ticker; - private final PriorityBlockingQueue marketSellOrderQueue; - private final PriorityBlockingQueue limitSellOrderQueue; - private final PriorityBlockingQueue marketBuyOrderQueue; - private final PriorityBlockingQueue limitBuyOrderQueue; - - public OrderQueueManager(String ticker) { - this.ticker = ticker; - this.marketSellOrderQueue = new PriorityBlockingQueue<>(); - this.limitSellOrderQueue = new PriorityBlockingQueue<>(); - this.marketBuyOrderQueue = new PriorityBlockingQueue<>(); - this.limitBuyOrderQueue = new PriorityBlockingQueue<>(); - } - - public void addOrder(Order order) { - if(order instanceof SellOrder) { - if(order.getIsMarketOrder()) { - marketSellOrderQueue.add((SellOrder) order); - } else { - limitSellOrderQueue.add((SellOrder) order); - } - } else { - if(order.getIsMarketOrder()) { - marketBuyOrderQueue.add((BuyOrder) order); - } else { - limitBuyOrderQueue.add((BuyOrder) order); - } - } - } - - public int getMarketSellOrderQueueSize() { - return marketSellOrderQueue.size(); - } - - public int getMarketBuyOrderQueueSize() { - return marketBuyOrderQueue.size(); - } - - public int getLimitSellOrderQueueSize() { - return limitSellOrderQueue.size(); - } - - public int getLimitBuyOrderQueueSize() { - return limitBuyOrderQueue.size(); - } - - public SellOrder getMarketSellOrder() { - return marketSellOrderQueue.peek(); - } - - public BuyOrder getMarketBuyOrder() { - return marketBuyOrderQueue.peek(); - } - - public SellOrder getLimitSellOrder() { - return limitSellOrderQueue.peek(); - } - - public BuyOrder getLimitBuyOrder() { - return limitBuyOrderQueue.peek(); - } - - public boolean removeOrderFromQueue(Order order) { - if (order instanceof SellOrder) { - if (order.getIsMarketOrder()) { - return marketSellOrderQueue.remove(order); - } else { - return limitSellOrderQueue.remove(order); - } - } else { - if (order.getIsMarketOrder()) { - return marketBuyOrderQueue.remove(order); - } else { - return limitBuyOrderQueue.remove(order); - } - } - } - -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java deleted file mode 100644 index cd394214..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Optional; - -@Deprecated -@Slf4j -@Component -public class OrderQueueManagerPool { - private final HashMap orderQueueManagerMap = new HashMap<>(); - - public OrderQueueManager getOrderQueueManager(String ticker){ - if(!orderQueueManagerMap.containsKey(ticker)){ - addOrderQueueManager(ticker); - } - - Optional orderQueueManagerOpt = Optional.ofNullable(orderQueueManagerMap.get(ticker)); - if(orderQueueManagerOpt.isEmpty()){ - log.debug("OrderQueueManager not found. with " + ticker); - throw new RuntimeException("OrderQueueManager not found with " + ticker); - } - return orderQueueManagerOpt.get(); - } - - protected synchronized void addOrderQueueManager(String ticker){ - if(!orderQueueManagerMap.containsKey(ticker)){ - orderQueueManagerMap.put(ticker, new OrderQueueManager(ticker)); - } - } -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java deleted file mode 100644 index 024d3f21..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.user.domain.Wallet; -import org.springframework.stereotype.Component; -import org.springframework.validation.FieldError; - -import java.util.List; - -@Component -public class LockAssetService { - public void lockAsset(Wallet wallet, Double orderSize) { - if(wallet.getSize() < orderSize){ - throw new DomainValidationException("not enough asset", - List.of(new FieldError("wallet", "size", "not enough asset"))); - } - wallet.setSize(wallet.getSize() - orderSize); - } -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java similarity index 61% rename from src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java index d1b5e37e..6e3d3981 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java @@ -3,5 +3,5 @@ import com.cleanengine.coin.user.domain.Wallet; import org.springframework.data.repository.CrudRepository; -public interface WalletExternalRepository extends CrudRepository, WalletExternalRepositoryCustom { +public interface OrderWalletRepository extends CrudRepository, OrderWalletRepositoryCustom { } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java similarity index 81% rename from src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java index d6ff1ec1..9f73edaa 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java @@ -4,6 +4,6 @@ import java.util.Optional; -public interface WalletExternalRepositoryCustom { +public interface OrderWalletRepositoryCustom { Optional findWalletBy(Integer userId, String ticker); } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java similarity index 92% rename from src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java index f4968bfb..471adcc3 100644 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java @@ -13,7 +13,7 @@ @Repository @Transactional @RequiredArgsConstructor -public class WalletExternalRepositoryCustomImpl implements WalletExternalRepositoryCustom { +public class OrderWalletRepositoryCustomImpl implements OrderWalletRepositoryCustom { private final EntityManager em; @Override diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java deleted file mode 100644 index 039fd9be..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.port.out.WalletUpdatePort; -import com.cleanengine.coin.user.domain.Wallet; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.validation.FieldError; - -import java.util.List; - -@Service -@RequiredArgsConstructor -public class WalletExternalService implements WalletUpdatePort { - private final WalletExternalRepository walletRepository; - private final LockAssetService lockAssetService; - - @Override - public void lockAssetForSellOrder(Integer userId, String ticker, Double orderSize) throws RuntimeException { - if(orderSize <= 0){ - throw new DomainValidationException("orderSize must be positive", - List.of(new FieldError("SellOrder", "orderSize", "orderSize must be positive"))); - } - Wallet wallet = findWalletBy(userId, ticker); - lockAssetService.lockAsset(wallet, orderSize); - } - - private Wallet findWalletBy(Integer userId, String ticker) { - Wallet wallet = walletRepository - .findWalletBy(userId, ticker) - .orElseThrow(()-> - new DomainValidationException("Wallet not found", - List.of(new FieldError("wallet", "userId", "user might not exist"), - new FieldError("wallet", "ticker", "ticker might be wrong")))); - return wallet; - } -} 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..ab11b755 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -3,7 +3,9 @@ import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.application.strategy.CreateOrderStrategy; -import jakarta.validation.Valid; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -12,6 +14,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; @@ -21,9 +24,11 @@ @Validated public class OrderService { private final List> createOrderStrategies; + private final Validator validator; @Transactional - public OrderInfo createOrder(@Valid OrderCommand.CreateOrder createOrder){ + public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ + validateCreateOrder(createOrder); CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() .filter(strategy -> strategy.supports(createOrder.isBuyOrder())).findFirst().orElseThrow(); @@ -39,4 +44,12 @@ public OrderInfo createOrderWithBot(String ticker, Boolean isBuyOrder, Double return createOrder(createOrder); } + + protected void validateCreateOrder(OrderCommand.CreateOrder createOrder) { + Set> violations = validator.validate(createOrder); + + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } } diff --git a/src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java b/src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java deleted file mode 100644 index 8f8d40a9..00000000 --- a/src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.cleanengine.coin.order.application.port.out; - -public interface AccountUpdatePort { - void lockDepositForBuyOrder(Integer userId, Double orderAmount) throws RuntimeException; -} diff --git a/src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java b/src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java deleted file mode 100644 index a12fbc66..00000000 --- a/src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.cleanengine.coin.order.application.port.out; - -public interface WalletUpdatePort { - void lockAssetForSellOrder(Integer userId, String ticker, Double orderSize) throws RuntimeException; -} diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java index c03211af..ef23efea 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/BuyOrderStrategy.java @@ -1,22 +1,25 @@ package com.cleanengine.coin.order.application.strategy; -import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.common.error.DomainValidationException; +import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderInfo; -import com.cleanengine.coin.order.application.port.out.AccountUpdatePort; import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.domainservice.CreateBuyOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; +import com.cleanengine.coin.user.domain.Account; import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; + +import java.util.List; @Component public class BuyOrderStrategy extends CreateOrderStrategy> { private final BuyOrderRepository buyOrderRepository; - private final AccountUpdatePort accountUpdatePort; private final CreateBuyOrderDomainService createOrderDomainService; @Override @@ -31,7 +34,15 @@ public void saveOrder(BuyOrder order) { @Override protected void keepHoldings(BuyOrder order) throws RuntimeException { - accountUpdatePort.lockDepositForBuyOrder(order.getUserId(), order.getLockedDeposit()); + Double lockedDeposit = order.getLockedDeposit(); + + Account account = accountRepository.findByUserId(order.getUserId()) + .orElseThrow(() -> new DomainValidationException("Account not found", + List.of(new FieldError("account", "userId", "user might not exist")))); + + account.decreaseCash(lockedDeposit); + + accountRepository.save(account); } @Override @@ -46,14 +57,12 @@ protected OrderInfo.BuyOrderInfo extractOrderInfo(Order order) { public BuyOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - WalletExternalRepository walletRepository, - AccountExternalRepository accountRepository, + OrderWalletRepository orderWalletRepository, + OrderAccountRepository orderAccountRepository, BuyOrderRepository buyOrderRepository, - AccountUpdatePort accountUpdatePort, CreateBuyOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); + super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); this.buyOrderRepository = buyOrderRepository; - this.accountUpdatePort = accountUpdatePort; this.createOrderDomainService = createOrderDomainService; } } diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java index cfb42d28..ee6c4c59 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java @@ -1,6 +1,8 @@ package com.cleanengine.coin.order.application.strategy; import com.cleanengine.coin.common.error.DomainValidationException; +import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; @@ -8,8 +10,6 @@ import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; -import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.Wallet; import lombok.AllArgsConstructor; @@ -21,8 +21,8 @@ public abstract class CreateOrderStrategy> { protected final PublishOrderCreatedPort publishOrderCreatedPort; protected final AssetService assetService; - protected final WalletExternalRepository walletRepository; - protected final AccountExternalRepository accountRepository; + protected final OrderWalletRepository walletRepository; + protected final OrderAccountRepository accountRepository; public S processCreatingOrder(OrderCommand.CreateOrder createOrderCommand){ validateTicker(createOrderCommand.ticker()); @@ -58,6 +58,7 @@ protected T createOrder(OrderCommand.CreateOrder createOrderCommand){ return order; } + // TODO 책임이 너무 많은 protected void createWalletIfNeeded(Integer userId, String ticker){ if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ Account account = accountRepository.findByUserId(userId).orElseThrow(); diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java index cc64b6e5..bb16c60c 100644 --- a/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java +++ b/src/main/java/com/cleanengine/coin/order/application/strategy/SellOrderStrategy.java @@ -1,22 +1,25 @@ package com.cleanengine.coin.order.application.strategy; -import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.common.error.DomainValidationException; +import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.AssetService; import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; -import com.cleanengine.coin.order.application.port.out.WalletUpdatePort; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.SellOrder; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateSellOrderDomainService; +import com.cleanengine.coin.user.domain.Wallet; import org.springframework.stereotype.Component; +import org.springframework.validation.FieldError; + +import java.util.List; @Component public class SellOrderStrategy extends CreateOrderStrategy { private final SellOrderRepository sellOrderRepository; - private final WalletUpdatePort walletUpdatePort; private final CreateSellOrderDomainService createOrderDomainService; @Override @@ -31,7 +34,20 @@ public void saveOrder(SellOrder order) { @Override protected void keepHoldings(SellOrder order) throws RuntimeException { - walletUpdatePort.lockAssetForSellOrder(order.getUserId(), order.getTicker(), order.getOrderSize()); + Integer userId = order.getUserId(); + String ticker = order.getTicker(); + Double orderSize = order.getOrderSize(); + + Wallet wallet = walletRepository + .findWalletBy(userId, ticker) + .orElseThrow(()-> + new DomainValidationException("Wallet not found", + List.of(new FieldError("wallet", "userId", "user might not exist"), + new FieldError("wallet", "ticker", "ticker might be wrong")))); + + wallet.decreaseSize(orderSize); + + walletRepository.save(wallet); } @Override @@ -46,14 +62,12 @@ protected OrderInfo.SellOrderInfo extractOrderInfo(Order order) { public SellOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - WalletExternalRepository walletRepository, - AccountExternalRepository accountRepository, + OrderWalletRepository orderWalletRepository, + OrderAccountRepository orderAccountRepository, SellOrderRepository sellOrderRepository, - WalletUpdatePort walletUpdatePort, CreateSellOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); + super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); this.sellOrderRepository = sellOrderRepository; - this.walletUpdatePort = walletUpdatePort; this.createOrderDomainService = createOrderDomainService; } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/Asset.java b/src/main/java/com/cleanengine/coin/order/domain/Asset.java index 5e70c9ac..8cb5ebf4 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/Asset.java +++ b/src/main/java/com/cleanengine/coin/order/domain/Asset.java @@ -1,11 +1,14 @@ package com.cleanengine.coin.order.domain; import jakarta.persistence.*; -import lombok.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Table(name = "asset") -@NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class Asset { @Id @Column(name = "ticker", length = 10, nullable = false) @@ -16,7 +19,13 @@ public class Asset { private byte[] icon; public Asset(String ticker, String name){ + if(ticker == null || name == null) throw new IllegalArgumentException("ticker, name cannot be null"); this.ticker = ticker; this.name = name; } + + public Asset(String ticker, String name, byte[] icon){ + this(ticker, name); + this.icon = icon; + } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/BuyOrder.java b/src/main/java/com/cleanengine/coin/order/domain/BuyOrder.java index 5966a750..587c67ce 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/BuyOrder.java +++ b/src/main/java/com/cleanengine/coin/order/domain/BuyOrder.java @@ -1,13 +1,13 @@ package com.cleanengine.coin.order.domain; -import jakarta.persistence.*; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.validation.FieldError; -import com.cleanengine.coin.common.error.DomainValidationException; import java.time.LocalDateTime; import java.util.ArrayList; @@ -19,7 +19,6 @@ @AttributeOverride(name="id", column=@Column(name="buy_order_id")) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Setter public class BuyOrder extends Order implements Comparable { @Column(name="locked_deposit", nullable = false, updatable = false) private Double lockedDeposit; @@ -27,26 +26,17 @@ public class BuyOrder extends Order implements Comparable { @Column(name="remaining_deposit", nullable = false) private Double remainingDeposit; - public static BuyOrder createMarketBuyOrder(String ticker, Integer userId, Double price, + public static BuyOrder createMarketBuyOrder(String ticker, Integer userId, Double deposit, LocalDateTime createdAt, Boolean isBot) { + // TODO command 객체의 validation으로 추출 List errors = new ArrayList<>(); - if(price == null){ - errors.add(new FieldError("BuyOrder", "price", "price cannot be null")); + if(deposit == null){ + errors.add(new FieldError("BuyOrder", "deposit", "deposit cannot be null")); } handleValidationErrors(errors); - BuyOrder buyOrder = new BuyOrder(); - buyOrder.ticker = ticker; - buyOrder.userId = userId; - buyOrder.state = OrderStatus.WAIT; - buyOrder.lockedDeposit = price; - buyOrder.orderSize = null; - buyOrder.price = null; - buyOrder.createdAt = createdAt; - buyOrder.isMarketOrder = true; - buyOrder.remainingDeposit = buyOrder.lockedDeposit; - buyOrder.remainingSize = null; - buyOrder.isBot = isBot; + BuyOrder buyOrder = new BuyOrder(null, ticker, userId, OrderStatus.WAIT, null, + null, null, createdAt, true, isBot, deposit, deposit); return buyOrder; } @@ -61,28 +51,12 @@ public static BuyOrder createLimitBuyOrder(String ticker, Integer userId, Double } handleValidationErrors(errors); - BuyOrder buyOrder = new BuyOrder(); - buyOrder.ticker = ticker; - buyOrder.userId = userId; - buyOrder.state = OrderStatus.WAIT; - buyOrder.lockedDeposit = orderSize * price; - buyOrder.orderSize = orderSize; - buyOrder.price = price; - buyOrder.createdAt = createdAt; - buyOrder.isMarketOrder = false; - buyOrder.remainingDeposit = buyOrder.lockedDeposit; - buyOrder.remainingSize = orderSize; - buyOrder.isBot = isBot; + Double deposit = orderSize * price; + BuyOrder buyOrder = new BuyOrder(null, ticker, userId, OrderStatus.WAIT, orderSize, price, + orderSize, createdAt, false, isBot, deposit, deposit); return buyOrder; } - private static void handleValidationErrors(List errors) { - if(errors.size() > 0){ - throw new DomainValidationException( - "Validation Error occurred Creating BuyOrder", errors); - } - } - @Override public int compareTo(BuyOrder order) { // 지정가 매수 가격 비교 @@ -98,6 +72,10 @@ public int compareTo(BuyOrder order) { } public void decreaseRemainingDeposit(Double amount) { + if(amount == null){ + throw new IllegalArgumentException("감소시킬 양은 null일 수 없습니다."); + } + if (remainingDeposit >= amount) { remainingDeposit -= amount; } else { @@ -105,12 +83,19 @@ public void decreaseRemainingDeposit(Double amount) { } } + @Override public void decreaseRemainingSize(Double amount) { - if (remainingSize >= amount) { - remainingSize -= amount; - } else { - throw new IllegalArgumentException("주문의 잔여 수량은 0 이상이어야 합니다."); + if(isMarketOrder){ + throw new IllegalArgumentException("시장가 매수 주문은 잔량을 수정할 수 없습니다."); } + super.decreaseRemainingSize(amount); } + protected BuyOrder(Long id, String ticker, Integer userId, OrderStatus state, Double orderSize, + Double price, Double remainingSize, LocalDateTime createdAt, Boolean isMarketOrder, Boolean isBot, + Double lockedDeposit, Double remainingDeposit) { + super(id, ticker, userId, state, orderSize, price, remainingSize, createdAt, isMarketOrder, isBot); + this.lockedDeposit = lockedDeposit; + this.remainingDeposit = remainingDeposit; + } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/Order.java b/src/main/java/com/cleanengine/coin/order/domain/Order.java index e5446a5f..50beb799 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/Order.java +++ b/src/main/java/com/cleanengine/coin/order/domain/Order.java @@ -1,15 +1,21 @@ package com.cleanengine.coin.order.domain; +import com.cleanengine.coin.common.error.DomainValidationException; import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.Setter; +import lombok.NoArgsConstructor; +import org.springframework.validation.FieldError; import java.time.LocalDateTime; +import java.util.List; import java.util.Objects; @MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Setter public abstract class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; @@ -56,4 +62,27 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(this.getClass(), this.id); } + + public void decreaseRemainingSize(Double amount) { + if(amount == null){ + throw new IllegalArgumentException("감소시킬 잔량은 null일 수 없습니다."); + } + if (remainingSize >= amount) { + remainingSize -= amount; + } else { + throw new IllegalArgumentException("주문의 잔여 수량은 0 이상이어야 합니다."); + } + } + + public void setState(OrderStatus state) { + if(state == null) throw new IllegalArgumentException("OrderState cannot be null"); + this.state = state; + } + + protected static void handleValidationErrors(List errors) { + if(!errors.isEmpty()){ + throw new DomainValidationException( + "Validation Error occurred Creating Order", errors); + } + } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/SellOrder.java b/src/main/java/com/cleanengine/coin/order/domain/SellOrder.java index d2183084..7ce06c78 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/SellOrder.java +++ b/src/main/java/com/cleanengine/coin/order/domain/SellOrder.java @@ -1,13 +1,13 @@ package com.cleanengine.coin.order.domain; -import jakarta.persistence.*; +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import org.springframework.validation.FieldError; -import com.cleanengine.coin.common.error.DomainValidationException; import java.time.LocalDateTime; import java.util.ArrayList; @@ -18,7 +18,6 @@ @AttributeOverride(name="id", column=@Column(name="sell_order_id")) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -@Setter public class SellOrder extends Order implements Comparable { public static SellOrder createMarketSellOrder(String ticker, Integer userId, Double orderSize, LocalDateTime createdAt, Boolean isBot) { @@ -29,16 +28,8 @@ public static SellOrder createMarketSellOrder(String ticker, Integer userId, Dou handleValidationErrors(errors); - SellOrder sellOrder = new SellOrder(); - sellOrder.ticker = ticker; - sellOrder.userId = userId; - sellOrder.state = OrderStatus.WAIT; - sellOrder.orderSize = orderSize; - sellOrder.price = null; - sellOrder.createdAt = createdAt; - sellOrder.isMarketOrder = true; - sellOrder.remainingSize = orderSize; - sellOrder.isBot = isBot; + SellOrder sellOrder = new SellOrder(null, ticker, userId, OrderStatus.WAIT, orderSize, null, + orderSize, createdAt, true, isBot); return sellOrder; } @@ -54,26 +45,11 @@ public static SellOrder createLimitSellOrder(String ticker, Integer userId, Doub handleValidationErrors(errors); - SellOrder sellOrder = new SellOrder(); - sellOrder.ticker = ticker; - sellOrder.userId = userId; - sellOrder.state = OrderStatus.WAIT; - sellOrder.orderSize = orderSize; - sellOrder.price = price; - sellOrder.createdAt = createdAt; - sellOrder.isMarketOrder = false; - sellOrder.remainingSize = orderSize; - sellOrder.isBot = isBot; + SellOrder sellOrder = new SellOrder(null, ticker, userId, OrderStatus.WAIT, orderSize, price, + orderSize, createdAt, false, isBot); return sellOrder; } - private static void handleValidationErrors(List errors) { - if(errors.size() > 0){ - throw new DomainValidationException( - "Validation Error occurred Creating SellOrder", errors); - } - } - @Override public int compareTo(SellOrder order) { // 지정가 매도 가격 비교 @@ -84,16 +60,11 @@ public int compareTo(SellOrder order) { } // 생성 시간 비교 - return this.createdAt.compareTo(order.createdAt); } - public void decreaseRemainingSize(Double amount) { - if (remainingSize >= amount) { - remainingSize -= amount; - } else { - throw new IllegalArgumentException("주문의 잔여 수량은 0 이상이어야 합니다."); - } + protected SellOrder(Long id, String ticker, Integer userId, OrderStatus state, Double orderSize, + Double price, Double remainingSize, LocalDateTime createdAt, Boolean isMarketOrder, Boolean isBot) { + super(id, ticker, userId, state, orderSize, price, remainingSize, createdAt, isMarketOrder, isBot); } - } diff --git a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java index 8d5358d3..3360b9e9 100644 --- a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java +++ b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java @@ -30,7 +30,7 @@ public void updateOrderBookOnNewOrder(Order order) { activeOrders(ticker).saveOrder(order); boolean isBuyOrder = order instanceof BuyOrder; - orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), order.getOrderSize()); + orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), order.getRemainingSize()); sendOrderBookUpdated(ticker); } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java index 3ba308b2..849d769b 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java @@ -2,8 +2,8 @@ import com.cleanengine.coin.common.error.DomainValidationException; import com.cleanengine.coin.order.application.OrderService; -import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; import com.cleanengine.coin.user.domain.Account; @@ -31,8 +31,8 @@ public class OrderGenerateService { private final OrderService orderService; private final TradeRepository tradeRepository; private final VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; - private final WalletExternalRepository walletExternalRepository; - private final AccountExternalRepository accountExternalRepository; + private final OrderWalletRepository orderWalletRepository; + private final OrderAccountRepository accountExternalRepository; private String ticker; @@ -183,12 +183,12 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, protected void resetBot(String ticker){ this.ticker = ticker; - Wallet wallet = walletExternalRepository.findWalletBy(SELL_ORDER_BOT_ID,ticker).get(); + Wallet wallet = orderWalletRepository.findWalletBy(SELL_ORDER_BOT_ID,ticker).get(); wallet.setSize(500_000_000.0); - Wallet wallet2 = walletExternalRepository.findWalletBy(BUY_ORDER_BOT_ID,ticker).get(); + Wallet wallet2 = orderWalletRepository.findWalletBy(BUY_ORDER_BOT_ID,ticker).get(); wallet2.setSize(0.0); - walletExternalRepository.save(wallet); - walletExternalRepository.save(wallet2); + orderWalletRepository.save(wallet); + orderWalletRepository.save(wallet2); Account account = accountExternalRepository.findByUserId(SELL_ORDER_BOT_ID).get(); account.setCash(0.0); 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/domain/Wallet.java b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java index f189c134..df793cc8 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/Wallet.java +++ b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java @@ -43,4 +43,16 @@ public static Wallet generateEmptyWallet(String ticker, Integer accountId){ wallet.setRoi(0.0); return wallet; } + + public void decreaseSize(Double orderSize) { + if(orderSize <= 0){ + throw new IllegalArgumentException("orderSize must be greater than zero."); + } + + if(this.getSize() < orderSize){ + throw new IllegalArgumentException("Cannot decrease size. Available size: " + this.getSize() + ", requested: " + orderSize); + } + + this.size = this.getSize() - orderSize; + } } 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/UserService.java b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java index 052e4730..625ce8a0 100644 --- a/src/main/java/com/cleanengine/coin/user/info/application/UserService.java +++ b/src/main/java/com/cleanengine/coin/user/info/application/UserService.java @@ -1,7 +1,5 @@ package com.cleanengine.coin.user.info.application; -import com.cleanengine.coin.user.info.infra.AccountRepository; -import com.cleanengine.coin.user.info.infra.WalletRepository; import com.cleanengine.coin.user.info.presentation.UserInfoDTO; import com.cleanengine.coin.user.info.infra.UserRepository; import org.springframework.stereotype.Service; @@ -10,13 +8,9 @@ public class UserService { private final UserRepository userRepository; - private final AccountRepository accountRepository; - private final WalletRepository walletRepository; - public UserService(UserRepository userRepository, AccountRepository accountRepository, WalletRepository walletRepository) { + public UserService(UserRepository userRepository) { this.userRepository = userRepository; - this.accountRepository = accountRepository; - this.walletRepository = walletRepository; } public UserInfoDTO retrieveUserInfoByUserId(Integer userId) { 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/main/java/com/cleanengine/coin/user/info/presentation/UserController.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java index 574cdfca..60580b4c 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserController.java @@ -1,14 +1,14 @@ package com.cleanengine.coin.user.info.presentation; import com.cleanengine.coin.common.response.ApiResponse; +import com.cleanengine.coin.common.response.ErrorResponse; +import com.cleanengine.coin.common.response.ErrorStatus; 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 com.cleanengine.coin.user.login.application.JWTUtil; import com.cleanengine.coin.user.info.application.UserService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; -import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.HttpStatus; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -23,30 +23,30 @@ public class UserController { private final UserService userService; private final AccountService accountService; private final WalletService walletService; - private final JWTUtil jwtUtil; - public UserController(UserService userService, AccountService accountService, WalletService walletService, JWTUtil jwtUtil) { + public UserController(UserService userService, AccountService accountService, WalletService walletService) { this.userService = userService; this.accountService = accountService; this.walletService = walletService; - this.jwtUtil = jwtUtil; } @GetMapping("/api/userinfo") - public ApiResponse retrieveUserInfo(HttpServletRequest request) { + public ApiResponse retrieveUserInfo() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.getPrincipal() instanceof CustomOAuth2User oAuth2User) { Integer userId = oAuth2User.getUserId(); UserInfoDTO userInfoDTO = userService.retrieveUserInfoByUserId(userId); + if (userInfoDTO == null) { + return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); + } Account account = accountService.retrieveAccountByUserId(userId); List wallets = walletService.retrieveWalletsByAccountId(account.getId()); userInfoDTO.setWallets(wallets); return ApiResponse.success(userInfoDTO, HttpStatus.OK); } - - throw new IllegalStateException("인증된 사용자를 찾을 수 없습니다."); + return ApiResponse.fail(ErrorResponse.of(ErrorStatus.UNAUTHORIZED_RESOURCE)); } } diff --git a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java index 29e96521..ca91c6ad 100644 --- a/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java +++ b/src/main/java/com/cleanengine/coin/user/info/presentation/UserInfoDTO.java @@ -10,7 +10,6 @@ @Getter @Setter @NoArgsConstructor -@AllArgsConstructor public class UserInfoDTO { private Integer userId; @@ -26,4 +25,17 @@ public class UserInfoDTO { private List wallets; + private UserInfoDTO(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { + this.userId = userId; + this.email = email; + this.nickname = nickname; + this.provider = provider; + this.cash = cash; + this.wallets = wallets; + } + + public static UserInfoDTO of(Integer userId, String email, String nickname, String provider, Double cash, List wallets) { + return new UserInfoDTO(userId, email, nickname, provider, cash, wallets); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java index fde4d463..d39e163d 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java @@ -33,11 +33,9 @@ public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oA public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { OAuth2User oAuth2User = super.loadUser(userRequest); - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - OAuth2Response oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); /* 추후 OAuth 플랫폼 추가 시 이런 식으로 Response 분기처리 - if (registrationId.equals("kakao")) { + if (userRequest.getClientRegistration().getRegistrationId().equals("kakao")) { oAuth2Response = new KakaoResponse(oAuth2User.getAttributes()); } else { @@ -66,9 +64,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic oAuthRepository.save(newOAuth); accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(newUser, newOAuth); - - return new CustomOAuth2User(userOAuthDetails); + return new CustomOAuth2User(UserOAuthDetails.of(newUser, newOAuth)); } else { OAuth existOAuth = oAuthRepository.findByProviderAndProviderUserId(provider, providerUserId); diff --git a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java index d3b3193f..89e9e942 100644 --- a/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java +++ b/src/main/java/com/cleanengine/coin/user/login/application/JWTFilter.java @@ -82,8 +82,7 @@ protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServlet } private static Authentication getAuthentication(Integer userId) { - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(); - userOAuthDetails.setUserId(userId); + UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(userId); CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); // 스프링 시큐리티 인증 토큰 생성 diff --git a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java index 998afff5..d90ef467 100644 --- a/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java +++ b/src/main/java/com/cleanengine/coin/user/login/infra/UserOAuthDetails.java @@ -2,15 +2,10 @@ import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; @Getter @Setter -@NoArgsConstructor -@AllArgsConstructor public class UserOAuthDetails { private Integer userId; @@ -23,11 +18,29 @@ public class UserOAuthDetails { private String name; - public UserOAuthDetails(User user, OAuth oAuth) { - this.userId = user.getId(); - this.provider = oAuth.getProvider(); - this.providerUserId = oAuth.getProviderUserId(); - this.email = oAuth.getEmail(); - this.name = oAuth.getNickname(); + @Builder + private UserOAuthDetails(Integer userId, String provider, String providerUserId, String email, String name) { + this.userId = userId; + this.provider = provider; + this.providerUserId = providerUserId; + this.email = email; + this.name = name; } + + public static UserOAuthDetails of(User user, OAuth oAuth) { + return UserOAuthDetails.builder() + .userId(user.getId()) + .provider(oAuth.getProvider()) + .providerUserId(oAuth.getProviderUserId()) + .email(oAuth.getEmail()) + .name(oAuth.getNickname()) + .build(); + } + + public static UserOAuthDetails of(int userId) { + return UserOAuthDetails.builder() + .userId(userId) + .build(); + } + } diff --git a/src/test/java/com/cleanengine/coin/order/application/dto/OrderCommandValidationTest.java b/src/test/java/com/cleanengine/coin/order/application/dto/OrderCommandValidationTest.java new file mode 100644 index 00000000..f4705a6e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/application/dto/OrderCommandValidationTest.java @@ -0,0 +1,84 @@ +package com.cleanengine.coin.order.application.dto; + +import com.cleanengine.coin.base.ValidatorTest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OrderCommandValidationTest { + @Nested + class CreateOrderValidationTest extends ValidatorTest { + @DisplayName("길이가 10이 넘는 ticker를 가진 CreateOrder 검증시 Exception을 반환함") + @Test + void validateCreateOrderWithLongTickerName_returnsException() { + String longTicker = "a".repeat(11); + + OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder( + longTicker, 3, true, false, + 30.0, 50.0, LocalDateTime.now(), false); + + List constraintViolationInfos = validate(createOrder); + assertEquals(1, constraintViolationInfos.size()); + + ConstraintViolationInfo violationInfo = constraintViolationInfos.get(0); + assertEquals("ticker", violationInfo.getFieldName()); + assertEquals(longTicker, violationInfo.getInvalidValue()); + } + + @DisplayName("길이가 0인 ticker를 가진 CreateOrder 검증시 Exception을 반환함") + @Test + void validateCreateOrderWithEmptyTickerName_returnsException() { + String emptyTicker = ""; + + OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder( + emptyTicker, 3, true, false, + 30.0, 50.0, LocalDateTime.now(), false); + + List constraintViolationInfos = validate(createOrder); + assertEquals(1, constraintViolationInfos.size()); + + ConstraintViolationInfo violationInfo = constraintViolationInfos.get(0); + assertEquals("ticker", violationInfo.getFieldName()); + assertEquals(emptyTicker, violationInfo.getInvalidValue()); + } + + @DisplayName("orderSize가 0인 CreateOrder 검증시 Exception을 반환함") + @Test + void validateCreateOrderWithZeroOrderSize_returnsException() { + Double zeroOrderSize = 0.0; + + OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder( + "BTC", 3, true, false, + zeroOrderSize, 50.0, LocalDateTime.now(), false); + + List constraintViolationInfos = validate(createOrder); + assertEquals(1, constraintViolationInfos.size()); + + ConstraintViolationInfo violationInfo = constraintViolationInfos.get(0); + assertEquals("orderSize", violationInfo.getFieldName()); + assertEquals(zeroOrderSize, violationInfo.getInvalidValue()); + } + + @DisplayName("price가 0인 CreateOrder 검증시 Exception을 반환함") + @Test + void validateCreateOrderWithZeroPrice_returnsException() { + Double zeroPrice = 0.0; + + OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder( + "BTC", 3, true, false, + 50.0, zeroPrice, LocalDateTime.now(), false); + + List constraintViolationInfos = validate(createOrder); + assertEquals(1, constraintViolationInfos.size()); + + ConstraintViolationInfo violationInfo = constraintViolationInfos.get(0); + assertEquals("price", violationInfo.getFieldName()); + assertEquals(zeroPrice, violationInfo.getInvalidValue()); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/domain/AssetTest.java b/src/test/java/com/cleanengine/coin/order/domain/AssetTest.java new file mode 100644 index 00000000..0a8d9072 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/domain/AssetTest.java @@ -0,0 +1,41 @@ +package com.cleanengine.coin.order.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class AssetTest { + @Nested + @DisplayName("Asset 생성 테스트") + class CreateAssetTest{ + @DisplayName("ticker가 null인 Asset을 생성시 Exception을 반환한다.") + @Test + void createAssetWithNullTicker_returnsException() { + String nullTicker = null; + + assertThrows(IllegalArgumentException.class, () -> new Asset(nullTicker, "name")); + } + + @DisplayName("name이 null인 Asset을 생성시 Exception을 반환한다.") + @Test + void createAssetWithNullName_returnsException() { + String nullName = null; + + assertThrows(IllegalArgumentException.class, () -> new Asset("BTC", nullName)); + } + @DisplayName("ticker와 name이 null이 아닌 Asset 생성시 제대로 초기화된다.") + @Test + void createAsset_initializedAsExpected() { + String ticker = "BTC"; + String name = "비트코인"; + + Asset asset = new Asset(ticker, name, null); + + assertEquals(ticker, asset.getTicker()); + assertEquals(name, asset.getName()); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/domain/BuyOrderTest.java b/src/test/java/com/cleanengine/coin/order/domain/BuyOrderTest.java new file mode 100644 index 00000000..c812772b --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/domain/BuyOrderTest.java @@ -0,0 +1,183 @@ +package com.cleanengine.coin.order.domain; + +import com.cleanengine.coin.common.error.DomainValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; + +public class BuyOrderTest { + protected static final LocalDateTime baseTime = LocalDateTime.now(); + + @Nested + @DisplayName("시장가 매수주문 생성 테스트") + class CreateMarketBuyOrderTest{ + @DisplayName("deposit이 null인 시장가 매수주문을 생성시 Exception을 반환함") + @Test + public void createMarketBuyOrderWithNullDeposit_throwException(){ + Double nullDeposit = null; + + assertThrows(DomainValidationException.class, ()->{ + BuyOrder.createMarketBuyOrder("BTC", 1, nullDeposit, baseTime, false); + }); + } + + @DisplayName("deposit이 null이 아닌 시장가 매수 주문을 생성시 deposit 관련 필드가 정상적으로 초기화 됨") + @Test + public void createMarketBuyOrderWithDeposit_initializeDepositCorrectly() { + Double nonNullDeposit = 1000.0; + + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder("BTC", 1, nonNullDeposit, baseTime, false); + + assertEquals(nonNullDeposit, buyOrder.getLockedDeposit()); + assertEquals(nonNullDeposit, buyOrder.getRemainingDeposit()); + } + + @DisplayName("시장가 매수 주문 생성시 OrderStatus가 WAIT로 초기화 됨") + @Test + public void createMarketBuyOrder_initializeOrderStatusWithWait() { + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder("BTC", 1, 1000.0, baseTime, false); + + assertEquals(OrderStatus.WAIT, buyOrder.getState()); + } + } + + @Nested + @DisplayName("지정가 매수주문 생성 테스트") + class CreateLimitBuyOrderTest{ + @DisplayName("orderSize가 null인 지정가 매수주문을 생성시 Exception을 반환함") + @Test + public void createLimitBuyOrderWithNullOrderSize_throwException(){ + Double nullOrderSize = null; + + assertThrows(DomainValidationException.class, ()->{ + BuyOrder.createLimitBuyOrder("BTC", 1, nullOrderSize, 100.0, baseTime, false); + }); + } + + @DisplayName("price가 null인 지정가 매수주문을 생성시 Exception을 반환함") + @Test + public void createLimitBuyOrderWithNullPrice_throwException(){ + Double nullPrice = null; + + assertThrows(DomainValidationException.class, ()->{ + BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, nullPrice, baseTime, false); + }); + } + + @DisplayName("orderSize와 price가 null이 아닌 지정가 매수 주문을 생성시 deposit 관련 필드가 정상적으로 초기화 됨") + @Test + public void createLimitBuyOrder_initializeDepositCorrectly() { + Double orderSize = 10.0; + Double price = 10.0; + + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, orderSize, price, baseTime, false); + + Double deposit = orderSize * price; + assertEquals(deposit, buyOrder.getLockedDeposit()); + assertEquals(deposit, buyOrder.getRemainingDeposit()); + } + + @DisplayName("지정가 매수 주문 생성시 OrderStatus가 WAIT로 초기화 됨") + @Test + public void createLimitBuyOrder_initializeOrderStatusWithWait() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 10.0, 10.0, baseTime, false); + + assertEquals(OrderStatus.WAIT, buyOrder.getState()); + } + } + + @Nested + @DisplayName("compareTo 테스트") + class CompareToTest{ + @DisplayName("가격이 큰 지정가 매수주문과 가격이 작은 지정가 매수주문을 compareTo시, 가격이 큰 주문이 음수 결과가 나와야 함") + @Test + void compareToLimitBuyOrdersWithDifferentPrices_biggerBuyOrder_returnNegative() { + BuyOrder biggerPriceBuyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 5.0, baseTime, false); + BuyOrder smallerPriceBuyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 1.0, baseTime, false); + + assertTrue(biggerPriceBuyOrder.compareTo(smallerPriceBuyOrder) < 0); + assertTrue(smallerPriceBuyOrder.compareTo(biggerPriceBuyOrder) > 0); + } + + @DisplayName("가격이 동일하고, 생성 시간이 동일한 지정가 매수 주문을 compareTo시, 0이 나와야 함") + @Test + void compareToLimitBuyOrderWithSamePricesAndSameTimes_returnZero() { + BuyOrder sameTimeBuyOrder1 = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 1.0, baseTime, false); + BuyOrder sameTimeBuyOrder2 = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 1.0, baseTime, false); + + assertEquals(0, sameTimeBuyOrder1.compareTo(sameTimeBuyOrder2)); + } + + @DisplayName("가격이 같고 생성시간이 다른 지정가 매수주문을 compareTo시 생성시간이 빠른 주문이 음수가 나와야 함") + @Test + void compareToLimitBuyOrdersWithDifferentTimes_earlierTimeBuyOrder_returnNegative() { + LocalDateTime earlierTime = baseTime.minusSeconds(1); + LocalDateTime laterTime = baseTime.plusSeconds(1); + + BuyOrder earlierTimeBuyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 1.0, earlierTime, false); + BuyOrder laterTimeBuyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 1.0, laterTime, false); + + assertTrue(earlierTimeBuyOrder.compareTo(laterTimeBuyOrder) < 0); + assertTrue(laterTimeBuyOrder.compareTo(earlierTimeBuyOrder) > 0); + } + + @DisplayName("시장가 매수 주문들에 대해 compareTo시 생성시간이 빠른 주문이 음수가 나와야 함") + @Test + void compareToMarketBuyOrders_earlierTimeBuyOrder_returnNegative() { + LocalDateTime earlierTime = baseTime.minusSeconds(1); + LocalDateTime laterTime = baseTime.plusSeconds(1); + + BuyOrder earlierTimeBuyOrder = BuyOrder.createMarketBuyOrder("BTC", 1, 100.0, earlierTime, false); + BuyOrder laterTimeBuyOrder = BuyOrder.createMarketBuyOrder("BTC", 1, 1000.0, laterTime, false); + + assertTrue(earlierTimeBuyOrder.compareTo(laterTimeBuyOrder) < 0); + assertTrue(laterTimeBuyOrder.compareTo(earlierTimeBuyOrder) > 0); + } + } + + @Nested + @DisplayName("decreaseRemainingDeposit 테스트") + class DecreaseRemainingDepositTest{ + @DisplayName("null인 amount로 decreaseRemainingDeposit 호출시, Exception을 반환한다.") + @Test + void decreaseRemainingDepositWithNullAmount_throwsException() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, baseTime, false); + + assertThrows(IllegalArgumentException.class, () -> buyOrder.decreaseRemainingDeposit(null)); + } + + @DisplayName("remainingDeposit보다 큰 amount로 decreaseRemainingDeposit 호출시, Exception을 반환한다.") + @Test + void decreaseRemainingDepositWithBiggerAmount_throwsException() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, baseTime, false); + + assertThrows(IllegalArgumentException.class, () -> buyOrder.decreaseRemainingDeposit(2000.0)); + } + + @DisplayName("remainingDeposit보다 작은 amount로 decreaseRemainingDeposit 호출시, 정상 적용된다.") + @Test + void decreaseRemainingDepositWithSmallerAmount_resultAsExpected() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, baseTime, false); + + buyOrder.decreaseRemainingDeposit(900.0); + + assertEquals(100.0, buyOrder.getRemainingDeposit()); + } + } + + @Nested + @DisplayName("decreaseRemainingSize 테스트") + class DecreaseRemainingSizeTest{ + @DisplayName("시장가 매수 주문에 대해 decreaseRemainingSize를 할 경우 Exception을 반환한다.") + @Test + void decreaseRemainingSizeWithMarketBuyOrder_throwsException() { + BuyOrder buyOrder = BuyOrder.createMarketBuyOrder("BTC", 1, 100.0, baseTime, false); + + assertThrows(IllegalArgumentException.class, () -> buyOrder.decreaseRemainingSize(10.0)); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/domain/OrderTest.java b/src/test/java/com/cleanengine/coin/order/domain/OrderTest.java new file mode 100644 index 00000000..7bc4a30b --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/domain/OrderTest.java @@ -0,0 +1,140 @@ +package com.cleanengine.coin.order.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.LimitBuyOrderGenerator.createLimitBuyOrderWithRandomPrice; +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("주문(Order) 엔티티 테스트") +class OrderTest { + @Nested + @DisplayName("equals 테스트") + class EqualsTest{ + @DisplayName("같은 주문 객체에 대해 equals 연산을 수행하면, true를 반환한다.") + @Test + void equalsSameObject_returnTrue() { + BuyOrder buyOrder = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + + assertTrue(buyOrder.equals(buyOrder)); + + } + @DisplayName("null인 주문 객체에 대해 equals 연산을 수행하면, false를 반환한다.") + @Test + void equalsNull_returnFalse() { + BuyOrder buyOrder = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + BuyOrder nullBuyOrder = null; + + assertFalse(buyOrder.equals(nullBuyOrder)); + + } + @DisplayName("id가 같은 매도 주문과 매수주문에 대해 equals 연산을 수행하면, false를 반환한다.") + @Test + void equalsSameIdBuyOrderAndSellOrder_returnFalse() { + BuyOrder buyOrder = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + SellOrder sellOrder = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false); + + assertFalse(buyOrder.equals(sellOrder)); + } + @DisplayName("id가 같은 매수주문 객체에 대해 equals 연산을 수행하면, true를 반환한다.") + @Test + void equalsSameIdBuyOrder_returnTrue() { + BuyOrder buyOrder = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + BuyOrder buyOrder2 = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 2.0, 2.0, 2.0, null, false, false, 2.0, 2.0); + + assertTrue(buyOrder.equals(buyOrder2)); + } + @DisplayName("id가 다른 매수주문 객체에 대해 equals 연산을 수행하면, false를 반환한다.") + @Test + void equalsDifferentIdBuyOrder_returnFalse() { + BuyOrder buyOrder = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + BuyOrder buyOrder2 = new BuyOrder(2L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + + assertFalse(buyOrder.equals(buyOrder2)); + } + @DisplayName("id가 같은 매도주문 객체에 대해 equals 연산을 수행하면, true를 반환한다.") + @Test + void equalsSameIdSellOrder_returnTrue() { + SellOrder sellOrder = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false); + SellOrder sellOrder2 = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 2.0, 2.0, 2.0, null, false, false); + + assertTrue(sellOrder.equals(sellOrder2)); + } + @DisplayName("id가 다른 매도주문 객체에 대해 equals 연산을 수행하면, false를 반환한다.") + @Test + void equalsDifferentIdSellOrder_returnFalse() { + SellOrder sellOrder = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false); + SellOrder sellOrder2 = new SellOrder(2L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false); + + assertFalse(sellOrder.equals(sellOrder2)); + } + } + + @Nested + @DisplayName("hashCode 테스트") + class HashCodeTest{ + @DisplayName("매도 주문이 id가 같으면 hashcode는 같은 값이어야 한다.") + @Test + void sameIdSellOrders_returnSameHashCode() { + SellOrder sellOrder1 = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false); + SellOrder sellOrder2 = new SellOrder(1L, "BTC", 1, OrderStatus.WAIT, 2.0, 2.0, 2.0, null, false, false); + + assertEquals(sellOrder1.hashCode(), sellOrder2.hashCode()); + } + + @DisplayName("매수 주문이 id가 같으면 hashcode는 같은 값이어야 한다.") + @Test + void sameIdBuyOrders_returnSameHashCode() { + BuyOrder buyOrder1 = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 1.0, 1.0, 1.0, null, false, false, 1.0, 1.0); + BuyOrder buyOrder2 = new BuyOrder(1L, "BTC", 1, OrderStatus.WAIT, 2.0, 2.0, 2.0, null, false, false, 2.0, 2.0); + + assertEquals(buyOrder1.hashCode(), buyOrder2.hashCode()); + } + } + + @Nested + @DisplayName("setState 테스트") + class SetStateTest{ + @DisplayName("null인 orderState로 setState를 하면, Exception을 반환한다.") + @Test + void setNullOrderState_throwIllegalArgumentException() { + BuyOrder buyOrder = createLimitBuyOrderWithRandomPrice(); + OrderStatus nullState = null; + + assertThrows(IllegalArgumentException.class, () -> buyOrder.setState(nullState)); + } + } + + @Nested + @DisplayName("decreaseRemainingSize 테스트") + class DecreaseRemainingSizeTest{ + @DisplayName("null인 amount로 decreaseRemainingSize 호출시, Exception을 반환한다.") + @Test + void decreaseRemainingSizeWithNullAmount_throwsException() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, LocalDateTime.now(), false); + + assertThrows(IllegalArgumentException.class, () -> buyOrder.decreaseRemainingSize(null)); + } + + @DisplayName("remainingSize보다 큰 amount로 decreaseRemainingSize 호출시, Exception을 반환한다.") + @Test + void decreaseRemainingSizeWithBiggerAmount_throwsException() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, LocalDateTime.now(), false); + + assertThrows(IllegalArgumentException.class, () -> buyOrder.decreaseRemainingSize(200.0)); + } + + @DisplayName("remainingSize보다 작은 amount로 decreaseRemainingSize 호출시, 정상 적용된다.") + @Test + void decreaseRemainingSizeWithSmallerAmount_resultAsExpected() { + BuyOrder buyOrder = BuyOrder.createLimitBuyOrder("BTC", 1, 100.0, 10.0, LocalDateTime.now(), false); + + buyOrder.decreaseRemainingSize(90.0); + + assertEquals(10.0, buyOrder.getRemainingSize()); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/order/domain/SellOrderTest.java b/src/test/java/com/cleanengine/coin/order/domain/SellOrderTest.java new file mode 100644 index 00000000..f8df69e7 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/domain/SellOrderTest.java @@ -0,0 +1,138 @@ +package com.cleanengine.coin.order.domain; + +import com.cleanengine.coin.common.error.DomainValidationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SellOrderTest { + protected static final LocalDateTime baseTime = LocalDateTime.now(); + + @Nested + @DisplayName("시장가 매도주문 생성 테스트") + class CreateMarketSellOrderTest{ + @DisplayName("orderSize가 null인 시장가 매도주문을 생성시 Exception을 반환함") + @Test + public void createMarketSellOrderWithNullOrderSize_throwException(){ + Double nullOrderSize = null; + + assertThrows(DomainValidationException.class, ()->{ + SellOrder.createMarketSellOrder("BTC", 1, nullOrderSize, baseTime, false); + }); + } + + @DisplayName("orderSize가 null이 아닌 시장가 매도 주문을 생성시 remainingSize 관련 필드가 정상적으로 초기화 됨") + @Test + public void createMarketSellOrder_initializeRemainingSizeCorrectly() { + Double orderSize = 10.0; + + SellOrder sellOrder = SellOrder.createMarketSellOrder("BTC", 1, orderSize, baseTime, false); + + assertEquals(orderSize, sellOrder.getRemainingSize()); + } + + @DisplayName("시장가 매도 주문 생성시 OrderStatus가 WAIT로 초기화 됨") + @Test + public void createMarketSellOrder_initializeOrderStatusWithWait() { + SellOrder sellOrder = SellOrder.createMarketSellOrder("BTC", 1, 10.0, baseTime, false); + + assertEquals(OrderStatus.WAIT, sellOrder.getState()); + } + } + + @Nested + @DisplayName("지정가 매도주문 생성 테스트") + class CreateLimitSellOrderTest{ + @DisplayName("orderSize가 null인 지정가 매도주문을 생성시 Exception을 반환함") + @Test + public void createLimitSellOrderWithNullOrderSize_throwException(){ + Double nullOrderSize = null; + + assertThrows(DomainValidationException.class, ()->{ + SellOrder.createLimitSellOrder("BTC", 1, nullOrderSize, 10.0, baseTime, false); + }); + } + + @DisplayName("price가 null인 지정가 매도주문을 생성시 Exception을 반환함") + @Test + public void createLimitSellOrderWithNullPrice_throwException(){ + Double nullPrice = null; + + assertThrows(DomainValidationException.class, ()->{ + SellOrder.createLimitSellOrder("BTC", 1, 10.0, nullPrice, baseTime, false); + }); + } + + @DisplayName("orderSize와 price가 null이 아닌 지정가 매도 주문을 생성시 remainingSize 관련 필드가 정상적으로 초기화 됨") + @Test + public void createLimitSellOrder_initializeRemainingSizeCorrectly() { + Double size = 10.0; + + SellOrder sellOrder = SellOrder.createLimitSellOrder("BTC", 1, size, size, baseTime, false); + + assertEquals(size, sellOrder.getRemainingSize()); + } + + @DisplayName("지정가 매도 주문 생성시 OrderStatus가 WAIT로 초기화 됨") + @Test + public void createLimitSellOrder_initializeOrderStatusWithWait() { + SellOrder sellOrder = SellOrder.createLimitSellOrder("BTC", 1, 10.0, 10.0, baseTime, false); + + assertEquals(OrderStatus.WAIT, sellOrder.getState()); + } + } + + @Nested + @DisplayName("compareTo 테스트") + class CompareToTest{ + @DisplayName("가격이 작은 지정가 매도주문과 가격이 큰 지정가 매도주문 compareTo시, 가격이 작은 주문이 음수 결과가 나와야 함") + @Test + void compareToLimitSellOrdersWithDifferentPrices_smallerSellOrder_returnNegative() { + SellOrder smallerPriceSellOrder = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 1.0, baseTime, false); + SellOrder biggerPriceSellOrder = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 5.0, baseTime, false); + + assertTrue(smallerPriceSellOrder.compareTo(biggerPriceSellOrder) < 0); + assertTrue(biggerPriceSellOrder.compareTo(smallerPriceSellOrder) > 0); + } + + @DisplayName("가격이 동일하고, 생성 시간이 동일한 지정가 매도 주문을 compareTo시, 0이 나와야 함") + @Test + void compareToLimitSellOrderWithSamePricesAndSameTimes_returnZero() { + SellOrder sameTimeSellOrder1 = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 1.0, baseTime, false); + SellOrder sameTimeSellOrder2 = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 1.0, baseTime, false); + + assertEquals(0, sameTimeSellOrder1.compareTo(sameTimeSellOrder2)); + } + + @DisplayName("가격이 같고 생성시간이 다른 지정가 매도주문을 compareTo시 생성시간이 빠른 주문이 음수가 나와야 함") + @Test + void compareToLimitSellOrdersWithDifferentTimes_earlierTimeSellOrder_returnNegative() { + LocalDateTime earlierTime = baseTime.minusSeconds(1); + LocalDateTime laterTime = baseTime.plusSeconds(1); + + SellOrder earlierTimeSellOrder = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 1.0, earlierTime, false); + SellOrder laterTimeSellOrder = SellOrder.createLimitSellOrder("BTC", 1, 100.0, 1.0, laterTime, false); + + assertTrue(earlierTimeSellOrder.compareTo(laterTimeSellOrder) < 0); + assertTrue(laterTimeSellOrder.compareTo(earlierTimeSellOrder) > 0); + } + + @DisplayName("시장가 매도 주문들에 대해 compareTo시 생성시간이 빠른 주문이 음수가 나와야 함") + @Test + void compareToMarketSellOrders_earlierTimeSellOrder_returnNegative() { + LocalDateTime earlierTime = baseTime.minusSeconds(1); + LocalDateTime laterTime = baseTime.plusSeconds(1); + + SellOrder earlierTimeSellOrder = SellOrder.createMarketSellOrder("BTC", 1, 100.0, earlierTime, false); + SellOrder laterTimeSellOrder = SellOrder.createMarketSellOrder("BTC", 1, 1000.0, laterTime, false); + + assertTrue(earlierTimeSellOrder.compareTo(laterTimeSellOrder) < 0); + assertTrue(laterTimeSellOrder.compareTo(earlierTimeSellOrder) > 0); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java b/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java index 7a82204d..7c4e6a81 100644 --- a/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/order/integration/buyorder/BuyOrderIntegrationTest.java @@ -1,8 +1,8 @@ package com.cleanengine.coin.order.integration.buyorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; @@ -27,10 +27,10 @@ public class BuyOrderIntegrationTest { OrderService orderService; @Autowired - AccountExternalRepository accountRepository; + OrderAccountRepository orderAccountRepository; @Autowired - WalletExternalRepository walletRepository; + OrderWalletRepository orderWalletRepository; //TODO 3,2가 예약어로 사용하는 만큼 1을 insert하는 테스트가 깨질 수 있다. 또한, sql로 초기화보다 EntityManager나 Repository로 초기화하는게 나은듯 @DisplayName("충분한 돈이 있는 유저가 시장가 매수주문 생성시 주문이 정상 생성됨.") @@ -41,7 +41,7 @@ void givenEnoughMoneyUser_WhenCreateMarketBuyOrder_ThenBuyOrderIsCreated() { true, true, null, 30.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = accountRepository.findByUserId(3).orElseThrow(); + Account account = orderAccountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0, account.getCash()); @@ -55,30 +55,30 @@ void givenEnoughMoneyUser_WhenCreateLimitBuyOrder_ThenSellOrderIsCreated() { true, false, 30.0, 40.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = accountRepository.findByUserId(3).orElseThrow(); + Account account = orderAccountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0*40.0, account.getCash()); } - @DisplayName("돈이 없는 유저가 시장가 매수주문 생성시 DomainValidationException을 반환함.") + @DisplayName("돈이 없는 유저가 시장가 매수주문 생성시 IllegalArgumentException을 반환함.") @Sql("classpath:db/user/user_zero_holdings.sql") @Test void givenZeroMoneyUser_WhenCreateMarketBuyOrder_ThenExceptionIsThrown() { OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, true, true, null, 40.0, LocalDateTime.now(),false); - assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); + assertThrows(IllegalArgumentException.class, () -> orderService.createOrder(command)); } - @DisplayName("돈이 없는 유저가 지정가 매수주문 생성시 DomainValidationException을 반환함.") + @DisplayName("돈이 없는 유저가 지정가 매수주문 생성시 IllegalArgumentException을 반환함.") @Sql("classpath:db/user/user_zero_holdings.sql") @Test void givenZeroMoneyUser_WhenCreateLimitBuyOrder_ThenExceptionIsThrown() { OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, true, false, 30.0, 40.0, LocalDateTime.now(),false); - assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); + assertThrows(IllegalArgumentException.class, () -> orderService.createOrder(command)); } @DisplayName("price를 누락한 시장가 매수주문이 들어올 경우 DomainValidationException을 반환함.") @@ -117,7 +117,7 @@ void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { orderService.createOrder(command); - Wallet wallet = walletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); assertNotNull(wallet); assertEquals("BTC", wallet.getTicker()); } diff --git a/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java b/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java index b50a3424..90aaf4be 100644 --- a/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/order/integration/sellorder/SellOrderIntegrationTest.java @@ -1,7 +1,7 @@ package com.cleanengine.coin.order.integration.sellorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; @@ -25,7 +25,7 @@ public class SellOrderIntegrationTest { OrderService orderService; @Autowired - WalletExternalRepository walletRepository; + OrderWalletRepository orderWalletRepository; @DisplayName("충분한 가상화폐가 있는 유저가 시장가 매도주문 생성시 주문이 생성됨.") @Sql("classpath:db/user/user_enough_holdings.sql") @@ -35,7 +35,7 @@ void givenEnoughMoneyUser_WhenCreateMarketSellOrder_ThenSellOrderIsCreated() { false, true, 30.0, null, LocalDateTime.now(),false); OrderInfo.SellOrderInfo sellOrderInfo = (OrderInfo.SellOrderInfo) orderService.createOrder(command); - Wallet wallet = walletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); assertNotNull(sellOrderInfo.getId()); assertEquals(200000-30.0, wallet.getSize()); @@ -49,7 +49,7 @@ void givenEnoughMoneyUser_WhenCreateLimitSellOrder_ThenSellOrderIsCreated() { false, false, 30.0, 40.0, LocalDateTime.now(),false); OrderInfo.SellOrderInfo sellOrderInfo = (OrderInfo.SellOrderInfo) orderService.createOrder(command); - Wallet wallet = walletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); assertNotNull(sellOrderInfo.getId()); assertEquals(200000-30.0, wallet.getSize()); @@ -62,20 +62,20 @@ void givenZeroMoneyUser_WhenCreateMarketSellOrder_ThenExceptionIsThrown() { OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, false, true, 30.0, null, LocalDateTime.now(),false); - assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); + assertThrows(IllegalArgumentException.class, () -> orderService.createOrder(command)); } - @DisplayName("가상화폐가 없는 유저가 지정가 매도주문 생성시 DomainValidationException을 반환함.") + @DisplayName("가상화폐가 없는 유저가 지정가 매도주문 생성시 IllegalArgumentException을 반환함.") @Sql("classpath:db/user/user_zero_holdings.sql") @Test void givenZeroMoneyUser_WhenCreateLimitSellOrder_ThenExceptionIsThrown() { OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, false, false, 30.0, 40.0, LocalDateTime.now(),false); - assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); + assertThrows(IllegalArgumentException.class, () -> orderService.createOrder(command)); } - @DisplayName("orderSize를 누락한 시장가 매도주문이 들어올 경우 DomainValidationException을 반환함.") + @DisplayName("orderSize를 누락한 시장가 매도주문이 들어올 경우 IllegalArgumentException을 반환함.") @Test void givenCommandWithoutOrderSize_WhenCreateMarketSellOrder_ThenExceptionIsThrown() { OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, @@ -115,7 +115,7 @@ void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { System.out.println(e.getMessage()); } - Wallet wallet = walletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); assertNotNull(wallet); assertEquals("BTC", wallet.getTicker()); } diff --git a/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java b/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java index 6112304a..1c3a1945 100644 --- a/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java +++ b/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java @@ -5,9 +5,11 @@ import java.lang.annotation.Retention; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; + @Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME) @WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class) public @interface WithCustomMockUser { String name() default "user"; - int id() default 1; + int id() default SELL_ORDER_BOT_ID; } diff --git a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java index d2636a56..96695066 100644 --- a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java +++ b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java @@ -14,10 +14,7 @@ public class WithCustomMockUserSecurityContextFactory implements WithSecurityCon public SecurityContext createSecurityContext(WithCustomMockUser annotation) { SecurityContext context = SecurityContextHolder.createEmptyContext(); - UserOAuthDetails userOAuthDetails = new UserOAuthDetails(); - userOAuthDetails.setUserId(annotation.id()); - userOAuthDetails.setName(annotation.name()); - + UserOAuthDetails userOAuthDetails = UserOAuthDetails.of(annotation.id()); CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); Authentication authentication = new UsernamePasswordAuthenticationToken(customOAuth2User, 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); + } +} diff --git a/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java index 40dab6a9..b3ee2fe3 100644 --- a/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java +++ b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java @@ -6,6 +6,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.*; +@DisplayName("계좌 단위테스트") class AccountTest { @DisplayName("계좌의 예수금을 5000만큼 증가시킨다.") @@ -40,10 +41,7 @@ void decreaseCash() { Account account = Account.of(1, 6000.0); // when, then - account.decreaseCash(5000.0); - - // then - assertEquals(1000.0, account.getCash()); + assertEquals(1000.0, account.decreaseCash(5000.0).getCash()); } @DisplayName("계좌의 예수금을 0만큼 감소시키면 예외가 발생한다.") diff --git a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java index 35c4b078..1a5efff8 100644 --- a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java +++ b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java @@ -11,6 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; @ActiveProfiles({"dev", "it", "h2-mem"}) +@DisplayName("계좌 서비스 - h2 통합테스트") @SpringBootTest class AccountServiceTest { @@ -19,19 +20,31 @@ class AccountServiceTest { @DisplayName("유저 ID와 예수금으로 신규 계좌를 생성한다.") @Test - void test() { + void createNewAccount() { // given int userId = 3; double cash = CommonValues.INITIAL_USER_CASH; // when - accountService.createNewAccount(userId, cash); - Account account = accountService.retrieveAccountByUserId(userId); + Account account = accountService.createNewAccount(userId, cash); + assertThat(account).isNotNull(); + + Account retrievedAccount = accountService.retrieveAccountByUserId(userId); // then - assertThat(account).isNotNull() + assertThat(retrievedAccount).isNotNull() .extracting(Account::getUserId, Account::getCash) .containsExactly(userId, cash); } + @DisplayName("존재하지 않는 userId로 조회 시 null을 반환한다.") + @Test + void retrieveAccountByInvalidUserId() { + // given, when + Account account = accountService.retrieveAccountByUserId(1000); + + // then + assertThat(account).isNull(); + } + } \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java new file mode 100644 index 00000000..7e77629e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/info/presentation/UserControllerTest.java @@ -0,0 +1,115 @@ +package com.cleanengine.coin.user.info.presentation; + +import com.cleanengine.coin.configuration.SecurityEndpoints; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.UserService; +import com.cleanengine.coin.user.info.application.WalletService; +import com.cleanengine.coin.user.login.application.CustomOAuth2UserService; +import com.cleanengine.coin.user.login.application.CustomSuccessHandler; +import com.cleanengine.coin.user.login.application.JWTUtil; +import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.stubbing.Answer; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; + +import java.util.Collection; +import java.util.List; + +import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@WebMvcTest(UserController.class) +public class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserService userService; + + @MockitoBean + private AccountService accountService; + + @MockitoBean + private WalletService walletService; + + @MockitoBean + private JWTUtil jwtUtil; + + @MockitoBean + private CustomOAuth2UserService customOAuth2UserService; + + @MockitoBean + private CustomSuccessHandler customSuccessHandler; + + @MockitoBean + private SecurityEndpoints.EndpointConfig endpointConfig; + + @Mock + private CustomOAuth2User customOAuth2User; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("정상적으로 존재하는 사용자 정보를 통해 조회에 성공한다.") + public void testRetrieveUserInfoSuccess() throws Exception { + int userId = 1; + String email = "test@test.com"; + String nickname = "test"; + String provider = "kakao"; + double cash = 1000.0; + + when(customOAuth2User.getUserId()).thenReturn(userId); + when(customOAuth2User.getAttributes()).thenReturn(null); + Collection authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + when(customOAuth2User.getAuthorities()).thenAnswer((Answer>) invocation -> authorities) + ; + + Authentication authenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, authorities + ); + + UserInfoDTO userInfoDTO = UserInfoDTO.of(userId, email, nickname, provider, cash, null); + when(userService.retrieveUserInfoByUserId(userId)).thenReturn(userInfoDTO); + + Account account = Account.of(userId, cash); + when(accountService.retrieveAccountByUserId(userId)).thenReturn(account); + + mockMvc.perform(get("/api/userinfo") + .with(authentication(authenticationToken))) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSuccess", is(true))) + .andExpect(MockMvcResultMatchers.jsonPath("$.data.cash", is((int) cash))); + + verify(userService, times(1)).retrieveUserInfoByUserId(userId); + verify(accountService, times(1)).retrieveAccountByUserId(userId); + verify(walletService, times(1)).retrieveWalletsByAccountId(account.getId()); + } + + @Test + @DisplayName("인증되지 않은 사용자가 private api 접근 시 리디렉션 응답을 반환한다.") + public void testRetrieveUserInfoUnauthorized() throws Exception { + mockMvc.perform(get("/api/userinfo")) + .andExpect(MockMvcResultMatchers.status().is3xxRedirection()); + verifyNoInteractions(userService, accountService, walletService); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java b/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java new file mode 100644 index 00000000..e3ddb2b9 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/login/application/JWTUtilTest.java @@ -0,0 +1,50 @@ +package com.cleanengine.coin.user.login.application; + +import io.jsonwebtoken.ExpiredJwtException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class JWTUtilTest { + + private final String secretKey = "secret-key-secret-key-secret-key-secret-key-secret-key-secret-key"; + private final JWTUtil jwtUtil = new JWTUtil(secretKey); + + @DisplayName("유저 ID와 유효기한으로 JWT를 생성한다.") + @Test + void createJwt() { + // given + int userId = 3; + Long expiredMs = 1000L; + + // when + String jwt = jwtUtil.createJwt(userId, expiredMs); + + // then + assertThat(jwt).isNotNull(); + assertThat(jwtUtil.getUserId(jwt)).isEqualTo(userId); + assertFalse(jwtUtil.isExpired(jwt)); + } + + @DisplayName("만료된 JWT를 감지한다.") + @Test + void expiredJwt() throws InterruptedException { + // given + int userId = 3; + Long expiredMs = 1L; + + // when + String jwt = jwtUtil.createJwt(userId, expiredMs); + + // then + Thread.sleep(2L); + assertThat(jwt).isNotNull(); + assertThatThrownBy(() -> jwtUtil.isExpired(jwt)) + .isInstanceOf(ExpiredJwtException.class); + } + + // 위조 검증 (userId, 만료기한, secret key 각각) +} \ No newline at end of file