diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f68f9e5..03a4ac01 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,10 +53,10 @@ jobs: - name: Connect to cloud server and run Docker commands uses: appleboy/ssh-action@v1.2.2 with: - host: ${{secrets.BACKEND_HOST}} + host: ${{secrets.CLOUD_HOST}} username: ${{secrets.CLOUD_USERNAME}} key: ${{secrets.CLOUD_SECRET_KEY}} port: ${{secrets.CLOUD_PORT}} script: | cd ~ - /bin/bash run_springboot.sh + /bin/bash deploy_spring.sh diff --git a/.gitignore b/.gitignore index 9b820d83..661ab51d 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ out/ ### 로컬 환경변수 ### local.properties -/logs \ No newline at end of file +/logs +docker-compose.override.yml \ No newline at end of file diff --git a/build.gradle b/build.gradle index ecfc5b0a..1e947b67 100644 --- a/build.gradle +++ b/build.gradle @@ -56,7 +56,7 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' implementation 'org.springframework.boot:spring-boot-starter-actuator' - + implementation 'io.micrometer:micrometer-registry-prometheus' implementation 'com.squareup.okhttp3:okhttp:4.12.0' implementation 'com.google.code.gson:gson:2.13.1' diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 2c7e82ef..6a4c95e3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,15 +7,16 @@ services: volumes: - ../build/libs/coin-0.0.1-SNAPSHOT.jar:/app/coin-0.0.1-SNAPSHOT.jar - /etc/localtime:/etc/localtime:ro - - /home/ubuntu/logs/springboot:/app/logs working_dir: /app - command: ["java", "-jar", "coin-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=dev,mariadb-local"] + command: ["java", "-jar", "coin-0.0.1-SNAPSHOT.jar", "--spring.profiles.active=dev,it,mariadb-local"] ports: - "8080:8080" + - "5005:5005" env_file: - ./local.properties environment: - TZ=Asia/Seoul + - JAVA_TOOL_OPTIONS=-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 depends_on: mariadb: condition: service_healthy diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql index 824352af..29b57767 100644 --- a/docker/mariadb/init.sql +++ b/docker/mariadb/init.sql @@ -6,6 +6,9 @@ create table account user_id int not null ); +create or replace index idx_account_user_id + on account (user_id); + create table asset ( ticker varchar(10) not null @@ -91,6 +94,9 @@ create table wallet ticker varchar(10) not null ); +create or replace index idx_wallet_account_id_ticker + on wallet (account_id, ticker); + INSERT INTO `if`.users (user_id, created_at) VALUES (1, '2025-05-16 09:30:00.000000'); diff --git a/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java b/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java new file mode 100644 index 00000000..2a25d7fa --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/annotation/TransactionLoggingAspect.java @@ -0,0 +1,28 @@ +package com.cleanengine.coin.common.annotation; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Aspect +@Component +public class TransactionLoggingAspect { + + private static final String TRANSACTION_ID_KEY = "txId"; + + @Around("@annotation(org.springframework.transaction.annotation.Transactional)") + public Object logTransaction(ProceedingJoinPoint joinPoint) throws Throwable { + String txId = UUID.randomUUID().toString().substring(0, 8); + MDC.put(TRANSACTION_ID_KEY, txId); + + try { + return joinPoint.proceed(); + } finally { + MDC.remove(TRANSACTION_ID_KEY); + } + } +} \ No newline at end of file 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 d5529057..052ae521 100644 --- a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java @@ -1,13 +1,13 @@ package com.cleanengine.coin.configuration.bootstrap; -import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.UserRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import lombok.RequiredArgsConstructor; import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; @@ -24,7 +24,7 @@ public class DBInitRunner implements CommandLineRunner { private final AccountRepository accountRepository; private final UserRepository userRepository; - private final OrderWalletRepository orderWalletRepository; + private final WalletRepository walletRepository; 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); - orderWalletRepository.saveAll(List.of(wallet, wallet2)); + walletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional @@ -80,7 +80,7 @@ protected void initBuyBotData() { wallet2.setTicker("TRUMP"); wallet2.setAccountId(account.getId()); wallet2.setSize(0.0); - orderWalletRepository.saveAll(List.of(wallet, wallet2)); + walletRepository.saveAll(List.of(wallet, wallet2)); } @Transactional diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java deleted file mode 100644 index 0ad71a03..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/OrderAccountRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.account; - -import com.cleanengine.coin.user.domain.Account; -import org.springframework.data.repository.CrudRepository; - -import java.util.Optional; - -public interface OrderAccountRepository extends CrudRepository { - Optional findByUserId(Integer userId); -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java deleted file mode 100644 index 6e3d3981..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; -import org.springframework.data.repository.CrudRepository; - -public interface OrderWalletRepository extends CrudRepository, OrderWalletRepositoryCustom { -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java deleted file mode 100644 index 9f73edaa..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustom.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; - -import java.util.Optional; - -public interface OrderWalletRepositoryCustom { - Optional findWalletBy(Integer userId, String ticker); -} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java deleted file mode 100644 index 471adcc3..00000000 --- a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/OrderWalletRepositoryCustomImpl.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.cleanengine.coin.order.adapter.out.persistentce.wallet; - -import com.cleanengine.coin.user.domain.Wallet; -import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; -import jakarta.persistence.TypedQuery; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Optional; - -@Repository -@Transactional -@RequiredArgsConstructor -public class OrderWalletRepositoryCustomImpl implements OrderWalletRepositoryCustom { - private final EntityManager em; - - @Override - public Optional findWalletBy(Integer userId, String ticker) { - TypedQuery query = em.createQuery( - "select w from Wallet w inner join Account a on w.accountId = a.id where a.userId = :userId and w.ticker = :ticker", - Wallet.class); - query.setParameter("userId", userId); - query.setParameter("ticker", ticker); - - try{ - Wallet wallet = query.getSingleResult(); - return Optional.of(wallet); - } catch (NoResultException e) { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/cleanengine/coin/order/application/AssetService.java b/src/main/java/com/cleanengine/coin/order/application/AssetService.java index cdea7404..e013b9dc 100644 --- a/src/main/java/com/cleanengine/coin/order/application/AssetService.java +++ b/src/main/java/com/cleanengine/coin/order/application/AssetService.java @@ -33,6 +33,10 @@ public List getAllAssetInfos(){ return assetRepository.findAll().stream().map(AssetInfo::from).toList(); } + public List getAllAssetTickers(){ + return assetRepository.findAll().stream().map(Asset::getTicker).toList(); + } + public boolean isAssetExist(String ticker){ if(assetCacheRepository.isAssetExists(ticker)) return true; 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 ab11b755..1a8ff5aa 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -8,6 +8,7 @@ import jakarta.validation.Validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -22,11 +23,11 @@ @Service @RequiredArgsConstructor @Validated -public class OrderService { +public class OrderService { private final List> createOrderStrategies; private final Validator validator; - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ validateCreateOrder(createOrder); CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() @@ -35,14 +36,14 @@ public OrderInfo createOrder(OrderCommand.CreateOrder createOrder){ return createOrderStrategy.processCreatingOrder(createOrder); } - @Transactional(propagation = Propagation.REQUIRES_NEW) - public OrderInfo createOrderWithBot(String ticker, Boolean isBuyOrder, Double orderSize, Double price){ + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) + public void createOrderWithBot(String ticker, Boolean isBuyOrder, Double orderSize, Double price){ Integer userId = isBuyOrder? BUY_ORDER_BOT_ID : SELL_ORDER_BOT_ID; OrderCommand.CreateOrder createOrder = new OrderCommand.CreateOrder(ticker, userId, isBuyOrder, false, orderSize, price, LocalDateTime.now(), true); - return createOrder(createOrder); + createOrder(createOrder); } protected void validateCreateOrder(OrderCommand.CreateOrder createOrder) { 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 ef23efea..7ce7c577 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,9 +1,7 @@ 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.order.command.BuyOrderRepository; -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; @@ -12,6 +10,8 @@ import com.cleanengine.coin.order.domain.domainservice.CreateBuyOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Component; import org.springframework.validation.FieldError; @@ -57,11 +57,11 @@ protected OrderInfo.BuyOrderInfo extractOrderInfo(Order order) { public BuyOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - OrderWalletRepository orderWalletRepository, - OrderAccountRepository orderAccountRepository, + WalletRepository walletRepository, + AccountRepository accountRepository, BuyOrderRepository buyOrderRepository, CreateBuyOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); this.buyOrderRepository = buyOrderRepository; 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 ee6c4c59..d7da7108 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,8 +1,6 @@ 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; @@ -10,8 +8,8 @@ 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.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.AllArgsConstructor; import org.springframework.validation.FieldError; @@ -21,14 +19,13 @@ public abstract class CreateOrderStrategy> { protected final PublishOrderCreatedPort publishOrderCreatedPort; protected final AssetService assetService; - protected final OrderWalletRepository walletRepository; - protected final OrderAccountRepository accountRepository; + protected final WalletRepository walletRepository; + protected final AccountRepository accountRepository; public S processCreatingOrder(OrderCommand.CreateOrder createOrderCommand){ validateTicker(createOrderCommand.ticker()); T order = createOrder(createOrderCommand); saveOrder(order); - createWalletIfNeeded(order.getUserId(), order.getTicker()); keepHoldings(order); publishOrderCreatedPort.publish(new OrderCreated(order)); return extractOrderInfo(order); @@ -49,21 +46,12 @@ protected void validateTicker(String ticker){ } protected T createOrder(OrderCommand.CreateOrder createOrderCommand){ - T order = createOrderDomainService().createOrder( + + return createOrderDomainService().createOrder( createOrderCommand.ticker(), createOrderCommand.userId(), createOrderCommand.isBuyOrder(), createOrderCommand.isMarketOrder(), createOrderCommand.orderSize(), createOrderCommand.price(), createOrderCommand.createdAt(), createOrderCommand.isBot()); - - return order; } - // TODO 책임이 너무 많은 - protected void createWalletIfNeeded(Integer userId, String ticker){ - if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ - Account account = accountRepository.findByUserId(userId).orElseThrow(); - Wallet wallet = Wallet.generateEmptyWallet(ticker, account.getId()); - walletRepository.save(wallet); - } - } } 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 bb16c60c..c4c4b966 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,9 +1,7 @@ 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.order.command.SellOrderRepository; -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; @@ -12,6 +10,8 @@ import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateSellOrderDomainService; import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Component; import org.springframework.validation.FieldError; @@ -39,7 +39,7 @@ protected void keepHoldings(SellOrder order) throws RuntimeException { Double orderSize = order.getOrderSize(); Wallet wallet = walletRepository - .findWalletBy(userId, ticker) + .findByAccountIdAndTicker(userId, ticker) .orElseThrow(()-> new DomainValidationException("Wallet not found", List.of(new FieldError("wallet", "userId", "user might not exist"), @@ -62,11 +62,11 @@ protected OrderInfo.SellOrderInfo extractOrderInfo(Order order) { public SellOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, AssetService assetService, - OrderWalletRepository orderWalletRepository, - OrderAccountRepository orderAccountRepository, + WalletRepository walletRepository, + AccountRepository accountRepository, SellOrderRepository sellOrderRepository, CreateSellOrderDomainService createOrderDomainService) { - super(publishOrderCreatedPort, assetService, orderWalletRepository, orderAccountRepository); + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); this.sellOrderRepository = sellOrderRepository; this.createOrderDomainService = createOrderDomainService; } diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java b/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java index 8fa1e380..e55a2059 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java @@ -4,10 +4,13 @@ import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.realitybot.domain.APIVWAPState; +import com.cleanengine.coin.realitybot.domain.VWAPMetricsRecorder; import com.cleanengine.coin.realitybot.dto.Ticks; import com.cleanengine.coin.realitybot.parser.TickParser; import com.cleanengine.coin.realitybot.service.OrderGenerateService; import com.cleanengine.coin.realitybot.service.TickServiceManager; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -29,16 +32,20 @@ public class ApiScheduler { private final Map lastSequentialIdMap = new ConcurrentHashMap<>(); private final AssetRepository assetRepository; private final CoinoneAPIClient coinoneAPIClient; + private final VWAPMetricsRecorder recorder; + private final MeterRegistry meterRegistry; private String ticker; // @Scheduled(fixedRate = 5000) public void MarketAllRequest() throws InterruptedException { - List tickers = assetRepository.findAll(); - for (Asset ticker : tickers){ - String tickerName = ticker.getTicker(); - MarketDataRequest(tickerName); -// Thread.sleep(500); - } + Timer timer = meterRegistry.timer("apischeduler.request.duration"); + timer.record(() -> { + List tickers = assetRepository.findAll(); + for (Asset ticker : tickers) { + String tickerName = ticker.getTicker(); + MarketDataRequest(tickerName); + } + }); } public void MarketDataRequest(String ticker){ @@ -62,7 +69,10 @@ public void MarketDataRequest(String ticker){ lastSequentialIdMap.put(ticker,lastSeqId); double vwap = apiVWAPState.getVWAP(); double volume = apiVWAPState.getAvgVolumePerOrder(); + recorder.recordApiVwap(ticker,vwap); + orderGenerateService.generateOrder(ticker,vwap,volume); //1tick 당 매수/매도 3개씩 제작 + // log.info("작동확인 {}의 가격 : {} , 볼륨 : {}",ticker, vwap, volume); } @@ -89,7 +99,7 @@ public void destroy() throws Exception { //담긴 Queue데이터 확인용 } catch (Exception e) { log.error("Bithumb API 오류 발생: {} → Coinone으로 대체 요청", e.getMessage()); - return coinoneAPIClient.get(ticker); + return coinoneAPIClient.geta(ticker); } }*/ diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/H2UnitPriceRefresher.java b/src/main/java/com/cleanengine/coin/realitybot/api/H2UnitPriceRefresher.java new file mode 100644 index 00000000..74c08104 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/api/H2UnitPriceRefresher.java @@ -0,0 +1,23 @@ +package com.cleanengine.coin.realitybot.api; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@Profile("h2-mem") +@RequiredArgsConstructor +public class H2UnitPriceRefresher implements ApplicationRunner { + private final UnitPriceRefresher unitPriceRefresher; + + public void run(ApplicationArguments args){ + log.info("Running Unit Price Refresher (h2-mem)..."); + unitPriceRefresher.initializeUnitPrices(); + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java b/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java index db3f2c8f..e50a1384 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java @@ -7,31 +7,22 @@ import com.cleanengine.coin.realitybot.vo.UnitPricePolicy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.ApplicationRunner; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; +import org.springframework.stereotype.Component; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Slf4j -@Service +@Component @RequiredArgsConstructor -public class UnitPriceRefresher implements ApplicationRunner { +public class UnitPriceRefresher { private final UnitPricePolicy unitPricePolicy; private final AssetRepository assetRepository; private final BithumbAPIClient bithumbAPIClient; private final OpeningPriceParser openingPriceParser; private final Map unitPriceCache = new ConcurrentHashMap<>(); - @Override - public void run(ApplicationArguments args){ - log.info("Running Unit Price Refresher..."); - initializeUnitPrices(); - } - public void initializeUnitPrices() { List tickers = assetRepository.findAll(); for (Asset ticker : tickers){ @@ -40,16 +31,6 @@ public void initializeUnitPrices() { } } - @Scheduled(cron = "${bot-handler.corn}") - public void refreshUnitPrices() { - initializeUnitPrices(); -// List tickers = assetRepository.findAll(); -// for (Asset ticker : tickers){ -// double unitPrice = fetchOpeningPriceFromAPI(ticker.getTicker()); -// unitPriceCache.put(ticker.getTicker(),unitPrice); -// } - - } private double fetchOpeningPriceFromAPI(String ticker) { String rawJson = bithumbAPIClient.getOpeningPrice(ticker); //api raw데이터 diff --git a/src/main/java/com/cleanengine/coin/realitybot/config/MetricConfig.java b/src/main/java/com/cleanengine/coin/realitybot/config/MetricConfig.java new file mode 100644 index 00000000..7eaf4177 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/config/MetricConfig.java @@ -0,0 +1,18 @@ +package com.cleanengine.coin.realitybot.config; + +import io.micrometer.core.instrument.MeterRegistry; +import org.springframework.boot.actuate.autoconfigure.metrics.MeterRegistryCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +//@Profile("actuator") +@Configuration +public class MetricConfig { + + @Bean + MeterRegistryCustomizer metricsCommonTags() { + return registry -> registry.config().commonTags("application", "my-app"); + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java b/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java index c26118a1..86aa2a3a 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java +++ b/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java @@ -1,6 +1,7 @@ package com.cleanengine.coin.realitybot.config; import com.cleanengine.coin.realitybot.api.ApiScheduler; +import com.cleanengine.coin.realitybot.api.UnitPriceRefresher; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableScheduling; @@ -18,17 +19,31 @@ public class SchedulerConfig implements SchedulingConfigurer { // @Autowired // private TaskScheduler apiScheduler; private final ApiScheduler apiScheduler; + private final UnitPriceRefresher unitPriceRefresher; + @Value("${bot-handler.cron}") + private final String cron; @Value("${bot-handler.fixed-rate}") private final Duration fixedRate; - protected SchedulerConfig(ApiScheduler apiScheduler, @Value("${bot-handler.fixed-rate}") Duration fixedRate) { + protected SchedulerConfig(ApiScheduler apiScheduler, @Value("${bot-handler.fixed-rate}") Duration fixedRate, UnitPriceRefresher unitPriceRefresher, + @Value("${bot-handler.cron}")String cron) { this.apiScheduler = apiScheduler; this.fixedRate = fixedRate; + this.unitPriceRefresher = unitPriceRefresher; + this.cron = cron; } @Override public void configureTasks(ScheduledTaskRegistrar registrar) { -// registrar.setScheduler(apiScheduler); //멀티 쓰레드 x + unitPriceRefresher.initializeUnitPrices(); //선반영 + + registrar.addCronTask(() -> { + try { + unitPriceRefresher.initializeUnitPrices(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, cron); registrar.addFixedRateTask(() -> { try { apiScheduler.MarketAllRequest(); diff --git a/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java b/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java index fa22e48b..aa801573 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java +++ b/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java @@ -17,8 +17,10 @@ public void addTick(Ticks tick){ if (ticksQueue.size() >= maxQueueSize) { //10개 이상이 되면 선착순으로 제거해나감 Ticks removed = ticksQueue.poll(); + if (removed != null){ calculator.removeTrade(removed.getTrade_price(), removed.getTrade_volume()); - } + } + } //초기엔 들어온 갯수에 따라 증가시켜서 계산함 ticksQueue.add(tick); calculator.recordTrade(tick.getTrade_price(),tick.getTrade_volume()); diff --git a/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPMetricsRecorder.java b/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPMetricsRecorder.java new file mode 100644 index 00000000..1d960704 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPMetricsRecorder.java @@ -0,0 +1,65 @@ +package com.cleanengine.coin.realitybot.domain; + +import io.micrometer.core.instrument.DistributionSummary; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Tags; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; + +@Component +@RequiredArgsConstructor +public class VWAPMetricsRecorder { + private final MeterRegistry meterRegistry; + private final ConcurrentHashMap> orderPriceMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap orderPriceSummery = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> apiVwapMap = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> platformVwapMap = new ConcurrentHashMap<>(); + + public void recordPrice(String ticker, boolean isBuy, double price){ + String type = isBuy ? "buy" : "sell"; + String timeStamp = Instant.now().toString(); +// String key = ticker +"|"+type+"|"+timeStamp; + String key = ticker +"|"+type; + + + AtomicReference priceRef = orderPriceMap.computeIfAbsent(key, k -> { + + AtomicReference value = new AtomicReference<>(price); + + meterRegistry.gauge("order_price", + Tags.of("ticker",ticker,"type",type) + ,value,AtomicReference::get); + return value; + }); + priceRef.set(price); + + DistributionSummary summary = orderPriceSummery.computeIfAbsent(key,k -> + DistributionSummary.builder("order_price_summary") + .tags(Tags.of("ticker",ticker,"type",type)) + .publishPercentiles(0.05,0.95) + .register(meterRegistry) + ); + summary.record(price); + } + + + public void recordApiVwap(String ticker, double price){ + apiVwapMap.computeIfAbsent(ticker, t -> { + AtomicReference ref = new AtomicReference<>(price); + meterRegistry.gauge("api_vwap",Tags.of("ticker",t),ref,AtomicReference::get); + return ref; + }).set(price); + } + public void recordPlatformVwap(String ticker, double price){ + platformVwapMap.computeIfAbsent(ticker, t -> { + AtomicReference ref = new AtomicReference<>(price); + meterRegistry.gauge("platform_vwap",Tags.of("ticker",t),ref,AtomicReference::get); + return ref; + }).set(price); + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java b/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java index 9513aab6..438246e6 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java +++ b/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java @@ -12,7 +12,7 @@ public class Ticks { private String trade_date_utc; // LocalDate private String trade_time_utc; // LocalTime private String timestamp; //instant 에러 발생 - private float trade_price; + private double trade_price; private double trade_volume; private float prev_closing_price; private double change_price; 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 4d2c878a..4529fe97 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java @@ -2,15 +2,11 @@ import com.cleanengine.coin.order.application.OrderService; import com.cleanengine.coin.realitybot.api.UnitPriceRefresher; +import com.cleanengine.coin.realitybot.domain.VWAPMetricsRecorder; import com.cleanengine.coin.realitybot.vo.DeviationPricePolicy; import com.cleanengine.coin.realitybot.vo.OrderPricePolicy; import com.cleanengine.coin.realitybot.vo.OrderVolumePolicy; -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; -import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.application.AccountService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -18,11 +14,6 @@ import org.springframework.stereotype.Service; import java.text.DecimalFormat; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; -import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; @Slf4j @Service @@ -31,37 +22,32 @@ public class OrderGenerateService { @Value("${bot-handler.order-level}") private int[] orderLevels; //체결 강도 - private double unitPrice = 0; //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 private final UnitPriceRefresher unitPriceRefresher; private final PlatformVWAPService platformVWAPService; private final OrderService orderService; - private final TradeRepository tradeRepository; - private final VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; private final OrderPricePolicy orderPricePolicy; private final DeviationPricePolicy deviationPricePolicy; private final OrderVolumePolicy orderVolumePolicy; - private final OrderWalletRepository orderWalletRepository; - private final OrderAccountRepository accountExternalRepository; - private String ticker; + private final AccountService accountService; + private final VWAPMetricsRecorder recorder; public void generateOrder(String ticker, double apiVWAP, double avgVolume) {//기준 주문금액, 주문량 받기 (tick당 계산되어 들어옴) - this.ticker = ticker; //호가 정책 적용 - this.unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); + //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 + double unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); - //최근 체결 내역 가져오기 - List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); +// //최근 체결 내역 가져오기 +// List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); // Platform 기반 가격 생성 (10개 이하, 10개 이상에 따른 가격 생성) - double platformVWAP = platformVWAPService.calculateVWAPbyTrades(ticker,trades,apiVWAP); - + double platformVWAP = platformVWAPService.calculateVWAPbyTrades(ticker,apiVWAP); + recorder.recordPlatformVwap(ticker,platformVWAP); //편차 계산 (vwap 기준) double trendLineRate = (platformVWAP - apiVWAP)/ apiVWAP; - for(int level : orderLevels) { //1주문당 3회 매수매도 처리 - OrderPricePolicy.OrderPrice basePrice = orderPricePolicy.calculatePrice(level,platformVWAP,unitPrice,trendLineRate); + OrderPricePolicy.OrderPrice basePrice = orderPricePolicy.calculatePrice(level,platformVWAP, unitPrice,trendLineRate); DeviationPricePolicy.AdjustPrice adjustPrice = deviationPricePolicy.adjust( basePrice.sell(), basePrice.buy(), trendLineRate, apiVWAP, unitPrice); @@ -74,15 +60,7 @@ public void generateOrder(String ticker, double apiVWAP, double avgVolume) {// createOrderWithFallback(ticker,false, sellVolume, sellPrice); createOrderWithFallback(ticker,true, buyVolume, buyPrice); - - try { - TimeUnit.MICROSECONDS.sleep(100); - } catch (InterruptedException e) { - throw new RuntimeException(e); - } -// vwaPerrorInJectionScheduler.enableInjection(); //에러 발생기 비활성화 - - /* DecimalFormat df = new DecimalFormat("#,##0.00"); +/* DecimalFormat df = new DecimalFormat("#,##0.00"); DecimalFormat dfv = new DecimalFormat("#,###.########"); //모니터링용 System.out.println("sellPrice = " + df.format(sellPrice)); @@ -92,8 +70,7 @@ public void generateOrder(String ticker, double apiVWAP, double avgVolume) {// System.out.println("buyVolume = " + dfv.format(buyVolume)); System.out.println("===================================="); - System.out.println(ticker+"의 현재 시장 vwap :"+df.format(apiVWAP)+" | 현재 플랫폼 vwap :"+df.format(platformVWAP)); -*/ + System.out.println(ticker+"의 현재 시장 vwap :"+df.format(apiVWAP)+" | 현재 플랫폼 vwap :"+df.format(platformVWAP));*/ } /*System.out.println("📦"+ticker+" [체결 기록 Top 10]"); trades.forEach(t -> @@ -109,13 +86,13 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, new DecimalFormat("#,###.########").format(volume)); return; } - + recorder.recordPrice(ticker,isBuy,price); try { orderService.createOrderWithBot(ticker, isBuy, volume, price); } catch (IllegalArgumentException e) { log.debug("잔량 부족: {}", e.getMessage()); try { - resetBot(ticker); + accountService.resetBot(ticker); orderService.createOrderWithBot(ticker, isBuy, volume, price); } catch (Exception e1) { log.error("주문 재시도 실패", e1); @@ -123,21 +100,4 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, } } - protected void resetBot(String ticker){ - this.ticker = ticker; - Wallet wallet = orderWalletRepository.findWalletBy(SELL_ORDER_BOT_ID,ticker).get(); - wallet.setSize(500_000_000.0); - Wallet wallet2 = orderWalletRepository.findWalletBy(BUY_ORDER_BOT_ID,ticker).get(); - wallet2.setSize(0.0); - orderWalletRepository.save(wallet); - orderWalletRepository.save(wallet2); - - Account account = accountExternalRepository.findByUserId(SELL_ORDER_BOT_ID).get(); - account.setCash(0.0); - Account account2 = accountExternalRepository.findByUserId(BUY_ORDER_BOT_ID).get(); - account2.setCash(500_000_000.0); - accountExternalRepository.save(account); - accountExternalRepository.save(account2); - } - } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java b/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java index b6544433..6aadb643 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java @@ -2,25 +2,65 @@ import com.cleanengine.coin.realitybot.domain.PlatformVWAPState; import com.cleanengine.coin.trade.entity.Trade; -import lombok.extern.slf4j.Slf4j; +import com.cleanengine.coin.trade.repository.TradeRepository; +import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @Service -@Slf4j +@RequiredArgsConstructor public class PlatformVWAPService {//TODO 가상 시장 조회용 사라질 예정임 + private final TradeRepository tradeRepository; + Map vwapMap = new ConcurrentHashMap<>(); + Map lastTradeTimeMap = new ConcurrentHashMap<>(); + - public double calculateVWAPbyTrades(String ticker,List trades,double apiVWAP) { + public double calculateVWAPbyTrades(String ticker,double apiVWAP) { PlatformVWAPState state = vwapMap.computeIfAbsent(ticker, PlatformVWAPState::new); - if (trades.size() < 10){ - //체결 내역이 10개 이하일 경우 자체 계산 - return generateVWAP(apiVWAP); + LocalDateTime lastTradeTime = lastTradeTimeMap.get(ticker); + + //최근 체결 내역 가져오기 + List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); + + if ( trades.size() < 10){ + //체결 내역이 10개 이하일 경우 자체 계산 + return generateVWAP(apiVWAP); + } + LocalDateTime newestTime = trades.get(0).getTradeTime(); + if (lastTradeTime == null) { + lastTradeTimeMap.put(ticker, newestTime); + state.addTrades(trades); + return state.getVWAP(); + } + boolean containsSameTime = false; + for (Trade trade : trades) { + if (trade.getTradeTime().isEqual(lastTradeTime)) { + containsSameTime = true; + break; } + } + + if (!containsSameTime) { + trades = tradeRepository.findByTickerAndTradeTimeGreaterThanEqualOrderByTradeTimeDesc(ticker, lastTradeTime); + newestTime = trades.get(0).getTradeTime(); + lastTradeTimeMap.put(ticker, newestTime); + } + + //================= state.addTrades(trades); + + /*System.out.println("📦"+ticker+" [체결 기록]"); + state.addTrades(trades);trades.forEach(t -> + System.out.printf("🕒 %s | 가격: %.0f | 수량: %.8f | 매수: #%d ↔ 매도: #%d%n", + t.getTradeTime(), t.getPrice(), t.getSize(), t.getBuyUserId(), t.getSellUserId()) + );*/ + + return state.getVWAP(); } diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java index 523015a0..6ef0e471 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java @@ -17,13 +17,18 @@ public class DeviationPricePolicy { */ public AdjustPrice adjust(double platformSell,double platformBuy, double trendLineRate, double apiVWAP, double unitPrice){ - double deviation = Math.abs(trendLineRate); - - if (deviation <= 0.01){ + double deviation = Math.abs(trendLineRate);//음수값 보정 + if (deviation <= 0.017){ return new AdjustPrice(platformSell,platformBuy); } double weight = getCorrectionWeight(deviation); - double closeness = 0.5 + (weight * 0.3); // 보간 가중치: 0.7 ~ 1.0 -> 0.5 +// double closeness = 1-weight; // 보간 가중치: 0.7 ~ 1.0 -> 0.5 + double closeness; // 보간 가중치: 0.7 ~ 1.0 -> 0.5 + if (deviation > 0.07){ + closeness = Math.max(0.2, 1 - weight); + } else { + closeness = 0.01; + } // double targetVWAP = (trendLineRate > 0) //만약 closeness 를 0.5 입력시 중간값 // ? apiVWAP + (platformSell - apiVWAP) * closeness // 고평가 → platformSell(25000) → apiVWAP(16000) 사이 가중치 %로 유도 @@ -39,6 +44,8 @@ public AdjustPrice adjust(double platformSell,double platformBuy, double trendLi buyTarget = apiVWAP - (apiVWAP - platformBuy) * closeness; } +// double adjustedSell = normalizeToUnit(sellTarget,unitPrice); +// double adjustedBuy = normalizeToUnit(buyTarget,unitPrice); double adjustedSell = normalizeToUnit(interpolate(platformSell,sellTarget ,weight),unitPrice); double adjustedBuy = normalizeToUnit(interpolate(platformBuy,buyTarget ,weight),unitPrice); @@ -70,7 +77,8 @@ private double getCorrectionWeight(double deviation) { * 선형 보간 함수: platformPrice → apiVWAP 사이 보간 */ private double interpolate(double platformPrice, double apiVWAP, double weight) { - return platformPrice * (1 - weight) + apiVWAP * weight; + double interWeight = Math.max(1,weight*1.2); + return platformPrice * (1 - interWeight) + apiVWAP * interWeight; } private double normalizeToUnit(double price, double unitPrice) { return Math.round(price / unitPrice) * unitPrice; diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java index 3f43eacf..0c19925f 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java @@ -22,12 +22,19 @@ public double calculateVolume(double avgVolume, double trendLineRate, boolean is //편차에 따른 거래량 보정 3% -> 최대 2.5배 증가 double deviation = Math.abs(trendLineRate); //절댓값 반환 - if (deviation>0.01){ //1% 초과할 경우 - double power = deviation * 100; //0.03 -> 3% -// double multiplier = 1.0 + (power * 0.5); //2.5배 (max로 사용) - double multiplier = Math.pow(1.1,power); //2.5배 (max로 사용) - rawVolume *= multiplier; //강한 추세 -> 강한 보정 + double power = deviation * 100; //0.03 -> 3% + double multiplier; + if (deviation >= 0.1){//1% 초과할 경우 + multiplier = 1.0 + (power * 0.5); //2.5배 (max로 사용) + } else if (deviation >= 0.01){ + double baseline = 5.0-((deviation - 0.01)/0.09)*2.0; + multiplier = baseline + (power * 0.5); + } else { + multiplier = 1.0 + (power * 0.5); } +// double multiplier = Math.pow(1.2,power); //2.5배 (max로 사용) + rawVolume *= multiplier; //강한 추세 -> 강한 보정 + //매수-매도 비중 조정 if (deviation <=0.001) //0.1%일 경우 안정권 , 추가적인 보정 x @@ -48,6 +55,9 @@ private double volumeExpansion(double rawVolume){ if(resultVolume <= 0) { //Volume이 0이하일 경우 재 계산 resultVolume = Math.round(rawVolume * 10000000.0) / 10000000.0; + if(resultVolume <= 0){ + resultVolume = 0.0000001; + } } return resultVolume; } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java deleted file mode 100644 index 4dbffceb..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; -import jakarta.annotation.PreDestroy; -import lombok.Getter; -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; -import org.springframework.core.annotation.Order; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -@Order(4) -@Slf4j -@RequiredArgsConstructor -@Service -public class TradeBatchProcessor implements ApplicationRunner { - - private final WaitingOrdersManager waitingOrdersManager; - private final TradeFlowService tradeFlowService; - private final List executors = new ArrayList<>(); - - @Getter - private final Map tradeQueueManagers = new HashMap<>(); - - @Value("${order.tickers}") String[] tickers; - - @Override - public void run(ApplicationArguments args) { - processTrades(); - } - - private void processTrades() { - for (String ticker : tickers) { - TradeQueueManager tradeQueueManager = new TradeQueueManager(tradeFlowService); - tradeQueueManagers.put(ticker, tradeQueueManager); // 정상 종료를 위해 저장 - - ExecutorService tradeExecutor = Executors.newSingleThreadExecutor(r -> { - Thread thread = new Thread(r); - thread.setName("Trade-" + ticker); - return thread; - }); - executors.add(tradeExecutor); - } - } - - @PreDestroy - public void shutdown() { - // 스레드풀 종료 - for (ExecutorService executor : executors) { - try { - executor.shutdown(); - - // 2초 동안 종료 대기 후 강제 종료 - if (!executor.awaitTermination(2, TimeUnit.SECONDS)) { - executor.shutdownNow(); - // 추가로 1초 더 대기 - if (!executor.awaitTermination(1, TimeUnit.SECONDS)) { - log.error("스레드풀이 완전히 종료되지 않았습니다"); - } - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } - } - -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java index a7256f70..cb7338f4 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutor.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -34,10 +35,12 @@ public class TradeExecutor { private final TradeExecutedEventPublisher tradeExecutedEventPublisher; private final TradeService tradeService; - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional(propagation = Propagation.REQUIRES_NEW, isolation = Isolation.READ_COMMITTED) public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { BuyOrder buyOrder = tradePair.getBuyOrder(); SellOrder sellOrder = tradePair.getSellOrder(); + log.trace("{} - 체결 시작: 매수[{} {}원 {}개] / 매도[{} {}원 {}개]", ticker, buyOrder.getId(), buyOrder.getPrice(), buyOrder.getRemainingSize(), + sellOrder.getId(), sellOrder.getPrice(), sellOrder.getRemainingSize()); double tradedPrice; double tradedSize; @@ -48,10 +51,9 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradedSize = tradeUnitPriceAndSize.tradedSize(); tradedPrice = tradeUnitPriceAndSize.tradedPrice(); if (approxEquals(tradedSize, 0.0)) { - log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); - return ; + checkZeroOrderAndThrowException(buyOrder, sellOrder); } - this.writeTradingLog(buyOrder, sellOrder); + writeTradingLog(buyOrder, sellOrder); totalTradedPrice = tradedPrice * tradedSize; // 주문 잔여수량, 잔여금액 감소 @@ -62,8 +64,8 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr sellOrder.decreaseRemainingSize(tradedSize); // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - this.removeCompletedBuyOrder(waitingOrders, buyOrder); - this.removeCompletedSellOrder(waitingOrders, sellOrder); + removeCompletedBuyOrder(waitingOrders, buyOrder); + removeCompletedSellOrder(waitingOrders, sellOrder); tradeService.updateOrder(buyOrder); tradeService.updateOrder(sellOrder); @@ -89,12 +91,24 @@ public void executeTrade(WaitingOrders waitingOrders, TradePair tr tradeExecutedEventPublisher.publish(tradeExecutedEvent); } - private Account increaseAccountCash(Order order, Double amount) { + private static void checkZeroOrderAndThrowException(BuyOrder buyOrder, SellOrder sellOrder) { + Order zeroOrder = null; + if (approxEquals(buyOrder.getRemainingDeposit(), 0.0)) + zeroOrder = buyOrder; + else if (approxEquals(sellOrder.getRemainingSize(), 0.0)) + zeroOrder = sellOrder; + if (zeroOrder == null) + throw new RuntimeException("수량이 0인 주문이 없는데도 체결 수량이 0인 현상 발생"); + throw new TradeZeroOrderException(String.format("체결 중단: 체결 수량이 0! 매수단가 : %s, 매도단가 : %s", + buyOrder.getPrice(), sellOrder.getPrice()), zeroOrder); + } + + private void increaseAccountCash(Order order, Double amount) { Account account = accountService.findAccountByUserId(order.getUserId()).orElseThrow(); - return accountService.save(account.increaseCash(amount)); + accountService.save(account.increaseCash(amount)); } - private Wallet updateWalletAfterTrade(Order order, String ticker, double tradedSize, double totalTradedPrice) { + private 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; @@ -103,11 +117,11 @@ private Wallet updateWalletAfterTrade(Order order, String ticker, double tradedS buyerWallet.setSize(updatedBuySize); buyerWallet.setBuyPrice(updatedBuyPrice); // TODO : ROI 계산 - return walletService.save(buyerWallet); + walletService.save(buyerWallet); } else if (order instanceof SellOrder) { // 매도 시에는 평단가 변동 없음 Wallet sellerWallet = walletService.findWalletByUserIdAndTicker(order.getUserId(), ticker); - return walletService.save(sellerWallet); + walletService.save(sellerWallet); } else { throw new BusinessException("Unsupported order type: " + order.getClass().getName(), ErrorStatus.INTERNAL_SERVER_ERROR); } @@ -151,7 +165,7 @@ private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) } } - private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { + private static void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", Thread.currentThread().threadId(), buyOrder.getTicker(), @@ -165,26 +179,26 @@ private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { sellOrder.getRemainingSize()); } - private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { + private static 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); + updateCompletedOrderStatus(order); } } - private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { + private static void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0); if (isOrderCompleted) { waitingOrders.removeOrder(order); - this.updateCompletedOrderStatus(order); + updateCompletedOrderStatus(order); } } - private void updateCompletedOrderStatus(Order order) { + private static void updateCompletedOrderStatus(Order order) { order.setState(OrderStatus.DONE); } @@ -196,4 +210,4 @@ private static boolean isLimitOrder(Order order) { return !order.getIsMarketOrder(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java index df367924..f0880bbb 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeFlowService.java @@ -1,5 +1,6 @@ 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.spi.WaitingOrders; import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; @@ -29,9 +30,14 @@ public void execMatchAndTrade(String ticker) { tradeExecutor.executeTrade(waitingOrders, tradePair.get(), ticker); tradePair = tradeMatcher.matchOrders(waitingOrders); continueProcessing = tradePair.isPresent(); + } catch (TradeZeroOrderException e) { + Order order = e.getOrder(); + waitingOrdersManager.getWaitingOrders(order.getTicker()).removeOrder(order); + log.warn("{} - {} 주문 {} 이 주문 수량 0 이므로 제거되었음.", order.getTicker(), order instanceof BuyOrder ? "매수" : "매도", order.getId()); + tradePair = tradeMatcher.matchOrders(waitingOrders); + continueProcessing = tradePair.isPresent(); } catch (Exception e) { - // TODO : 회복 필요 - log.error("Error processing trades for {}: {}", ticker, e.getMessage()); + log.error("{} - 체결 에러 발생: {}", ticker, e.getMessage()); continueProcessing = false; } } diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java deleted file mode 100644 index 0b198e55..00000000 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; -import org.springframework.scheduling.annotation.Async; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionalEventListener; - -@Slf4j -@Component -public class TradeQueueManager { - - private final TradeFlowService tradeFlowService; - - public TradeQueueManager(TradeFlowService tradeFlowService) { - this.tradeFlowService = tradeFlowService; - } - - @EventListener - public void handleOrderInserted(OrderInsertedToQueue orderInsertedToQueue) { - String ticker = orderInsertedToQueue.order().getTicker(); - tradeFlowService.execMatchAndTrade(ticker); - } - -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java b/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java new file mode 100644 index 00000000..1646a098 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeZeroOrderException.java @@ -0,0 +1,16 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.order.domain.Order; +import lombok.Getter; + +@Getter +public class TradeZeroOrderException extends RuntimeException { + + Order order; + + public TradeZeroOrderException(String message, Order order) { + super(message); + this.order = order; + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java new file mode 100644 index 00000000..197862be --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/event/OrderInsertedQueueStartMatchingHandler.java @@ -0,0 +1,53 @@ +package com.cleanengine.coin.trade.event; + +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; +import com.cleanengine.coin.trade.application.TradeFlowService; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Component +@Order(4) +@RequiredArgsConstructor +public class OrderInsertedQueueStartMatchingHandler { + private final Map tickerExecutorServices = new ConcurrentHashMap<>(); + private final TradeFlowService tradeFlowService; + + @EventListener + public void handleOrderInserted(OrderInsertedToQueue orderInsertedToQueue) { + String ticker = orderInsertedToQueue.order().getTicker(); + + if(!tickerExecutorServices.containsKey(ticker)) { + addThreadExecutor(ticker); + } + + ExecutorService executorService = tickerExecutorServices.get(ticker); + executorService.execute(() -> tradeFlowService.execMatchAndTrade(ticker)); + } + + protected synchronized void addThreadExecutor(String ticker) { + if (tickerExecutorServices.containsKey(ticker)) { + return; + } + + ExecutorService executorService = Executors.newSingleThreadExecutor(r->{ + Thread thread = new Thread(r); + thread.setName("Trade-" + ticker); + return thread; + }); + + tickerExecutorServices.put(ticker, executorService); + } + + @PreDestroy + public void shutdown() { + tickerExecutorServices.values().forEach(ExecutorService::shutdown); + } +} diff --git a/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java b/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java index a8854cc6..30bc3de2 100644 --- a/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java +++ b/src/main/java/com/cleanengine/coin/trade/repository/TradeRepository.java @@ -23,4 +23,5 @@ public interface TradeRepository extends JpaRepository { List findByBuyUserIdAndTicker(Integer buyUserId, String ticker); List findBySellUserIdAndTicker(Integer sellUserId, String ticker); List findTop10ByTickerOrderByTradeTimeDesc(String ticker); + List findByTickerAndTradeTimeGreaterThanEqualOrderByTradeTimeDesc(String ticker, LocalDateTime lastTime); } diff --git a/src/main/java/com/cleanengine/coin/trade/service/TradeService.java b/src/main/java/com/cleanengine/coin/trade/service/TradeService.java deleted file mode 100644 index 970e54e1..00000000 --- a/src/main/java/com/cleanengine/coin/trade/service/TradeService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.cleanengine.coin.trade.service; - -import com.cleanengine.coin.trade.entity.Trade; - -public interface TradeService { - - /** - * 거래 데이터를 생성하여 저장합니다. - * - * @param ticker 종목 코드 - * @param price 가격 - * @param size 거래량 - * @return 저장된 Trade 엔티티 - */ - Trade createTrade(String ticker, Double price, Double size, Integer buyUserId, Integer sellUserId); - - /** - * 임의의 거래 데이터를 생성하여 저장합니다. - * 테스트/개발용 메서드입니다. - * - * @return 저장된 Trade 엔티티 - */ - Trade generateRandomTrade(); -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java b/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java deleted file mode 100644 index 7617d3b9..00000000 --- a/src/main/java/com/cleanengine/coin/trade/service/TradeServiceImpl.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.cleanengine.coin.trade.service; - -import com.cleanengine.coin.trade.entity.Trade; -import com.cleanengine.coin.trade.repository.TradeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.Random; - -@Service -@RequiredArgsConstructor -public class TradeServiceImpl implements TradeService { - - private final TradeRepository tradeRepository; - private final Random random = new Random(); - - @Override - public Trade createTrade(String ticker, Double price, Double size, Integer buyUserId, Integer sellUserId) { - Trade trade = new Trade(); - trade.setTicker(ticker); - trade.setPrice(price); - trade.setSize(size); - trade.setBuyUserId(buyUserId); - trade.setSellUserId(sellUserId); - trade.setTradeTime(LocalDateTime.now()); - return tradeRepository.save(trade); - } - - @Override - public Trade generateRandomTrade() { - // 임의의 티커 목록 - String[] tickers = {"BTC", "TRUMP"}; - String ticker = tickers[random.nextInt(tickers.length)]; - - // 가격: 1000 ~ 50000 (소수점 2자리로 반올림) - double price = Math.round((1000 + (50000 - 1000) * random.nextDouble()) * 100) / 100.0; - - // 거래량: 0.1 ~ 5.0 (소수점 2자리로 반올림) - double size = Math.round((0.1 + (5.0 - 0.1) * random.nextDouble()) * 100) / 100.0; - - // 임의의 사용자 ID 생성 - int buyUserId = random.nextInt(1000) + 1; // 1 ~ 1000 - int sellUserId = random.nextInt(1000) + 1; - - // 동일한 사용자 간 거래 방지 - while (buyUserId == sellUserId) { - sellUserId = random.nextInt(1000) + 1; - } - - return createTrade(ticker, price, size, buyUserId, sellUserId); - } -} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java b/src/main/java/com/cleanengine/coin/user/info/application/AccountService.java index cbc78928..94329ad2 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 @@ -1,20 +1,26 @@ 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.AccountRepository; +import com.cleanengine.coin.user.info.infra.WalletRepository; +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.BUY_ORDER_BOT_ID; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; + +@RequiredArgsConstructor @Service public class AccountService { private final AccountRepository accountRepository; + private final WalletRepository walletRepository; - public AccountService(AccountRepository accountRepository) { - this.accountRepository = accountRepository; - } - + @Transactional public Account retrieveAccountByUserId(Integer userId) { return accountRepository.findByUserId(userId).orElse(null); } @@ -32,4 +38,20 @@ public Account createNewAccount(Integer userId, double cash) { return accountRepository.save(account); } + @Transactional + public void resetBot(String ticker) { + Account sellBotAccount = accountRepository.findByUserId(SELL_ORDER_BOT_ID).orElseThrow(); + sellBotAccount.setCash(0.0); + Account buyBotAccount = accountRepository.findByUserId(BUY_ORDER_BOT_ID).orElseThrow(); + buyBotAccount.setCash(500_000_000.0); + accountRepository.save(sellBotAccount); + accountRepository.save(buyBotAccount); + + Wallet wallet = walletRepository.findByAccountIdAndTicker(SELL_ORDER_BOT_ID, ticker).orElseThrow(); + wallet.setSize(500_000_000.0); + Wallet wallet2 = walletRepository.findByAccountIdAndTicker(BUY_ORDER_BOT_ID, ticker).orElseThrow(); + wallet2.setSize(0.0); + walletRepository.save(wallet); + walletRepository.save(wallet2); + } } 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 2dd61722..10fbcef3 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,9 +1,11 @@ package com.cleanengine.coin.user.info.application; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.WalletRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -12,12 +14,15 @@ public class WalletService { private final WalletRepository walletRepository; private final AccountRepository accountRepository; + private final AssetRepository assetRepository; - public WalletService(WalletRepository walletRepository, AccountRepository accountRepository) { + public WalletService(WalletRepository walletRepository, AccountRepository accountRepository, AssetRepository assetRepository) { this.walletRepository = walletRepository; this.accountRepository = accountRepository; + this.assetRepository = assetRepository; } + @Transactional public List findByAccountId(Integer accountId) { return walletRepository.findByAccountId(accountId); } @@ -32,4 +37,11 @@ public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { .orElseGet(() -> Wallet.of(ticker, accountId)); } + public void createNewWallets(Integer accountId) { + assetRepository.findAll() + .stream() + .map(asset -> Wallet.of(asset.getTicker(), accountId)) + .forEach(walletRepository::save); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java index 59390cba..4c337cc5 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/AccountRepository.java @@ -1,12 +1,15 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Account; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.Optional; public interface AccountRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByUserId(Integer userId); } diff --git a/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java b/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java index a9f24c2f..25bf9a9b 100644 --- a/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java +++ b/src/main/java/com/cleanengine/coin/user/info/infra/WalletRepository.java @@ -1,13 +1,17 @@ package com.cleanengine.coin.user.info.infra; import com.cleanengine.coin.user.domain.Wallet; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import java.util.List; import java.util.Optional; public interface WalletRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findByAccountIdAndTicker(Integer accountId, String ticker); + @Lock(LockModeType.PESSIMISTIC_WRITE) List findByAccountId(Integer accountId); } 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 22717307..ac090bfb 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 @@ -1,15 +1,18 @@ package com.cleanengine.coin.user.login.application; import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.WalletService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; import com.cleanengine.coin.user.login.infra.KakaoResponse; import com.cleanengine.coin.user.login.infra.OAuth2Response; import com.cleanengine.coin.user.login.infra.UserOAuthDetails; import com.cleanengine.coin.user.info.infra.OAuthRepository; import com.cleanengine.coin.user.info.infra.UserRepository; +import org.jetbrains.annotations.NotNull; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; @@ -22,11 +25,13 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; private final OAuthRepository oAuthRepository; private final AccountService accountService; + private final WalletService walletService; - public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountService accountService) { + public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountService accountService, WalletService walletService) { this.userRepository = userRepository; this.oAuthRepository = oAuthRepository; this.accountService = accountService; + this.walletService = walletService; } @Override @@ -51,21 +56,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic UserOAuthDetails existData = userRepository.findUserByOAuthProviderAndProviderId(provider, providerUserId); if (existData == null) { - User newUser = new User(); - userRepository.save(newUser); - - OAuth newOAuth = new OAuth(); - newOAuth.setUserId(newUser.getId()); - newOAuth.setProvider(provider); - newOAuth.setProviderUserId(providerUserId); - newOAuth.setEmail(email); - newOAuth.setNickname(name); - // TODO : KAKAO Token 관련 정보 추가 - oAuthRepository.save(newOAuth); - accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); - - UserOAuthDetails newUserOAuthDetails = UserOAuthDetails.of(newUser, newOAuth); - return CustomOAuth2User.of(newUserOAuthDetails); + return createNewUser(provider, providerUserId, email, name); } else { OAuth existOAuth = oAuthRepository.findByProviderAndProviderUserId(provider, providerUserId); @@ -80,6 +71,26 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic } } + @NotNull + protected CustomOAuth2User createNewUser(String provider, String providerUserId, String email, String name) { + User newUser = new User(); + userRepository.save(newUser); + + OAuth newOAuth = new OAuth(); + newOAuth.setUserId(newUser.getId()); + newOAuth.setProvider(provider); + newOAuth.setProviderUserId(providerUserId); + newOAuth.setEmail(email); + newOAuth.setNickname(name); + // TODO : KAKAO Token 관련 정보 추가 + oAuthRepository.save(newOAuth); + Account newAccount = accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); + walletService.createNewWallets(newAccount.getId()); + + UserOAuthDetails newUserOAuthDetails = UserOAuthDetails.of(newUser, newOAuth); + return CustomOAuth2User.of(newUserOAuthDetails); + } + protected OAuth2User doSuperLoadMethod(OAuth2UserRequest userRequest) { return super.loadUser(userRequest); } diff --git a/src/main/resources/application-actuator.yml b/src/main/resources/application-actuator.yml new file mode 100644 index 00000000..167d9532 --- /dev/null +++ b/src/main/resources/application-actuator.yml @@ -0,0 +1,12 @@ +management: + endpoints: + web: + exposure: + include: prometheus,health,metrics + endpoint: + health: + show-details: always + prometheus: + metrics: + export: + enabled: true \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 6ec6fe43..d204d6a6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -27,7 +27,4 @@ spring: frontend: url: "http://localhost:5173/callback" -bot-handler: - fixed-rate: 5000 # 5초마다 실행 - corn : "0 0 0 * * *" # 매일 자정마다 호가 - order-level : 1,2,3,4,5 #오더북 단계 설정 - 주문량 증가 + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b1ca5d89..e7402301 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: allowed-origins: http://localhost:63342,http://localhost:8080,http://localhost:5500,http://localhost:5173,https://investfuture.my endpoints: public: - paths: /api/login,/api/asset,/api/oauth2,/api/healthcheck,/api/coin/realtime,/api/coin/min,/api/minute-ohlc,/v3/api-docs,/swagger,/swagger-ui,/swagger-ui.html,/swagger-resources,/webjars,/h2-console,/favicon.ico + paths: /api/login,/api/asset,/api/oauth2,/api/healthcheck,/api/coin/realtime,/api/coin/min,/api/minute-ohlc,/v3/api-docs,/swagger,/swagger-ui,/swagger-ui.html,/swagger-resources,/webjars,/h2-console,/favicon.ico,/actuator,/test websocket: paths: /api/coin/min,/api/coin/realtime,/api/coin/orderbook jwt: @@ -47,5 +47,5 @@ server: bot-handler: fixed-rate: 5000 # 5초마다 실행 - corn : "0 0 0 * * *" # 매일 자정마다 호가 + cron : "0 0 0 * * *" # 매일 자정마다 호가 order-level : 1,2,3,4,5 #오더북 단계 설정 - 주문량 증가 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index acfdc5cc..974b3fe7 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,7 +1,7 @@ - + @@ -54,6 +54,12 @@ + + + + + + \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java index 2e1c0698..27c80d8c 100644 --- a/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java +++ b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java @@ -46,7 +46,10 @@ static void mariadbProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.username", MariaDBTestContainerExtension.container::getUsername); registry.add("spring.datasource.password", MariaDBTestContainerExtension.container::getPassword); registry.add("logging.level.org.hibernate.SQL", () -> "debug"); - registry.add("spring.jpa.show-sql", () -> "true"); + registry.add("logging.level.org.springframework.data", () -> "debug"); + registry.add("spring.jpa.properties.hibernate.format-sql", () -> "false"); + registry.add("logging.level.org.hibernate.orm.jdbc.bind", () -> "trace"); + registry.add("logging.level.org.hibernate.orm.jdbc.extract", () -> "trace"); } } } diff --git a/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java b/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java new file mode 100644 index 00000000..a3a805d5 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/base/MariaDBIntegrationTest.java @@ -0,0 +1,54 @@ +package com.cleanengine.coin.base; + +import com.cleanengine.coin.configuration.TimeZoneConfig; +import com.cleanengine.coin.tool.extension.MariaDBTestContainerExtension; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.context.jdbc.SqlConfig; + +/** + * Mariadb가 적용된 영속성 Adapter(Repository)를 통합 테스트하기 위한 Base 테스트 클래스입니다. + * JPA Entity가 MariaDB에서 제대로 매핑되는지 확인을 위한 기본적인 insert/select와 직접 작성한 쿼리를 테스트바랍니다. + * API단으로 수행하는 통합 테스트는 AcceptanceTest를 사용바랍니다. + */ +@SpringBootTest +@Tag("testcontainers") +@ActiveProfiles({"dev", "it"}) +@ExtendWith(MariaDBTestContainerExtension.class) +@Import(TimeZoneConfig.class) +@Sql( + scripts = "classpath:db/mariadb/data/delete.sql", + executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD, + config=@SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) +) +public abstract class MariaDBIntegrationTest { + @PersistenceContext + protected EntityManager em; + + @DynamicPropertySource + static void mariadbProperties(DynamicPropertyRegistry registry) { + if(MariaDBTestContainerExtension.container != null && MariaDBTestContainerExtension.container.isRunning()) { + registry.add("spring.datasource.url", MariaDBTestContainerExtension.container::getJdbcUrl); + registry.add("spring.datasource.driver-class-name", MariaDBTestContainerExtension.container::getDriverClassName); + registry.add("spring.datasource.database", MariaDBTestContainerExtension.container::getDatabaseName); + registry.add("spring.datasource.username", MariaDBTestContainerExtension.container::getUsername); + registry.add("spring.datasource.password", MariaDBTestContainerExtension.container::getPassword); + registry.add("logging.level.org.hibernate.SQL", () -> "debug"); + registry.add("logging.level.org.springframework.data", () -> "debug"); + registry.add("spring.jpa.properties.hibernate.format-sql", () -> "false"); + registry.add("logging.level.org.hibernate.orm.jdbc.bind", () -> "trace"); + registry.add("logging.level.org.hibernate.orm.jdbc.extract", () -> "trace"); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java new file mode 100644 index 00000000..6223c10c --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/application/OrderServiceTest.java @@ -0,0 +1,99 @@ +package com.cleanengine.coin.order.application; + +import com.cleanengine.coin.base.MariaDBIntegrationTest; +import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.trade.repository.TradeRepository; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.info.infra.AccountRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class OrderServiceTest extends MariaDBIntegrationTest { + @Autowired + OrderService orderService; + + @Autowired + TradeRepository tradeRepository; + + @Autowired + BuyOrderRepository buyOrderRepository; + @Autowired + SellOrderRepository sellOrderRepository; + + @Autowired + AccountRepository accountRepository; + + @Sql("initializeBotUser.sql") + @DisplayName("동시에 5개의 매도요청과 5개의 매수요청이 들어왔을 때 주문에 대한 체결이 정상적으로 처리된다.") + @Test + public void create10OrdersSimultaneously_orderShouldBeProcessedSuccessfully() throws InterruptedException { + + int numberOfThreads = 100; + + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(numberOfThreads); + + OrderCommand.CreateOrder buyOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.BUY_ORDER_BOT_ID,true, false, 100.0, 100.0, LocalDateTime.now(),false); + OrderCommand.CreateOrder sellOrderCommand = new OrderCommand.CreateOrder("BTC", CommonValues.SELL_ORDER_BOT_ID,false, false, 100.0, 100.0, LocalDateTime.now(),false); + + for (int i = 0; i < numberOfThreads; i++) { + executorService.submit(() -> { + try{ + startLatch.await(); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); + orderService.createOrder(buyOrderCommand); +// orderService.createOrder(sellOrderCommand); + } catch (Exception e) { + System.out.println(e.getMessage()); + } + finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + + endLatch.await(); + // 비동기적으로 처리되는 체결이 완료될때까지 대기 + Thread.sleep(2000); + + long resultCount = tradeRepository.count(); + long buyOrderCount = buyOrderRepository.count(); + long sellOrderCount = sellOrderRepository.count(); + + System.out.println(buyOrderCount); + System.out.println(sellOrderCount); +// extracted(); + assertEquals(5, resultCount); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + protected void extracted() { + Account account = accountRepository.findByUserId(CommonValues.BUY_ORDER_BOT_ID).get(); + System.out.println(account.getCash()); + } +} 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 7c4e6a81..538e25f7 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,13 +1,12 @@ package com.cleanengine.coin.order.integration.buyorder; 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.OrderService; import com.cleanengine.coin.order.application.dto.OrderCommand; import com.cleanengine.coin.order.application.dto.OrderInfo; 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -27,10 +26,10 @@ public class BuyOrderIntegrationTest { OrderService orderService; @Autowired - OrderAccountRepository orderAccountRepository; + AccountRepository accountRepository; @Autowired - OrderWalletRepository orderWalletRepository; + WalletRepository walletRepository; //TODO 3,2가 예약어로 사용하는 만큼 1을 insert하는 테스트가 깨질 수 있다. 또한, sql로 초기화보다 EntityManager나 Repository로 초기화하는게 나은듯 @DisplayName("충분한 돈이 있는 유저가 시장가 매수주문 생성시 주문이 정상 생성됨.") @@ -41,7 +40,7 @@ void givenEnoughMoneyUser_WhenCreateMarketBuyOrder_ThenBuyOrderIsCreated() { true, true, null, 30.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = orderAccountRepository.findByUserId(3).orElseThrow(); + Account account = accountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0, account.getCash()); @@ -55,7 +54,7 @@ void givenEnoughMoneyUser_WhenCreateLimitBuyOrder_ThenSellOrderIsCreated() { true, false, 30.0, 40.0, LocalDateTime.now(),false); OrderInfo.BuyOrderInfo buyOrderInfo = (OrderInfo.BuyOrderInfo) orderService.createOrder(command); - Account account = orderAccountRepository.findByUserId(3).orElseThrow(); + Account account = accountRepository.findByUserId(3).orElseThrow(); assertNotNull(buyOrderInfo.getId()); assertEquals(200000-30.0*40.0, account.getCash()); @@ -107,18 +106,4 @@ void givenCommandWithoutOrderSize_WhenCreateLimitBuyOrder_ThenExceptionIsThrown( assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); } - - @DisplayName("Wallet이 없는 사용자가 주문 요청을 할 경우 Wallet이 생성된다.") - @Sql("classpath:db/user/user_without_wallet.sql") - @Test - void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { - OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, - true, false, 30.0, 40.0, LocalDateTime.now(),false); - - orderService.createOrder(command); - - 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 90aaf4be..d13bd365 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,11 +1,11 @@ package com.cleanengine.coin.order.integration.sellorder; import com.cleanengine.coin.common.error.DomainValidationException; -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; import com.cleanengine.coin.user.domain.Wallet; +import com.cleanengine.coin.user.info.infra.WalletRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -25,7 +25,7 @@ public class SellOrderIntegrationTest { OrderService orderService; @Autowired - OrderWalletRepository orderWalletRepository; + WalletRepository walletRepository; @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 = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = walletRepository.findByAccountIdAndTicker(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 = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); + Wallet wallet = walletRepository.findByAccountIdAndTicker(3, "BTC").orElseThrow(); assertNotNull(sellOrderInfo.getId()); assertEquals(200000-30.0, wallet.getSize()); @@ -101,22 +101,4 @@ void givenCommandWithoutOrderSize_WhenCreateLimitSellOrder_ThenExceptionIsThrown assertThrows(DomainValidationException.class, () -> orderService.createOrder(command)); } - - @DisplayName("Wallet이 없는 사용자가 주문 요청을 할 경우 Wallet이 생성된다.") - @Sql("classpath:db/user/user_without_wallet.sql") - @Test - void givenUserWithoutWallet_WhenCreateOrder_ThenWalletIsCreated() { - OrderCommand.CreateOrder command = new OrderCommand.CreateOrder("BTC", 3, - false, false, 30.0, 40.0, LocalDateTime.now(),false); - - try{ - orderService.createOrder(command); - } catch (Exception e) { - System.out.println(e.getMessage()); - } - - Wallet wallet = orderWalletRepository.findWalletBy(3, "BTC").orElseThrow(); - assertNotNull(wallet); - assertEquals("BTC", wallet.getTicker()); - } } diff --git a/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java b/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java index 74bad746..57fde90d 100644 --- a/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java +++ b/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java @@ -2,10 +2,7 @@ import com.cleanengine.coin.realitybot.api.ApiSchedulerTest; import com.cleanengine.coin.realitybot.api.BithumbAPIClientTest; -import com.cleanengine.coin.realitybot.api.RefresherRunnerTest; -import com.cleanengine.coin.realitybot.api.UnitPriceRefresherTest; import com.cleanengine.coin.realitybot.config.ApiClientConfigTest; -import com.cleanengine.coin.realitybot.config.SchedulerConfigTest; import com.cleanengine.coin.realitybot.domain.APIVWAPStateTest; import com.cleanengine.coin.realitybot.domain.PlatformVWAPStateTest; import com.cleanengine.coin.realitybot.domain.VWAPCalculatorTest; @@ -24,13 +21,10 @@ @Suite @SelectClasses({ - RefresherRunnerTest.class, BithumbAPIClientTest.class, - UnitPriceRefresherTest.class, OpeningPriceTest.class, PlatformVWAPStateTest.class, UnitPricePolicyTest.class, - SchedulerConfigTest.class, ApiSchedulerTest.class, ApiClientConfigTest.class, PlatformVWAPServiceTest.class, diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java deleted file mode 100644 index cfb7bf79..00000000 --- a/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.cleanengine.coin.realitybot.api; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.ApplicationArguments; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.SpyBean; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -@SpringBootTest -public class RefresherRunnerTest { - @SpyBean - private UnitPriceRefresher unitPriceRefresher; - - @DisplayName("어플리케이션 실행 시 호가 단위 수집") - @Test - public void runwithrefrecher(){ - verify(unitPriceRefresher,times(1)).run(any(ApplicationArguments.class)); - verify(unitPriceRefresher,times(1)).initializeUnitPrices(); - } - -// @DisplayName(" ") -} diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java deleted file mode 100644 index 38fcb970..00000000 --- a/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.cleanengine.coin.realitybot.api; - -import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; -import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.realitybot.dto.OpeningPrice; -import com.cleanengine.coin.realitybot.parser.OpeningPriceParser; -import com.cleanengine.coin.realitybot.vo.UnitPricePolicy; -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 java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) -public class UnitPriceRefresherTest { - @InjectMocks - private UnitPriceRefresher unitPriceRefresher; - - @Mock - private AssetRepository assetRepository; - - @Mock - private BithumbAPIClient bithumbAPIClient; - - @Mock - private OpeningPriceParser openingPriceParser; - - @Mock - private UnitPricePolicy unitPricePolicy; - - @DisplayName("run 시작시 호가 단위를 불러오는 지 여부") - @Test - void testRefresherUnitPrice() { - //given - String ticker = "BTC"; - Asset btc = new Asset(ticker,"비트코인",null); - - String json = "[{\"market\": \"KRW-BTC\", \"opening_price\": 1000000, \"trade_price\": 1010000}]"; - OpeningPrice parsed = new OpeningPrice(); - parsed.setOpening_price(1000000); - - when(assetRepository.findAll()).thenReturn(List.of(btc)); - when(bithumbAPIClient.getOpeningPrice(ticker)).thenReturn(json); - when(openingPriceParser.parseGson(json)).thenReturn(parsed); - when(unitPricePolicy.getUnitPrice(1000000)).thenReturn(100.0); - //when - unitPriceRefresher.refreshUnitPrices(); - - //then - double unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); - assertEquals(100.0, unitPrice); - } - -} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java b/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java deleted file mode 100644 index 3b573a5a..00000000 --- a/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package com.cleanengine.coin.realitybot.config; - -import com.cleanengine.coin.realitybot.api.ApiScheduler; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.scheduling.config.ScheduledTaskRegistrar; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.verify; - -@ExtendWith(MockitoExtension.class) -public class SchedulerConfigTest { - - @Mock - ApiScheduler apiScheduler; - - @Mock - ScheduledTaskRegistrar scheduledTaskRegistrar; - - @InjectMocks - SchedulerConfig schedulerConfig; - - @BeforeEach - void setUp() { - schedulerConfig = new SchedulerConfig(apiScheduler,Duration.ofMillis(500)); - } - - - @DisplayName("fixedrate를 적용 후 정상 작동하는 지") - @Test - void testConfigureTasksOnFixedRate() throws InterruptedException { - //when - schedulerConfig.configureTasks(scheduledTaskRegistrar); - - //then - ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class);//스케줄러에 등록된 작업 - ArgumentCaptor intervalCaptor = ArgumentCaptor.forClass(Duration.class);//실행 주기 - //mock에게 전달 된 인자를 캡처해서 확인가능하게 해줌 - //내부 속성을 확인할 때, 동적으로 생성된 값을 검증 할 때 - - verify(scheduledTaskRegistrar).addFixedRateTask(taskCaptor.capture(), intervalCaptor.capture()); - //addFixedRateTask를 실행 시 인자 두개를 캡쳐함 - - Runnable task = taskCaptor.getValue(); - //그 캡처된 인자중 task는 실제 실행하는 작업 (schedulerconfig에 구현한 것 -> apiScheduler.MarketAllRequest();) - task.run(); //가져와서 동적으로 실행할 수 있게 됨 -> apiScheduler.MarketAllRequest(); - - verify(apiScheduler).MarketAllRequest(); //작동 검증 - - Duration interval = intervalCaptor.getValue(); - assertEquals(Duration.ofMillis(500), interval); - } - @DisplayName("marketallrequest가 예외 발생 시 에러를 던지는 지 확인") - @Test - void testCheckErrorbyMarketallRequest() throws InterruptedException { - //given - doThrow(new InterruptedException()).when(apiScheduler).MarketAllRequest(); - //메서드 실행 시 에러 던지도록 셋팅 - - //when - schedulerConfig.configureTasks(scheduledTaskRegistrar); - - //then - ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class);//스케줄러에 등록된 작업 - verify(scheduledTaskRegistrar).addFixedRateTask(taskCaptor.capture(), any(Duration.class)); - - Runnable task = taskCaptor.getValue(); - assertThrows(RuntimeException.class, () -> task.run()); - } - -} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java index b4138452..58105cc7 100644 --- a/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java +++ b/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java @@ -38,7 +38,7 @@ void testCalculateVWAPLessThan10Trades() { double apiVWAP = 1000.0; //0.1%의 보정값 //when - double result = platformVWAPService.calculateVWAPbyTrades(ticker, trades, apiVWAP); + double result = platformVWAPService.calculateVWAPbyTrades(ticker, apiVWAP); //than assertEquals(apiVWAP, result,1); @@ -67,7 +67,7 @@ void testCalculateVWAPMoreThan10Trades() { when(platformVwapState.getVWAP()).thenReturn(15000.0); platformVWAPService.vwapMap.put(ticker, platformVwapState); //when - double result = platformVWAPService.calculateVWAPbyTrades(ticker, trades, apiVWAP); + double result = platformVWAPService.calculateVWAPbyTrades(ticker, apiVWAP); //then verify(platformVwapState).addTrades(trades); diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPErrorInjector.java similarity index 60% rename from src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java rename to src/test/java/com/cleanengine/coin/realitybot/service/VWAPErrorInjector.java index 059e24d1..6806f901 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java +++ b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPErrorInjector.java @@ -2,8 +2,6 @@ import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.time.LocalDateTime; @@ -12,23 +10,14 @@ import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; @Component -@RequiredArgsConstructor -public class VWAPerrorInJectionScheduler { - +public class VWAPErrorInjector { private final TradeRepository tradeRepository; - private boolean shouldInject = false; - private boolean hasInjected = false; - - public void enableInjection() { - if (hasInjected) return; // 이미 실행한 적 있으면 무시 - this.shouldInject = true; + public VWAPErrorInjector(TradeRepository tradeRepository) { + this.tradeRepository = tradeRepository; } - @Scheduled(fixedRate = 30000) // 혹은 따로 수동 호출도 가능 - public void injectFakeTrade() { - if (!shouldInject || hasInjected) return; - + public void injectErrorTrade(){ Trade fakeTrade = new Trade(); fakeTrade.setTicker("TRUMP"); fakeTrade.setBuyUserId(BUY_ORDER_BOT_ID); // 테스트용 유저 ID @@ -40,9 +29,5 @@ public void injectFakeTrade() { fakeTrade.setTradeTime(LocalDateTime.now()); tradeRepository.save(fakeTrade); - hasInjected = true; // ✅ 한 번 실행했으니 플래그 설정 - shouldInject = false; - - System.out.println("🚨 혼동 Trade 1건 삽입 완료!"); } } diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java index 2432d626..a5c884a8 100644 --- a/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java +++ b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java @@ -21,20 +21,20 @@ public class VWAPerrorInJectionSchedulerTest { TradeRepository tradeRepository; @InjectMocks - VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; + VWAPErrorInjector vwapErrorInjector; + @Test @DisplayName("enableInjection() 호출 전에는 작동 안한다") void doNotingInjection(){ - vwaPerrorInJectionScheduler.injectFakeTrade(); + vwapErrorInjector.injectErrorTrade(); verify(tradeRepository,never()).save(any()); } @Test @DisplayName("호출 후에 fateTrade 삽입") void injectOnceAfterEnable(){ - vwaPerrorInJectionScheduler.enableInjection(); - vwaPerrorInJectionScheduler.injectFakeTrade(); + vwapErrorInjector.injectErrorTrade(); ArgumentCaptor captor = ArgumentCaptor.forClass(Trade.class); verify(tradeRepository,times(1)).save(captor.capture()); @@ -42,18 +42,4 @@ void injectOnceAfterEnable(){ Trade trade = captor.getValue(); assertEquals("TRUMP",trade.getTicker()); } - @Test - @DisplayName("한 번 삽입 이후 재삽입 되지 않음") - void onlyOnecInject(){ - vwaPerrorInJectionScheduler.enableInjection(); - verify(tradeRepository, never()).save(any()); - vwaPerrorInJectionScheduler.injectFakeTrade(); - verify(tradeRepository,times(1)).save(any()); - - vwaPerrorInJectionScheduler.injectFakeTrade(); - vwaPerrorInJectionScheduler.enableInjection(); - verify(tradeRepository,times(1)).save(any()); - - } - } \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java index dd4aa933..733a3a38 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeExecuteLoadTest.java @@ -1,90 +1,210 @@ 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.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; 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.Disabled; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.ApplicationArguments; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; -@SpringBootTest +@ActiveProfiles({"dev", "it", "h2-mem"}) @Disabled +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@SpringBootTest class TradeExecuteLoadTest { @Autowired - TradeBatchProcessor tradeBatchProcessor; + private ApplicationEventPublisher eventPublisher; @Autowired - ApplicationArguments applicationArguments; + WaitingOrdersManager waitingOrdersManager; @Autowired - OrderService orderService; + TradeRepository tradeRepository; @Autowired - WaitingOrdersManager waitingOrdersManager; + SellOrderRepository sellOrderRepository; @Autowired - TradeRepository tradeRepository; + BuyOrderRepository buyOrderRepository; private final String ticker = "BTC"; + @DisplayName("워밍업: Spring 컨텍스트 및 JVM 최적화") + @Order(1) + @Test + void warmUp() throws InterruptedException { + runSingleTest(1000); + runSingleTest(10000); + runSingleTest(50000); + } + @BeforeEach void setUp() { - tradeBatchProcessor.shutdown(); - waitingOrdersManager.getWaitingOrders(ticker); - // TODO : 티커마다 큐, DB 초기화 + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + waitingOrders.clearAllQueues(); + tradeRepository.deleteAll(); + sellOrderRepository.deleteAll(); + buyOrderRepository.deleteAll(); + } + @DisplayName("매수, 매도 각 1000건에 대한 처리 성능을 10회 진행한다.") + @Order(2) + @Test + void basicLoadTestWith1000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 1000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); + + // when + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); + } + + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); } - @DisplayName("1000건의 매수 매도 주문을 요청 후 처리 성능을 조회한다.") + @DisplayName("매수, 매도 각 10000건에 대한 처리 성능을 10회 진행한다.") + @Order(3) @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); + void basicLoadTestWith10000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 10000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); + + // when + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); } - 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(); + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); + } + + @DisplayName("매수, 매도 각 100000건에 대한 처리 성능을 10회 진행한다.") + @Order(4) + @Test + void basicLoadTestWith100000OrdersEachSide() throws InterruptedException { + // given + int orderCount = 100000; + int repeatCount = 10; + List executionTimes = new ArrayList<>(); + List queueInsertTimes = new ArrayList<>(); // when - tradeBatchProcessor.run(applicationArguments); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - throw new RuntimeException(e); + for (int i = 0; i < repeatCount; ++i) { + long[] times = runSingleTest(orderCount); + queueInsertTimes.add(times[0]); + executionTimes.add(times[1]); + System.out.printf("Run-%d: 큐 삽입 소요시간 = %d ms, 체결 소요시간 = %d ms%n", (i + 1), times[0], times[1]); + } + + // 통계 출력 + printStatistics(queueInsertTimes, executionTimes, orderCount); + } + + private long[] runSingleTest(int orderCount) throws InterruptedException { + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + long testStart = System.nanoTime(); + + // 주문 생성 및 큐 삽입 + ExecutorService executor = Executors.newFixedThreadPool(10); + for (int i = 0; i < orderCount; i++) { + executor.submit(() -> { + SellOrder limitSellOrder = SellOrder.createLimitSellOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), true); + BuyOrder limitBuyOrder = BuyOrder.createLimitBuyOrder(ticker, 2, 10.0, 130_000_000.0, LocalDateTime.now(), true); + waitingOrders.addOrder(limitSellOrder); + waitingOrders.addOrder(limitBuyOrder); + }); + } + + // 큐 삽입 완료 대기 + executor.shutdown(); + boolean queueTerminated = executor.awaitTermination(10, TimeUnit.SECONDS); + long queueInsertEnd = System.nanoTime(); + long queueInsertTime = (queueInsertEnd - testStart) / 1_000_000; + + // 단일 이벤트 발행 (체결 시작) + long eventStart = System.nanoTime(); + SellOrder dummyOrder = SellOrder.createLimitSellOrder(ticker, 1, 10.0, 130_000_000.0, LocalDateTime.now(), true); + eventPublisher.publishEvent(new OrderInsertedToQueue(dummyOrder)); + + long eventEnd = System.nanoTime(); + long executionTime = (eventEnd - eventStart) / 1_000_000; + + // 결과 출력 + if (tradeRepository.findAll().size() != orderCount) { + PriorityQueueStore buyOrderPriorityQueueStore = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT); + PriorityQueueStore sellOrderPriorityQueueStore = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT); + System.out.print("체결 종료 - 체결내역[: " + tradeRepository.findAll().size() + "건]"); + System.out.println("잔여 주문[매도 " + sellOrderPriorityQueueStore.size() + "건, 매수 " + buyOrderPriorityQueueStore.size() + "건]"); } - // then - tradeBatchProcessor.shutdown(); - long testEnd = System.currentTimeMillis(); + // 큐와 DB 초기화 + waitingOrders.clearAllQueues(); + tradeRepository.deleteAll(); + sellOrderRepository.deleteAll(); + buyOrderRepository.deleteAll(); + + return new long[]{queueInsertTime, executionTime}; + } - System.out.println("trade table size : " + tradeRepository.findAll().size()); + private void printStatistics(List queueInsertTimes, List executionTimes, int orderCount) { + // 큐 삽입 시간 통계 + double queueAvg = queueInsertTimes.stream().mapToLong(Long::longValue).average().orElse(0.0); + long queueMin = queueInsertTimes.stream().mapToLong(Long::longValue).min().orElse(0); + long queueMax = queueInsertTimes.stream().mapToLong(Long::longValue).max().orElse(0); + double queueStdDev = calculateStdDev(queueInsertTimes, queueAvg); + + // 체결 시간 통계 + double executionAvg = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0.0); + long executionMin = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); + long executionMax = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); + double executionStdDev = calculateStdDev(executionTimes, executionAvg); + + // 처리량 통계 (체결 시간 기반) + double throughputAvg = (orderCount * 2) / (executionAvg / 1000.0); + double throughputMin = (orderCount * 2) / (executionMax / 1000.0); + double throughputMax = (orderCount * 2) / (executionMin / 1000.0); + + System.out.printf("=== 통계 결과 ===%n"); + System.out.printf("큐 삽입 시간 - 평균: %.2f ms, 최소: %d ms, 최대: %d ms, 표준편차: %.2f ms%n", + queueAvg, queueMin, queueMax, queueStdDev); + System.out.printf("체결 시간 - 평균: %.2f ms, 최소: %d ms, 최대: %d ms, 표준편차: %.2f ms%n", + executionAvg, executionMin, executionMax, executionStdDev); + System.out.printf("처리량 - 평균: %.2f orders/sec, 최소: %.2f orders/sec, 최대: %.2f orders/sec%n", + throughputAvg, throughputMin, throughputMax); + } - System.out.println("test time : " + (testEnd - testStart) + " ms"); - System.out.println("buyOrderPriorityQueueStore.size() : " + buyOrderPriorityQueueStore.size()); - System.out.println("sellOrderPriorityQueueStore.size() : " + sellOrderPriorityQueueStore.size()); + private double calculateStdDev(List times, double mean) { + double sum = times.stream().mapToDouble(t -> Math.pow(t - mean, 2)).sum(); + return Math.sqrt(sum / times.size()); } } diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java index fd75eb3a..5ba177d0 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeFlowServiceIntegrationTest.java @@ -27,8 +27,6 @@ @Disabled class TradeFlowServiceIntegrationTest { - private static TradeBatchProcessor staticTradeBatchProcessor; - private static final double MINIMUM_ORDER_SIZE = 0.00000001; @Autowired @@ -38,8 +36,6 @@ class TradeFlowServiceIntegrationTest { @Autowired TradeRepository tradeRepository; @Autowired - TradeBatchProcessor tradeBatchProcessor; - @Autowired private WaitingOrdersManager waitingOrdersManager; private final String ticker = "BTC"; @@ -47,9 +43,6 @@ class TradeFlowServiceIntegrationTest { @BeforeEach void setUp() { - if (staticTradeBatchProcessor == null) { - staticTradeBatchProcessor = tradeBatchProcessor; - } WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); waitingOrders.clearAllQueues(); tradeRepository.deleteAll(); @@ -57,11 +50,6 @@ void setUp() { sellOrderRepository.deleteAll(); } - @AfterAll - static void cleanup() { - staticTradeBatchProcessor.shutdown(); - } - // TODO : 모든 케이스에서 각 객체의 값까지 정합성이 맞는지 테스트 필요 @DisplayName("지정가매수-지정가매도 완전체결") diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java deleted file mode 100644 index bce3c445..00000000 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.cleanengine.coin.trade.application; - -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; -import com.cleanengine.coin.order.domain.spi.WaitingOrders; -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.LoggerFactory; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.Level; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.*; - -class TradeQueueManagerTest { - - private ListAppender listAppender; - private Logger tradeQueueManagerLogger; - - @BeforeEach - void setUp() { - // 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); - } - - @AfterEach - void tearDown() { - // 테스트 후 Appender를 정리하여 다른 테스트에 영향을 주지 않도록 합니다. - if (tradeQueueManagerLogger != null && listAppender != null) { - tradeQueueManagerLogger.detachAppender(listAppender); - listAppender.stop(); - } - } - - @DisplayName("체결 엔진 동작 중 예외 발생 시 catch 후 로깅되어야 한다.") - @Test - void catchExceptionWhenExecMatchAndTrade() { - // given - String ticker = "BTC"; - String errorMessage = "예외 발생"; - TradeFlowService mockTradeFlowService = mock(TradeFlowService.class); - WaitingOrders mockWaitingOrders = mock(WaitingOrders.class); - - when(mockWaitingOrders.getTicker()).thenReturn(ticker); - - TradeQueueManager tradeQueueManager = new TradeQueueManager(mockTradeFlowService); - - doAnswer(invocation -> { - throw new RuntimeException(errorMessage); - }).when(mockTradeFlowService).execMatchAndTrade(ticker); - - // when, then -// tradeQueueManager.run(); - - // then -// verify(mockTradeFlowService, times(1)).execMatchAndTrade(ticker); -// -// assertThat(listAppender.list).hasSize(1); -// ILoggingEvent loggingEvent = listAppender.list.get(0); -// -// assertThat(loggingEvent.getLevel()).isEqualTo(Level.ERROR); -// assertThat(loggingEvent.getFormattedMessage()) -// .isEqualTo("Error processing trades for " + ticker + ": " + errorMessage); - - } - -} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java index 13c3f7b1..c2e1f850 100644 --- a/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java +++ b/src/test/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserServiceTest.java @@ -4,6 +4,7 @@ import com.cleanengine.coin.user.domain.OAuth; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.info.application.AccountService; +import com.cleanengine.coin.user.info.application.WalletService; import com.cleanengine.coin.user.login.infra.CustomOAuth2User; import com.cleanengine.coin.user.login.infra.UserOAuthDetails; import com.cleanengine.coin.user.info.infra.OAuthRepository; @@ -42,6 +43,8 @@ class CustomOAuth2UserServiceTest { @Mock private AccountService accountService; @Mock + private WalletService walletService; + @Mock private OAuth2UserRequest userRequest; @Mock private OAuth2User oAuth2UserFromSuper; @@ -57,7 +60,7 @@ class CustomOAuth2UserServiceTest { @BeforeEach void setUp() { // DefaultOAuth2UserService.loadUser만 mocking하기 위해 spy 사용 - customOAuth2UserService = Mockito.spy(new CustomOAuth2UserService(userRepository, oAuthRepository, accountService)); + customOAuth2UserService = Mockito.spy(new CustomOAuth2UserService(userRepository, oAuthRepository, accountService, walletService)); Map profile = Map.of("nickname", "Test User"); Map kakaoAccount = Map.of( diff --git a/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql new file mode 100644 index 00000000..420258ea --- /dev/null +++ b/src/test/resources/com/cleanengine/coin/order/application/initializeBotUser.sql @@ -0,0 +1,12 @@ +INSERT INTO asset(ticker, name) VALUES ('BTC', '비트코인'); + +INSERT INTO users (user_id, created_at) VALUES (1, '2025-05-16 09:30:00.000000'); +INSERT INTO users (user_id, created_at) VALUES (2, '2025-05-16 09:30:00.000000'); + +INSERT INTO account (account_id, cash, user_id) VALUES (1, 0, 1); +INSERT INTO account (account_id, cash, user_id) VALUES (2, 500000000, 2); + +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (1, 1, 0, 0, 500000000, 'BTC'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (2, 1, 0, 0, 500000000, 'TRUMP'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (3, 2, 0, 0, 500000000, 'BTC'); +INSERT INTO wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (4, 2, 0, 0, 500000000, 'TRUMP');