diff --git a/.github/workflows/code-analyze-sonarqube.yml b/.github/workflows/code-analyze-sonarqube.yml new file mode 100644 index 00000000..5c258ca8 --- /dev/null +++ b/.github/workflows/code-analyze-sonarqube.yml @@ -0,0 +1,61 @@ +name: Code Analyze With SonarQube + +run-name: Run code analyze triggered by ${{github.actor}} + +on: + pull_request: + types: [opened, reopened, synchronize] + branches: + - main + - dev + paths: + - 'src/**' + push: + branches: + - dev + paths: + - 'src/**' + +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: 21 + distribution: 'temurin' + + - name: Cache SonarQube packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Cache Gradle packages + uses: actions/cache@v4 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Build with tests + env: + JWT_SECRET: ${{ secrets.JWT_SECRET }} + continue-on-error: true + run: ./gradlew build --info + + - name: SonarQube Analysis + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} + run: ./gradlew sonar --info diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 0dd67a8a..e6c01564 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,13 @@ -name: CI/CD Deploy +name: CI/CD Deploy and Start services -run-name: Deploy springboot as backend image to ECR by ${{github.actor}} +run-name: Deploy to ECR and Start services by ${{github.actor}} on: push: - branches: [ "main" ] + branches: + - main + paths: + - 'src/**' jobs: build: @@ -29,20 +32,31 @@ jobs: - name: Build with Gradle Wrapper run: ./gradlew build -x test - - name: Build image - run: | - docker build -t ${{ secrets.AWS_ORGANIZATION }}/backend . - docker tag ${{ secrets.AWS_ORGANIZATION }}/backend:latest ${{ secrets.AWS_ECR_BACKEND_REPO }}:latest - - - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v4 + - name: Login to DockerHub + uses: docker/login-action@v3 with: - role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} - aws-region: ${{ secrets.AWS_REGION }} + username: ${{secrets.DOCKER_USERNAME}} + password: ${{secrets.DOCKER_TOKEN}} - - name: Login to Amazon ECR - uses: aws-actions/amazon-ecr-login@v2 + - name: Build and Push Docker Image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{secrets.DOCKER_USERNAME}}/if-be:latest - - name: Push image to ECR - run: | - docker push ${{ secrets.AWS_ECR_BACKEND_REPO }}:latest \ No newline at end of file + start-services: + needs: build + runs-on: ubuntu-latest + steps: + - name: Connect to cloud server and run Docker commands + uses: appleboy/ssh-action@v1.2.2 + with: + host: ${{secrets.BACKEND_HOST}} + username: ${{secrets.CLOUD_USERNAME}} + key: ${{secrets.CLOUD_SECRET_KEY}} + port: ${{secrets.CLOUD_PORT}} + script: | + cd ~ + /bin/bash run_springboot.sh diff --git a/.github/workflows/start-service.yml b/.github/workflows/start-service.yml deleted file mode 100644 index 90f6cdff..00000000 --- a/.github/workflows/start-service.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Start services - -run-name: Start containers in cloud service by ${{ github.actor }} - -on: - workflow_run: - workflows: [CI/CD Deploy] - types: - - completed - -jobs: - start-services: - runs-on: ubuntu-latest - steps: - - name: Connect to cloud server - uses: appleboy/ssh-action@v1.2.2 - with: - host: ${{secrets.CLOUD_HOST}} - username: ${{secrets.CLOUD_USERNAME}} - key: ${{secrets.CLOUD_SECRET_KEY}} - port: ${{secrets.CLOUD_PORT}} - script: | - cd ~ - sudo docker compose stop backend - sudo docker compose rm -f backend - sudo docker compose pull backend - sudo docker compose up -d backend diff --git a/README.md b/README.md deleted file mode 100644 index 522858a7..00000000 --- a/README.md +++ /dev/null @@ -1,94 +0,0 @@ -# Invest Future -> 만약에 이때(IF) 투자했다면 지금 얼마를 벌었을까?
-> -> 항상 저희는 상상을 하곤 합니다. ~~(이 때 돈 넣었으면 지금 4배는 벌었는데)~~
-> 하지만 현실은 돈이 부족하기 떄문에 투자를 하기 쉽지 않습니다.
->
-> IF에서는 모의투자를 통해 투자에 대한 경험을 쌓고, 투자에 대한 이해를 높일 수 있습니다.
-
-- 배포 URL : https://investfuture.my -

- -## 다른 모의투자 서비스와의 차이점 - 많은 모의투자서비스는 3가지 유형이 있습니다. - 1. 턴제 모의투자 - 2. 실시간 모의투자 (거래가 차트에 반영되지 않음) - 3. 자체 시장 모의투자 (거래가 차트에 반영 됨) -
- - - - - - - - - - - - - - - - -
ImageImageImage
시장 개입 여부DB 지원 여부봇 지원 여부
- - -기존 모의투자 서비스는 실제 거래소의 시세를 지원하면 사용자의 투자 행동이 차트에 반영되지 않거나,
-차트에 반영되지만 실제 시장과는 다른 자체 시장을 형성하는 서비스가 제공되고 있습니다. - - Invest Future(IF)는 실제 시장의 추세를 따라가고, 사용자의 투자 행동이 차트에 반영되는 모의투자 서비스입니다. - - -
-
- - -## How it works? - -> 기존 실시간 모의투자 서비스에서 사용자의 투자 행동이 차트에 반영되지 않는 이유는 사용자의 투자 행동이 차트에 반영될경우 시간이 지날수록 실제 시장의 차트와 모의투자 서비스의 차트의 괴리가 커지기 때문입니다.
-> 비유하자면 시간이 지날수록 멀티버스가 발생합니다. - -Image -Invest Future(IF)는 사용자의 투자 행동으로 서비스의 차트와 실제 시장의 차트의 괴리가 커지면 자체 매수봇과 매도봇이 매수 매도를 하면서 실제 시장과의 차이를 보간합니다. - - - - -## 프로젝트 특징 - -Image - -### 세부 로직 -
- 주문/체결 -
- Image -

설명 :

-
-
- -
- 보간 -
- Image -

설명 :

-
-
- -
- 차트/DB -
- Image -

설명 :

-
-
- - - -## 1차 구현 [5.2 ~ 5.15] - -코인 한 개(트럼프 코인)을 매수 할 수 있습니다. -사용자의 주문에 따라 그래프차트와 호가창, 실시간 체결창에 반영됩니다. - -https://github.com/user-attachments/assets/779b77f1-e778-4a53-b37f-d79a2dc187e8 diff --git a/build.gradle b/build.gradle index bf5d0264..f3e70cbb 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.4.5' id 'io.spring.dependency-management' version '1.1.7' + id "org.sonarqube" version "6.0.1.5171" + id 'jacoco' } group = 'com.cleanengine' @@ -23,6 +25,26 @@ repositories { mavenCentral() } +jacoco { //추가함 + toolVersion = "0.8.13" +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } + +// afterEvaluate { +// getClassDirectories().setFrom(files(classDirectories.files.collect{ +// fileTree(dir : it, includes: [ +// "com/cleanengine/coin/realitybot/**" +// ]) +// })) +// } +} + dependencies { implementation 'org.springframework.boot:spring-boot-starter-websocket' // WS + STOMP @@ -45,8 +67,13 @@ dependencies { runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.4.5' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mariadb' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.junit.platform:junit-platform-suite:1.10.0' // Spring Security + OAuth2 implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -58,5 +85,22 @@ dependencies { } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform{ + excludeTags 'testcontainers' + } + finalizedBy jacocoTestReport } + +sonar { + properties { + property "sonar.projectKey", "CleanEngine_cleanengine-be_2b6f2f63-fa39-426c-b9c7-8aa127fd14d8" + property "sonar.projectName", "cleanengine-be" + property "sonar.host.url", System.getenv('SONAR_HOST_URL') ?: 'http://localhost:9000' + property "sonar.token", System.getenv('SONAR_TOKEN') ?: '' + property "sonar.java.source", '21' + property "sonar.java.target", '21' + property "sonar.sourceEncoding", "UTF-8" + property "sonar.java.coveragePlugin", "jacoco" + property "sonar.coverage.jacoco.xmlReportPaths", "${project.buildDir}/reports/jacoco/test/jacocoTestReport.xml" + } +} \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..2c7e82ef --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,51 @@ +version: '3.8' + +services: + app: + image: eclipse-temurin:21-jre-alpine + container_name: my-spring-app + 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"] + ports: + - "8080:8080" + env_file: + - ./local.properties + environment: + - TZ=Asia/Seoul + depends_on: + mariadb: + condition: service_healthy + networks: + - app-network + + mariadb: + image: mariadb:latest + container_name: mariadb + ports: + - "3306:3306" + env_file: + - ./local.properties + environment: + - TZ=Asia/Seoul + volumes: + - mariadb_data:/var/lib/mysql + - ./mariadb/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: [ "CMD", "healthcheck.sh", "--connect", "--innodb_initialized" ] + interval: 10s + timeout: 5s + retries: 10 + networks: + - app-network + +networks: + app-network: + driver: bridge + +volumes: + mariadb_data: + driver: local diff --git a/docker/mariadb/init.sql b/docker/mariadb/init.sql new file mode 100644 index 00000000..824352af --- /dev/null +++ b/docker/mariadb/init.sql @@ -0,0 +1,106 @@ +create table account +( + account_id int auto_increment + primary key, + cash double not null, + user_id int not null +); + +create table asset +( + ticker varchar(10) not null + primary key, + name varchar(100) null, + icon blob null +); + +create table buy_orders +( + buy_order_id bigint auto_increment + primary key, + created_at datetime(6) not null, + is_bot bit not null, + is_marketorder bit not null, + order_size double null, + price double null, + remaining_size double null, + state enum ('CANCELED', 'DONE', 'WAIT') not null, + ticker varchar(10) not null, + user_id int not null, + locked_deposit double not null, + remaining_deposit double not null +); + +create table oauth +( + oauth_id int auto_increment + primary key, + access_token text null, + email varchar(255) null, + expires_at datetime(6) null, + nickname varchar(255) null, + provider varchar(20) not null, + provider_user_id varchar(255) not null, + refresh_token text null, + scope varchar(255) null, + user_id int not null +); + +create table sell_orders +( + sell_order_id bigint auto_increment + primary key, + created_at datetime(6) not null, + is_bot bit not null, + is_marketorder bit not null, + order_size double null, + price double null, + remaining_size double null, + state enum ('CANCELED', 'DONE', 'WAIT') not null, + ticker varchar(10) not null, + user_id int not null +); + +create table trade +( + trade_id int auto_increment + primary key, + buy_user_id int not null, + price double not null, + sell_user_id int not null, + size double not null, + ticker varchar(255) not null, + trade_time datetime(6) not null +); + +create table users +( + user_id int auto_increment + primary key, + created_at datetime(6) not null +); + +create table wallet +( + wallet_id bigint auto_increment + primary key, + account_id int not null, + buy_price double null, + roi double null, + size double not null, + ticker varchar(10) not null +); + + + +INSERT INTO `if`.users (user_id, created_at) VALUES (1, '2025-05-16 09:30:00.000000'); +INSERT INTO `if`.users (user_id, created_at) VALUES (2, '2025-05-16 09:30:00.000000'); + +INSERT INTO `if`.account (account_id, cash, user_id) VALUES (1, 0, 1); +INSERT INTO `if`.account (account_id, cash, user_id) VALUES (2, 500000000, 2); + +INSERT INTO `if`.asset (ticker, name) VALUES ('BTC', '비트코인'); +INSERT INTO `if`.asset (ticker, name) VALUES ('TRUMP', '오피셜트럼프'); + +INSERT INTO `if`.wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (1, 1, 0, 0, 500000000, 'BTC'); +INSERT INTO `if`.wallet (wallet_id, account_id, buy_price, roi, size, ticker) VALUES (2, 1, 0, 0, 500000000, 'TRUMP'); diff --git a/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java b/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java index d237d1f5..b13f113c 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/ChartDataController.java @@ -4,7 +4,7 @@ import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; import com.cleanengine.coin.chart.service.ChartSubscriptionService; import com.cleanengine.coin.chart.service.RealTimeOhlcService; -import com.cleanengine.coin.common.annotation.WorkingServerProfile; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -12,9 +12,11 @@ import org.springframework.stereotype.Component; import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Component -@WorkingServerProfile +//@WorkingServerProfile @RequiredArgsConstructor @Slf4j public class ChartDataController { @@ -22,6 +24,10 @@ public class ChartDataController { private final ChartSubscriptionService subscriptionService; private final SimpMessagingTemplate messagingTemplate; private final RealTimeOhlcService realTimeOhlcService; + // 티커별 마지막으로 전송한 OHLC 데이터 캐싱 + @Getter + private final Map lastSentOhlcDataMap = new ConcurrentHashMap<>(); + /** * 1초마다 실행 - 실시간 OHLC 데이터 전송 @@ -46,20 +52,36 @@ public void publishRealTimeOhlc() { RealTimeOhlcDto ohlcData = realTimeOhlcService.getRealTimeOhlc(ticker); if (ohlcData == null) { - log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 빈 데이터 전송", ticker); - RealTimeOhlcDto emptyData = new RealTimeOhlcDto(); - emptyData.setTicker(ticker); - emptyData.setTimestamp(LocalDateTime.now()); - emptyData.setOpen(0.0); - emptyData.setHigh(0.0); - emptyData.setLow(0.0); - emptyData.setClose(0.0); - emptyData.setVolume(0.0); - - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); + // 이전에 전송한 데이터가 있는지 확인 + RealTimeOhlcDto lastSentData = lastSentOhlcDataMap.get(ticker); + + if (lastSentData != null) { + // 이전 데이터가 있으면 타임스탬프만 업데이트하여 재사용 + log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 이전 데이터 재사용", ticker); + RealTimeOhlcDto updatedData = new RealTimeOhlcDto(lastSentData.getTicker(), LocalDateTime.now(), // 현재 시간으로 업데이트 + lastSentData.getOpen(), lastSentData.getHigh(), lastSentData.getLow(), lastSentData.getClose(), lastSentData.getVolume()); + + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, updatedData); + lastSentOhlcDataMap.put(ticker, updatedData); // 캐시 업데이트 + } else { + // 이전 데이터도 없는 경우 빈 데이터 전송 (첫 구독 시) + log.debug("티커 {}의 이전 OHLC 데이터도 없습니다. 빈 데이터 전송", ticker); + RealTimeOhlcDto emptyData = new RealTimeOhlcDto(); + emptyData.setTicker(ticker); + emptyData.setTimestamp(LocalDateTime.now()); + emptyData.setOpen(0.0); + emptyData.setHigh(0.0); + emptyData.setLow(0.0); + emptyData.setClose(0.0); + emptyData.setVolume(0.0); + + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); + lastSentOhlcDataMap.put(ticker, emptyData); // 캐시 업데이트 + } } else { // 조회된 실시간 OHLC 데이터 전송 messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, ohlcData); + lastSentOhlcDataMap.put(ticker, ohlcData); // 캐시 업데이트 log.debug("실시간 OHLC 데이터 전송: {}", ohlcData); } } catch (Exception e) { @@ -69,6 +91,7 @@ public void publishRealTimeOhlc() { } catch (Exception e) { log.error("△ 실시간 OHLC 데이터 발행 중 오류: {}", e.getMessage(), e); } + } diff --git a/src/main/java/com/cleanengine/coin/chart/controller/PrevRateController.java b/src/main/java/com/cleanengine/coin/chart/controller/PrevRateController.java index 87220608..414a1dd4 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/PrevRateController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/PrevRateController.java @@ -1,12 +1,10 @@ package com.cleanengine.coin.chart.controller; -import com.cleanengine.coin.chart.dto.PrevRateDto; -import com.cleanengine.coin.chart.service.RealTimeDataPrevRateService; +import com.cleanengine.coin.chart.service.ChartSubscriptionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @Controller @@ -14,21 +12,10 @@ @Slf4j public class PrevRateController { - private final RealTimeDataPrevRateService prevRateService; - private final SimpMessagingTemplate messagingTemplate; + private final ChartSubscriptionService chartSubscriptionService; @MessageMapping("/subscribe/prevRate/{ticker}") public void subscribePrevRate(@DestinationVariable String ticker) { - log.debug("티커 {} 전일 대비 변동률 구독 요청", ticker); - - // 서비스를 통해 전일 대비 변동률 데이터 얻기 - PrevRateDto data = prevRateService.generatePrevRateData(ticker); - - // 티커별 토픽으로 전송 - messagingTemplate.convertAndSend( - "/topic/prevRate/" + ticker, - data - ); - log.debug("전송 완료: /topic/prevRate/{} -> {}", ticker, data); + chartSubscriptionService.subscribePrevRate(ticker); } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/controller/PrevRateScheduler.java b/src/main/java/com/cleanengine/coin/chart/controller/PrevRateScheduler.java deleted file mode 100644 index b45e1f2a..00000000 --- a/src/main/java/com/cleanengine/coin/chart/controller/PrevRateScheduler.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.cleanengine.coin.chart.controller; - -import com.cleanengine.coin.common.annotation.WorkingServerProfile; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@WorkingServerProfile -@RequiredArgsConstructor -public class PrevRateScheduler { - private final PrevRateController prevRateController; - - @Scheduled(fixedDelay = 1000) - public void sendPrevRate(){ - prevRateController.subscribePrevRate("TRUMP"); - } -} diff --git a/src/main/java/com/cleanengine/coin/chart/controller/RealTimeRateScheduler.java b/src/main/java/com/cleanengine/coin/chart/controller/RealTimeRateScheduler.java deleted file mode 100644 index a4c8bb4b..00000000 --- a/src/main/java/com/cleanengine/coin/chart/controller/RealTimeRateScheduler.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.cleanengine.coin.chart.controller; - -import com.cleanengine.coin.common.annotation.WorkingServerProfile; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -@WorkingServerProfile -@RequiredArgsConstructor -public class RealTimeRateScheduler { - private final RealTimeTradeController realTimeTradeController; - - @Scheduled(fixedDelay = 5000) - public void sendPrevRate(){ - realTimeTradeController.realTimeTradeRate("TRUMP"); - } -} diff --git a/src/main/java/com/cleanengine/coin/chart/controller/RealTimeTradeController.java b/src/main/java/com/cleanengine/coin/chart/controller/RealTimeTradeController.java index 4f3ed025..b3c1ec84 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/RealTimeTradeController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/RealTimeTradeController.java @@ -1,12 +1,11 @@ package com.cleanengine.coin.chart.controller; -import com.cleanengine.coin.chart.dto.RealTimeDataDto; -import com.cleanengine.coin.chart.service.RealTimeTradeService; + +import com.cleanengine.coin.chart.service.ChartSubscriptionService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.DestinationVariable; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Controller; @Controller @@ -14,8 +13,7 @@ @Slf4j public class RealTimeTradeController { - private final SimpMessagingTemplate messagingTemplate; - private final RealTimeTradeService realTimeTradeService; + private final ChartSubscriptionService chartSubscriptionService; /** * 클라이언트가 /app/subscribe/realTimeTradeRate/{ticker} 로 send() 하면 @@ -23,16 +21,6 @@ public class RealTimeTradeController { */ @MessageMapping("/subscribe/realTimeTradeRate/{ticker}") public void realTimeTradeRate(@DestinationVariable String ticker) { - log.debug("티커 {} 실시간 구독 요청", ticker); - - // 서비스로부터 DTO 생성 - RealTimeDataDto data = realTimeTradeService.generateRealTimeData(ticker); - - // 티커별 토픽으로 전송 - messagingTemplate.convertAndSend( - "/topic/realTimeTradeRate/" + ticker, - data - ); - log.debug("전송 완료: /topic/realTimeTradeRate/{} -> {}", ticker, data); + chartSubscriptionService.subscribeRealTimeTradeRate(ticker); } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java b/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java index 07ac65d5..be07435d 100644 --- a/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java +++ b/src/main/java/com/cleanengine/coin/chart/controller/WebSocketMessageController.java @@ -3,7 +3,9 @@ import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; import com.cleanengine.coin.chart.service.ChartSubscriptionService; import com.cleanengine.coin.chart.service.RealTimeOhlcService; +import lombok.Getter; import lombok.RequiredArgsConstructor; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessagingTemplate; @@ -17,9 +19,9 @@ public class WebSocketMessageController { private final ChartSubscriptionService subscriptionService; - private final RealTimeOhlcService realTimeOhlcService; private final SimpMessagingTemplate messagingTemplate; + private final ChartDataController chartDataController; /** * 실시간 OHLC 데이터 구독 처리 @@ -35,13 +37,27 @@ public void subscribeRealTimeOhlc(RealTimeTradeMappingDto request) { // 구독 즉시 최근 실시간 OHLC 데이터 전송 RealTimeOhlcDto latestOhlcData = realTimeOhlcService.getRealTimeOhlc(ticker); + RealTimeOhlcDto lastSentData = chartDataController.getLastSentOhlcDataMap().get(ticker); + if (latestOhlcData == null) { - log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다.", ticker); - // 데이터가 없으면 빈 데이터 전송 - messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, createEmptyRealTimeOhlcDto(ticker)); + if (lastSentData != null) { + // 이전에 전송한 데이터가 있으면 재사용 + log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 이전 데이터 재사용", ticker); + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, lastSentData); + } else { + // 이전 데이터도 없는 경우 빈 데이터 전송 + log.debug("티커 {}의 실시간 OHLC 데이터가 없습니다. 빈 데이터 전송", ticker); + RealTimeOhlcDto emptyData = createEmptyRealTimeOhlcDto(ticker); + messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, emptyData); + // 빈 데이터도 캐시에 저장 + chartDataController.getLastSentOhlcDataMap().put(ticker, emptyData); + } } else { log.debug("티커 {}의 실시간 OHLC 데이터 전송: {}", ticker, latestOhlcData); messagingTemplate.convertAndSend("/topic/realTimeOhlc/" + ticker, latestOhlcData); + // 데이터 캐시에 저장 + chartDataController.getLastSentOhlcDataMap().put(ticker, latestOhlcData); + } } @@ -60,15 +76,10 @@ private RealTimeOhlcDto createEmptyRealTimeOhlcDto(String ticker) { /** * WebSocket 매핑용 DTO */ + @Setter + @Getter public static class RealTimeTradeMappingDto { private String ticker; - public String getTicker() { - return ticker; - } - - public void setTicker(String ticker) { - this.ticker = ticker; - } } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java b/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java new file mode 100644 index 00000000..7b5d9270 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/chart/handler/TradeEventHandler.java @@ -0,0 +1,84 @@ +package com.cleanengine.coin.chart.handler; + +import com.cleanengine.coin.chart.dto.PrevRateDto; +import com.cleanengine.coin.chart.dto.RealTimeDataDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; +import com.cleanengine.coin.chart.service.ChartSubscriptionService; // 의존성 추가 +import com.cleanengine.coin.chart.service.RealTimeDataPrevRateService; +import com.cleanengine.coin.chart.service.RealTimeTradeService; +import com.cleanengine.coin.chart.service.WebsocketSendService; +import com.cleanengine.coin.trade.application.TradeExecutedEvent; +import com.cleanengine.coin.trade.entity.Trade; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class TradeEventHandler { + private final RealTimeTradeService realTimeTradeService; + private final WebsocketSendService websocketSendService; + private final ChartSubscriptionService chartSubscriptionService; // 주입 + private final RealTimeDataPrevRateService realTimeDataPrevRateService; + //event로 이벤틀 처리해야한다. + //eventListener는 void로 처리를 해야한다 + @EventListener + public void handleTradeEvent(TradeExecutedEvent event) { + Trade trade = event.getTrade(); + if (trade == null) { + log.warn("Trade 객체가 null"); + return; + } + + String ticker = trade.getTicker(); + TradeEventDto tradeEventDto = getTradeEventDto(trade); + log.debug("TradeEventHandler 수신 : {}", trade); + + // 실시간 데이터 체결내역 + // 해당 종목에 대한 구독자가 있는지 확인 + if (chartSubscriptionService.isSubscribedToRealTimeTradeRate(ticker)) { + log.debug("종목 {} 실시간 체결 정보 구독자 확인됨. 데이터 처리 및 전송 시작.", ticker); + + + //실시간 체결가 및 변동률 전송 + try { + RealTimeDataDto dto = realTimeTradeService.generateRealTimeData(tradeEventDto); + websocketSendService.sendChangeRate(dto, dto.getTicker()); // dto.getTicker()는 이미 ticker와 동일 + log.debug("실시간 체결가 및 변동률 업데이트 전송 완료 : {}", ticker); + } catch (Exception e) { + log.error("종목 {} 실시간 체결가 및 변동률 업데이트 전송 중 오류: {}", ticker, e.getMessage(), e); + } + } else { + log.debug("종목 {} 실시간 체결 정보 구독자 없음. 데이터 전송 생략.", ticker); + } + + //전날 종가 변동률 전송 + if(chartSubscriptionService.isSubscribedToPrevRate(ticker)) { + log.debug("종목 {} 전일 대비 변동률 구독자 확인됨. 데이터 전송 시작.", ticker); + try { + PrevRateDto dto = realTimeDataPrevRateService.generatePrevRateData(tradeEventDto); + websocketSendService.sendPrevRate(dto, dto.getTicker()); // dto.getTicker()는 이미 ticker와 동일 + log.debug("종목 {} 전일 대비 변동률 전송 완료 : {}", ticker, dto); + } catch (Exception e) { + log.error("종목 {} 전일 대비 변동률 전송 중 오류: {}", ticker, e.getMessage(), e); + } + }else { + log.debug("종목 {} 전일 대비 변동률 구독자 없음. 데이터 전송 생략.", ticker); + } + + } + + @NotNull + public static TradeEventDto getTradeEventDto(Trade trade) { + return new TradeEventDto( + trade.getTicker(), + trade.getSize(), + trade.getPrice(), + trade.getTradeTime() + ); + } + +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java b/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java index ef089115..26d3f177 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/ChartSubscriptionService.java @@ -9,61 +9,65 @@ @Service @Slf4j public class ChartSubscriptionService { - // 구독된 티커 목록 관리 - private final Set subscribedTickers = ConcurrentHashMap.newKeySet(); // 실시간 OHLC 구독 목록 관리 + //이건 종목에 따라 확장성을 고려해서 만든것 종목으로 불러오기 private final Set realTimeOhlcSubscribedTickers = ConcurrentHashMap.newKeySet(); - // 실시간 거래 구독 목록 관리 - private final Set realTimeTradeSubscribedTickers = ConcurrentHashMap.newKeySet(); + // 실시간 체결 정보 구독 목록 관리 + private final Set realTimeTradeRateSubscribedTickers = ConcurrentHashMap.newKeySet(); - /** - * 티커 구독 추가 (캔들) + + //전날 종가 데이터 구독 목록 관리 + private final Set PrevRateSubscribedTickers = ConcurrentHashMap.newKeySet(); + + + /* + 실시간 체결 내역 구독 */ - public void subscribe(String ticker) { - log.debug("캔들 티커 구독 추가: {}", ticker); - subscribedTickers.add(ticker); + public void subscribeRealTimeTradeRate(String ticker) { + validateTicker(ticker); + log.debug("실시간 체결 정보 티커 구독 추가: {}", ticker); + realTimeTradeRateSubscribedTickers.add(ticker); } - /** - * 티커 구독 해제 (캔들) - */ - public void unsubscribe(String ticker) { - log.debug("캔들 티커 구독 해제: {}", ticker); - subscribedTickers.remove(ticker); + //구독 해지 + public void unsubscribeRealTimeTradeRate(String ticker) { + validateTicker(ticker); + log.debug("실시간 체결 정보 티커 구독 해지: {}", ticker); + realTimeTradeRateSubscribedTickers.remove(ticker); } - /** - * 모든 구독된 티커 조회 (캔들) - */ - public Set getAllSubscribedTickers() { - return subscribedTickers; + //모든 구독 종목 반환 + public Set getAllRealTimeTradeRateSubscribedTickers() { + return realTimeTradeRateSubscribedTickers; } - /** - * 해당 티커가 구독되었는지 확인 (캔들) - */ - public boolean isSubscribed(String ticker) { - return subscribedTickers.contains(ticker); + //종목에 대한 구독 여부 + public boolean isSubscribedToRealTimeTradeRate(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } + return realTimeTradeRateSubscribedTickers.contains(ticker); } + /** * 실시간 OHLC 티커 구독 추가 */ public void subscribeRealTimeOhlc(String ticker) { + validateTicker(ticker); log.debug("실시간 OHLC 티커 구독 추가: {}", ticker); realTimeOhlcSubscribedTickers.add(ticker); } - /** - * 실시간 OHLC 티커 구독 해제 - */ public void unsubscribeRealTimeOhlc(String ticker) { - log.debug("실시간 OHLC 티커 구독 해제: {}", ticker); + validateTicker(ticker); + log.debug("실시간 OHLC 티커 구독 해지: {}", ticker); realTimeOhlcSubscribedTickers.remove(ticker); } + /** * 모든 실시간 OHLC 구독된 티커 조회 */ @@ -71,40 +75,46 @@ public Set getAllRealTimeOhlcSubscribedTickers() { return realTimeOhlcSubscribedTickers; } - /** - * 해당 티커가 실시간 OHLC 구독되었는지 확인 - */ - public boolean isRealTimeOhlcSubscribed(String ticker) { + public boolean isSubscribedToRealTimeOhlc(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } return realTimeOhlcSubscribedTickers.contains(ticker); } - /** - * 실시간 거래 데이터 티커 구독 추가 + + /* + 전날 종가 변동률 구독 추가,삭제,조회 */ - public void subscribeRealTime(String ticker) { - log.debug("실시간 거래 데이터 티커 구독 추가: {}", ticker); - realTimeTradeSubscribedTickers.add(ticker); + public void subscribePrevRate(String ticker) { + validateTicker(ticker); + log.debug("전날 종가 변동률 티커 구독 추가: {}", ticker); + PrevRateSubscribedTickers.add(ticker); } - /** - * 실시간 거래 데이터 티커 구독 해제 - */ - public void unsubscribeRealTime(String ticker) { - log.debug("실시간 거래 데이터 티커 구독 해제: {}", ticker); - realTimeTradeSubscribedTickers.remove(ticker); + public void unsubscribePrevRate(String ticker) { + validateTicker(ticker); + log.debug("전날 종가 변동률 티커 구독 해지: {}", ticker); + PrevRateSubscribedTickers.remove(ticker); } - /** - * 모든 실시간 거래 데이터 구독된 티커 조회 - */ - public Set getAllRealTimeSubscribedTickers() { - return realTimeTradeSubscribedTickers; + public Set getAllPrevRateSubscribedTickers(String ticker) { + return PrevRateSubscribedTickers; } - /** - * 해당 티커가 실시간 거래 데이터 구독되었는지 확인 - */ - public boolean isRealTimeSubscribed(String ticker) { - return realTimeTradeSubscribedTickers.contains(ticker); + public boolean isSubscribedToPrevRate(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + return false; // 유효하지 않은 티커는 구독되지 않은 것으로 처리 + } + return PrevRateSubscribedTickers.contains(ticker); } + + //CCmap은 null을 허용시키기때문에 null 종목이 들어가도 npe발생안되는 이슈 테스트에서 발견 + //검증 로직 추가 + private void validateTicker(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("유효하지 않은 티커입니다: " + ticker); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java index 5ce9b1e6..b4e7f17f 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateService.java @@ -1,11 +1,12 @@ package com.cleanengine.coin.chart.service; import com.cleanengine.coin.chart.dto.PrevRateDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; import com.cleanengine.coin.chart.repository.RealTimeTradeRepository; import com.cleanengine.coin.trade.entity.Trade; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -15,44 +16,48 @@ @Slf4j public class RealTimeDataPrevRateService { - private final SimpMessagingTemplate messagingTemplate; private final RealTimeTradeRepository tradeRepository; - // 기존 메서드 - 컨트롤러에서 호출된 후 내부적으로 전송 - public void publishPrevRate(String ticker) { - PrevRateDto data = generatePrevRateData(ticker); - messagingTemplate.convertAndSend("/topic/prevRate/" + ticker, data); - log.debug("전송 완료: /topic/prevRate/{} -> {}", ticker, data); + public PrevRateDto generatePrevRateData(TradeEventDto currentTrade) { + return generatePrevRateData(currentTrade, LocalDateTime.now()); } - // 새로운 메서드 - 컨트롤러에서 데이터만 가져오기 위해 사용 - public PrevRateDto generatePrevRateData(String ticker) { - // 전일 종가 계산 - LocalDateTime today = LocalDateTime.now(); - LocalDateTime yesterdayStart = today.minusDays(1).withHour(0).withMinute(0).withSecond(0); - LocalDateTime yesterdayEnd = today.minusDays(1).withHour(23).withMinute(59).withSecond(59); + PrevRateDto generatePrevRateData(TradeEventDto currentTrade, LocalDateTime currentTime) { + String ticker = currentTrade.getTicker(); + YesterDay yesterDay = getYesterDay(currentTime); + log.debug("조회 시간 범위: {} ~ {}", yesterDay.yesterdayStart(), yesterDay.yesterdayEnd()); Trade yesterdayLastTrade = tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( - ticker, yesterdayStart, yesterdayEnd); - - // 현재가 - Trade currentTrade = tradeRepository.findFirstByTickerOrderByTradeTimeDesc(ticker); + ticker, yesterDay.yesterdayStart(), yesterDay.yesterdayEnd()); - if (yesterdayLastTrade == null || currentTrade == null) { - log.debug("전일 또는 현재 거래 데이터가 없습니다: {}", ticker); - return new PrevRateDto(ticker, 0.0, 0.0, 0.0, LocalDateTime.now()); + if (yesterdayLastTrade == null) { + log.debug("전일 거래 데이터가 없습니다: {}", ticker); + return new PrevRateDto(ticker, 0.0, currentTrade.getPrice(), 0.0, currentTime); } - double prevClose = yesterdayLastTrade.getPrice(); double currentPrice = currentTrade.getPrice(); - double changeRate = ((currentPrice - prevClose) / prevClose) * 100; + double changeRate = getChangeRate(currentPrice, prevClose); return new PrevRateDto( ticker, prevClose, currentPrice, changeRate, - LocalDateTime.now() + currentTime ); } + + static double getChangeRate(double currentPrice, double prevClose) { + return ((currentPrice - prevClose) / prevClose) * 100; + } + + @NotNull + static YesterDay getYesterDay(LocalDateTime today) { + LocalDateTime yesterdayStart = today.minusDays(1).withHour(0).withMinute(0).withSecond(0); + LocalDateTime yesterdayEnd = today.minusDays(1).withHour(23).withMinute(59).withSecond(59); + return new YesterDay(yesterdayStart, yesterdayEnd); + } + + record YesterDay(LocalDateTime yesterdayStart, LocalDateTime yesterdayEnd) { + } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java index af7bd463..19298eb1 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeOhlcService.java @@ -5,10 +5,10 @@ import com.cleanengine.coin.trade.repository.TradeRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import java.time.LocalDateTime; -import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -31,56 +31,90 @@ public class RealTimeOhlcService { */ public RealTimeOhlcDto getRealTimeOhlc(String ticker) { try { - // 현재 시간 LocalDateTime now = LocalDateTime.now(); - // 마지막 처리 시간 (없으면 현재 시간에서 1초 전) - LocalDateTime lastProcessedTime = lastProcessedTimeMap.getOrDefault( - ticker, now.minusSeconds(1)); + // 시간 범위 계산 + TimeRange timeRange = calculateTimeRange(ticker, now); - // 1초 전부터 현재까지의 데이터 조회 - List recentTrades = tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( - ticker, - lastProcessedTime, - now - ); + // 거래 데이터 조회 및 전처리 + List recentTrades = getProcessedTradeData(ticker, timeRange); - // 시간 순서대로 정렬이 필요하면 뒤집음 - Collections.reverse(recentTrades); - - // 거래 데이터가 없으면 마지막으로 캐싱된 데이터 반환 + // 거래 데이터가 없으면 캐시된 데이터 반환 if (recentTrades.isEmpty()) { - return lastOhlcDataMap.getOrDefault(ticker, null); + return getCachedData(ticker); } - // 새로운 마지막 처리 시간 업데이트 - lastProcessedTimeMap.put(ticker, now); - - // OHLC 계산 - Double open = recentTrades.get(0).getPrice(); - Double high = recentTrades.stream().mapToDouble(Trade::getPrice).max().orElse(0.0); - Double low = recentTrades.stream().mapToDouble(Trade::getPrice).min().orElse(0.0); - Double close = recentTrades.get(recentTrades.size() - 1).getPrice(); - Double volume = recentTrades.stream().mapToDouble(Trade::getSize).sum(); - - // RealTimeOhlcDto 생성 - RealTimeOhlcDto ohlcData = new RealTimeOhlcDto( - ticker, - now, - open, - high, - low, - close, - volume - ); - - // 캐시에 저장 - lastOhlcDataMap.put(ticker, ohlcData); + calculateOhlcv ohlcv = getCalculateOhlcv(recentTrades); + + RealTimeOhlcDto ohlcData = createOhlcDto(ticker, now, ohlcv); + + // 캐시 업데이트 + updateCache(ticker, now, ohlcData); return ohlcData; } catch (Exception e) { log.error("실시간 OHLC 데이터 생성 중 오류: {}", e.getMessage(), e); - return lastOhlcDataMap.getOrDefault(ticker, null); + return getCachedData(ticker); } } + + // 시간 범위 계산 + TimeRange calculateTimeRange(String ticker, LocalDateTime now) { + LocalDateTime lastProcessedTime = lastProcessedTimeMap.getOrDefault( + ticker, now.minusSeconds(1)); + return new TimeRange(lastProcessedTime, now); + } + + // 거래 데이터 조회 및 전처리 + List getProcessedTradeData(String ticker, TimeRange timeRange) { + return tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + ticker, + timeRange.start(), + timeRange.end() + ); + } + + // 캐시 업데이트 + void updateCache(String ticker, LocalDateTime now, RealTimeOhlcDto ohlcData) { + lastProcessedTimeMap.put(ticker, now); + lastOhlcDataMap.put(ticker, ohlcData); + } + + // 캐시된 데이터 조회 + RealTimeOhlcDto getCachedData(String ticker) { + return lastOhlcDataMap.getOrDefault(ticker, null); + } + + // DTO 생성 + RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime timestamp, calculateOhlcv ohlcv) { + return new RealTimeOhlcDto( + ticker, + timestamp, + ohlcv.open(), + ohlcv.high(), + ohlcv.low(), + ohlcv.close(), + ohlcv.volume() + ); + } + + // OHLCV 계산 메서드 + @NotNull + static calculateOhlcv getCalculateOhlcv(List trades) { + // trades는 시간 오름차순 정렬되어 있음 + Double open = trades.getFirst().getPrice(); // 첫 번째(가장 오래된) = Open ✅ + Double close = trades.getLast().getPrice(); // 마지막(가장 최근) = Close ✅ + Double high = trades.stream().mapToDouble(Trade::getPrice).max().orElse(0.0); + Double low = trades.stream().mapToDouble(Trade::getPrice).min().orElse(0.0); + Double volume = trades.stream().mapToDouble(Trade::getSize).sum(); + + return new calculateOhlcv(open, high, low, close, volume); + } + + + + + record TimeRange(LocalDateTime start, LocalDateTime end) {} + + record calculateOhlcv(Double open, Double high, Double low, Double close, Double volume) {} } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java b/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java index 6b191261..54c6eb57 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java +++ b/src/main/java/com/cleanengine/coin/chart/service/RealTimeTradeService.java @@ -1,14 +1,13 @@ + package com.cleanengine.coin.chart.service; import com.cleanengine.coin.chart.dto.RealTimeDataDto; import com.cleanengine.coin.chart.dto.TradeEventDto; -import com.cleanengine.coin.trade.application.TradeBatchProcessor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.UUID; - import java.time.LocalDateTime; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -17,81 +16,140 @@ @RequiredArgsConstructor @Slf4j public class RealTimeTradeService { - //tradeService를 받아야한다 - private final TradeBatchProcessor tradeBatchProcessor; - - //실시간 거래 데이터 보안성에 적합한 ConcuerrentHashMap 사용 + //실시간 거래 데이터 보안성에 적합한 ConcurrentHashMap 사용 //전에 데이터를 캐싱처리해서 변동률 계산 private final Map previousTradeMap = new ConcurrentHashMap<>(); - public RealTimeDataDto generateRealTimeData(String ticker){ - // 최신 거래 이벤트 데이터 조회 - TradeEventDto tradeEventDto = tradeBatchProcessor.retrieveTradeEventDto(ticker); + //이벤트 Dto 받아서 체결내역에 필요한 데이터들을 보내주는것 + public RealTimeDataDto generateRealTimeData(TradeEventDto tradeEventDto) { + try { + TradeInfo currentTradeInfo = extractTradeInfo(tradeEventDto); + + ChangeRateResult changeRateResult = calculateChangeRate(tradeEventDto, currentTradeInfo); + + updateTradeCache(tradeEventDto, changeRateResult); + + return createRealTimeDataDto(currentTradeInfo, changeRateResult.changeRate()); - // 거래 데이터가 없는 경우 처리 - if(tradeEventDto == null){ - log.debug("실시간 거래 데이터가 존재하지않습니다: {}", ticker); - return new RealTimeDataDto(ticker, 0, 0, 0, LocalDateTime.now(), UUID.randomUUID().toString()); + } catch (Exception e) { + log.error("실시간 데이터 생성 중 오류: {}", e.getMessage(), e); + // 기본값으로 DTO 생성 + TradeInfo currentTradeInfo = extractTradeInfo(tradeEventDto); + return createRealTimeDataDto(currentTradeInfo, 0.0); } + } + + // 거래 정보 추출 + TradeInfo extractTradeInfo(TradeEventDto tradeEventDto) { + return new TradeInfo( + tradeEventDto.getTicker(), + tradeEventDto.getPrice(), + tradeEventDto.getSize(), + tradeEventDto.getTimestamp() + ); + } + + // 변동률 계산 + ChangeRateResult calculateChangeRate(TradeEventDto currentTrade, TradeInfo currentTradeInfo) { + TradeEventDto previousTrade = previousTradeMap.get(currentTradeInfo.ticker()); - // 현재 가격 및 시간 정보 추출 - double currentPrice = tradeEventDto.getPrice(); - double currentSize = tradeEventDto.getSize(); - LocalDateTime currentTime = tradeEventDto.getTimestamp(); + logTradeComparison(currentTrade, previousTrade); - // 변동률 계산 - double changeRate = 0.0; - TradeEventDto previousTrade = previousTradeMap.get(ticker); - //참조 오류로 동일한 객체를 보고있어서 변동률 계산에 오류가 발생 + if (!shouldCalculateChangeRate(previousTrade, currentTrade)) { + return new ChangeRateResult(0.0, false); + } + + if (!isNewTrade(previousTrade, currentTrade)) { + log.debug("동일한 타임스탬프의 거래 데이터가 다시 수신됨: {}", currentTrade.getTimestamp()); + return new ChangeRateResult(0.0, false); + } + + double changeRate = getChangeRate(currentTradeInfo.price(), previousTrade.getPrice()); + log.debug("변동률 계산: 현재가={}, 이전가={}, 변동률={}%", + currentTradeInfo.price(), previousTrade.getPrice(), changeRate); + + return new ChangeRateResult(changeRate, true); + } + + // 변동률 계산 조건 검사 + boolean shouldCalculateChangeRate(TradeEventDto previousTrade, TradeEventDto currentTrade) { + if (previousTrade == null) { + log.debug("이전 거래 정보가 없어 변동률을 0으로 설정: {}", currentTrade.getTicker()); + return false; + } + + if (previousTrade.getPrice() <= 0) { + log.debug("이전 거래 가격이 유효하지 않음: {}", previousTrade.getPrice()); + return false; + } + + if (previousTrade == currentTrade) { + log.debug("동일한 거래 객체가 다시 수신됨 (참조 동일): {}", currentTrade.getTicker()); + return false; + } + + return true; + } + + // 새로운 거래인지 판단 + boolean isNewTrade(TradeEventDto previousTrade, TradeEventDto currentTrade) { + if (previousTrade.getTimestamp() == null || currentTrade.getTimestamp() == null) { + return true; + } + return !previousTrade.getTimestamp().equals(currentTrade.getTimestamp()); + } + + // 거래 비교 로그 + void logTradeComparison(TradeEventDto currentTrade, TradeEventDto previousTrade) { log.debug("타임스탬프 비교 - 현재: {}, 이전: {}, 동일객체: {}", - tradeEventDto.getTimestamp(), + currentTrade.getTimestamp(), previousTrade != null ? previousTrade.getTimestamp() : "없음", - previousTrade == tradeEventDto); - - // 이전 거래가 있고, 새로운 거래 데이터인 경우에만 변동률 계산 - if (previousTrade != null && previousTrade.getPrice() > 0 && previousTrade != tradeEventDto) { - // 타임스탬프 비교로 새로운 거래인지 확인 - if (previousTrade.getTimestamp() == null || tradeEventDto.getTimestamp() == null || - !previousTrade.getTimestamp().equals(tradeEventDto.getTimestamp())) { - - double previousPrice = previousTrade.getPrice(); - changeRate = ((currentPrice - previousPrice) / previousPrice) * 100; - log.debug("변동률 계산: 현재가={}, 이전가={}, 변동률={}%", - currentPrice, previousPrice, changeRate); - - // 새로운 거래 데이터 저장 - previousTradeMap.put(ticker, new TradeEventDto( - tradeEventDto.getTicker(), - tradeEventDto.getSize(), - tradeEventDto.getPrice(), - tradeEventDto.getTimestamp() - )); - } else { - log.debug("동일한 타임스탬프의 거래 데이터가 다시 수신됨: {}", tradeEventDto.getTimestamp()); - } - } else { - if (previousTrade == null) { - log.debug("이전 거래 정보가 없어 변동률을 0으로 설정: {}", ticker); - // 첫 거래 데이터 저장 (복사본 저장) - previousTradeMap.put(ticker, new TradeEventDto( - tradeEventDto.getTicker(), - tradeEventDto.getSize(), - tradeEventDto.getPrice(), - tradeEventDto.getTimestamp() - )); - } else if (previousTrade == tradeEventDto) { - log.debug("동일한 거래 객체가 다시 수신됨 (참조 동일): {}", ticker); - } + previousTrade == currentTrade); + } + + // 캐시 업데이트 + void updateTradeCache(TradeEventDto tradeEventDto, ChangeRateResult changeRateResult) { + if (changeRateResult.shouldUpdate() || !previousTradeMap.containsKey(tradeEventDto.getTicker())) { + TradeEventDto cachedTrade = createCachedTradeDto(tradeEventDto); + previousTradeMap.put(tradeEventDto.getTicker(), cachedTrade); } - // RealTimeDataDto 객체 생성 및 반환 + } + + // 캐시용 TradeEventDto 생성 (복사본) + TradeEventDto createCachedTradeDto(TradeEventDto cashDataDto) { + return new TradeEventDto( + cashDataDto.getTicker(), + cashDataDto.getSize(), + cashDataDto.getPrice(), + cashDataDto.getTimestamp() + ); + } + + // RealTimeDataDto 생성 + RealTimeDataDto createRealTimeDataDto(TradeInfo tradeInfo, double changeRate) { return new RealTimeDataDto( - ticker, - currentSize, - currentPrice, + tradeInfo.ticker(), + tradeInfo.size(), + tradeInfo.price(), changeRate, - currentTime, - UUID.randomUUID().toString() + tradeInfo.timestamp(), + generateTransactionId() ); } + // 트랜잭션 ID 생성 + String generateTransactionId() { + return UUID.randomUUID().toString(); + } + + // 변동률 계산 + public double getChangeRate(double currentPrice, double previousPrice) { + return ((currentPrice - previousPrice) / previousPrice) * 100; + } + + // 거래 정보를 담는 record + record TradeInfo(String ticker, double price, double size, LocalDateTime timestamp) {} + + // 변동률 계산 결과를 담는 record + record ChangeRateResult(double changeRate, boolean shouldUpdate) {} } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java b/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java new file mode 100644 index 00000000..0ef1ff8d --- /dev/null +++ b/src/main/java/com/cleanengine/coin/chart/service/WebsocketSendService.java @@ -0,0 +1,52 @@ +package com.cleanengine.coin.chart.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class WebsocketSendService { + private final SimpMessagingTemplate messagingTemplate; + + //직전 데이터등락율 보내는 메세지 형식 + public void sendChangeRate(Object data, String ticker) { + log.debug("티커 {} 실시간 구독 요청", ticker); + + String topic = buildTopic("realTimeTradeRate", ticker); + sendMessage(topic, data); + + log.debug("전송 완료: {} -> {}", topic, data); + } + + //전날 종가 변동률로 보내는 메세지 형식 + public void sendPrevRate(Object data, String ticker) { + log.debug("티커 {} 전일 대비 변동률 보내는 요청", ticker); + + String topic = buildTopic("prevRate", ticker); // 기존 로직 유지 + sendMessage(topic, data); + + log.debug("전송 완료: {} -> {}", topic, data); + } + + // 테스트하기 좋게 분리된 메서드들 + String buildTopic(String topicType, String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("티커는 비어있을 수 없습니다"); + } + return "/topic/" + topicType + "/" + ticker; + } + + void sendMessage(String topic, Object data) { + if (topic == null || topic.trim().isEmpty()) { + throw new IllegalArgumentException("토픽은 비어있을 수 없습니다"); + } + if (data == null) { + throw new IllegalArgumentException("데이터는 null일 수 없습니다"); + } + + messagingTemplate.convertAndSend(topic, data); + } +} \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java b/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java index 5febc78b..5d1d20e6 100644 --- a/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java +++ b/src/main/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImpl.java @@ -4,11 +4,12 @@ import com.cleanengine.coin.chart.repository.MinuteOhlcDataRepository; import com.cleanengine.coin.trade.entity.Trade; import lombok.RequiredArgsConstructor; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; -import java.util.LinkedHashMap; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -21,49 +22,98 @@ public class MinuteOhlcDataServiceImpl implements MinuteOhlcDataService { @Override public List getMinuteOhlcData(String ticker) { - // 1) 해당 티커의 모든 트레이드를 시간 순으로 조회 - List trades = tradeRepository.findByTickerOrderByTradeTimeAsc(ticker); + validateTicker(ticker); + + List trades = getTradeData(ticker); + + if (trades.isEmpty()) { + return List.of(); + } + + Map> groupedByMinute = groupTradesByMinute(trades); + + return convertToOhlcData(ticker, groupedByMinute); + } - // 2) 분 단위로 그룹핑 (tradeTime 을 분 단위로 자르고 순서 유지) - Map> byMinute = trades.stream() + // 입력 검증 + void validateTicker(String ticker) { + if (ticker == null || ticker.trim().isEmpty()) { + throw new IllegalArgumentException("티커는 비어있을 수 없습니다"); + } + } + + // 거래 데이터 조회 + List getTradeData(String ticker) { + return tradeRepository.findByTickerOrderByTradeTimeAsc(ticker); + } + + // 분 단위 그룹핑 로직 + Map> groupTradesByMinute(List trades) { + return trades.stream() .collect(Collectors.groupingBy( - t -> t.getTradeTime().truncatedTo(ChronoUnit.MINUTES), + this::truncateToMinute, LinkedHashMap::new, Collectors.toList() )); + } + + + LocalDateTime truncateToMinute(Trade trade) { + return trade.getTradeTime().truncatedTo(ChronoUnit.MINUTES); + } - // 3) 각 분 그룹마다 OHLC + **거래량(volume)** 계산 - return byMinute.entrySet().stream() - .map(entry -> { - LocalDateTime minute = entry.getKey(); - List bucket = entry.getValue(); - - double open = bucket.get(0).getPrice(); - double close = bucket.get(bucket.size() - 1).getPrice(); - double high = bucket.stream() - .mapToDouble(Trade::getPrice) - .max() - .orElse(open); - double low = bucket.stream() - .mapToDouble(Trade::getPrice) - .min() - .orElse(open); - - // ← 여기를 바꿔서 “거래량”을 size 필드의 합으로 계산 - double volume = bucket.stream() - .mapToDouble(Trade::getSize) - .sum(); - - return new RealTimeOhlcDto( - ticker, - minute, - open, - high, - low, - close, - volume - ); - }) + // OHLC 데이터 변환 (메인 비즈니스 로직) + List convertToOhlcData(String ticker, Map> groupedByMinute) { + return groupedByMinute.entrySet().stream() + .map(entry -> createOhlcDto(ticker, entry.getKey(), entry.getValue())) .collect(Collectors.toList()); } + + RealTimeOhlcDto createOhlcDto(String ticker, LocalDateTime minute, List trades) { + validateTradeList(trades); + + OhlcData ohlcData = calculateOhlcData(trades); + + return new RealTimeOhlcDto( + ticker, + minute, + ohlcData.open(), + ohlcData.high(), + ohlcData.low(), + ohlcData.close(), + ohlcData.volume() + ); + } + + void validateTradeList(List trades) { + if (trades == null || trades.isEmpty()) { + throw new IllegalArgumentException("거래 데이터가 없습니다"); + } + } + + // OHLC 계산 로직 + @NotNull + static OhlcData calculateOhlcData(List trades) { + double open = trades.get(0).getPrice(); + double close = trades.get(trades.size() - 1).getPrice(); + + double high = trades.stream() + .mapToDouble(Trade::getPrice) + .max() + .orElse(open); + + double low = trades.stream() + .mapToDouble(Trade::getPrice) + .min() + .orElse(open); + + double volume = trades.stream() + .mapToDouble(Trade::getSize) + .sum(); + + return new OhlcData(open, high, low, close, volume); + } + + // OHLC 데이터를 위한 레코드 (불변 객체) + record OhlcData(double open, double high, double low, double close, double volume) {} } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/common/CommonValues.java b/src/main/java/com/cleanengine/coin/common/CommonValues.java index d28bb807..296c4c52 100644 --- a/src/main/java/com/cleanengine/coin/common/CommonValues.java +++ b/src/main/java/com/cleanengine/coin/common/CommonValues.java @@ -4,6 +4,7 @@ public abstract class CommonValues { public static final double MINIMUM_ORDER_SIZE = 0.00000001; public static final int SELL_ORDER_BOT_ID = 1; public static final int BUY_ORDER_BOT_ID = 2; + public static final double INITIAL_USER_CASH = 50_000_000.0; public static boolean approxEquals(double a, double b) { return Math.abs(a - b) < MINIMUM_ORDER_SIZE; diff --git a/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryKeyValueStore.java b/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryKeyValueStore.java new file mode 100644 index 00000000..dc71a144 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryKeyValueStore.java @@ -0,0 +1,46 @@ +package com.cleanengine.coin.common.adapter.out.store; + +import com.cleanengine.coin.common.domain.port.KeyValueStore; + +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryKeyValueStore implements KeyValueStore { + private final Map map = new ConcurrentHashMap<>(); + + @Override + public void put(K key, V value) { + map.put(key, value); + } + + @Override + public Optional get(K key) { + return Optional.ofNullable(map.get(key)); + } + + @Override + public Optional remove(K key) { + return Optional.ofNullable(map.remove(key)); + } + + @Override + public boolean isExist(K key) { + return map.containsKey(key); + } + + @Override + public void clear() { + map.clear(); + } + + @Override + public boolean isEmpty() { + return map.isEmpty(); + } + + @Override + public long size() { + return map.size(); + } +} diff --git a/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryPriorityQueueStore.java b/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryPriorityQueueStore.java new file mode 100644 index 00000000..fab21a92 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/adapter/out/store/InMemoryPriorityQueueStore.java @@ -0,0 +1,47 @@ +package com.cleanengine.coin.common.adapter.out.store; + +import com.cleanengine.coin.common.domain.port.PriorityQueueStore; + +import java.util.concurrent.PriorityBlockingQueue; + +public class InMemoryPriorityQueueStore> implements PriorityQueueStore { + private final PriorityBlockingQueue queue = new PriorityBlockingQueue<>(); + + @Override + public void put(T item) { + if(item == null) throw new IllegalArgumentException("item cannot be null."); + queue.add(item); + } + + @Override + public T poll() { + return queue.poll(); + } + + @Override + public T peek() { + return queue.peek(); + } + + @Override + public T remove(T item) { + if(item == null) throw new IllegalArgumentException("item cannot be null."); + queue.remove(item); + return item; + } + + @Override + public long size() { + return queue.size(); + } + + @Override + public boolean isEmpty() { + return queue.isEmpty(); + } + + @Override + public void clear() { + queue.clear(); + } +} diff --git a/src/main/java/com/cleanengine/coin/common/domain/port/KeyValueStore.java b/src/main/java/com/cleanengine/coin/common/domain/port/KeyValueStore.java new file mode 100644 index 00000000..5f2c9d05 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/domain/port/KeyValueStore.java @@ -0,0 +1,19 @@ +package com.cleanengine.coin.common.domain.port; + +import java.util.Optional; + +public interface KeyValueStore { + void put(K key, V value); + + Optional get(K key); + + Optional remove(K key); + + boolean isExist(K key); + + void clear(); + + boolean isEmpty(); + + long size(); +} diff --git a/src/main/java/com/cleanengine/coin/common/domain/port/PriorityQueueStore.java b/src/main/java/com/cleanengine/coin/common/domain/port/PriorityQueueStore.java new file mode 100644 index 00000000..34f94e85 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/common/domain/port/PriorityQueueStore.java @@ -0,0 +1,17 @@ +package com.cleanengine.coin.common.domain.port; + +public interface PriorityQueueStore > { + void put(T item); + + T poll(); + + T peek(); + + T remove(T item); + + long size(); + + boolean isEmpty(); + + void clear(); +} diff --git a/src/main/java/com/cleanengine/coin/common/error/BaseExceptionHandler.java b/src/main/java/com/cleanengine/coin/common/error/BaseExceptionHandler.java index 1b729273..6d7f8b57 100644 --- a/src/main/java/com/cleanengine/coin/common/error/BaseExceptionHandler.java +++ b/src/main/java/com/cleanengine/coin/common/error/BaseExceptionHandler.java @@ -3,18 +3,22 @@ import com.cleanengine.coin.common.response.ApiResponse; import com.cleanengine.coin.common.response.ErrorResponse; import com.cleanengine.coin.common.response.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.NoHandlerFoundException; +import org.springframework.web.servlet.resource.NoResourceFoundException; -import java.util.Arrays; +import java.time.LocalDateTime; import java.util.List; @RestControllerAdvice @@ -54,6 +58,24 @@ protected ResponseEntity> handleHttpRequestMethodNotSupporte return ApiResponse.fail(response).toResponseEntity(); } + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + final ErrorResponse response = ErrorResponse.of(ErrorStatus.INVALID_INPUT_FORMAT); + return ApiResponse.fail(response).toResponseEntity(); + } + + @ExceptionHandler(NoResourceFoundException.class) + protected ResponseEntity> handleNoStaticResourceFoundException(NoResourceFoundException e) { + final ErrorResponse response = ErrorResponse.of(ErrorStatus.API_ENDPOINT_NOT_EXIST); + return ApiResponse.fail(response).toResponseEntity(); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + protected ResponseEntity> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { + final ErrorResponse response = ErrorResponse.of(ErrorStatus.INVALID_INPUT_VALUE); + return ApiResponse.fail(response).toResponseEntity(); + } + @ExceptionHandler(DomainValidationException.class) protected ResponseEntity> handleDomainValidationException(DomainValidationException e) { final ErrorResponse response = ErrorResponse.of(e.getErrorStatus(), e.getFieldErrors()); @@ -61,10 +83,16 @@ protected ResponseEntity> handleDomainValidationException(Do } @ExceptionHandler(Exception.class) - protected ResponseEntity> handleException(Exception e) { + protected ResponseEntity> handleException(Exception e, HttpServletRequest request) { final ErrorResponse response = ErrorResponse.of(ErrorStatus.INTERNAL_SERVER_ERROR); - log.warn("Handling 되지 않는 에러 발생" + e.getMessage()); - log.warn("Handling 되지 않는 에러 발생" + Arrays.toString(e.getStackTrace())); + StringBuilder logMessage = new StringBuilder(); + logMessage.append("=======API 요청 중 Handling 되지 않는 에러 발생======="); + logMessage.append("\n발생 시간 : ").append(LocalDateTime.now()); + logMessage.append("\n요청 URI : ").append(request.getRequestURI()); + logMessage.append("\n예외 타입 : ").append(e.getClass().getName()); + logMessage.append("\n메시지 : ").append(e.getMessage()); + log.warn("{}", logMessage, e); + return ApiResponse.fail(response).toResponseEntity(); } } diff --git a/src/main/java/com/cleanengine/coin/common/error/WebSocketExceptionHandler.java b/src/main/java/com/cleanengine/coin/common/error/WebSocketExceptionHandler.java index 22fd198f..9d754ca1 100644 --- a/src/main/java/com/cleanengine/coin/common/error/WebSocketExceptionHandler.java +++ b/src/main/java/com/cleanengine/coin/common/error/WebSocketExceptionHandler.java @@ -1,10 +1,15 @@ package com.cleanengine.coin.common.error; +import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageExceptionHandler; +import org.springframework.messaging.handler.annotation.Payload; import org.springframework.messaging.simp.annotation.SendToUser; import org.springframework.web.bind.annotation.ControllerAdvice; +import java.time.LocalDateTime; + @ControllerAdvice +@Slf4j public class WebSocketExceptionHandler { @MessageExceptionHandler(DomainValidationException.class) @@ -12,4 +17,15 @@ public class WebSocketExceptionHandler { public String handleDomainValidationException(DomainValidationException e) { return e.getMessage(); } + + @MessageExceptionHandler(Exception.class) + public void handleException(Exception e, @Payload String payload) { + StringBuilder logMessage = new StringBuilder(); + logMessage.append("=======Websocket 요청 중 Handling 되지 않는 에러 발생======="); + logMessage.append("\n발생 시간 : ").append(LocalDateTime.now()); + logMessage.append("\n요청 내용 : ").append(payload); + logMessage.append("\n예외 타입 : ").append(e.getClass().getName()); + logMessage.append("\n메시지 : ").append(e.getMessage()); + log.warn("{}", logMessage, e); + } } diff --git a/src/main/java/com/cleanengine/coin/common/response/ErrorStatus.java b/src/main/java/com/cleanengine/coin/common/response/ErrorStatus.java index 54ce216d..8c58aae3 100644 --- a/src/main/java/com/cleanengine/coin/common/response/ErrorStatus.java +++ b/src/main/java/com/cleanengine/coin/common/response/ErrorStatus.java @@ -11,7 +11,8 @@ public enum ErrorStatus { INVALID_TYPE("A02", HttpStatus.BAD_REQUEST, "Invalid Type entered"), INVALID_INPUT_VALUE("A03", HttpStatus.BAD_REQUEST, "Invalid Input entered"), API_ENDPOINT_NOT_EXIST("A04", HttpStatus.NOT_FOUND, "API Endpoint doesn't exist"), - UNAUTHORIZED_RESOURCE("A05", HttpStatus.BAD_REQUEST, "Current user can't access this resource"), + UNAUTHORIZED_RESOURCE("A05", HttpStatus.UNAUTHORIZED, "Current user can't access this resource"), + INVALID_INPUT_FORMAT("A06", HttpStatus.BAD_REQUEST, "Invalid Input Format"), INTERNAL_SERVER_ERROR("A88", HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected Server Error occurred. Contact Backend Admin."), NOT_CLASSIFIED_BUSINESS_ERROR("A99", HttpStatus.BAD_REQUEST, "Unclassified Business Error."); diff --git a/src/main/java/com/cleanengine/coin/configuration/SecurityConfig.java b/src/main/java/com/cleanengine/coin/configuration/SecurityConfig.java index b77a4547..51e8265c 100644 --- a/src/main/java/com/cleanengine/coin/configuration/SecurityConfig.java +++ b/src/main/java/com/cleanengine/coin/configuration/SecurityConfig.java @@ -50,7 +50,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .cors(corsCustomizer -> corsCustomizer.configurationSource(request -> { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(Collections.singletonList(frontendBaseUrl)); + configuration.setAllowedOrigins(List.of(frontendBaseUrl,"http://localhost:63343")); configuration.setAllowedMethods(Collections.singletonList("*")); configuration.setAllowCredentials(true); configuration.setAllowedHeaders(Collections.singletonList("*")); diff --git a/src/main/java/com/cleanengine/coin/configuration/WebSocketConfig.java b/src/main/java/com/cleanengine/coin/configuration/WebSocketConfig.java index d371e5fc..8c34c4bd 100644 --- a/src/main/java/com/cleanengine/coin/configuration/WebSocketConfig.java +++ b/src/main/java/com/cleanengine/coin/configuration/WebSocketConfig.java @@ -1,26 +1,24 @@ package com.cleanengine.coin.configuration; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; -import com.cleanengine.coin.configuration.SecurityEndpoints.EndpointConfig; @Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - private final EndpointConfig endpointConfig; - - @Value("${spring.security.allowed-origins}") - private String[] allowedOrigins; - - public WebSocketConfig(EndpointConfig endpointConfig) { - this.endpointConfig = endpointConfig; - } + private static final String[] ALLOWED_ORIGINS = { + "http://localhost:63342", + "http://localhost:63343", + "http://localhost:8080", + "http://localhost:5500", + "http://localhost:5173", + "https://investfuture.my" + }; @Override public void configureMessageBroker(MessageBrokerRegistry config) { @@ -30,14 +28,13 @@ public void configureMessageBroker(MessageBrokerRegistry config) { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { - endpointConfig.getWebsocketEndpoints() - .forEach(endpoint -> registerEndpoint(registry, endpoint)); - + registerEndpoint(registry, "/api/coin/min"); + registerEndpoint(registry, "/api/coin/orderbook"); } private void registerEndpoint(StompEndpointRegistry registry, String endpoint) { registry.addEndpoint(endpoint) - .setAllowedOrigins(allowedOrigins); + .setAllowedOrigins(ALLOWED_ORIGINS); } } 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 e23d08f2..7177eb7d 100644 --- a/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/DBInitRunner.java @@ -1,8 +1,8 @@ package com.cleanengine.coin.configuration.bootstrap; import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; -import com.cleanengine.coin.order.infra.AssetRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.User; import com.cleanengine.coin.user.domain.Wallet; @@ -18,7 +18,7 @@ import java.util.List; @Component -@Profile({"dev & !it"}) +@Profile({"(dev & !it & !mariadb-local) | h2-mem"}) @Order(1) @RequiredArgsConstructor public class DBInitRunner implements CommandLineRunner { @@ -30,9 +30,13 @@ public class DBInitRunner implements CommandLineRunner { @Transactional @Override public void run(String... args) throws Exception { - initSellBotData(); - initBuyBotData(); - initAssetData(); + if(userRepository.count() == 0){ + initSellBotData(); + initBuyBotData(); + } + if(assetRepository.count() == 0){ + initAssetData(); + } } @Transactional @@ -83,7 +87,7 @@ protected void initBuyBotData() { protected void initAssetData() { assetRepository.saveAll(List.of( new Asset("BTC", "비트코인"), - new Asset("TRUMP", "트럼프") + new Asset("TRUMP", "오피셜 트럼프") )); } } diff --git a/src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java b/src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java new file mode 100644 index 00000000..e4387640 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/configuration/bootstrap/IconInitializer.java @@ -0,0 +1,76 @@ +package com.cleanengine.coin.configuration.bootstrap; + +import com.cleanengine.coin.common.annotation.WorkingServerProfile; +import com.cleanengine.coin.order.domain.Asset; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.FileCopyUtils; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; + +@Component +@Order(2) +@WorkingServerProfile +@Slf4j +@RequiredArgsConstructor +public class IconInitializer implements ApplicationRunner { + private final AssetRepository assetRepository; + private final ResourceLoader resourceLoader; + + @Override + public void run(ApplicationArguments args) throws Exception { + List assets = loadAssets(); + + for(Asset asset : assets){ + if(asset.getIcon() != null) continue; + byte[] encodedIconBytes = loadEncodedIcon(asset.getTicker()); + asset.setIcon(encodedIconBytes); + assetRepository.save(asset); + } + } + + private List loadAssets(){ + return assetRepository.findAll(); + } + + public byte[] loadEncodedIcon(String ticker){ + Resource resource = getResource(ticker); + String svgContent = convertToStr(resource); + String base64Str = convertToBase64(svgContent); + return base64Str.getBytes(StandardCharsets.UTF_8); + } + + private String convertToStr(Resource resource){ + String svgContent; + try (Reader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + svgContent = FileCopyUtils.copyToString(reader); + } catch (IOException e) { + throw new RuntimeException(e); + } + return svgContent; + } + + private Resource getResource(String ticker){ + Resource resource = resourceLoader.getResource("classpath:icons/" + ticker + ".svg"); + if(!resource.exists()){ + log.debug("Icon not found. with " + ticker); + } + return resource; + } + + private String convertToBase64(String svgStr){ + return Base64.getEncoder().encodeToString(svgStr.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java new file mode 100644 index 00000000..4af03dbb --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedAddQueueHandler.java @@ -0,0 +1,26 @@ +package com.cleanengine.coin.order.adapter.in.event; + +import com.cleanengine.coin.order.application.event.OrderCreated; +import com.cleanengine.coin.order.application.event.OrderInsertedToQueue; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class SpringOrderCreatedAddQueueHandler { + private final WaitingOrdersManager waitingOrdersManager; + private final ApplicationEventPublisher applicationEventPublisher; + + @TransactionalEventListener(OrderCreated.class) + public void handleOrderCreated(OrderCreated event) { + Order order = event.order(); + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(order.getTicker()); + waitingOrders.addOrder(order); + applicationEventPublisher.publishEvent(new OrderInsertedToQueue(order)); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java new file mode 100644 index 00000000..ba4572e9 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/event/SpringOrderCreatedUpdateOrderBookHandler.java @@ -0,0 +1,18 @@ +package com.cleanengine.coin.order.adapter.in.event; + +import com.cleanengine.coin.order.application.event.OrderCreated; +import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class SpringOrderCreatedUpdateOrderBookHandler { + private final UpdateOrderBookUsecase updateOrderBookUsecase; + + @TransactionalEventListener(OrderCreated.class) + public void handleOrderCreated(OrderCreated event) { + updateOrderBookUsecase.updateOrderBookOnNewOrder(event.order()); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/presentation/AssetController.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/asset/AssetController.java similarity index 92% rename from src/main/java/com/cleanengine/coin/order/presentation/AssetController.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/asset/AssetController.java index 1f4ae573..4c1cf89c 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/AssetController.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/asset/AssetController.java @@ -1,7 +1,7 @@ -package com.cleanengine.coin.order.presentation; +package com.cleanengine.coin.order.adapter.in.web.asset; import com.cleanengine.coin.common.response.ApiResponse; -import com.cleanengine.coin.order.application.AssetInfo; +import com.cleanengine.coin.order.application.dto.AssetInfo; import com.cleanengine.coin.order.application.AssetService; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/OrderController.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderController.java similarity index 89% rename from src/main/java/com/cleanengine/coin/order/presentation/OrderController.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderController.java index a9343098..9d5f1add 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/OrderController.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderController.java @@ -1,9 +1,9 @@ -package com.cleanengine.coin.order.presentation; +package com.cleanengine.coin.order.adapter.in.web.order; import com.cleanengine.coin.common.response.ApiResponse; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; 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.login.infra.CustomOAuth2User; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/OrderRequestDto.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderRequestDto.java similarity index 78% rename from src/main/java/com/cleanengine/coin/order/presentation/OrderRequestDto.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderRequestDto.java index 0215e495..4b017cc9 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/OrderRequestDto.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderRequestDto.java @@ -1,8 +1,8 @@ -package com.cleanengine.coin.order.presentation; +package com.cleanengine.coin.order.adapter.in.web.order; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.presentation.validation.OrderType; -import com.cleanengine.coin.order.presentation.validation.Side; +import com.cleanengine.coin.order.adapter.in.web.order.validation.OrderType; +import com.cleanengine.coin.order.adapter.in.web.order.validation.Side; +import com.cleanengine.coin.order.application.dto.OrderCommand; import com.fasterxml.jackson.annotation.JsonCreator; import java.time.LocalDateTime; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/OrderResponseDto.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderResponseDto.java similarity index 95% rename from src/main/java/com/cleanengine/coin/order/presentation/OrderResponseDto.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderResponseDto.java index 610484f3..ef316c83 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/OrderResponseDto.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/OrderResponseDto.java @@ -1,6 +1,6 @@ -package com.cleanengine.coin.order.presentation; +package com.cleanengine.coin.order.adapter.in.web.order; -import com.cleanengine.coin.order.application.OrderInfo; +import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.domain.OrderStatus; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/validation/OrderType.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderType.java similarity index 88% rename from src/main/java/com/cleanengine/coin/order/presentation/validation/OrderType.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderType.java index 5d9e9e75..93a961c4 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/validation/OrderType.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderType.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.presentation.validation; +package com.cleanengine.coin.order.adapter.in.web.order.validation; import jakarta.validation.Constraint; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/validation/OrderTypeValidator.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderTypeValidator.java similarity index 90% rename from src/main/java/com/cleanengine/coin/order/presentation/validation/OrderTypeValidator.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderTypeValidator.java index af2bc244..4a4cc056 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/validation/OrderTypeValidator.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/OrderTypeValidator.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.presentation.validation; +package com.cleanengine.coin.order.adapter.in.web.order.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/validation/Side.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/Side.java similarity index 88% rename from src/main/java/com/cleanengine/coin/order/presentation/validation/Side.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/Side.java index 0544fd2b..6895d90a 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/validation/Side.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/Side.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.presentation.validation; +package com.cleanengine.coin.order.adapter.in.web.order.validation; import jakarta.validation.Constraint; diff --git a/src/main/java/com/cleanengine/coin/order/presentation/validation/SideValidator.java b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/SideValidator.java similarity index 86% rename from src/main/java/com/cleanengine/coin/order/presentation/validation/SideValidator.java rename to src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/SideValidator.java index 3cd6c771..2cfcfa23 100644 --- a/src/main/java/com/cleanengine/coin/order/presentation/validation/SideValidator.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/in/web/order/validation/SideValidator.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.presentation.validation; +package com.cleanengine.coin.order.adapter.in.web.order.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/event/SpringOrderCreatedPublisher.java b/src/main/java/com/cleanengine/coin/order/adapter/out/event/SpringOrderCreatedPublisher.java new file mode 100644 index 00000000..554c1a77 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/event/SpringOrderCreatedPublisher.java @@ -0,0 +1,18 @@ +package com.cleanengine.coin.order.adapter.out.event; + +import com.cleanengine.coin.order.application.event.OrderCreated; +import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class SpringOrderCreatedPublisher implements PublishOrderCreatedPort { + private final ApplicationEventPublisher applicationEventPublisher; + + @Override + public void publish(OrderCreated orderCreated) { + applicationEventPublisher.publishEvent(orderCreated); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java similarity index 81% rename from src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java index c4f25c0a..d5e70575 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalRepository.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.account; +package com.cleanengine.coin.order.adapter.out.persistentce.account; import com.cleanengine.coin.user.domain.Account; import org.springframework.data.repository.CrudRepository; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java similarity index 91% rename from src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalService.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java index 45ebfc76..d70869ac 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/account/AccountExternalService.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/AccountExternalService.java @@ -1,7 +1,7 @@ -package com.cleanengine.coin.order.external.adapter.account; +package com.cleanengine.coin.order.adapter.out.persistentce.account; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.port.AccountUpdatePort; +import com.cleanengine.coin.order.application.port.out.AccountUpdatePort; import com.cleanengine.coin.user.domain.Account; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/account/LockDepositService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java similarity index 91% rename from src/main/java/com/cleanengine/coin/order/external/adapter/account/LockDepositService.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java index 3b23d3ec..1e83ed90 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/account/LockDepositService.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/account/LockDepositService.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.account; +package com.cleanengine.coin.order.adapter.out.persistentce.account; import com.cleanengine.coin.common.error.DomainValidationException; import com.cleanengine.coin.user.domain.Account; diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetCacheRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetCacheRepository.java new file mode 100644 index 00000000..3c1f3665 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetCacheRepository.java @@ -0,0 +1,26 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.asset; + +import com.cleanengine.coin.order.domain.Asset; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class AssetCacheRepository { + private final ConcurrentHashMap assetCache = new ConcurrentHashMap<>(); + + public synchronized void saveAsset(Asset asset) { + if(!assetCache.containsKey(asset.getTicker())){ + assetCache.put(asset.getTicker(), asset); + } + } + + public Optional getAsset(String ticker){ + return Optional.ofNullable(assetCache.get(ticker)); + } + + public boolean isAssetExists(String ticker){ + return assetCache.containsKey(ticker); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/infra/AssetRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java similarity index 58% rename from src/main/java/com/cleanengine/coin/order/infra/AssetRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java index 2133b64f..4115e9f7 100644 --- a/src/main/java/com/cleanengine/coin/order/infra/AssetRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/asset/AssetRepository.java @@ -1,7 +1,11 @@ -package com.cleanengine.coin.order.infra; +package com.cleanengine.coin.order.adapter.out.persistentce.asset; import com.cleanengine.coin.order.domain.Asset; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface AssetRepository extends JpaRepository { + @Override + List findAll(); } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrders.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrders.java new file mode 100644 index 00000000..67ae4cf8 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrders.java @@ -0,0 +1,62 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order; + +import com.cleanengine.coin.common.adapter.out.store.InMemoryKeyValueStore; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.ActiveOrders; +import com.cleanengine.coin.common.domain.port.KeyValueStore; +import lombok.Getter; + +import java.util.Optional; + +public class InMemoryActiveOrders implements ActiveOrders { + @Getter + private final String ticker; + private final InMemoryKeyValueStore activeBuyOrders = new InMemoryKeyValueStore<>(); + private final InMemoryKeyValueStore activeSellOrders = new InMemoryKeyValueStore<>(); + + public InMemoryActiveOrders(String ticker) { + this.ticker = ticker; + } + + @Override + public void saveOrder(Order order) { + if(order.getIsMarketOrder()){ + return; + } + if(order instanceof BuyOrder){ + activeBuyOrders.put(order.getId(), (BuyOrder) order); + } else { + activeSellOrders.put(order.getId(), (SellOrder) order); + } + } + + @Override + public Optional getOrder(Long orderId, boolean isBuyOrder) { + if(isBuyOrder) { + return activeBuyOrders.get(orderId).map(order-> order); + } else { + return activeSellOrders.get(orderId).map(order-> order); + } + } + + @Override + public Optional removeOrder(Long orderId, boolean isBuyOrder) { + if(isBuyOrder) { + return activeBuyOrders.remove(orderId).map(order-> order); + } else { + return activeSellOrders.remove(orderId).map(order-> order); + } + } + + @Override + public KeyValueStore getBuyOrderKeyValueStore() { + return activeBuyOrders; + } + + @Override + public KeyValueStore getSellOrderKeyValueStore() { + return activeSellOrders; + } +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrdersManager.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrdersManager.java new file mode 100644 index 00000000..cb2ba131 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/InMemoryActiveOrdersManager.java @@ -0,0 +1,61 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order; + +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.spi.ActiveOrders; +import com.cleanengine.coin.order.domain.spi.ActiveOrdersManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Optional; + +@Slf4j +@Component +public class InMemoryActiveOrdersManager implements ActiveOrdersManager { + private final HashMap activeOrdersMap = new HashMap<>(); + + public void saveOrder(String ticker, Order order) { + ActiveOrders activeOrders = getActiveOrders(ticker); + activeOrders.saveOrder(order); + } + + public Optional getOrder(String ticker, Long orderId, boolean isBuyOrder) { + ActiveOrders activeOrders = getActiveOrders(ticker); + return activeOrders.getOrder(orderId,isBuyOrder); + } + + public void removeOrder(String ticker, Long orderId, boolean isBuyOrder) { + ActiveOrders activeOrders = getActiveOrders(ticker); + activeOrders.removeOrder(orderId, isBuyOrder); + } + + protected synchronized void addActiveOrderManager(String ticker) { + if(!activeOrdersMap.containsKey(ticker)){ + activeOrdersMap.put(ticker, new InMemoryActiveOrders(ticker)); + } + } + + @Override + public ActiveOrders getActiveOrders(String ticker) { + if(!activeOrdersMap.containsKey(ticker)){ + addActiveOrderManager(ticker); + } + + Optional activeOrderManager = Optional.ofNullable(activeOrdersMap.get(ticker)); + if(activeOrderManager.isEmpty()){ + log.debug("ActiveOrderManager not found with " + ticker); + throw new RuntimeException("ActiveOrderManager not found with " + ticker); + } + return activeOrderManager.get(); + } + + @Override + public void removeActiveOrders(String ticker) { + activeOrdersMap.remove(ticker); + } + + @Override + public void close() { + // TODO InMemory상 close시 처리할게 있나? + } +} diff --git a/src/main/java/com/cleanengine/coin/order/infra/BuyOrderRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/BuyOrderRepository.java similarity index 71% rename from src/main/java/com/cleanengine/coin/order/infra/BuyOrderRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/BuyOrderRepository.java index 3df02889..d3b3cd40 100644 --- a/src/main/java/com/cleanengine/coin/order/infra/BuyOrderRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/BuyOrderRepository.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.infra; +package com.cleanengine.coin.order.adapter.out.persistentce.order.command; import com.cleanengine.coin.order.domain.BuyOrder; import org.springframework.data.repository.CrudRepository; diff --git a/src/main/java/com/cleanengine/coin/order/infra/SellOrderRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/SellOrderRepository.java similarity index 72% rename from src/main/java/com/cleanengine/coin/order/infra/SellOrderRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/SellOrderRepository.java index ad5dc3fc..e568308c 100644 --- a/src/main/java/com/cleanengine/coin/order/infra/SellOrderRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/command/SellOrderRepository.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.infra; +package com.cleanengine.coin.order.adapter.out.persistentce.order.command; import com.cleanengine.coin.order.domain.SellOrder; import org.springframework.data.repository.CrudRepository; diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/BuyOrderQueryRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/BuyOrderQueryRepository.java new file mode 100644 index 00000000..8cb7c169 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/BuyOrderQueryRepository.java @@ -0,0 +1,14 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order.query; + +import com.cleanengine.coin.order.domain.BuyOrder; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BuyOrderQueryRepository extends CrudRepository { + @Query("select o from buy_orders o where o.state = 'WAIT'") + List findIncompletedBuyOrders(); +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/SellOrderQueryRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/SellOrderQueryRepository.java new file mode 100644 index 00000000..f6e7e1b8 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/query/SellOrderQueryRepository.java @@ -0,0 +1,14 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order.query; + +import com.cleanengine.coin.order.domain.SellOrder; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SellOrderQueryRepository extends CrudRepository { + @Query("select o from sell_orders o where o.state = 'WAIT'") + List findIncompletedSellOrders(); +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrders.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrders.java new file mode 100644 index 00000000..f56da598 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrders.java @@ -0,0 +1,91 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; + +import com.cleanengine.coin.common.adapter.out.store.InMemoryPriorityQueueStore; +import com.cleanengine.coin.common.domain.port.PriorityQueueStore; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.OrderType; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class InMemoryWaitingOrders implements WaitingOrders { + private final String ticker; + private final InMemoryPriorityQueueStore limitBuyOrderPriorityQueueStore = + new InMemoryPriorityQueueStore<>(); + private final InMemoryPriorityQueueStore marketBuyOrderPriorityQueueStore = + new InMemoryPriorityQueueStore<>(); + private final InMemoryPriorityQueueStore limitSellOrderPriorityQueueStore = + new InMemoryPriorityQueueStore<>(); + private final InMemoryPriorityQueueStore marketSellOrderPriorityQueueStore = + new InMemoryPriorityQueueStore<>(); + + @Override + public String getTicker() { + return ticker; + } + + @Override + public void addOrder(Order order) { + if(order == null) throw new IllegalArgumentException("order cannot be null."); + + if (order instanceof BuyOrder) { + if(order.getIsMarketOrder()) { + marketBuyOrderPriorityQueueStore.put((BuyOrder) order); + } + else{ + limitBuyOrderPriorityQueueStore.put((BuyOrder) order); + } + } else { + if(order.getIsMarketOrder()) { + marketSellOrderPriorityQueueStore.put((SellOrder) order); + } + else{ + limitSellOrderPriorityQueueStore.put((SellOrder) order); + } + } + } + + public PriorityQueueStore getBuyOrderPriorityQueueStore(OrderType orderType) { + return orderType == OrderType.MARKET ? marketBuyOrderPriorityQueueStore : limitBuyOrderPriorityQueueStore; + } + + public PriorityQueueStore getSellOrderPriorityQueueStore(OrderType orderType) { + return orderType == OrderType.MARKET ? marketSellOrderPriorityQueueStore : limitSellOrderPriorityQueueStore; + } + + @Override + public void removeOrder(Order order) { + if(order == null) throw new IllegalArgumentException("order cannot be null."); + + if (order instanceof BuyOrder) { + if(order.getIsMarketOrder()) { + marketBuyOrderPriorityQueueStore.remove((BuyOrder) order); + } + else{ + limitBuyOrderPriorityQueueStore.remove((BuyOrder) order); + } + } else { + if(order.getIsMarketOrder()) { + marketSellOrderPriorityQueueStore.remove((SellOrder) order); + } + else{ + limitSellOrderPriorityQueueStore.remove((SellOrder) order); + } + } + } + + @Override + public void clearAllQueues() { + limitBuyOrderPriorityQueueStore.clear(); + marketBuyOrderPriorityQueueStore.clear(); + limitSellOrderPriorityQueueStore.clear(); + marketSellOrderPriorityQueueStore.clear(); + } + + @Override + public void close() { + + } +} diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrdersManager.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrdersManager.java new file mode 100644 index 00000000..83d29bf6 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/InMemoryWaitingOrdersManager.java @@ -0,0 +1,45 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; + +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Optional; + +@Slf4j +@Component +public class InMemoryWaitingOrdersManager implements WaitingOrdersManager { + private final HashMap waitingOrdersMap = new HashMap<>(); + + @Override + public WaitingOrders getWaitingOrders(String ticker) { + if(!waitingOrdersMap.containsKey(ticker)) { + addWaitingOrders(ticker); + } + + Optional waitingOrdersOpt = Optional.ofNullable(waitingOrdersMap.get(ticker)); + if(waitingOrdersOpt.isEmpty()){ + log.debug("WaitingOrders not found. with " + ticker); + throw new RuntimeException("WaitingOrders not found with " + ticker); + } + return waitingOrdersOpt.get(); + } + + @Override + public void removeWaitingOrders(String ticker) { + + } + + @Override + public void close() { + + } + + protected synchronized void addWaitingOrders(String ticker){ + if(!waitingOrdersMap.containsKey(ticker)){ + waitingOrdersMap.put(ticker, new InMemoryWaitingOrders(ticker)); + } + } +} diff --git a/src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManager.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java similarity index 51% rename from src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManager.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java index d2679cb7..9d0a142c 100644 --- a/src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManager.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManager.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.application.queue; +package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; @@ -7,6 +7,7 @@ import java.util.concurrent.PriorityBlockingQueue; +@Deprecated @Getter public class OrderQueueManager { private final String ticker; @@ -38,4 +39,53 @@ public void addOrder(Order order) { } } } + + public int getMarketSellOrderQueueSize() { + return marketSellOrderQueue.size(); + } + + public int getMarketBuyOrderQueueSize() { + return marketBuyOrderQueue.size(); + } + + public int getLimitSellOrderQueueSize() { + return limitSellOrderQueue.size(); + } + + public int getLimitBuyOrderQueueSize() { + return limitBuyOrderQueue.size(); + } + + public SellOrder getMarketSellOrder() { + return marketSellOrderQueue.peek(); + } + + public BuyOrder getMarketBuyOrder() { + return marketBuyOrderQueue.peek(); + } + + public SellOrder getLimitSellOrder() { + return limitSellOrderQueue.peek(); + } + + public BuyOrder getLimitBuyOrder() { + return limitBuyOrderQueue.peek(); + } + + public boolean removeOrderFromQueue(Order order) { + if (order instanceof SellOrder) { + if (order.getIsMarketOrder()) { + return marketSellOrderQueue.remove(order); + } else { + return limitSellOrderQueue.remove(order); + } + } else { + if (order.getIsMarketOrder()) { + return marketBuyOrderQueue.remove(order); + } else { + return limitBuyOrderQueue.remove(order); + } + } + } + } diff --git a/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java new file mode 100644 index 00000000..cd394214 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/order/queue/OrderQueueManagerPool.java @@ -0,0 +1,33 @@ +package com.cleanengine.coin.order.adapter.out.persistentce.order.queue; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Optional; + +@Deprecated +@Slf4j +@Component +public class OrderQueueManagerPool { + private final HashMap orderQueueManagerMap = new HashMap<>(); + + public OrderQueueManager getOrderQueueManager(String ticker){ + if(!orderQueueManagerMap.containsKey(ticker)){ + addOrderQueueManager(ticker); + } + + Optional orderQueueManagerOpt = Optional.ofNullable(orderQueueManagerMap.get(ticker)); + if(orderQueueManagerOpt.isEmpty()){ + log.debug("OrderQueueManager not found. with " + ticker); + throw new RuntimeException("OrderQueueManager not found with " + ticker); + } + return orderQueueManagerOpt.get(); + } + + protected synchronized void addOrderQueueManager(String ticker){ + if(!orderQueueManagerMap.containsKey(ticker)){ + orderQueueManagerMap.put(ticker, new OrderQueueManager(ticker)); + } + } +} diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/LockAssetService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java similarity index 89% rename from src/main/java/com/cleanengine/coin/order/external/adapter/wallet/LockAssetService.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java index 397197f0..024d3f21 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/LockAssetService.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/LockAssetService.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.wallet; +package com.cleanengine.coin.order.adapter.out.persistentce.wallet; import com.cleanengine.coin.common.error.DomainValidationException; import com.cleanengine.coin.user.domain.Wallet; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepository.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java similarity index 76% rename from src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepository.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java index 922037f5..d1b5e37e 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepository.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepository.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.wallet; +package com.cleanengine.coin.order.adapter.out.persistentce.wallet; import com.cleanengine.coin.user.domain.Wallet; import org.springframework.data.repository.CrudRepository; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustom.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java similarity index 74% rename from src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustom.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java index 9f494b42..d6ff1ec1 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustom.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustom.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.wallet; +package com.cleanengine.coin.order.adapter.out.persistentce.wallet; import com.cleanengine.coin.user.domain.Wallet; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustomImpl.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java similarity index 94% rename from src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustomImpl.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java index 25037342..f4968bfb 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalRepositoryCustomImpl.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalRepositoryCustomImpl.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.external.adapter.wallet; +package com.cleanengine.coin.order.adapter.out.persistentce.wallet; import com.cleanengine.coin.user.domain.Wallet; import jakarta.persistence.EntityManager; diff --git a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalService.java b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java similarity index 91% rename from src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalService.java rename to src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java index 5b018182..039fd9be 100644 --- a/src/main/java/com/cleanengine/coin/order/external/adapter/wallet/WalletExternalService.java +++ b/src/main/java/com/cleanengine/coin/order/adapter/out/persistentce/wallet/WalletExternalService.java @@ -1,7 +1,7 @@ -package com.cleanengine.coin.order.external.adapter.wallet; +package com.cleanengine.coin.order.adapter.out.persistentce.wallet; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.port.WalletUpdatePort; +import com.cleanengine.coin.order.application.port.out.WalletUpdatePort; import com.cleanengine.coin.user.domain.Wallet; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/src/main/java/com/cleanengine/coin/order/application/AssetInfo.java b/src/main/java/com/cleanengine/coin/order/application/AssetInfo.java deleted file mode 100644 index 55f4d52e..00000000 --- a/src/main/java/com/cleanengine/coin/order/application/AssetInfo.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cleanengine.coin.order.application; - -import com.cleanengine.coin.order.domain.Asset; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; - -@JsonPropertyOrder({"ticker", "name"}) -public record AssetInfo( - String ticker, - String name -){ - public static AssetInfo from(Asset asset){ - return new AssetInfo(asset.getTicker(), asset.getName()); - } -} 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 d44a2e04..cdea7404 100644 --- a/src/main/java/com/cleanengine/coin/order/application/AssetService.java +++ b/src/main/java/com/cleanengine/coin/order/application/AssetService.java @@ -1,29 +1,48 @@ package com.cleanengine.coin.order.application; import com.cleanengine.coin.common.error.DomainValidationException; +import com.cleanengine.coin.order.application.dto.AssetInfo; import com.cleanengine.coin.order.domain.Asset; -import com.cleanengine.coin.order.infra.AssetRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetCacheRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.validation.FieldError; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor public class AssetService { private final AssetRepository assetRepository; + private final AssetCacheRepository assetCacheRepository; public AssetInfo getAssetInfo(String ticker){ - Asset asset = assetRepository.findById(ticker).orElseThrow( - () -> new DomainValidationException( - String.format("Asset %s not found", ticker), - List.of(new FieldError("Asset", "ticker", "Asset not found"))) - ); - return AssetInfo.from(asset); + Optional assetOpt = getAsset(ticker); + if(assetOpt.isEmpty()){ + throw new DomainValidationException( + String.format("Asset %s not found", ticker), + List.of(new FieldError("Asset", "ticker", "Asset not found"))); + } + + return AssetInfo.from(assetOpt.get()); } public List getAllAssetInfos(){ return assetRepository.findAll().stream().map(AssetInfo::from).toList(); } + + public boolean isAssetExist(String ticker){ + if(assetCacheRepository.isAssetExists(ticker)) return true; + + Optional asset = getAsset(ticker); + asset.ifPresent(assetCacheRepository::saveAsset); + + return asset.isPresent(); + } + + protected Optional getAsset(String ticker){ + return assetRepository.findById(ticker); + } } diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderCreated.java b/src/main/java/com/cleanengine/coin/order/application/OrderCreated.java deleted file mode 100644 index 9892643c..00000000 --- a/src/main/java/com/cleanengine/coin/order/application/OrderCreated.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.cleanengine.coin.order.application; - -import com.cleanengine.coin.order.domain.Order; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class OrderCreated { - private final Order order; - public OrderCreated(Order order){ - this.order = order; - } -} diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java b/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java new file mode 100644 index 00000000..cf28ed4b --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/OrderRestoreService.java @@ -0,0 +1,43 @@ +package com.cleanengine.coin.order.application; + +import com.cleanengine.coin.common.annotation.WorkingServerProfile; +import com.cleanengine.coin.order.adapter.out.persistentce.order.query.BuyOrderQueryRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.query.SellOrderQueryRepository; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; +import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@WorkingServerProfile +@org.springframework.core.annotation.Order(3) +@RequiredArgsConstructor +public class OrderRestoreService implements ApplicationRunner { + + private final WaitingOrdersManager waitingOrdersManager; + private final UpdateOrderBookUsecase updateOrderBookUsecase; + private final BuyOrderQueryRepository buyOrderQueryRepository; + private final SellOrderQueryRepository sellOrderQueryRepository; + + @Override + public void run(ApplicationArguments args) throws Exception { + List buyOrders = buyOrderQueryRepository.findIncompletedBuyOrders(); + buyOrders.forEach(this::restoreOrder); + List sellOrders = sellOrderQueryRepository.findIncompletedSellOrders(); + sellOrders.forEach(this::restoreOrder); + } + + protected void restoreOrder(Order order){ + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(order.getTicker()); + waitingOrders.addOrder(order); + updateOrderBookUsecase.updateOrderBookOnNewOrder(order); + } +} 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 0bf97fd5..3d723e7c 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderService.java +++ b/src/main/java/com/cleanengine/coin/order/application/OrderService.java @@ -1,10 +1,10 @@ package com.cleanengine.coin.order.application; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.order.application.strategy.CreateOrderStrategy; -import com.cleanengine.coin.order.domain.Order; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -19,17 +19,15 @@ @Service @RequiredArgsConstructor @Validated -public class OrderService { //facade +public class OrderService { private final List> createOrderStrategies; - private final ApplicationEventPublisher applicationEventPublisher; @Transactional public OrderInfo createOrder(@Valid OrderCommand.CreateOrder createOrder){ CreateOrderStrategy createOrderStrategy = createOrderStrategies.stream() .filter(strategy -> strategy.supports(createOrder.isBuyOrder())).findFirst().orElseThrow(); - Order order = createOrderStrategy.processCreatingOrder(createOrder); - applicationEventPublisher.publishEvent(new OrderCreated(order)); - return createOrderStrategy.extractOrderInfo(order); + + return createOrderStrategy.processCreatingOrder(createOrder); } @Transactional(propagation = Propagation.REQUIRES_NEW) diff --git a/src/main/java/com/cleanengine/coin/order/application/dto/AssetInfo.java b/src/main/java/com/cleanengine/coin/order/application/dto/AssetInfo.java new file mode 100644 index 00000000..9af97fe1 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/dto/AssetInfo.java @@ -0,0 +1,25 @@ +package com.cleanengine.coin.order.application.dto; + +import com.cleanengine.coin.order.domain.Asset; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import java.nio.charset.StandardCharsets; + +@JsonPropertyOrder({"ticker", "name"}) +public record AssetInfo( + String ticker, + String name, + String svgIconBase64 +){ + public static AssetInfo from(Asset asset){ + byte[] iconBytes = asset.getIcon(); + + String iconStr = null; + + if(iconBytes != null){ + iconStr = new String(iconBytes, StandardCharsets.UTF_8); + } + + return new AssetInfo(asset.getTicker(), asset.getName(), iconStr); + } +} diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderCommand.java b/src/main/java/com/cleanengine/coin/order/application/dto/OrderCommand.java similarity index 96% rename from src/main/java/com/cleanengine/coin/order/application/OrderCommand.java rename to src/main/java/com/cleanengine/coin/order/application/dto/OrderCommand.java index 7656f125..39e11f0f 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderCommand.java +++ b/src/main/java/com/cleanengine/coin/order/application/dto/OrderCommand.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.application; +package com.cleanengine.coin.order.application.dto; import com.cleanengine.coin.common.validation.ConstraintMessageTemplate; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/cleanengine/coin/order/application/OrderInfo.java b/src/main/java/com/cleanengine/coin/order/application/dto/OrderInfo.java similarity index 96% rename from src/main/java/com/cleanengine/coin/order/application/OrderInfo.java rename to src/main/java/com/cleanengine/coin/order/application/dto/OrderInfo.java index 58cdf70d..11e996df 100644 --- a/src/main/java/com/cleanengine/coin/order/application/OrderInfo.java +++ b/src/main/java/com/cleanengine/coin/order/application/dto/OrderInfo.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.application; +package com.cleanengine.coin.order.application.dto; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; diff --git a/src/main/java/com/cleanengine/coin/order/application/event/OrderCreated.java b/src/main/java/com/cleanengine/coin/order/application/event/OrderCreated.java new file mode 100644 index 00000000..be67e15d --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/event/OrderCreated.java @@ -0,0 +1,7 @@ +package com.cleanengine.coin.order.application.event; + +import com.cleanengine.coin.order.domain.Order; + +public record OrderCreated ( + Order order +){ } diff --git a/src/main/java/com/cleanengine/coin/order/application/event/OrderInsertedToQueue.java b/src/main/java/com/cleanengine/coin/order/application/event/OrderInsertedToQueue.java new file mode 100644 index 00000000..dfe5caff --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/event/OrderInsertedToQueue.java @@ -0,0 +1,7 @@ +package com.cleanengine.coin.order.application.event; + +import com.cleanengine.coin.order.domain.Order; + +public record OrderInsertedToQueue ( + Order order +){}; diff --git a/src/main/java/com/cleanengine/coin/order/application/port/AccountUpdatePort.java b/src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java similarity index 70% rename from src/main/java/com/cleanengine/coin/order/application/port/AccountUpdatePort.java rename to src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java index 0c4e7b8f..8f8d40a9 100644 --- a/src/main/java/com/cleanengine/coin/order/application/port/AccountUpdatePort.java +++ b/src/main/java/com/cleanengine/coin/order/application/port/out/AccountUpdatePort.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.application.port; +package com.cleanengine.coin.order.application.port.out; public interface AccountUpdatePort { void lockDepositForBuyOrder(Integer userId, Double orderAmount) throws RuntimeException; diff --git a/src/main/java/com/cleanengine/coin/order/application/port/out/PublishOrderCreatedPort.java b/src/main/java/com/cleanengine/coin/order/application/port/out/PublishOrderCreatedPort.java new file mode 100644 index 00000000..6a2106b9 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/application/port/out/PublishOrderCreatedPort.java @@ -0,0 +1,7 @@ +package com.cleanengine.coin.order.application.port.out; + +import com.cleanengine.coin.order.application.event.OrderCreated; + +public interface PublishOrderCreatedPort { + void publish(OrderCreated orderCreated); +} diff --git a/src/main/java/com/cleanengine/coin/order/application/port/WalletUpdatePort.java b/src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java similarity index 71% rename from src/main/java/com/cleanengine/coin/order/application/port/WalletUpdatePort.java rename to src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java index ff4bc069..a12fbc66 100644 --- a/src/main/java/com/cleanengine/coin/order/application/port/WalletUpdatePort.java +++ b/src/main/java/com/cleanengine/coin/order/application/port/out/WalletUpdatePort.java @@ -1,4 +1,4 @@ -package com.cleanengine.coin.order.application.port; +package com.cleanengine.coin.order.application.port.out; public interface WalletUpdatePort { void lockAssetForSellOrder(Integer userId, String ticker, Double orderSize) throws RuntimeException; diff --git a/src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerPool.java b/src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerPool.java deleted file mode 100644 index d04c2734..00000000 --- a/src/main/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerPool.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.cleanengine.coin.order.application.queue; - -import com.cleanengine.coin.order.domain.Order; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Optional; - -// TODO infra 계층으로 이동해야 -@Slf4j -@Component -public class OrderQueueManagerPool { - // TODO 원래라면 OrderQueueUnit 각각이 Bean으로 등록되는게 깔끔할 수도 있다. - private final HashMap orderQueueManagerMap = new HashMap<>(); - - @Autowired - public OrderQueueManagerPool(@Value("${order.tickers}") String[] tickers) { - for(String ticker : tickers) { - orderQueueManagerMap.put(ticker, new OrderQueueManager(ticker)); - } - } - - // TODO 불필요 할지도 - public void addOrder(String ticker, Order order){ - Optional orderQueueManager = Optional.ofNullable(orderQueueManagerMap.get(ticker)); - orderQueueManager.ifPresent(queueManager -> queueManager.addOrder(order)); - } - - public OrderQueueManager getOrderQueueManager(String ticker){ - Optional orderQueueManager = Optional.ofNullable(orderQueueManagerMap.get(ticker)); - if(orderQueueManager.isEmpty()){ - log.debug("OrderQueueManager not found. check order.tickers on startup"); - throw new RuntimeException("OrderQueueManager not found. check order.tickers on startup"); - } - return orderQueueManager.get(); - } -} 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 5a206b24..c03211af 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,37 +1,27 @@ package com.cleanengine.coin.order.application.strategy; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; -import com.cleanengine.coin.order.application.port.AccountUpdatePort; -import com.cleanengine.coin.order.application.queue.OrderQueueManagerPool; +import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.order.application.dto.OrderInfo; +import com.cleanengine.coin.order.application.port.out.AccountUpdatePort; +import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.domainservice.CreateBuyOrderDomainService; -import com.cleanengine.coin.order.external.adapter.account.AccountExternalRepository; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; -import com.cleanengine.coin.order.infra.BuyOrderRepository; -import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; -import lombok.RequiredArgsConstructor; +import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor public class BuyOrderStrategy extends CreateOrderStrategy> { private final BuyOrderRepository buyOrderRepository; - private final CreateBuyOrderDomainService createOrderDomainService; - private final OrderQueueManagerPool orderQueueManagerPool; private final AccountUpdatePort accountUpdatePort; - private final UpdateOrderBookUsecase updateOrderBookUsecase; - private final WalletExternalRepository walletRepository; - private final AccountExternalRepository accountRepository; + private final CreateBuyOrderDomainService createOrderDomainService; @Override - public BuyOrder createOrder(OrderCommand.CreateOrder createOrderCommand) { - return createOrderDomainService.createOrder(createOrderCommand.ticker(), createOrderCommand.userId(), - createOrderCommand.isBuyOrder(), createOrderCommand.isMarketOrder(), createOrderCommand.orderSize(), - createOrderCommand.price(), createOrderCommand.createdAt(), createOrderCommand.isBot()); + public boolean supports(Boolean isBuyOrder) { + return isBuyOrder; } @Override @@ -40,31 +30,30 @@ public void saveOrder(BuyOrder order) { } @Override - protected void createWallet(Integer userId, String ticker) { - if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ - Account account = accountRepository.findByUserId(userId).orElseThrow(); - Wallet wallet = new Wallet(null, ticker, account.getId(), 0.0, 0.0, 0.0); - walletRepository.save(wallet); - } - } - - @Override - public OrderInfo.BuyOrderInfo extractOrderInfo(Order order) { - return new OrderInfo.BuyOrderInfo((BuyOrder) order); + protected void keepHoldings(BuyOrder order) throws RuntimeException { + accountUpdatePort.lockDepositForBuyOrder(order.getUserId(), order.getLockedDeposit()); } @Override - public boolean supports(Boolean isBuyOrder) { - return isBuyOrder; + protected CreateOrderDomainService createOrderDomainService() { + return createOrderDomainService; } @Override - protected void keepHoldings(BuyOrder order) throws RuntimeException { - accountUpdatePort.lockDepositForBuyOrder(order.getUserId(), order.getLockedDeposit()); + protected OrderInfo.BuyOrderInfo extractOrderInfo(Order order) { + return new OrderInfo.BuyOrderInfo((BuyOrder) order); } - @Override - protected void updateOrderBook(BuyOrder order) { - updateOrderBookUsecase.updateOrderBookOnNewOrder(order); + public BuyOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, + AssetService assetService, + WalletExternalRepository walletRepository, + AccountExternalRepository accountRepository, + BuyOrderRepository buyOrderRepository, + AccountUpdatePort accountUpdatePort, + CreateBuyOrderDomainService createOrderDomainService) { + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); + this.buyOrderRepository = buyOrderRepository; + this.accountUpdatePort = accountUpdatePort; + this.createOrderDomainService = createOrderDomainService; } } diff --git a/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java b/src/main/java/com/cleanengine/coin/order/application/strategy/CreateOrderStrategy.java index 27c66efc..cfb42d28 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,27 +1,68 @@ package com.cleanengine.coin.order.application.strategy; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; -import com.cleanengine.coin.order.application.queue.OrderQueueManagerPool; +import com.cleanengine.coin.common.error.DomainValidationException; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.order.application.dto.OrderInfo; +import com.cleanengine.coin.order.application.event.OrderCreated; +import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; +import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.user.domain.Wallet; +import lombok.AllArgsConstructor; +import org.springframework.validation.FieldError; +import java.util.List; + +@AllArgsConstructor public abstract class CreateOrderStrategy> { + protected final PublishOrderCreatedPort publishOrderCreatedPort; + protected final AssetService assetService; + protected final WalletExternalRepository walletRepository; + protected final AccountExternalRepository accountRepository; - public T processCreatingOrder(OrderCommand.CreateOrder createOrderCommand){ + public S processCreatingOrder(OrderCommand.CreateOrder createOrderCommand){ + validateTicker(createOrderCommand.ticker()); T order = createOrder(createOrderCommand); saveOrder(order); - createWallet(order.getUserId(), order.getTicker()); + createWalletIfNeeded(order.getUserId(), order.getTicker()); keepHoldings(order); - updateOrderBook(order); - return order; + publishOrderCreatedPort.publish(new OrderCreated(order)); + return extractOrderInfo(order); } public abstract boolean supports(Boolean isBuyOrder); - protected abstract T createOrder(OrderCommand.CreateOrder createOrderCommand); protected abstract void saveOrder(T order); - protected abstract void createWallet(Integer userId, String ticker); protected abstract void keepHoldings(T order) throws RuntimeException; - protected abstract void updateOrderBook(T order); - public abstract S extractOrderInfo(Order order); + protected abstract CreateOrderDomainService createOrderDomainService(); + protected abstract S extractOrderInfo(Order order); + + protected void validateTicker(String ticker){ + if(!assetService.isAssetExist(ticker)){ + throw new DomainValidationException("Asset not supported "+ticker, + List.of(new FieldError("BuyOrder", "ticker", "Asset not supported"))); + } + } + + protected T createOrder(OrderCommand.CreateOrder createOrderCommand){ + T order = createOrderDomainService().createOrder( + createOrderCommand.ticker(), createOrderCommand.userId(), + createOrderCommand.isBuyOrder(), createOrderCommand.isMarketOrder(), + createOrderCommand.orderSize(), createOrderCommand.price(), + createOrderCommand.createdAt(), createOrderCommand.isBot()); + + return order; + } + + 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 cccf894a..cc64b6e5 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,35 +1,27 @@ package com.cleanengine.coin.order.application.strategy; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; -import com.cleanengine.coin.order.application.port.WalletUpdatePort; +import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.order.application.dto.OrderInfo; +import com.cleanengine.coin.order.application.port.out.PublishOrderCreatedPort; +import com.cleanengine.coin.order.application.port.out.WalletUpdatePort; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.domainservice.CreateOrderDomainService; import com.cleanengine.coin.order.domain.domainservice.CreateSellOrderDomainService; -import com.cleanengine.coin.order.external.adapter.account.AccountExternalRepository; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; -import com.cleanengine.coin.order.infra.SellOrderRepository; -import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; -import com.cleanengine.coin.user.domain.Account; -import com.cleanengine.coin.user.domain.Wallet; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor public class SellOrderStrategy extends CreateOrderStrategy { private final SellOrderRepository sellOrderRepository; - private final CreateSellOrderDomainService createSellOrderDomainService; private final WalletUpdatePort walletUpdatePort; - private final UpdateOrderBookUsecase updateOrderBookUsecase; - private final WalletExternalRepository walletRepository; - private final AccountExternalRepository accountRepository; + private final CreateSellOrderDomainService createOrderDomainService; @Override - public SellOrder createOrder(OrderCommand.CreateOrder createOrderCommand) { - return createSellOrderDomainService.createOrder(createOrderCommand.ticker(), createOrderCommand.userId(), - createOrderCommand.isBuyOrder(), createOrderCommand.isMarketOrder(), createOrderCommand.orderSize(), - createOrderCommand.price(), createOrderCommand.createdAt(), createOrderCommand.isBot()); + public boolean supports(Boolean isBuyOrder) { + return !isBuyOrder; } @Override @@ -38,31 +30,30 @@ public void saveOrder(SellOrder order) { } @Override - protected void createWallet(Integer userId, String ticker) { - if(walletRepository.findWalletBy(userId, ticker).isEmpty()){ - Account account = accountRepository.findByUserId(userId).orElseThrow(); - Wallet wallet = new Wallet(null, ticker, account.getId(), 0.0, 0.0, 0.0); - walletRepository.save(wallet); - } - } - - @Override - public OrderInfo.SellOrderInfo extractOrderInfo(Order order) { - return new OrderInfo.SellOrderInfo((SellOrder) order); + protected void keepHoldings(SellOrder order) throws RuntimeException { + walletUpdatePort.lockAssetForSellOrder(order.getUserId(), order.getTicker(), order.getOrderSize()); } @Override - public boolean supports(Boolean isBuyOrder) { - return !isBuyOrder; + protected CreateOrderDomainService createOrderDomainService() { + return createOrderDomainService; } @Override - protected void keepHoldings(SellOrder order) throws RuntimeException { - walletUpdatePort.lockAssetForSellOrder(order.getUserId(), order.getTicker(), order.getOrderSize()); + protected OrderInfo.SellOrderInfo extractOrderInfo(Order order) { + return new OrderInfo.SellOrderInfo((SellOrder) order); } - @Override - protected void updateOrderBook(SellOrder order) { - updateOrderBookUsecase.updateOrderBookOnNewOrder(order); + public SellOrderStrategy(PublishOrderCreatedPort publishOrderCreatedPort, + AssetService assetService, + WalletExternalRepository walletRepository, + AccountExternalRepository accountRepository, + SellOrderRepository sellOrderRepository, + WalletUpdatePort walletUpdatePort, + CreateSellOrderDomainService createOrderDomainService) { + super(publishOrderCreatedPort, assetService, walletRepository, accountRepository); + this.sellOrderRepository = sellOrderRepository; + this.walletUpdatePort = walletUpdatePort; + this.createOrderDomainService = createOrderDomainService; } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/Asset.java b/src/main/java/com/cleanengine/coin/order/domain/Asset.java index 448f10e5..5e70c9ac 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/Asset.java +++ b/src/main/java/com/cleanengine/coin/order/domain/Asset.java @@ -1,13 +1,7 @@ package com.cleanengine.coin.order.domain; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import jakarta.persistence.*; +import lombok.*; @Entity @Table(name = "asset") @@ -18,4 +12,11 @@ public class Asset { private String ticker; @Column(name = "name", length = 100) private String name; + @Column(name = "icon", columnDefinition = "BLOB") @Lob @Setter + private byte[] icon; + + public Asset(String ticker, String name){ + this.ticker = ticker; + this.name = name; + } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/Order.java b/src/main/java/com/cleanengine/coin/order/domain/Order.java index d47e64ba..e5446a5f 100644 --- a/src/main/java/com/cleanengine/coin/order/domain/Order.java +++ b/src/main/java/com/cleanengine/coin/order/domain/Order.java @@ -5,6 +5,7 @@ import lombok.Setter; import java.time.LocalDateTime; +import java.util.Objects; @MappedSuperclass @Getter @@ -42,4 +43,17 @@ public abstract class Order { @Column(name="is_bot", nullable = false, updatable = false) protected Boolean isBot; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || this.getClass() != o.getClass()) return false; + Order order = (Order) o; + return id.equals(order.id); + } + + @Override + public int hashCode() { + return Objects.hash(this.getClass(), this.id); + } } diff --git a/src/main/java/com/cleanengine/coin/order/domain/OrderType.java b/src/main/java/com/cleanengine/coin/order/domain/OrderType.java new file mode 100644 index 00000000..d0831546 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/domain/OrderType.java @@ -0,0 +1,5 @@ +package com.cleanengine.coin.order.domain; + +public enum OrderType { + MARKET, LIMIT +} diff --git a/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrders.java b/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrders.java new file mode 100644 index 00000000..407fdf6e --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrders.java @@ -0,0 +1,21 @@ +package com.cleanengine.coin.order.domain.spi; + +import com.cleanengine.coin.common.domain.port.KeyValueStore; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.SellOrder; + +import java.util.Optional; + +public interface ActiveOrders { + String getTicker(); + + void saveOrder(Order order); + + Optional getOrder(Long orderId, boolean isBuyOrder); + + Optional removeOrder(Long orderId, boolean isBuyOrder); + + KeyValueStore getBuyOrderKeyValueStore(); + KeyValueStore getSellOrderKeyValueStore(); +} diff --git a/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrdersManager.java b/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrdersManager.java new file mode 100644 index 00000000..d8f612bf --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/domain/spi/ActiveOrdersManager.java @@ -0,0 +1,13 @@ +package com.cleanengine.coin.order.domain.spi; + +import java.io.Closeable; + +public interface ActiveOrdersManager extends Closeable { + + ActiveOrders getActiveOrders(String ticker); + + void removeActiveOrders(String ticker); + + @Override + void close(); +} diff --git a/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrders.java b/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrders.java new file mode 100644 index 00000000..1add4745 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrders.java @@ -0,0 +1,27 @@ +package com.cleanengine.coin.order.domain.spi; + +import com.cleanengine.coin.common.domain.port.PriorityQueueStore; +import com.cleanengine.coin.order.domain.BuyOrder; +import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.OrderType; +import com.cleanengine.coin.order.domain.SellOrder; + +import java.io.Closeable; + +public interface WaitingOrders extends Closeable { + + String getTicker(); + + void addOrder(Order order); + + PriorityQueueStore getBuyOrderPriorityQueueStore(OrderType orderType); + PriorityQueueStore getSellOrderPriorityQueueStore(OrderType orderType); + + // TODO removeOrder 여기에 있는 것이 나을지 고민 + void removeOrder(Order order); + + void clearAllQueues(); + + @Override + void close(); +} diff --git a/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrdersManager.java b/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrdersManager.java new file mode 100644 index 00000000..48a04171 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/order/domain/spi/WaitingOrdersManager.java @@ -0,0 +1,12 @@ +package com.cleanengine.coin.order.domain.spi; + +import java.io.Closeable; + +public interface WaitingOrdersManager extends Closeable { + WaitingOrders getWaitingOrders(String ticker); + + void removeWaitingOrders(String ticker); + + @Override + void close(); +} diff --git a/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManager.java b/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManager.java deleted file mode 100644 index 2d855962..00000000 --- a/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManager.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.cleanengine.coin.order.infra; - -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.SellOrder; -import lombok.Getter; - -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -public class ActiveOrderManager { - @Getter - private final String ticker; - private final ConcurrentHashMap activeBuyOrders = new ConcurrentHashMap<>(); - private final ConcurrentHashMap activeSellOrders = new ConcurrentHashMap<>(); - - public ActiveOrderManager(String ticker) { - this.ticker = ticker; - } - - public void saveOrder(Order order) { - if(order.getIsMarketOrder()){ - return; - } - if(order instanceof BuyOrder){ - activeBuyOrders.put(order.getId(), (BuyOrder) order); - } else { - activeSellOrders.put(order.getId(), (SellOrder) order); - } - } - - public Optional getOrder(Long orderId, boolean isBuyOrder) { - if(isBuyOrder) { - return Optional.ofNullable(activeBuyOrders.get(orderId)); - } else { - return Optional.ofNullable(activeSellOrders.get(orderId)); - } - } - - public void removeOrder(Long orderId, boolean isBuyOrder) { - if(isBuyOrder) { - activeBuyOrders.remove(orderId); - } else { - activeSellOrders.remove(orderId); - } - } -} diff --git a/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManagerPool.java b/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManagerPool.java deleted file mode 100644 index 9bced8b8..00000000 --- a/src/main/java/com/cleanengine/coin/order/infra/ActiveOrderManagerPool.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.cleanengine.coin.order.infra; - -import com.cleanengine.coin.order.domain.Order; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Optional; - -@Slf4j -@Component -public class ActiveOrderManagerPool { - private final HashMap activeOrderManagerPool = new HashMap<>(); - - @Autowired - public ActiveOrderManagerPool(@Value("${order.tickers}") String[] tickers) { - for (String ticker : tickers) { - activeOrderManagerPool.put(ticker, new ActiveOrderManager(ticker)); - } - } - - public void saveOrder(String ticker, Order order) { - ActiveOrderManager activeOrderManager = getActiveOrderManager(ticker); - activeOrderManager.saveOrder(order); - } - - public Optional getOrder(String ticker, Long orderId, boolean isBuyOrder) { - ActiveOrderManager activeOrderManager = getActiveOrderManager(ticker); - return activeOrderManager.getOrder(orderId,isBuyOrder); - } - - public void removeOrder(String ticker, Long orderId, boolean isBuyOrder) { - ActiveOrderManager activeOrderManager = getActiveOrderManager(ticker); - activeOrderManager.removeOrder(orderId, isBuyOrder); - } - - private ActiveOrderManager getActiveOrderManager(String ticker) { - Optional activeOrderManager = Optional.ofNullable(activeOrderManagerPool.get(ticker)); - if(activeOrderManager.isEmpty()){ - log.debug("ActiveOrderManager not found. check order.tickers on startup"); - throw new RuntimeException("ActiveOrderManager not found. check order.tickers on startup"); - } - return activeOrderManager.get(); - } -} diff --git a/src/main/java/com/cleanengine/coin/order/infra/OrderCreatedEventHandler.java b/src/main/java/com/cleanengine/coin/order/infra/OrderCreatedEventHandler.java deleted file mode 100644 index 2b561df0..00000000 --- a/src/main/java/com/cleanengine/coin/order/infra/OrderCreatedEventHandler.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.cleanengine.coin.order.infra; - -import com.cleanengine.coin.order.application.OrderCreated; -import com.cleanengine.coin.order.application.queue.OrderQueueManagerPool; -import com.cleanengine.coin.order.domain.Order; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionalEventListener; - -@Component -@RequiredArgsConstructor -public class OrderCreatedEventHandler { - private final OrderQueueManagerPool orderQueueManagerPool; - - @TransactionalEventListener(OrderCreated.class) - public void handleOrderCreated(OrderCreated event) { - Order order = event.getOrder(); - orderQueueManagerPool.addOrder(order.getTicker(), order); - } -} diff --git a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java index ede41fc9..8d5358d3 100644 --- a/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java +++ b/src/main/java/com/cleanengine/coin/orderbook/application/service/OrderBookService.java @@ -3,7 +3,8 @@ import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; import com.cleanengine.coin.order.domain.OrderStatus; -import com.cleanengine.coin.order.infra.ActiveOrderManagerPool; +import com.cleanengine.coin.order.domain.spi.ActiveOrders; +import com.cleanengine.coin.order.domain.spi.ActiveOrdersManager; import com.cleanengine.coin.orderbook.domain.OrderBookDomainService; import com.cleanengine.coin.orderbook.dto.OrderBookInfo; import com.cleanengine.coin.orderbook.dto.OrderBookUnitInfo; @@ -18,7 +19,7 @@ @Component @RequiredArgsConstructor public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUsecase { - private final ActiveOrderManagerPool activeOrderManagerPool; + private final ActiveOrdersManager activeOrdersManager; private final OrderBookDomainService orderBookDomainService; private final OrderBookUpdatedNotifierPort orderBookUpdatedNotifierPort; @@ -26,7 +27,7 @@ public class OrderBookService implements UpdateOrderBookUsecase, ReadOrderBookUs public void updateOrderBookOnNewOrder(Order order) { if(order.getIsMarketOrder()){return;} String ticker = order.getTicker(); - activeOrderManagerPool.saveOrder(ticker, order); + activeOrders(ticker).saveOrder(order); boolean isBuyOrder = order instanceof BuyOrder; orderBookDomainService.updateOrderBookOnNewOrder(ticker, isBuyOrder, order.getPrice(), order.getOrderSize()); @@ -43,13 +44,15 @@ public void updateOrderBookOnTradeExecuted(String ticker, Long buyOrderId, Long } private void updateOrderBookOnTradeExecuted(String ticker, Long orderId, boolean isBuyOrder, Double orderSize) { - Optional orderOptional = activeOrderManagerPool.getOrder(ticker, orderId, isBuyOrder); + ActiveOrders activeOrders = activeOrders(ticker); + + Optional orderOptional = activeOrders.getOrder(orderId, isBuyOrder); // 시장가일 경우에는 ManagerPool에 없음 if(orderOptional.isPresent()){ Order order = orderOptional.get(); orderBookDomainService.updateOrderBookOnTradeExecuted(ticker, isBuyOrder, order.getPrice(), orderSize); if(order.getState().equals(OrderStatus.DONE) || approxEquals(order.getOrderSize(), 0.0)){ - activeOrderManagerPool.removeOrder(ticker, orderId, isBuyOrder); + activeOrders.removeOrder(orderId, isBuyOrder); } } } @@ -72,4 +75,8 @@ private void sendOrderBookUpdated(String ticker){ public OrderBookInfo getOrderBook(String ticker) { return extractOrderBookInfo(ticker); } + + private ActiveOrders activeOrders(String ticker){ + return activeOrdersManager.getActiveOrders(ticker); + } } diff --git a/src/main/java/com/cleanengine/coin/orderbook/domain/OrderBookDomainService.java b/src/main/java/com/cleanengine/coin/orderbook/domain/OrderBookDomainService.java index 0efbd6c2..c1f644fb 100644 --- a/src/main/java/com/cleanengine/coin/orderbook/domain/OrderBookDomainService.java +++ b/src/main/java/com/cleanengine/coin/orderbook/domain/OrderBookDomainService.java @@ -1,11 +1,7 @@ package com.cleanengine.coin.orderbook.domain; -import com.cleanengine.coin.common.error.DomainValidationException; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.springframework.validation.FieldError; import java.util.HashMap; import java.util.List; @@ -16,12 +12,6 @@ @Component public class OrderBookDomainService { private final HashMap orderBookPool = new HashMap<>(); - @Autowired - public OrderBookDomainService(@Value("${order.tickers}") String[] tickers) { - for (String ticker : tickers) { - orderBookPool.put(ticker, new OrderBook(ticker)); - } - } public void updateOrderBookOnNewOrder(String ticker, boolean isBuyOrder, Double price, Double orderSize) { getOrderBook(ticker).updateOrderBookOnNewOrder(isBuyOrder, price, orderSize); @@ -39,14 +29,23 @@ public List getSellOrderBookList(String ticker, int size) { return getOrderBook(ticker).getSellOrderBookList(size); } - private OrderBook getOrderBook(String ticker) { - Optional orderBook = Optional.ofNullable(orderBookPool.get(ticker)); - if(orderBook.isEmpty()){ - String message = "OrderBook not found. Check ticker sent from client or order.tickers on startup"; - log.debug(message); - throw new DomainValidationException(message, - List.of(new FieldError("OrderBook", "ticker", message))); + protected OrderBook getOrderBook(String ticker) { + if(!orderBookPool.containsKey(ticker)){ + addOrderBook(ticker); + } + + Optional orderBookOpt = Optional.ofNullable(orderBookPool.get(ticker)); + if(orderBookOpt.isEmpty()){ + log.debug("OrderBook not found. with " + ticker); + throw new RuntimeException("OrderBook not found with " + ticker); + } + + return orderBookOpt.get(); + } + + protected synchronized void addOrderBook(String ticker){ + if(!orderBookPool.containsKey(ticker)){ + orderBookPool.put(ticker, new OrderBook(ticker)); } - return orderBook.get(); } } diff --git a/src/main/java/com/cleanengine/coin/orderbook/infra/SpringTradeExecutedUpdateOrderBookHandler.java b/src/main/java/com/cleanengine/coin/orderbook/infra/SpringTradeExecutedUpdateOrderBookHandler.java new file mode 100644 index 00000000..6d96f693 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/orderbook/infra/SpringTradeExecutedUpdateOrderBookHandler.java @@ -0,0 +1,21 @@ +package com.cleanengine.coin.orderbook.infra; + +import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; +import com.cleanengine.coin.trade.application.TradeExecutedEvent; +import com.cleanengine.coin.trade.entity.Trade; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class SpringTradeExecutedUpdateOrderBookHandler { + private final UpdateOrderBookUsecase updateOrderBookUsecase; + + @TransactionalEventListener + public void onTradeExecutedEvent(TradeExecutedEvent tradeExecutedEvent) { + Trade trade = tradeExecutedEvent.getTrade(); + updateOrderBookUsecase.updateOrderBookOnTradeExecuted(trade.getTicker(), + tradeExecutedEvent.getBuyOrderId(), tradeExecutedEvent.getSellOrderId(), trade.getSize()); + } +} 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 64c48c5f..1753e944 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java @@ -1,83 +1,78 @@ package com.cleanengine.coin.realitybot.api; import com.cleanengine.coin.common.annotation.WorkingServerProfile; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; import com.cleanengine.coin.realitybot.dto.Ticks; +import com.cleanengine.coin.realitybot.service.ApiVWAPService; import com.cleanengine.coin.realitybot.service.OrderGenerateService; -import com.cleanengine.coin.realitybot.service.TickService; -import com.cleanengine.coin.realitybot.service.VirtualTradeService; +import com.cleanengine.coin.realitybot.service.TickParser; +import com.cleanengine.coin.realitybot.service.TickServiceManager; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.DisposableBean; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.List; -import java.util.Queue; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Slf4j @Component @WorkingServerProfile @RequiredArgsConstructor -public class ApiScheduler implements DisposableBean { +public class ApiScheduler { private final BithumbAPIClient bithumbAPIClient; - private final TickService tickService; + private final TickParser tickParser; private final OrderGenerateService orderGenerateService; - private long lastMaxSequentialId = 1L; - private final Queue ticksQueue; - private final VirtualTradeService virtualTradeService; + private final TickServiceManager tickServiceManager; + private final Map lastSequentialIdMap = new ConcurrentHashMap<>(); + private final AssetRepository assetRepository; + 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); + } + } + public void MarketDataRequest(String ticker){ + this.ticker = ticker; + String rawJson = bithumbAPIClient.get(ticker); //api raw데이터 + List gson = TickParser.parseGson(rawJson); //json을 list로 변환 - @Scheduled(fixedRate = 5000) //5초마다 실행 - public void MarketDataRequest(){ - String rawJson = bithumbAPIClient.get(); //api raw데이터 - List gson = TickService.paraseGson(rawJson); //json을 list로 변환 + ApiVWAPService apiVWAPService = tickServiceManager.getService(ticker); + long lastSeqId = lastSequentialIdMap.getOrDefault(ticker,0L); //api 중복검사하여 queue에 저장하기 for (int i = gson.size()-1; i >=0 ; i--) {//2차 : 10 - 역순으로 정렬되어 - 순회해야 함. Ticks ticks = gson.get(i); - if (ticks.getSequential_id() > lastMaxSequentialId){ //중복 검증용 - - //추세 갯수 지정 및 queue관리 - if (ticksQueue.size()>=10){ //갯수를 10개 까지만으로 제한 - ticksQueue.poll(); //queue에서 빼기, 추세를 구현하기만 하면 필요가 있을까? -> 없음 - }//sequentialId가 역으로 받아 poll을 먼저해야 add가능 + if (ticks.getSequential_id() > lastSeqId){ //중복 검증용 + apiVWAPService.addTick(ticks); + lastSeqId = Math.max(lastSeqId, ticks.getSequential_id()); //중복 id 갱신 - //중복 검사 후 ticks 추가 - ticksQueue.add(ticks); - lastMaxSequentialId = Math.max(lastMaxSequentialId, ticks.getSequential_id()); //중복 id 갱신 - - /*모니터링용 - System.out.println(ticks.getSequential_id()); - System.out.println("if = "+ticksQueue.size()); - */ } } - - //api 값으로 추세(VWAP)와 가상추세(VirtualVWAP) 구하기 - if (ticksQueue.size()>=10){ //10개 이전 작동시 order 에런 발생 (-300~300원 대량주문 / 호가 단위 때문에) - - tickService.processVWAP();//평균 체결 금액(VWAP) 구하기 (추세) - - /*//모니터링용 - log.info("generateOrder vwap 확인용 = {}",tickService.getVwap()); - log.info("generateOrder volume 확인용 = {}",tickService.getTotalVolume());*/ - - //생성 된 vwap으로 주문 로직 실행 TODO 비동기로 전환하기 - orderGenerateService.generateOrder(tickService.getVwap(),(tickService.getTotalVolume()/30)); //1tick 당 매수/매도 3개씩 제작 -// virtualTradeService.matchOrder();//일치하면 체결 진행 TODO 합칠 때 제거 - virtualTradeService.matchOrderbyIterator();//일치하면 체결 진행 TODO 합칠 때 제거 - } - + lastSequentialIdMap.put(ticker,lastSeqId); + double vwap = apiVWAPService.getVWAP(); + double volume = apiVWAPService.getAvgVolumePerOrder(); + orderGenerateService.generateOrder(ticker,vwap,volume); //1tick 당 매수/매도 3개씩 제작 +// log.info("작동확인 {}의 가격 : {} , 볼륨 : {}",ticker, vwap, volume); } - @Override + +/* @Override public void destroy() throws Exception { //담긴 Queue데이터 확인용 // log.info("종료 전 큐 데이터 출력"); - ticksQueue.forEach(tick -> log.debug(tick.toString())); // +// ticksQueue.forEach(tick -> log.debug(tick.toString())); // // log.info("총 {}건의 데이터 출력 완료",ticksQueue.size()); // orderQueueManagerService.logAllOrders(); // virtualTradeService.printOrderSummary(); - } + }*/ } diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java b/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java index 6d98d129..a0ee5f31 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java @@ -16,19 +16,22 @@ public class BithumbAPIClient { private OkHttpClient client; private Gson gson; + private String ticker; - public String get(){ //API를 responseBody에 담아 반환 + + public String get(String ticker){ //API를 responseBody에 담아 반환 + this.ticker = ticker; client = new OkHttpClient(); gson = new Gson(); Request request = new Request.Builder() - .url("https://api.bithumb.com/v1/trades/ticks?market=krw-TRUMP&count=10") + .url("https://api.bithumb.com/v1/trades/ticks?market=krw-"+ticker+"&count=10") .get() .addHeader("accept", "application/json") .build(); try (Response response = client.newCall(request).execute()){ String responseBody = response.body().string(); // return gson.toJson(response.body().string()); - log.debug("Bithumb API 응답 : {}",responseBody); + log.debug("{}의 Bithumb API 응답 : {}",ticker,responseBody); return responseBody; } catch (IOException e) { throw new RuntimeException(e); diff --git a/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java b/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java index 9d234809..6de9a720 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java +++ b/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java @@ -3,7 +3,6 @@ import com.cleanengine.coin.realitybot.api.BithumbAPIClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -15,6 +14,7 @@ public ApiController(BithumbAPIClient bithumbAPIClient) { } @GetMapping("/test") public String getApiData(){ - return bithumbAPIClient.get(); +// return bithumbAPIClient.get(ticekr); + return null; }} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java b/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java new file mode 100644 index 00000000..e1a9b0b3 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java @@ -0,0 +1,47 @@ +package com.cleanengine.coin.realitybot.service; + + +import com.cleanengine.coin.realitybot.dto.Ticks; + +import java.util.LinkedList; +import java.util.Queue; + + +public class ApiVWAPService { + private final Queue ticksQueue = new LinkedList<>(); + private double vwap; + private double totalPriceVolume; + private double totalVolume; + + public void addTick(Ticks tick){ + if (ticksQueue.size() >= 10) { + //10개 이상이 되면 선착순으로 제거해나감 + Ticks removed = ticksQueue.poll(); + totalPriceVolume -= removed.getTrade_price() * removed.getTrade_volume(); + totalVolume -= removed.getTrade_volume(); + } + //초기엔 들어온 갯수에 따라 증가시켜서 계산함 + ticksQueue.add(tick); + totalPriceVolume += tick.getTrade_price() * tick.getTrade_volume(); + totalVolume += tick.getTrade_volume(); + //갯수 만큼 계산하기 때문에 정상 작동 + calculateVWAP(); + } + + private void calculateVWAP() { + vwap = (totalVolume == 0) ? 0.0 : totalPriceVolume / totalVolume; + } + + public double getVWAP() { + return vwap; + } + + public double getAvgVolumePerOrder() { + return totalVolume / 30.0; + } + + public int getTickSize() { + return ticksQueue.size(); + } + +} 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 88d1a16f..3ba308b2 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java @@ -1,18 +1,19 @@ package com.cleanengine.coin.realitybot.service; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.configuration.bootstrap.DBInitRunner; import com.cleanengine.coin.order.application.OrderService; -import com.cleanengine.coin.order.external.adapter.account.AccountExternalRepository; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; 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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; +import java.text.DecimalFormat; import java.util.List; import java.util.concurrent.TimeUnit; @@ -21,49 +22,39 @@ @Slf4j @Service +@Order(5) @RequiredArgsConstructor public class OrderGenerateService { private final int[] orderLevels = {1,2,3}; private final int unitPrice = 10; //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 - private final OrderQueueManagerService queueManager; private final PlatformVWAPService platformVWAPService; private final OrderService orderService; private final TradeRepository tradeRepository; private final VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; private final WalletExternalRepository walletExternalRepository; private final AccountExternalRepository accountExternalRepository; + private String ticker; - //TODO 비동기 처리로 전환 필요 + 시장 확인 후 주문량에 따른 오더 조절 필요 - public void generateOrder(double apiVWAP, double avgVolum) {//기준 주문금액, 주문량 받기 (tick당 계산되어 들어옴) + public void generateOrder(String ticker, double apiVWAP, double avgVolume) {//기준 주문금액, 주문량 받기 (tick당 계산되어 들어옴) + this.ticker = ticker; - //todo 여기에 recordTrade를 불러 올 예정 - //불러와서 vwap을 넣어야 함 - List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc("TRUMP"); + //최근 체결 내역 가져오기 + List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); - // Platform 기반 가격 , 최초 0.0원 -// double platformVWAP = platformVWAPService.getPlatformVWAP(); //order를 넣고 체결한 trade queue 값을 기준으로 계산 - double platformVWAP = platformVWAPService.calculateVWAPbyTrades(trades); - //todo 0513 이거 체결량 그만큼 채워지는 거 아니면 작동 안되도록 전환 필요 - if(platformVWAP == 0.0){ //최초 실행 시 vwap 계산 - platformVWAP = generateVirtualVWAP(apiVWAP); //0.1% 보정값 랜덤 생성 - } + // Platform 기반 가격 생성 (10개 이하, 10개 이상에 따른 가격 생성) + double platformVWAP = platformVWAPService.calculateVWAPbyTrades(ticker,trades,apiVWAP); - //근데 이거 platforVWAP 이 값이 있을 경우 작동해야 함 + //편차 계산 (vwap 기준) double trendLineRate = (platformVWAP - apiVWAP)/ apiVWAP; - boolean isWithinRange = Math.abs(trendLineRate) <= 0.01; - - //VWAP 선택기 = 최초1회 API, 이후 Tick기반 todo 제거 대상 -// double virtualVWAP = virtualMarketService.switcherVWAP(tickService.getTicksQueue(), platformVWAP); - - - //TODO 체결 제작 후 VirtualVWAP이 아닌 PlatformVWAP 로 전환필요 ✔ + //편차가 +-1% 이상 발생하면 true 반환 + boolean isWithinRange = Math.abs(trendLineRate) <= 0.001; //TODO 호가 단위에 따른 편차 보정 필요 for(int level : orderLevels) { //1주문당 3회 매수매도 처리 - double priceOffset = unitPrice * level; //3단계 호가 각각 처리 - double randomOffset = level1TradeMaker(platformVWAP,getDynamicMaxRate(trendLineRate));//랜덤 오차 부여 (가격 조정용) - double deviation = Math.abs(trendLineRate); - + double priceOffset = unitPrice * level; //호가 단위만큼 단계별 offset 설정 + //randomoffset는 1단계 밀집 주문을 위해 offset 편차가 많이 안나도록 동적으로 max를 제한함 + double randomOffset = Math.abs(level1TradeMaker(platformVWAP,getDynamicMaxRate(trendLineRate))); + double deviation = Math.abs(trendLineRate); //편차 구하기 double sellPrice; double buyPrice; @@ -86,9 +77,8 @@ public void generateOrder(double apiVWAP, double avgVolum) {//기준 주문금 } //주문 실행 -// sellPrice = normalizeToUnit(virtualVWAP + priceOffset);//todo : 제거 대상 - double sellVolume = getRandomVolum(avgVolum); - double buyVolume = getRandomVolum(avgVolum); + double sellVolume = getRandomVolum(avgVolume); + double buyVolume = getRandomVolum(avgVolume); if (platformVWAP != 0){ if (isWithinRange){ @@ -102,11 +92,11 @@ public void generateOrder(double apiVWAP, double avgVolum) {//기준 주문금 } double correctionRate = 0.1; if (trendLineRate < -0.01) { // platformVWAP이 너무 낮음 - sellPrice = normalizeToUnit(sellPrice - (apiVWAP * correctionRate)); // 매도 더 싸게 → 체결 유도 - buyPrice = normalizeToUnit(buyPrice + (apiVWAP * correctionRate)); // 매수 더 비싸게 → 체결 유도 + sellPrice = normalizeToUnit(sellPrice + (apiVWAP * correctionRate)); // 매도 비싸게 + buyPrice = normalizeToUnit(buyPrice + (apiVWAP * correctionRate)); // 매수 비싸게 } else if (trendLineRate > 0.01) { // platformVWAP이 너무 높음 - sellPrice = normalizeToUnit(sellPrice + (apiVWAP * correctionRate)); // 매도 더 비싸게 - buyPrice = normalizeToUnit(buyPrice - (apiVWAP * correctionRate)); // 매수 더 싸게 + sellPrice = normalizeToUnit(sellPrice - (apiVWAP * correctionRate)); // 매도 싸게 + buyPrice = normalizeToUnit(buyPrice - (apiVWAP * correctionRate)); // 매수 싸게 //platform vwap -> vwap으로 변환 } @@ -115,26 +105,30 @@ public void generateOrder(double apiVWAP, double avgVolum) {//기준 주문금 if (deviation > 0.01) { double power = trendLineRate * 100; // 3% → 3 if (trendLineRate < 0) { - buyVolume *= 1.0 + power * 0.5; // 3% → 2.5배 + buyVolume *= 1.0 + Math.abs(power) * 0.5; // 3% → 2.5배 + sellVolume *= 1.0 + Math.abs(power) * 0.5; buyPrice = normalizeToUnit(apiVWAP * (1 + 0.002 * power)); // +0.6% + sellPrice = normalizeToUnit(apiVWAP * (1 + 0.002 * power)); // +0.6% } else { - sellVolume *= 1.0 + power * 0.5; + buyVolume *= 1.0 + Math.abs(power) * 0.5; // 3% → 2.5배 + buyPrice = normalizeToUnit(apiVWAP * (1 - 0.002 * power)); // -0.6% + sellVolume *= 1.0 + Math.abs(power) * 0.5; sellPrice = normalizeToUnit(apiVWAP * (1 - 0.002 * power)); // -0.6% } } - createOrderWithFallback("TRUMP",false, sellVolume,sellPrice); - createOrderWithFallback("TRUMP",true, buyVolume,buyPrice); + createOrderWithFallback(ticker,false, sellVolume,sellPrice); + createOrderWithFallback(ticker,true, buyVolume,buyPrice); - queueManager.addSellOrder(sellPrice, sellVolume); - queueManager.addBuyOrder(buyPrice, buyVolume); //Queue 추가 +// queueManager.addSellOrder(sellPrice, sellVolume); +// queueManager.addBuyOrder(buyPrice, buyVolume); //Queue 추가 } else { //스위치 시켜야 할까? - createOrderWithFallback("TRUMP",false, sellVolume,sellPrice); - createOrderWithFallback("TRUMP",true, buyVolume,buyPrice); + createOrderWithFallback(ticker,false, sellVolume,sellPrice); + createOrderWithFallback(ticker,true, buyVolume,buyPrice); - queueManager.addSellOrder(sellPrice, sellVolume); - queueManager.addBuyOrder(buyPrice, buyVolume); +// queueManager.addSellOrder(sellPrice, sellVolume); +// queueManager.addBuyOrder(buyPrice, buyVolume); } @@ -145,46 +139,41 @@ public void generateOrder(double apiVWAP, double avgVolum) {//기준 주문금 } catch (InterruptedException e) { throw new RuntimeException(e); } +// vwaPerrorInJectionScheduler.enableInjection(); //에러 발생기 비활성화 -/* //모니터링용 + /* //모니터링용 System.out.println("sellPrice = " + sellPrice); System.out.println("sellVolume = " + sellVolume); -// buyPrice = normalizeToUnit(virtualVWAP - priceOffset);//todo : 제거 대상 //모니터링용 System.out.println("buyPrice = " + buyPrice); System.out.println("buyVolume = " + buyVolume); System.out.println("===================================="); - System.out.println("현재 시장 vwap "+apiVWAP+" 현재 플랫폼 vwap"+platformVWAP); - System.out.println("====================================");*/ -// vwaPerrorInJectionScheduler.enableInjection(); //에러 발생기 비활성화 + DecimalFormat df = new DecimalFormat("#,##0.00"); + System.out.println(ticker+"의 현재 시장 vwap :"+df.format(apiVWAP)+" | 현재 플랫폼 vwap :"+df.format(platformVWAP));*/ + } -/* System.out.println("📦 [체결 기록 Top 10]"); + /* System.out.println("📦"+ticker+" [체결 기록 Top 10]"); trades.forEach(t -> - System.out.printf("🕒 %s | 가격: %.0f | 수량: %.2f | 매수: #%d ↔ 매도: #%d%n", + System.out.printf("🕒 %s | 가격: %.0f | 수량: %.8f | 매수: #%d ↔ 매도: #%d%n", t.getTradeTime(), t.getPrice(), t.getSize(), t.getBuyUserId(), t.getSellUserId()) );*/ } - //todo VirtualMarketService 여기에도 있는데 공통화 필요? , 계수는 조금 다른긴함 -> vms 제거 대상 - private double generateVirtualVWAP(double apiVWAP) {//가상 vwap 계산 , 최초 1회 사용 - double maxDeviationaRate = 0.001; //보정값 0.1%만 - double deviation = (Math.random() * 2 - 1)* maxDeviationaRate; //편차 계산 - return apiVWAP * (1+deviation); // +=deviation 난수 생성 후 계산 (범위는 -1~+1) - } - - - private double getDynamicMaxRate(double trendLineRate) { - return 0.01 + Math.abs(trendLineRate) * 0.5; // 더 벌어질수록 보정폭 확대 - } - private void createOrderWithFallback(String ticker,boolean isBuy, double volume, double price ) { + if (volume <= 0 || price <= 0){ + log.error("잘못된 주문이 발생 [종목 : {}] ,[isBuy : {}] ,[금액 : {}] ,[수량 : {}] 주문은 생성 취소",ticker,isBuy, + new DecimalFormat("#,###.########").format(price), + new DecimalFormat("#,###.########").format(volume)); + return; + } + try { orderService.createOrderWithBot(ticker, isBuy, volume, price); } catch (DomainValidationException e) { log.debug("잔량 부족: {}", e.getMessage()); try { - resetBot(); + resetBot(ticker); orderService.createOrderWithBot(ticker, isBuy, volume, price); } catch (Exception e1) { log.error("주문 재시도 실패", e1); @@ -192,10 +181,11 @@ private void createOrderWithFallback(String ticker,boolean isBuy, double volume, } } - protected void resetBot(){ - Wallet wallet = walletExternalRepository.findWalletBy(SELL_ORDER_BOT_ID,"TRUMP").get(); + protected void resetBot(String ticker){ + this.ticker = ticker; + Wallet wallet = walletExternalRepository.findWalletBy(SELL_ORDER_BOT_ID,ticker).get(); wallet.setSize(500_000_000.0); - Wallet wallet2 = walletExternalRepository.findWalletBy(BUY_ORDER_BOT_ID,"TRUMP").get(); + Wallet wallet2 = walletExternalRepository.findWalletBy(BUY_ORDER_BOT_ID,ticker).get(); wallet2.setSize(0.0); walletExternalRepository.save(wallet); walletExternalRepository.save(wallet2); @@ -210,17 +200,30 @@ protected void resetBot(){ //==================================order 정규화용 ============================================ - private double level1TradeMaker(double apiVWAP, double maxRate){ + private double level1TradeMaker(double platformVWAP, double maxRate){ + //시장가에 해당하는 호가는 거래 체결 강하게 하기 위함 double percent = (Math.random() * 2-1)*maxRate; - return apiVWAP * percent; + return platformVWAP * percent; + } + + private double getDynamicMaxRate(double trendLineRate) { + // 편차가 벌어지면 벌어질수록 보정폭 확대 + // 5% = 2.51의 가중치 + // 11% = 5.51의 가중치 + return 0.01 + Math.abs(trendLineRate) * 0.5; } private int normalizeToUnit(double price){ //호가단위로 변환 return (int)(Math.round(price / unitPrice)) * unitPrice; -// return (int) (price / unitPrice) * unitPrice; } private double getRandomVolum(double avgVolum){ //볼륨 랜덤 입력 double rawVolume = avgVolum * (0.5+Math.random()); - return Math.round(rawVolume * 1000.0)/1000.0; + //호가 단위에 따라 0원이 발생 가능성 + double resultVolume = Math.round(rawVolume * 1000.0)/1000.0; + if(resultVolume <= 0){ + //Volume이 0이하일 경우 재 계산 + resultVolume = Math.round(rawVolume * 10000000.0)/10000000.0; + } + return resultVolume; } } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/OrderQueueManagerService.java b/src/main/java/com/cleanengine/coin/realitybot/service/OrderQueueManagerService.java index e2917f06..f737db01 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderQueueManagerService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderQueueManagerService.java @@ -2,14 +2,16 @@ import com.cleanengine.coin.realitybot.dto.TestOrder; import lombok.Getter; -import org.springframework.stereotype.Component; import java.util.Comparator; import java.util.PriorityQueue; @Getter -@Component +//@Component public class OrderQueueManagerService { + /*해당 코드는 초기 개별 모듈로 작업할 때 가상의 체결을 만드는 코드였습니다. + * 이젠 쓰이지 않는 코드이나 어떤 에러가 발생할 때 재사용하기 위한 용도로 삭제하지 않았습니다. + * */ //체결용 출력 private final PriorityQueue buyqueue = new PriorityQueue<>(new Comparator() { 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 f89e5a8b..d3b0821f 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java @@ -1,69 +1,32 @@ package com.cleanengine.coin.realitybot.service; +import com.cleanengine.coin.realitybot.vo.VWAPState; import com.cleanengine.coin.trade.entity.Trade; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import java.util.LinkedList; import java.util.List; -import java.util.Queue; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; @Service +@Slf4j public class PlatformVWAPService {//TODO 가상 시장 조회용 사라질 예정임 - private final Queue tradeQueue = new LinkedList<>(); //테스트를 위한 큐 -> 체결 db에서 데이터 조회 - private int maxQueueSize = 10; - - private double totalPriceVolume = 0; - private double totalVolume = 0; - - public void recordTrade(double price, double volume) { - - if (volume <= 0) return; - - if (tradeQueue.size() >= maxQueueSize) { - Vwap removed = tradeQueue.poll(); - totalPriceVolume -= removed.price * removed.volume; - totalVolume -= removed.volume; - } - - tradeQueue.offer(new Vwap(price, volume)); - totalPriceVolume += price * volume; - totalVolume += volume; -// System.out.println("=== 최신 체결 큐 확인 : " + tradeQueue.peek().toString()); -// System.out.println("=== platformVWAP 변동 : "+getPlatformVWAP()); - } - - public double getPlatformVWAP() { - return totalVolume == 0 ? 0.0 : totalPriceVolume / totalVolume; + Map vwapMap = new ConcurrentHashMap<>(); + + public double calculateVWAPbyTrades(String ticker,List trades,double apiVWAP) { + VWAPState state = vwapMap.computeIfAbsent(ticker, VWAPState::new); + if (trades.size() < 10){ + //체결 내역이 10개 이하일 경우 자체 계산 + return generateVWAP(apiVWAP); + } + state.calculateVWAPbyTrades(trades); + return state.getVWAP(); } - public double calculateVWAPbyTrades(List trades) { - for (Trade trade : trades) { - double price = trade.getPrice(); - double volume = trade.getSize(); - if (volume <= 0) continue; - totalPriceVolume += price * volume; - totalVolume += volume; - + public double generateVWAP ( double apiVWAP){ + double maxDeviationaRate = 0.001; //보정값 0.1%만 + double deviation = (Math.random() * 2 - 1) * maxDeviationaRate; //편차 계산 + return apiVWAP * (1 + deviation); // +=deviation 난수 생성 후 계산 (범위는 -1~+1) } - return getPlatformVWAP(); - } - - private static class Vwap { //원래 trade였는데 가상 계산 떄문에 냅두기 - double price; - double volume; - - public Vwap(double price, double volume) { - this.price = price; - this.volume = volume; - } - - @Override - public String toString() { - return "Trade{" + - "price=" + price + - ", volume=" + volume + - '}'; - } - } -} - + } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java b/src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java new file mode 100644 index 00000000..c82f6e84 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java @@ -0,0 +1,29 @@ +package com.cleanengine.coin.realitybot.service; + +import com.cleanengine.coin.realitybot.dto.Ticks; +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +@Getter +public class TickParser { + private static final Gson gson = new Gson(); + + public static List parseGson(String json) { + return gson.fromJson(json, new TypeToken>() {}.getType()); + } + /* + * TickService의 목적을 분류하여 api 로 받아온 값을 parsing 과 api vwap을 계산하는 로직으로 분류하였습니다. + * 따라서 processVWAP과 CalculateVWAP은 제거되었으며 + * 해당 클래스는 TickParser로 명칭을 변경하였습니다. + * */ + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TickService.java b/src/main/java/com/cleanengine/coin/realitybot/service/TickService.java deleted file mode 100644 index 8c43bcc1..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/TickService.java +++ /dev/null @@ -1,62 +0,0 @@ -package com.cleanengine.coin.realitybot.service; - -import com.cleanengine.coin.realitybot.dto.Ticks; -import com.google.gson.Gson; -import com.google.gson.reflect.TypeToken; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Queue; - -@Service -@RequiredArgsConstructor -@Slf4j -@Getter -public class TickService implements TicketServiceInterface { - private static final Gson gson = new Gson();//이거 왜 static으로? service에서 받아오면 되는데 - private final Queue ticksQueue; - - private double vwap; - private double totalPriceVolume; - private double totalVolume; - - public static List paraseGson(String json) { - return gson.fromJson(json, new TypeToken>() {}.getType()); - } - - public void processVWAP(){ //vwap 구하기 실행 -// System.out.println("매서드 실행 시"); - if (ticksQueue.size()<10) {return;} //10개 이상일 경우 실행 - vwap = calculateVWAP(ticksQueue); //vwap 계산 실행 - -// log.info("현재 VWAP: {}",vwap); -// System.out.println("=== 현재 VWAP 가격 "+vwap); - } - - @Override - public double calculateVWAP(Queue ticksQueue) { //vwap (거래량 가중 평균가) 계산기 - //누적 거래 금액 (가격 * 거래량) - totalPriceVolume = 0.0; - // 누적 거래량을 저장 - totalVolume = 0.0; - - //queue 순회하여 각 틱당 가격과 거래량 누적 합산 - for (Ticks ticks : ticksQueue) { - totalPriceVolume += ticks.getTrade_price() * ticks.getTrade_volume();//거래 금액 누적 - totalVolume += ticks.getTrade_volume();//거래량 누적 - - /*//모니터링용 코드 - log.info("추가 된 가격 : {}",ticks.getTrade_price()); - log.info("추가 된 아이디 : {}",ticks.getSequential_id()); - */ - } - //0 은 오류 방지 - //VWAP 반환 - return totalVolume == 0? 0.0: totalPriceVolume / totalVolume; - - } - -} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java b/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java new file mode 100644 index 00000000..a49ab9c6 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java @@ -0,0 +1,18 @@ +package com.cleanengine.coin.realitybot.service; + +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Service +public class TickServiceManager { + /*해당 코드는 ticker 별로 apiVWAP을 계산하기 위해 만들어진 코드입니다. + * 초기엔 전역에서 vwap을 계산하거나 sequentialid를 변수에 담았으나 인스턴스가 종목별로 생성되어야 해서 작성되었습니다. + * ConcurrentHashMap을 통해 중복 검사 후 종목명으로 만들어진 게 없다면 새로 만듭니다. + * */ + private final Map tickServiceMap = new ConcurrentHashMap<>(); + public ApiVWAPService getService(String ticker) { + return tickServiceMap.computeIfAbsent(ticker, t -> new ApiVWAPService()); + } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TicketServiceInterface.java b/src/main/java/com/cleanengine/coin/realitybot/service/TicketServiceInterface.java deleted file mode 100644 index fd73a858..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/TicketServiceInterface.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.cleanengine.coin.realitybot.service; - - -import com.cleanengine.coin.realitybot.dto.Ticks; - -import java.util.Queue; - -public interface TicketServiceInterface { - double calculateVWAP(Queue ticksQueue); -} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java b/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java index bbf68b5d..289144e5 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java @@ -3,10 +3,14 @@ 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; +import static com.cleanengine.coin.common.CommonValues.BUY_ORDER_BOT_ID; +import static com.cleanengine.coin.common.CommonValues.SELL_ORDER_BOT_ID; + @Component @RequiredArgsConstructor public class VWAPerrorInJectionScheduler { @@ -14,27 +18,31 @@ public class VWAPerrorInJectionScheduler { private final TradeRepository tradeRepository; private boolean shouldInject = false; + private boolean hasInjected = false; + public void enableInjection() { + if (hasInjected) return; // 이미 실행한 적 있으면 무시 this.shouldInject = true; } -// @Scheduled(fixedRate = 60000) // 혹은 따로 수동 호출도 가능 + @Scheduled(fixedRate = 60000) // 혹은 따로 수동 호출도 가능 public void injectFakeTrade() { - if (!shouldInject) return; + if (!shouldInject || hasInjected) return; Trade fakeTrade = new Trade(); fakeTrade.setTicker("TRUMP"); - fakeTrade.setBuyUserId(9999); // 테스트용 유저 ID - fakeTrade.setSellUserId(9998); // 테스트용 유저 ID + fakeTrade.setBuyUserId(BUY_ORDER_BOT_ID); // 테스트용 유저 ID + fakeTrade.setSellUserId(SELL_ORDER_BOT_ID); // 테스트용 유저 ID fakeTrade.setPrice(25000.0); // 말도 안되는 고가 (예: 시장 평균이 19,000일 때) // fakeTrade.setPrice(18900.0); // 말도 안되는 고가 (예: 시장 평균이 19,000일 때) - fakeTrade.setSize(3000.0); // 대량 체결 + fakeTrade.setSize(300.0); // 대량 체결 // fakeTrade.setSize(100.0); // 대량 체결 fakeTrade.setTradeTime(LocalDateTime.now()); tradeRepository.save(fakeTrade); + hasInjected = true; // ✅ 한 번 실행했으니 플래그 설정 shouldInject = false; -// System.out.println("🚨 혼동 Trade 1건 삽입 완료!"); + System.out.println("🚨 혼동 Trade 1건 삽입 완료!"); } } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualMarketService.java b/src/main/java/com/cleanengine/coin/realitybot/service/VirtualMarketService.java deleted file mode 100644 index b6dc42e6..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualMarketService.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.cleanengine.coin.realitybot.service; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; - -import java.util.Random; - -@Service -@Slf4j -public class VirtualMarketService {//이거 안씀이젬 - private final Random random = new Random(); - private final TickService tickService; - - //1회용 으로 전환 - private boolean initialized = false; - private double initialVWAP; - - public VirtualMarketService(TickService tickService) { - this.tickService = tickService; - } - -// public double switcherVWAP(Queue ticksQueue, double platformVWAP){ -// if (!initialized || platformVWAP == 0.0) { -// initialVWAP = getVirtualMarketPrice(ticksQueue); -// initialized = true; -// log.info("초기 VWAP 생성: {}", initialVWAP); -// return initialVWAP; -// } else { -// log.info("초기 VWAP 생성: {}", initialVWAP); -// log.info("vwap 생성 : {}",platformVWAP); -// return platformVWAP; -// } -// } - -// public double getVirtualMarketPrice(Queue ticksQueue) { //10개로 제한 된 ticks를 받음 -// double realVWAP = tickService.calculateVWAP(ticksQueue);//다시 평균가 계산 : 또 계산할 필요가 있을까? -// double adjustment = getRandomAdjustment(realVWAP); //랜덤 조정 : +-0.5% 범위 내 랜덤 값 부여 -// -// double virtualPrice = realVWAP + adjustment; // 최종 가격 -// -// /*//모니터링 (가상 = trade 체결금액에서 vwap)만 구하면 됨 -// log.info("현실 VWAP : {}, 보정치 : {}, 가상 VWAP : {}", realVWAP, adjustment, virtualPrice); -// */ -// -// return virtualPrice; -// } - -// private double getRandomAdjustment(double realVWAP) { //가상 VWAP 제작용 랜덤 값 부여 -// double maxDeviationRate = 0.005; //(0.5%는 보정값) -// double deviation = realVWAP * maxDeviationRate;//편차 계산 -// -// return (random.nextDouble()*2-1)* deviation; // +=deviation 난수 생성 후 계산 (범위는 -1~+1) -// } -} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java b/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java index 41bd11ed..a32983d5 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java @@ -1,11 +1,10 @@ package com.cleanengine.coin.realitybot.service; import com.cleanengine.coin.realitybot.dto.TestOrder; -import org.springframework.stereotype.Service; import java.util.*; -@Service +//@Service public class VirtualTradeService { private final OrderQueueManagerService queueManager; private final PlatformVWAPService platformVWAPService; @@ -14,8 +13,10 @@ public VirtualTradeService(OrderQueueManagerService queueManager, PlatformVWAPSe this.platformVWAPService = platformVWAPService; this.queueManager = queueManager; } + /*해당 코드는 초기 개별 모듈로 작업할 때 가상의 체결을 만드는 코드였습니다. + * 이젠 쓰이지 않는 코드이나 어떤 에러가 발생할 때 재사용하기 위한 용도로 삭제하지 않았습니다. + * */ - //todo 비활성화 예정 BUT recordTrade()를 받아와야함. //가상 주문 매칭 및 체결 처리를 담당하는 서비스 public void matchOrder(){ //매수, 매도 주문 큐 관리 @@ -26,10 +27,6 @@ public void matchOrder(){ //주문 추출 TestOrder buyOrder = buyQueue.peek(); //가장 높은 매수 주문 TestOrder sellOrder = sellQueue.peek(); // 가장 낮은 매도 주문 - /*//모니터링용 - System.out.println("=== queue확인 - 매수큐 가격 : "+buyOrder.getPrice()+", 수량 : "+buyOrder.getVolume()+"==="); - System.out.println("=== queue확인 - 매도큐 가격 : "+sellOrder.getPrice()+", 수량 : "+sellOrder.getVolume()+"==="); -*/ //체결 조건 부여 : 현재 느슨한 체결 (1:1은 문제 발생/어짜피 매서드 호출 힘너무 쓰면 안됨) //매수 희망가 >= 매도 희망가 @@ -51,9 +48,8 @@ public void matchOrder(){ if (buyOrder.getVolume() <= 0) buyQueue.poll(); if (sellOrder.getVolume() <= 0) sellQueue.poll(); - //VWAP 계산을 위한 거래 기록 TODO JPA로 받아온 값이 들어가야 함 - platformVWAPService.recordTrade(matchedPrice,matchedVolume); - } //쌓이긴 하는데 100원따리로 쌓임 , generateorder가 0원 받을 때 해결해야 함. +// platformVWAPService.recordTrade(matchedPrice,matchedVolume); + } else { break; } @@ -77,9 +73,6 @@ public void matchOrderbyIterator(){ if ((int)buyOrder.getPrice() >= (int)sellOrder.getPrice()){ double matchVolume = Math.min(buyOrder.getVolume(), sellOrder.getVolume()); if (matchVolume <=0) continue; -// System.out.printf("=== 체결 완료 : %.1f / %.4f \n",sellOrder.getPrice(),matchVolume); - platformVWAPService.recordTrade(sellOrder.getPrice(),matchVolume); - buyOrder.setVolume(buyOrder.getVolume() - matchVolume); sellOrder.setVolume(sellOrder.getVolume() - matchVolume); @@ -95,7 +88,6 @@ public void matchOrderbyIterator(){ } } -// System.out.println("===exctued 값 buy : "+excutedBuy+"sell"+excutedSell); queueManager.getBuyqueue().removeAll(excutedBuy); queueManager.getSellqueue().removeAll(excutedSell); diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java b/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java new file mode 100644 index 00000000..04458350 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java @@ -0,0 +1,14 @@ +package com.cleanengine.coin.realitybot.vo; + +import lombok.Getter; + +@Getter +public enum APITicker { + TRUMP("TRUMP"), BTC("BTC"); + + private final String name; + APITicker(String name) { + this.name = name; + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java b/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java new file mode 100644 index 00000000..8f7e6e7f --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java @@ -0,0 +1,78 @@ +package com.cleanengine.coin.realitybot.vo; + +import com.cleanengine.coin.trade.entity.Trade; +import lombok.Getter; +import lombok.Setter; + +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; + +@Getter +@Setter +public class VWAPState { + + public VWAPState(String ticker) { + this.ticker = ticker; + } + + private String ticker; + private final Queue tradeQueue = new LinkedList<>(); //테스트를 위한 큐 -> 체결 db에서 데이터 조회 + private int maxQueueSize = 10; + + private double totalPriceVolume = 0; + private double totalVolume = 0; + private double vwap = 0; + + + //이건 처음에나 필요했지 queue나 10개씩 받아오면서 필요 없는 로직이 되어버림 + public void recordTrade(double price, double volume) { + +// if (volume <= 0) return; +// if (tradeQueue.size() >= maxQueueSize) { +// Vwap removed = tradeQueue.poll(); +// totalPriceVolume -= removed.price * removed.volume; +// totalVolume -= removed.volume; +// } + + tradeQueue.offer(new Vwap(price, volume)); + totalPriceVolume += price * volume; + totalVolume += volume; + } + + public double getVWAP() { + vwap = totalVolume == 0 ? 0.0 : totalPriceVolume / totalVolume; + return vwap; + } + + public void calculateVWAPbyTrades(List trades) { + for (Trade trade : trades) { + double price = trade.getPrice(); + double volume = trade.getSize(); + recordTrade(price,volume); +// if (volume <= 0) continue; +// totalPriceVolume += price * volume; +// totalVolume += volume; + + } + getVWAP(); + } + + private static class Vwap { //원래 trade였는데 가상 계산 떄문에 냅두기 + double price; + double volume; + + public Vwap(double price, double volume) { + this.price = price; + this.volume = volume; + } + + @Override + public String toString() { + return "Trade{" + + "price=" + price + + ", volume=" + volume + + '}'; + } + } +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java index 0bb75ba2..77a34382 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeBatchProcessor.java @@ -1,7 +1,7 @@ package com.cleanengine.coin.trade.application; import com.cleanengine.coin.chart.dto.TradeEventDto; -import com.cleanengine.coin.order.application.queue.OrderQueueManagerPool; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; import jakarta.annotation.PreDestroy; import lombok.Getter; @@ -10,6 +10,7 @@ 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; @@ -21,11 +22,12 @@ import java.util.concurrent.TimeUnit; @Service +@Order(4) public class TradeBatchProcessor implements ApplicationRunner { Logger logger = LoggerFactory.getLogger(TradeBatchProcessor.class); - private final OrderQueueManagerPool orderQueueManagerPool; + private final WaitingOrdersManager waitingOrdersManager; private final TradeService tradeService; private final List executors = new ArrayList<>(); @@ -35,8 +37,8 @@ public class TradeBatchProcessor implements ApplicationRunner { @Value("${order.tickers}") String[] tickers; - public TradeBatchProcessor(OrderQueueManagerPool orderQueueManagerPool, TradeService tradeService, UpdateOrderBookUsecase updateOrderBookUsecase) { - this.orderQueueManagerPool = orderQueueManagerPool; + public TradeBatchProcessor(WaitingOrdersManager waitingOrdersManager, TradeService tradeService, UpdateOrderBookUsecase updateOrderBookUsecase) { + this.waitingOrdersManager = waitingOrdersManager; this.tradeService = tradeService; this.updateOrderBookUsecase = updateOrderBookUsecase; } @@ -48,7 +50,7 @@ public void run(ApplicationArguments args) { private void processTrades() { for (String ticker : tickers) { - TradeQueueManager tradeQueueManager = new TradeQueueManager(orderQueueManagerPool.getOrderQueueManager(ticker), + TradeQueueManager tradeQueueManager = new TradeQueueManager(waitingOrdersManager.getWaitingOrders(ticker), updateOrderBookUsecase, tradeService); tradeQueueManagers.put(ticker, tradeQueueManager); // 정상 종료를 위해 저장 @@ -95,19 +97,9 @@ public void shutdown() { } } + @Deprecated public TradeEventDto retrieveTradeEventDto(String ticker) { - TradeQueueManager tradeQueueManager = this.tradeQueueManagers.get(ticker); - if (tradeQueueManager == null) { - return null; - } - - TradeEventDto lastTradeEventDto = tradeQueueManager.getLastTradeEventDto(); - - // 서비스 시작 후 체결 내역이 없으면 null 반환 - if (lastTradeEventDto.getSize() == 0.0 || lastTradeEventDto.getPrice() == 0.0) { - return null; - } - return lastTradeEventDto; + return null; } } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java new file mode 100644 index 00000000..11f90ebb --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEvent.java @@ -0,0 +1,13 @@ +package com.cleanengine.coin.trade.application; + +import com.cleanengine.coin.trade.entity.Trade; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class TradeExecutedEvent { + Trade trade; + Long buyOrderId; + Long sellOrderId; +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisher.java b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisher.java new file mode 100644 index 00000000..7e6d5dbc --- /dev/null +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeExecutedEventPublisher.java @@ -0,0 +1,19 @@ +package com.cleanengine.coin.trade.application; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +public class TradeExecutedEventPublisher { + + private final ApplicationEventPublisher publisher; + + public TradeExecutedEventPublisher(ApplicationEventPublisher publisher) { + this.publisher = publisher; + } + + public void publish(TradeExecutedEvent event) { + publisher.publishEvent(event); + } + +} diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java index 6f7bdf44..a65af58d 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeQueueManager.java @@ -1,81 +1,29 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.chart.dto.TradeEventDto; -import com.cleanengine.coin.order.application.queue.OrderQueueManager; -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.SellOrder; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; import com.cleanengine.coin.orderbook.application.service.UpdateOrderBookUsecase; -import lombok.Getter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.time.LocalDateTime; -import java.util.Optional; -import java.util.concurrent.PriorityBlockingQueue; +import lombok.extern.slf4j.Slf4j; +@Slf4j public class TradeQueueManager { - private static final Logger logger = LoggerFactory.getLogger(TradeQueueManager.class); - private long lastLogTime = 0; - private static final long LOG_INTERVAL = 1000; - private final double MINIMUM_ORDER_SIZE = 0.00000001; - private volatile boolean running = true; // 무한루프 종료 플래그 - @Getter - private final OrderQueueManager orderQueueManager; private final String ticker; - private final PriorityBlockingQueue marketSellOrderQueue; - private final PriorityBlockingQueue limitSellOrderQueue; - private final PriorityBlockingQueue marketBuyOrderQueue; - private final PriorityBlockingQueue limitBuyOrderQueue; - - private final UpdateOrderBookUsecase updateOrderBookUsecase; - private final TradeService tradeService; - @Getter - private final TradeEventDto lastTradeEventDto; - - public TradeQueueManager(OrderQueueManager orderQueueManager, UpdateOrderBookUsecase updateOrderBookUsecase, TradeService tradeService) { - this.orderQueueManager = orderQueueManager; - this.updateOrderBookUsecase = updateOrderBookUsecase; + public TradeQueueManager(WaitingOrders waitingOrders, UpdateOrderBookUsecase updateOrderBookUsecase, TradeService tradeService) { this.tradeService = tradeService; - // TODO : orderQueueManager의 필드를 꺼내서 쓰는 게 맞는 방식인지 고려 - this.ticker = orderQueueManager.getTicker(); - this.marketSellOrderQueue = orderQueueManager.getMarketSellOrderQueue(); - this.limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - this.marketBuyOrderQueue = orderQueueManager.getMarketBuyOrderQueue(); - this.limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - lastTradeEventDto = new TradeEventDto(); - lastTradeEventDto.setTicker(ticker); + this.ticker = waitingOrders.getTicker(); } public void run() { - /* - 문제 - 큐가 비어있는데 굳이 일을해야할까? - - 큐가 안비어있을떄는 특정 스레드를 호출해서 일을 시키고, 아니면 스레드 블락킹(대기큐) - -> BQ 메커니즘 - (참고) queueManager.getMarketSellOrderQueue().notify(); - BlockingQueue 에 데이터가 없으면 스레드가 블록되는데, 이걸 활용해서 구현? - 시장가 처리는 큐의 데이터 존재 여부를 통해 할 수 있겠지만.. - 비어있을 때가 기준이 아닌, 새로운 주문이 요청되었을 때 한 번 순회하는 게 이상적 - */ + // TODO : 주문 시 이벤트 기반으로 동작하도록 개선 while (running) { try { - Optional> targetTradePair = this.matchOrders(); - if (targetTradePair.isEmpty()) { - continue; - } else { - this.executeTrade(targetTradePair.get().getBuyOrder(), targetTradePair.get().getSellOrder()); - } -// Thread.sleep(10); -// } catch (InterruptedException e) { + tradeService.execMatchAndTrade(ticker); } catch (Exception e) { - logger.error("Error processing trades for {}: {}", this.ticker, e.getMessage()); - throw e; + log.error("Error processing trades for {}: {}", this.ticker, e.getMessage()); } } } @@ -84,210 +32,4 @@ public void stop() { this.running = false; // 무한루프 종료 플래그 } - private Optional> matchOrders() { // 반환값 : 체결여부 - this.writeQueueLog(); - TradePair targetTradePair = null; - - // 시장가 주문 우선처리 - // TODO : race condition 방지 방안? (isEmpty <-> peek 간격 발생) - if (hasElement(this.marketSellOrderQueue) && hasElement(this.limitBuyOrderQueue)) { - // 1. 시장가 매도 주문, 지정가 매수 주문 - SellOrder marketSellOrder = this.marketSellOrderQueue.peek(); - BuyOrder limitBuyOrder = this.limitBuyOrderQueue.peek(); - targetTradePair = new TradePair<>(marketSellOrder, limitBuyOrder); - } else if (hasElement(this.marketBuyOrderQueue) && hasElement(this.limitSellOrderQueue)) { - // 2. 시장가 매수 주문, 지정가 매도 주문 - BuyOrder marketBuyOrder = this.marketBuyOrderQueue.peek(); - SellOrder limitSellOrder = this.limitSellOrderQueue.peek(); - targetTradePair = new TradePair<>(marketBuyOrder, limitSellOrder); - } else if (hasElement(this.limitSellOrderQueue) && hasElement(this.limitBuyOrderQueue)) { - // 3. 지정가 주문 - targetTradePair = this.matchBetweenLimitOrders(); - } - return Optional.ofNullable(targetTradePair); - } - - private void writeQueueLog() { - long currentTime = System.currentTimeMillis(); - if (currentTime - lastLogTime > LOG_INTERVAL) { - logger.debug("주문 큐 - 시장가매도[{}], 지정가매도[{}], 시장가매수[{}], 지정가매수[{}]", - this.marketSellOrderQueue.size(), - this.limitSellOrderQueue.size(), - this.marketBuyOrderQueue.size(), - this.limitBuyOrderQueue.size()); - lastLogTime = currentTime; - } - } - - private TradePair matchBetweenLimitOrders() { - PriorityBlockingQueue limitSellQueue = this.limitSellOrderQueue; - PriorityBlockingQueue limitBuyQueue = this.limitBuyOrderQueue; - - if (this.hasElement(limitSellQueue) && this.hasElement(limitBuyQueue)) { - SellOrder sellOrder = limitSellQueue.peek(); - BuyOrder buyOrder = limitBuyQueue.peek(); - - if (this.canMatch(buyOrder, sellOrder)) { - return new TradePair<>(buyOrder, sellOrder); - } else - return null; - } else - return null; - } - - private boolean hasElement(PriorityBlockingQueue queue) { - return !queue.isEmpty(); - } - - private boolean canMatch(BuyOrder buyOrder, SellOrder sellOrder) { - if (buyOrder == null || sellOrder == null) - return false; - return buyOrder.getPrice() >= sellOrder.getPrice(); - } - - protected void executeTrade(BuyOrder buyOrder, SellOrder sellOrder) { - this.writeTradingLog(buyOrder, sellOrder); - // 주문 관련 처리를 먼저 해서 동기화 이슈 가능성을 최소로 하고, 체결 내역 처리는 별도 스레드로 분리하는 게 낫겠다 - double tradedPrice; - double tradedSize; - double totalTradedPrice; - - // 체결 단가, 수량 확정 - TradeUnitPriceAndSize tradeUnitPriceAndSize = getTradeUnitPriceAndSize(buyOrder, sellOrder); - tradedSize = tradeUnitPriceAndSize.tradedSize(); - tradedPrice = tradeUnitPriceAndSize.tradedPrice(); - if (tradedSize < MINIMUM_ORDER_SIZE) { - return; - } - totalTradedPrice = tradedPrice * tradedSize; - - // 주문 잔여수량, 잔여금액 감소 - if (isMarketOrder(buyOrder)) - buyOrder.decreaseRemainingDeposit(totalTradedPrice); - else - buyOrder.decreaseRemainingSize(tradedSize); - sellOrder.decreaseRemainingSize(tradedSize); - - // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) - this.removeCompletedBuyOrder(buyOrder); - this.removeCompletedSellOrder(sellOrder); - - // DB 테이블 저장에 걸리는 시간 측정용 - long beforeTime = System.currentTimeMillis(); - tradeService.saveOrder(buyOrder); - tradeService.saveOrder(sellOrder); - long afterTime = System.currentTimeMillis(); - logger.debug("주문 테이블에 update하는 데 걸린 시간 : {}ms", afterTime - beforeTime); - - // 예수금 처리 - // - 매수 잔여금액 반환 - if (isMarketOrder(buyOrder)) { - ; // TODO : 시장가 거래 시 1원 단위 등 작은 금액이 남을 수도 있는데 처리방안 - } else { - if (buyOrder.getPrice() - tradedPrice > MINIMUM_ORDER_SIZE) { // 매도 호가보다 높은 가격에 매수를 시도한 경우, 차액 반환 - double totalRefundAmount = (buyOrder.getPrice() - tradedPrice) * tradedSize; - if (totalRefundAmount > MINIMUM_ORDER_SIZE) - tradeService.increaseAccountCash(buyOrder, totalRefundAmount); - } - } - - // - 매도 예수금 처리 - tradeService.increaseAccountCash(sellOrder, totalTradedPrice); - - // 지갑 누적계산 - tradeService.updateWalletAfterTrade(buyOrder, this.ticker, tradedSize, totalTradedPrice); - tradeService.updateWalletAfterTrade(sellOrder, this.ticker, tradedSize, totalTradedPrice); - - // 마지막 체결내역 임시 보관(웹소켓 전송용) - lastTradeEventDto.setSize(tradedSize); - lastTradeEventDto.setPrice(tradedPrice); - lastTradeEventDto.setTimestamp(LocalDateTime.now()); - - // 체결내역 저장 - tradeService.insertNewTrade(this.ticker, buyOrder, sellOrder, tradedSize, tradedPrice); - - // 호가 조회를 위한 Order Service 메서드 호출 - updateOrderBookUsecase.updateOrderBookOnTradeExecuted(ticker, buyOrder.getId(), sellOrder.getId(), tradedSize); - - // TODO: DB처리는 트랜잭션 묶고, 이후 큐 보상 트랜잭션 처리 코드 작성 - } - - private static TradeUnitPriceAndSize getTradeUnitPriceAndSize(BuyOrder buyOrder, SellOrder sellOrder) { - double tradedPrice; - double tradedSize; - if (isMarketOrder(buyOrder)) { // 시장가매수-지정가매도 - tradedPrice = sellOrder.getPrice(); - if (buyOrder.getRemainingDeposit() >= tradedPrice * sellOrder.getRemainingSize()) { // 매수 잔여예수금이 매도 잔여량보다 크거나 같은 경우 (매수 부분체결 or 완전체결, 매도 완전체결) - tradedSize = sellOrder.getRemainingSize(); - } else { - tradedSize = buyOrder.getRemainingDeposit() / tradedPrice; - } - } else if (isMarketOrder(sellOrder)) { // 시장가매도-지정가매수 - tradedPrice = buyOrder.getPrice(); - tradedSize = Math.min(sellOrder.getRemainingSize(), buyOrder.getRemainingSize()); - } else { // 지정가매수-지정가매도 - tradedPrice = getTradedUnitPrice(buyOrder, sellOrder); - tradedSize = Math.min(buyOrder.getRemainingSize(), sellOrder.getRemainingSize()); - } - TradeUnitPriceAndSize tradeUnitPriceAndSize = new TradeUnitPriceAndSize(tradedSize, tradedPrice); - return tradeUnitPriceAndSize; - } - - private record TradeUnitPriceAndSize(double tradedSize, double tradedPrice) { - } - - private static Boolean isMarketOrder(Order order) { - return order.getIsMarketOrder(); - } - - private static Boolean isLimitOrder(Order order) { - return !order.getIsMarketOrder(); - } - - private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { - logger.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", - Thread.currentThread().threadId(), - buyOrder.getTicker(), - buyOrder.getId(), - buyOrder.getUserId(), - isMarketOrder(buyOrder) ? "시장가" : "지정가(" + buyOrder.getPrice() + "원)", - buyOrder.getRemainingSize() == null ? buyOrder.getRemainingDeposit() : buyOrder.getRemainingSize(), - sellOrder.getId(), - sellOrder.getUserId(), - isMarketOrder(sellOrder) ? "시장가" : "지정가(" + sellOrder.getPrice() + "원)", - sellOrder.getRemainingSize()); - } - - private void removeCompletedBuyOrder(BuyOrder order) { - boolean isOrderCompleted = (isMarketOrder(order) && order.getRemainingDeposit() < MINIMUM_ORDER_SIZE) || - (isLimitOrder(order) && order.getRemainingSize() < MINIMUM_ORDER_SIZE); - - if (isOrderCompleted) { - PriorityBlockingQueue orderQueue = - isMarketOrder(order) ? this.marketBuyOrderQueue : this.limitBuyOrderQueue; - orderQueue.remove(order); - tradeService.updateCompletedOrderStatus(order); - } - } - - private void removeCompletedSellOrder(SellOrder order) { - boolean isOrderCompleted = order.getRemainingSize() < MINIMUM_ORDER_SIZE; - - if (isOrderCompleted) { - PriorityBlockingQueue orderQueue = - order.getIsMarketOrder() ? this.marketSellOrderQueue : this.limitSellOrderQueue; - orderQueue.remove(order); - tradeService.updateCompletedOrderStatus(order); - } - } - - private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) { - // 주문 시간을 비교하여 먼저 들어온 주문의 가격으로 거래 - if (buyOrder.getCreatedAt().isBefore(sellOrder.getCreatedAt())) { - return buyOrder.getPrice(); - } else { - return sellOrder.getPrice(); - } - } - } \ No newline at end of file diff --git a/src/main/java/com/cleanengine/coin/trade/application/TradeService.java b/src/main/java/com/cleanengine/coin/trade/application/TradeService.java index 12ca4330..5ffe6609 100644 --- a/src/main/java/com/cleanengine/coin/trade/application/TradeService.java +++ b/src/main/java/com/cleanengine/coin/trade/application/TradeService.java @@ -1,26 +1,30 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.chart.dto.TradeEventDto; import com.cleanengine.coin.common.error.BusinessException; import com.cleanengine.coin.common.response.ErrorStatus; -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.OrderStatus; -import com.cleanengine.coin.order.domain.SellOrder; -import com.cleanengine.coin.order.infra.BuyOrderRepository; -import com.cleanengine.coin.order.infra.SellOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; +import com.cleanengine.coin.order.domain.*; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; import com.cleanengine.coin.user.domain.Account; import com.cleanengine.coin.user.domain.Wallet; import com.cleanengine.coin.user.info.infra.AccountRepository; import com.cleanengine.coin.user.info.infra.WalletRepository; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; -import java.util.Map; import java.util.Optional; +import static com.cleanengine.coin.common.CommonValues.approxEquals; + +@Slf4j @Service +@Transactional public class TradeService { private final TradeRepository tradeRepository; @@ -28,13 +32,22 @@ public class TradeService { private final SellOrderRepository sellOrderRepository; private final AccountRepository accountRepository; private final WalletRepository walletRepository; + @Getter + private final WaitingOrdersManager waitingOrdersManager; + private final TradeExecutedEventPublisher tradeExecutedEventPublisher; + + // 1초마다 큐 로깅 + private long lastLogTime = 0; + private static final long LOG_INTERVAL = 1000; - public TradeService(TradeRepository tradeRepository, BuyOrderRepository buyOrderRepository, SellOrderRepository sellOrderRepository, AccountRepository accountRepository, WalletRepository walletRepository) { + public TradeService(TradeRepository tradeRepository, BuyOrderRepository buyOrderRepository, SellOrderRepository sellOrderRepository, AccountRepository accountRepository, WalletRepository walletRepository, WaitingOrdersManager waitingOrdersManager, TradeExecutedEventPublisher tradeExecutedEventPublisher) { this.tradeRepository = tradeRepository; this.buyOrderRepository = buyOrderRepository; this.sellOrderRepository = sellOrderRepository; this.accountRepository = accountRepository; this.walletRepository = walletRepository; + this.waitingOrdersManager = waitingOrdersManager; + this.tradeExecutedEventPublisher = tradeExecutedEventPublisher; } public Trade saveTrade(Trade trade) { @@ -85,7 +98,7 @@ public Wallet findWalletByUserIdAndTicker(Integer userId, String ticker) { .orElseGet(() -> createNewWallet(account.getId(), ticker)); } - private Wallet createNewWallet(Integer accountId, String ticker) { + public Wallet createNewWallet(Integer accountId, String ticker) { Wallet newWallet = new Wallet(); newWallet.setAccountId(accountId); newWallet.setTicker(ticker); @@ -114,4 +127,201 @@ public void updateCompletedOrderStatus(Order order) { order.setState(OrderStatus.DONE); } + private void writeQueueLog(WaitingOrders waitingOrders) { + long currentTime = System.currentTimeMillis(); + if (currentTime - lastLogTime > LOG_INTERVAL) { + log.debug("주문 큐 - 시장가매도[{}], 지정가매도[{}], 시장가매수[{}], 지정가매수[{}]", + waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), + waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size()); + lastLogTime = currentTime; + } + } + + public void execMatchAndTrade(String ticker) { + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + // TODO : peek() 해온 Order 객체들을 lock -> 체결 도중 취소 방지 + this.matchOrders(waitingOrders) + .ifPresent(tradePair -> executeTrade(waitingOrders, tradePair, ticker)); + } + + private Optional> matchOrders(WaitingOrders waitingOrders) { // 반환값 : 체결여부 + this.writeQueueLog(waitingOrders); + + TradePair targetTradePair; + + // 시장가 주문 우선처리 + SellOrder marketSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).peek(); + SellOrder limitSellOrder = waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).peek(); + BuyOrder marketBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek(); + BuyOrder limitBuyOrder = waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).peek(); + + if (marketSellOrder != null && limitBuyOrder != null) { + // 1. 시장가 매도 주문, 지정가 매수 주문 + targetTradePair = new TradePair<>(marketSellOrder, limitBuyOrder); + } else if (marketBuyOrder != null && limitSellOrder != null) { + // 2. 시장가 매수 주문, 지정가 매도 주문 + targetTradePair = new TradePair<>(marketBuyOrder, limitSellOrder); + } else { + // 3. 지정가 주문 + targetTradePair = this.matchBetweenLimitOrders(limitBuyOrder, limitSellOrder); + } + return Optional.ofNullable(targetTradePair); + } + + private TradePair matchBetweenLimitOrders(BuyOrder limitBuyOrder, SellOrder limitSellOrder) { + if (limitSellOrder == null || limitBuyOrder == null) + return null; + + if (this.canMatch(limitBuyOrder, limitSellOrder)) + return new TradePair<>(limitBuyOrder, limitSellOrder); + else + return null; + } + + private boolean canMatch(BuyOrder buyOrder, SellOrder sellOrder) { + return buyOrder.getPrice() >= sellOrder.getPrice(); + } + + private record TradeUnitPriceAndSize(double tradedSize, double tradedPrice) { + } + + public void executeTrade(WaitingOrders waitingOrders, TradePair tradePair, String ticker) { + BuyOrder buyOrder = tradePair.getBuyOrder(); + SellOrder sellOrder = tradePair.getSellOrder(); + + double tradedPrice; + double tradedSize; + double totalTradedPrice; + + // 체결 단가, 수량 확정 + TradeUnitPriceAndSize tradeUnitPriceAndSize = getTradeUnitPriceAndSize(buyOrder, sellOrder); + tradedSize = tradeUnitPriceAndSize.tradedSize(); + tradedPrice = tradeUnitPriceAndSize.tradedPrice(); + if (approxEquals(tradedSize, 0.0)) { + log.debug("체결 중단! 체결 시도 수량 : {}, 매수단가 : {}, 매도단가 : {}", tradedSize, buyOrder.getPrice(), sellOrder.getPrice()); + return; + } + this.writeTradingLog(buyOrder, sellOrder); + + totalTradedPrice = tradedPrice * tradedSize; + // 주문 잔여수량, 잔여금액 감소 + if (isMarketOrder(buyOrder)) + buyOrder.decreaseRemainingDeposit(totalTradedPrice); + else + buyOrder.decreaseRemainingSize(tradedSize); + sellOrder.decreaseRemainingSize(tradedSize); + + // 주문 완전체결 처리(잔여금액 or 잔여수량이 0) + this.removeCompletedBuyOrder(waitingOrders, buyOrder); + this.removeCompletedSellOrder(waitingOrders, sellOrder); + + // DB 테이블 저장에 걸리는 시간 측정용 + long beforeTime = System.currentTimeMillis(); + this.saveOrder(buyOrder); + this.saveOrder(sellOrder); + long afterTime = System.currentTimeMillis(); + log.debug("주문 테이블에 update하는 데 걸린 시간 : {}ms", afterTime - beforeTime); + + // 예수금 처리 + // - 매수 잔여금액 반환 + if (isMarketOrder(buyOrder)) { + ; // TODO : 시장가 거래 시 1원 단위 등 작은 금액이 남을 수도 있는데 처리방안 + } else { + if (buyOrder.getPrice() > tradedPrice) { // 매도 호가보다 높은 가격에 매수를 시도한 경우, 차액 반환 + double totalRefundAmount = (buyOrder.getPrice() - tradedPrice) * tradedSize; + this.increaseAccountCash(buyOrder, totalRefundAmount); + } + } + + // - 매도 예수금 처리 + this.increaseAccountCash(sellOrder, totalTradedPrice); + + // 지갑 누적계산 + this.updateWalletAfterTrade(buyOrder, ticker, tradedSize, totalTradedPrice); + this.updateWalletAfterTrade(sellOrder, ticker, tradedSize, totalTradedPrice); + + // 체결내역 저장 + Trade trade = this.insertNewTrade(ticker, buyOrder, sellOrder, tradedSize, tradedPrice); + + TradeExecutedEvent tradeExecutedEvent = + TradeExecutedEvent.builder() + .trade(trade) + .buyOrderId(buyOrder.getId()) + .sellOrderId(sellOrder.getId()) + .build(); + tradeExecutedEventPublisher.publish(tradeExecutedEvent); + } + + private static TradeUnitPriceAndSize getTradeUnitPriceAndSize(BuyOrder buyOrder, SellOrder sellOrder) { + double tradedPrice; + double tradedSize; + if (isMarketOrder(buyOrder)) { // 시장가매수-지정가매도 + tradedPrice = sellOrder.getPrice(); + if (buyOrder.getRemainingDeposit() >= tradedPrice * sellOrder.getRemainingSize()) { // 매수 잔여예수금이 매도 잔여량보다 크거나 같은 경우 (매수 부분체결 or 완전체결, 매도 완전체결) + tradedSize = sellOrder.getRemainingSize(); + } else { + tradedSize = buyOrder.getRemainingDeposit() / tradedPrice; + } + } else if (isMarketOrder(sellOrder)) { // 시장가매도-지정가매수 + tradedPrice = buyOrder.getPrice(); + tradedSize = Math.min(sellOrder.getRemainingSize(), buyOrder.getRemainingSize()); + } else { // 지정가매수-지정가매도 + tradedPrice = getTradedUnitPrice(buyOrder, sellOrder); + tradedSize = Math.min(buyOrder.getRemainingSize(), sellOrder.getRemainingSize()); + } + return new TradeUnitPriceAndSize(tradedSize, tradedPrice); + } + + private static double getTradedUnitPrice(BuyOrder buyOrder, SellOrder sellOrder) { + // 주문 시간을 비교하여 먼저 들어온 주문의 가격으로 거래 + if (buyOrder.getCreatedAt().isBefore(sellOrder.getCreatedAt())) { + return buyOrder.getPrice(); + } else { + return sellOrder.getPrice(); + } + } + + private void writeTradingLog(BuyOrder buyOrder, SellOrder sellOrder) { + log.debug("[{}] 체결 확정! 종목: {}, ({}: {}가 {}로 {}만큼 매수주문), ({}: {}가 {}로 {}만큼 매도주문)", + Thread.currentThread().threadId(), + buyOrder.getTicker(), + buyOrder.getId(), + buyOrder.getUserId(), + isMarketOrder(buyOrder) ? "시장가" : "지정가(" + buyOrder.getPrice() + "원)", + buyOrder.getRemainingSize() == null ? buyOrder.getRemainingDeposit() : buyOrder.getRemainingSize(), + sellOrder.getId(), + sellOrder.getUserId(), + isMarketOrder(sellOrder) ? "시장가" : "지정가(" + sellOrder.getPrice() + "원)", + sellOrder.getRemainingSize()); + } + + private static Boolean isMarketOrder(Order order) { + return order.getIsMarketOrder(); + } + + private static Boolean isLimitOrder(Order order) { + return !order.getIsMarketOrder(); + } + + private void removeCompletedBuyOrder(WaitingOrders waitingOrders, BuyOrder order) { + boolean isOrderCompleted = (isMarketOrder(order) && approxEquals(order.getRemainingDeposit(), 0.0)) || + (isLimitOrder(order) && approxEquals(order.getRemainingSize(), 0.0)); + + if (isOrderCompleted) { + waitingOrders.removeOrder(order); + this.updateCompletedOrderStatus(order); + } + } + + private void removeCompletedSellOrder(WaitingOrders waitingOrders, SellOrder order) { + boolean isOrderCompleted = approxEquals(order.getRemainingSize(), 0.0); + + if (isOrderCompleted) { + waitingOrders.removeOrder(order); + this.updateCompletedOrderStatus(order); + } + } + } diff --git a/src/main/java/com/cleanengine/coin/user/domain/Account.java b/src/main/java/com/cleanengine/coin/user/domain/Account.java index 0c5ecb01..2af1d406 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/Account.java +++ b/src/main/java/com/cleanengine/coin/user/domain/Account.java @@ -1,17 +1,13 @@ package com.cleanengine.coin.user.domain; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; @Setter @Getter @Entity @Table(name = "account") @NoArgsConstructor -@AllArgsConstructor public class Account { @Id @@ -25,6 +21,19 @@ public class Account { @Column(name = "cash", nullable = false) private Double cash; + @Builder + private Account(Integer userId, Double cash) { + this.userId = userId; + this.cash = cash; + } + + public static Account of(Integer userId, Double cash) { + return Account.builder() + .userId(userId) + .cash(cash) + .build(); + } + // Cash 증가 public Account increaseCash(Double amount) { if (amount <= 0) { diff --git a/src/main/java/com/cleanengine/coin/user/domain/Wallet.java b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java index 0e68256b..f189c134 100644 --- a/src/main/java/com/cleanengine/coin/user/domain/Wallet.java +++ b/src/main/java/com/cleanengine/coin/user/domain/Wallet.java @@ -34,4 +34,13 @@ public class Wallet { @Column(name = "roi") private Double roi; // Return on Investment (수익률) + public static Wallet generateEmptyWallet(String ticker, Integer accountId){ + Wallet wallet = new Wallet(); + wallet.setTicker(ticker); + wallet.setAccountId(accountId); + wallet.setSize(0.0); + wallet.setBuyPrice(0.0); + wallet.setRoi(0.0); + return wallet; + } } 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 ba0ec138..6e1535ff 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 @@ -17,4 +17,9 @@ public Account retrieveAccountByUserId(Integer userId) { return accountRepository.findByUserId(userId).orElse(null); } + public Account createNewAccount(Integer userId, double cash) { + Account account = Account.of(userId, cash); + return accountRepository.save(account); + } + } diff --git a/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java b/src/main/java/com/cleanengine/coin/user/login/application/CustomOAuth2UserService.java index 24bac57b..fde4d463 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,13 +1,13 @@ package com.cleanengine.coin.user.login.application; -import com.cleanengine.coin.user.domain.Account; +import com.cleanengine.coin.common.CommonValues; 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.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.AccountRepository; import com.cleanengine.coin.user.info.infra.OAuthRepository; import com.cleanengine.coin.user.info.infra.UserRepository; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; @@ -21,12 +21,12 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; private final OAuthRepository oAuthRepository; - private final AccountRepository accountRepository; + private final AccountService accountService; - public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountRepository accountRepository) { + public CustomOAuth2UserService(UserRepository userRepository, OAuthRepository oAuthRepository, AccountService accountService) { this.userRepository = userRepository; this.oAuthRepository = oAuthRepository; - this.accountRepository = accountRepository; + this.accountService = accountService; } @Override @@ -64,11 +64,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic newOAuth.setNickname(name); // TODO : KAKAO Token 관련 정보 추가 oAuthRepository.save(newOAuth); - - Account newAccount = new Account(); - newAccount.setUserId(newUser.getId()); - newAccount.setCash((double) 50_000_000_000L); - accountRepository.save(newAccount); + accountService.createNewAccount(newUser.getId(), CommonValues.INITIAL_USER_CASH); UserOAuthDetails userOAuthDetails = new UserOAuthDetails(newUser, newOAuth); diff --git a/src/main/resources/application-h2-mem.yml b/src/main/resources/application-h2-mem.yml new file mode 100644 index 00000000..d0a6e1fc --- /dev/null +++ b/src/main/resources/application-h2-mem.yml @@ -0,0 +1,4 @@ +spring: + jpa: + hibernate: + ddl-auto: update diff --git a/src/main/resources/application-it.yml b/src/main/resources/application-it.yml new file mode 100644 index 00000000..bc275b92 --- /dev/null +++ b/src/main/resources/application-it.yml @@ -0,0 +1,4 @@ +spring: + jpa: + hibernate: + ddl-auto: validate diff --git a/src/main/resources/application-mariadb-local.yml b/src/main/resources/application-mariadb-local.yml new file mode 100644 index 00000000..fc220807 --- /dev/null +++ b/src/main/resources/application-mariadb-local.yml @@ -0,0 +1,9 @@ +spring: + jpa: + hibernate: + ddl-auto: update + datasource: + url: jdbc:mariadb://mariadb:3306/${MARIADB_DATABASE} + driver-class-name: org.mariadb.jdbc.Driver + username: ${MARIADB_USER} + password: ${MARIADB_PASSWORD} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 275af44a..e240f9e2 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,7 +3,7 @@ spring: cookie: secure: true datasource: - url: jdbc:mariadb://mariadb:3306/${MARIADB_DATABASE} + url: jdbc:mariadb://${MARIADB_HOST}:3306/${MARIADB_DATABASE} driver-class-name: org.mariadb.jdbc.Driver username: ${MARIADB_USER} password: ${MARIADB_PASSWORD} diff --git a/src/main/resources/icons/BTC.svg b/src/main/resources/icons/BTC.svg new file mode 100644 index 00000000..dbd25db7 --- /dev/null +++ b/src/main/resources/icons/BTC.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/icons/DOGE.svg b/src/main/resources/icons/DOGE.svg new file mode 100644 index 00000000..a5d319cb --- /dev/null +++ b/src/main/resources/icons/DOGE.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/ETH.svg b/src/main/resources/icons/ETH.svg new file mode 100644 index 00000000..bc31816d --- /dev/null +++ b/src/main/resources/icons/ETH.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/PEPE.svg b/src/main/resources/icons/PEPE.svg new file mode 100644 index 00000000..fc99221c --- /dev/null +++ b/src/main/resources/icons/PEPE.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/SOL.svg b/src/main/resources/icons/SOL.svg new file mode 100644 index 00000000..82d67cdd --- /dev/null +++ b/src/main/resources/icons/SOL.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/SUI.svg b/src/main/resources/icons/SUI.svg new file mode 100644 index 00000000..630d5dae --- /dev/null +++ b/src/main/resources/icons/SUI.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/main/resources/icons/TRUMP.svg b/src/main/resources/icons/TRUMP.svg new file mode 100644 index 00000000..c60a4f93 --- /dev/null +++ b/src/main/resources/icons/TRUMP.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/icons/USDT.svg b/src/main/resources/icons/USDT.svg new file mode 100644 index 00000000..17a852e4 --- /dev/null +++ b/src/main/resources/icons/USDT.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/WLD.svg b/src/main/resources/icons/WLD.svg new file mode 100644 index 00000000..13967bf6 --- /dev/null +++ b/src/main/resources/icons/WLD.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main/resources/icons/XRP.svg b/src/main/resources/icons/XRP.svg new file mode 100644 index 00000000..d0d128ff --- /dev/null +++ b/src/main/resources/icons/XRP.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 458850e9..acfdc5cc 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -3,11 +3,12 @@ + - INFO + ${LOG_LEVEL} ${LOG_PATTERN} @@ -29,7 +30,7 @@ ${LOG_DIR}/was1.%d{yyyy-MM-dd}.%i.log 10MB 30 - 100MB + 50MB @@ -37,6 +38,11 @@ + + + + + @@ -44,6 +50,10 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/websocket-test.html b/src/main/resources/templates/websocket-test.html index aa26c865..0f1775a6 100644 --- a/src/main/resources/templates/websocket-test.html +++ b/src/main/resources/templates/websocket-test.html @@ -1,361 +1,489 @@ - + - 전일 대비 변동률 테스트 - - + 코인 데이터 종합 테스트 -

코인 실시간 데이터 테스트

-
- - -
- -
-
실시간 변동률
-
전일 대비 변동률
+

코인 데이터 종합 테스트

+ +
+

연결 설정

+
+
+ + + +
+
+ +
+

중요: SockJS를 사용하지 않고 기본 WebSocket으로 연결합니다.

+
+
+
+
+ + +
+
상태: 연결 안됨
-
-
- - +
+
+
실시간 거래
+
전일 대비
+
OHLC 차트
+
디버그 로그
-
+

실시간 거래 데이터

-
-

구독 후 실시간 데이터가 여기에 표시됩니다.

+ + +
구독하면 여기에 데이터가 표시됩니다.
+
+ +
+

전일 대비 데이터

+ + +
구독하면 여기에 데이터가 표시됩니다.
+ +
+

데이터 문제 원인 분석

+

전일 대비 데이터가 모두 0.0으로 표시되는 이유:

+
    +
  1. 데이터베이스에 어제 및 현재 거래 데이터가 없음
  2. +
  3. RealTimeDataPrevRateService.java의 generatePrevRateData 메서드에서 DB 쿼리 결과가 null
  4. +
  5. 그 결과 데이터 모두 0.0으로 초기화된 PrevRateDto 객체 반환
  6. +
+

해결 방법: 실시간 거래 데이터(price 값이 있음)를 활용하여 전일 대비 데이터 대체

- - - - - - - - - - - - - - - - - - - -
-
-
-
- - +
+

OHLC 차트 데이터

+ + +
구독하면 여기에 데이터가 표시됩니다.
-
-

전일 대비 변동률 데이터

-
-

구독 후 전일 대비 변동률 데이터가 여기에 표시됩니다.

-
- - - - - - - - - - - - - - - - - - - - +
+

디버그 로그

+
-

상태 및 수신된 메시지:

-
+ + \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/base/ControllerTest.java b/src/test/java/com/cleanengine/coin/base/ControllerTest.java new file mode 100644 index 00000000..9c9c2c82 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/base/ControllerTest.java @@ -0,0 +1,55 @@ +package com.cleanengine.coin.base; + +import com.cleanengine.coin.common.response.ApiResponse; +import com.cleanengine.coin.configuration.JacksonConfig; +import com.cleanengine.coin.configuration.TimeZoneConfig; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +/** + * URL 패턴, HTTP 메서드 매칭 검증 + * JSON 직렬화, 역직렬화 검증(DTO 테스트로 넘길수 있음) + * 사실 다른 유형의 테스트들 중 가장 덜 중요한 느낌. 시간 부족하다면 선택과 집중을.. + */ +@ActiveProfiles("dev") +@Import({JacksonConfig.class, TimeZoneConfig.class}) +public abstract class ControllerTest { + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + protected ResultActions performGet(String url) throws Exception { + return mockMvc.perform( + get(url) + .contentType("application/json") + .accept("application/json") + .with(csrf())); + } + + protected ResultActions performPost(String url, T requestDto) throws Exception { + return mockMvc.perform( + post(url) + .contentType("application/json") + .accept("application/json") + .content(objectMapper.writeValueAsString(requestDto)) + .with(csrf())); + } + + protected T convertAs(String jsonString, Class clazz) throws Exception { + JavaType javaType = objectMapper.getTypeFactory().constructParametricType(ApiResponse.class, clazz); + + ApiResponse apiResponse = objectMapper.readValue(jsonString, javaType); + return apiResponse.data(); + } +} diff --git a/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java new file mode 100644 index 00000000..2e1c0698 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/base/MariaDBAdapterTest.java @@ -0,0 +1,52 @@ +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.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를 사용바랍니다. + */ +@DataJpaTest +@Disabled +@Tag("testcontainers") +@ActiveProfiles({"dev", "it"}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@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 MariaDBAdapterTest { + @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("spring.jpa.show-sql", () -> "true"); + } + } +} diff --git a/src/test/java/com/cleanengine/coin/base/WebSocketTest.java b/src/test/java/com/cleanengine/coin/base/WebSocketTest.java new file mode 100644 index 00000000..b48ab103 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/base/WebSocketTest.java @@ -0,0 +1,58 @@ +package com.cleanengine.coin.base; + +import org.junit.jupiter.api.BeforeEach; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.messaging.converter.MappingJackson2MessageConverter; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaders; +import org.springframework.messaging.simp.stomp.StompSession; +import org.springframework.messaging.simp.stomp.StompSessionHandlerAdapter; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.messaging.WebSocketStompClient; + +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles({"dev", "it", "h2-mem"}) +public abstract class WebSocketTest { + + @LocalServerPort + protected int port; + + protected WebSocketStompClient stompClient; + protected StompSession session; + + protected BlockingQueue responseQueue = new LinkedBlockingQueue<>(); + + @BeforeEach + public void setUp() throws Exception { + responseQueue.clear(); + stompClient = new WebSocketStompClient(new StandardWebSocketClient()); + stompClient.setMessageConverter(new MappingJackson2MessageConverter()); + + String wsUrl = "ws://localhost:" + port + "/api/coin/min"; + CompletableFuture connectFuture = stompClient.connectAsync(wsUrl, new StompSessionHandlerAdapter() { + @Override + public void afterConnected(StompSession session, StompHeaders connectedHeaders) { + System.out.println("Test STOMP Client Connected: " + session.getSessionId()); + } + @Override + public void handleException(StompSession session, StompCommand command, StompHeaders headers, byte[] payload, Throwable exception) { + System.err.println("STOMP Exception: " + exception); + exception.printStackTrace(); + } + @Override + public void handleTransportError(StompSession session, Throwable exception) { + System.err.println("Transport error: " + exception); + exception.printStackTrace(); + } + }); + + session = connectFuture.get(5, TimeUnit.SECONDS); + } +} diff --git a/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java new file mode 100644 index 00000000..a3ac5db7 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/ChartSubscriptionServiceTest.java @@ -0,0 +1,494 @@ +package com.cleanengine.coin.chart.service; + +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.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.assertThatCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("ChartSubscriptionService 단위 테스트") +class ChartSubscriptionServiceTest { + + @InjectMocks + private ChartSubscriptionService service; + + private String testTicker1; + private String testTicker2; + private String testTicker3; + + @BeforeEach + void setUp() { + testTicker1 = "BTC"; + testTicker2 = "ETH"; + testTicker3 = "TRUMP"; + } + + // ===== 실시간 체결 내역 구독 테스트 ===== + @Test + @DisplayName("실시간 체결 정보 구독을 정상적으로 추가한다") + void subscribeRealTimeTradeRate_ValidTicker_AddsSubscription() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).contains(testTicker1); + } + + @Test + @DisplayName("실시간 체결 정보 구독을 정상적으로 해지한다") + void unsubscribeRealTimeTradeRate_SubscribedTicker_RemovesSubscription() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + + // when + service.unsubscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 실시간 체결 정보 구독 티커를 올바르게 반환한다") + void getAllRealTimeTradeRateSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker2); + service.subscribeRealTimeTradeRate(testTicker3); + + // when + Set result = service.getAllRealTimeTradeRateSubscribedTickers(); + + // then + assertThat(result).hasSize(3); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2, testTicker3); + } + + @Test + @DisplayName("실시간 체결 정보 구독 상태를 정확하게 확인한다") + void isSubscribedToRealTimeTradeRate_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribeRealTimeTradeRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker2)).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate("NONEXISTENT")).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 구독해도 중복되지 않는다") + void subscribeRealTimeTradeRate_DuplicateSubscription_NoDuplicates() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker1); + + // then + Set subscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 실시간 OHLC 구독 테스트 ===== + @Test + @DisplayName("실시간 OHLC 구독을 정상적으로 추가한다") + void subscribeRealTimeOhlc_ValidTicker_AddsSubscription() { + // when + service.subscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).contains(testTicker1); + } + + @Test + @DisplayName("실시간 OHLC 구독을 정상적으로 해지한다") + void unsubscribeRealTimeOhlc_SubscribedTicker_RemovesSubscription() { + // given + service.subscribeRealTimeOhlc(testTicker1); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + + // when + service.unsubscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isFalse(); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 실시간 OHLC 구독 티커를 올바르게 반환한다") + void getAllRealTimeOhlcSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribeRealTimeOhlc(testTicker1); + service.subscribeRealTimeOhlc(testTicker2); + + // when + Set result = service.getAllRealTimeOhlcSubscribedTickers(); + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + @Test + @DisplayName("실시간 OHLC 구독 상태를 정확하게 확인한다") + void isSubscribedToRealTimeOhlc_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribeRealTimeOhlc(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker2)).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 OHLC 구독해도 중복되지 않는다") + void subscribeRealTimeOhlc_DuplicateSubscription_NoDuplicates() { + // when + service.subscribeRealTimeOhlc(testTicker1); + service.subscribeRealTimeOhlc(testTicker1); + + // then + Set subscriptions = service.getAllRealTimeOhlcSubscribedTickers(); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 전날 종가 변동률 구독 테스트 ===== + @Test + @DisplayName("전날 종가 변동률 구독을 정상적으로 추가한다") + void subscribePrevRate_ValidTicker_AddsSubscription() { + // when + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + assertThat(service.getAllPrevRateSubscribedTickers(testTicker1)).contains(testTicker1); + } + + @Test + @DisplayName("전날 종가 변동률 구독을 정상적으로 해지한다") + void unsubscribePrevRate_SubscribedTicker_RemovesSubscription() { + // given + service.subscribePrevRate(testTicker1); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + + // when + service.unsubscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isFalse(); + assertThat(service.getAllPrevRateSubscribedTickers(testTicker1)).doesNotContain(testTicker1); + } + + @Test + @DisplayName("모든 전날 종가 변동률 구독 티커를 올바르게 반환한다") + void getAllPrevRateSubscribedTickers_MultipleSubscriptions_ReturnsAllTickers() { + // given + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker2); + + // when + Set result = service.getAllPrevRateSubscribedTickers("irrelevant"); // 파라미터는 무시됨 + + // then + assertThat(result).hasSize(2); + assertThat(result).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + @Test + @DisplayName("전날 종가 변동률 구독 상태를 정확하게 확인한다") + void isSubscribedToPrevRate_VariousStates_ReturnsCorrectStatus() { + // given + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToPrevRate(testTicker2)).isFalse(); + } + + @Test + @DisplayName("동일한 티커를 여러 번 전날 종가 구독해도 중복되지 않는다") + void subscribePrevRate_DuplicateSubscription_NoDuplicates() { + // when + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker1); + + // then + Set subscriptions = service.getAllPrevRateSubscribedTickers(testTicker1); + assertThat(subscriptions).hasSize(1); + assertThat(subscriptions).contains(testTicker1); + } + + // ===== 혼합 시나리오 테스트 ===== + @Test + @DisplayName("서로 다른 구독 타입은 독립적으로 관리된다") + void multipleSubscriptionTypes_IndependentManagement() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeOhlc(testTicker1); + service.subscribePrevRate(testTicker1); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isTrue(); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + + // when - OHLC만 해지 + service.unsubscribeRealTimeOhlc(testTicker1); + + // then - 다른 구독은 유지됨 + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker1)).isFalse(); + assertThat(service.isSubscribedToPrevRate(testTicker1)).isTrue(); + } + + @Test + @DisplayName("각 구독 타입별로 다른 티커를 구독할 수 있다") + void differentTickersPerSubscriptionType() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeOhlc(testTicker2); + service.subscribePrevRate(testTicker3); + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).containsOnly(testTicker1); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).containsOnly(testTicker2); + assertThat(service.getAllPrevRateSubscribedTickers("irrelevant")).containsOnly(testTicker3); + } + + @Test + @DisplayName("대량의 티커 구독을 효율적으로 처리한다") + void bulkSubscriptions_EfficientHandling() { + // given + String[] tickers = new String[100]; + for (int i = 0; i < 100; i++) { + tickers[i] = "TICKER_" + i; + } + + // when + for (String ticker : tickers) { + service.subscribeRealTimeTradeRate(ticker); + service.subscribeRealTimeOhlc(ticker); + service.subscribePrevRate(ticker); + } + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).hasSize(100); + assertThat(service.getAllRealTimeOhlcSubscribedTickers()).hasSize(100); + assertThat(service.getAllPrevRateSubscribedTickers("irrelevant")).hasSize(100); + + // 특정 티커들이 모든 타입에 구독되어 있는지 확인 + assertThat(service.isSubscribedToRealTimeTradeRate("TICKER_50")).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc("TICKER_50")).isTrue(); + assertThat(service.isSubscribedToPrevRate("TICKER_50")).isTrue(); + } + + // ===== 입력 검증 테스트 ===== + @Test + @DisplayName("null 티커로 구독 시 예외가 발생한다") + void subscribeWithNullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.subscribeRealTimeTradeRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribeRealTimeOhlc(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribePrevRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("빈 문자열 티커로 구독 시 예외가 발생한다") + void subscribeWithEmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.subscribeRealTimeTradeRate("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribeRealTimeOhlc(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.subscribePrevRate("\t\n")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("null 티커로 구독 해지 시 예외가 발생한다") + void unsubscribeWithNullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.unsubscribeRealTimeTradeRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.unsubscribeRealTimeOhlc(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + + assertThatThrownBy(() -> service.unsubscribePrevRate(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("유효하지 않은 티커입니다"); + } + + @Test + @DisplayName("유효하지 않은 티커의 구독 상태 확인 시 false를 반환한다") + void isSubscribedWithInvalidTicker_ReturnsFalse() { + // when & then + assertThat(service.isSubscribedToRealTimeTradeRate(null)).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate("")).isFalse(); + assertThat(service.isSubscribedToRealTimeTradeRate(" ")).isFalse(); + + assertThat(service.isSubscribedToRealTimeOhlc(null)).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc("")).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc(" ")).isFalse(); + + assertThat(service.isSubscribedToPrevRate(null)).isFalse(); + assertThat(service.isSubscribedToPrevRate("")).isFalse(); + assertThat(service.isSubscribedToPrevRate(" ")).isFalse(); + } + + // ===== 엣지 케이스 테스트 ===== + @Test + @DisplayName("공백이 포함된 유효한 티커는 정상적으로 처리된다") + void subscribeWithValidTickerContainingSpaces_HandledCorrectly() { + // given + String tickerWithSpaces = " BTC "; + + // when + service.subscribeRealTimeTradeRate(tickerWithSpaces); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(tickerWithSpaces)).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("BTC")).isFalse(); // 공백 포함은 다른 키 + } + + @Test + @DisplayName("대소문자가 다른 티커는 서로 다른 구독으로 처리된다") + void subscribeWithDifferentCase_TreatedAsDifferent() { + // when + service.subscribeRealTimeTradeRate("BTC"); + service.subscribeRealTimeTradeRate("btc"); + service.subscribeRealTimeTradeRate("Btc"); + + // then + assertThat(service.getAllRealTimeTradeRateSubscribedTickers()).hasSize(3); + assertThat(service.isSubscribedToRealTimeTradeRate("BTC")).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("btc")).isTrue(); + assertThat(service.isSubscribedToRealTimeTradeRate("Btc")).isTrue(); + } + + @Test + @DisplayName("특수 문자가 포함된 티커도 정상적으로 처리된다") + void subscribeWithSpecialCharacters_HandledCorrectly() { + // given + String specialTicker1 = "BTC-USD"; + String specialTicker2 = "ETH/USDT"; + String specialTicker3 = "DOT_BTC"; + + // when + service.subscribeRealTimeTradeRate(specialTicker1); + service.subscribeRealTimeOhlc(specialTicker2); + service.subscribePrevRate(specialTicker3); + + // then + assertThat(service.isSubscribedToRealTimeTradeRate(specialTicker1)).isTrue(); + assertThat(service.isSubscribedToRealTimeOhlc(specialTicker2)).isTrue(); + assertThat(service.isSubscribedToPrevRate(specialTicker3)).isTrue(); + } + + // ===== 동시성 테스트 (단위 테스트 수준) ===== + @Test + @DisplayName("동일한 구독 타입에서 여러 티커를 동시에 관리할 수 있다") + void concurrentTickerManagement_SameSubscriptionType() { + // when + service.subscribeRealTimeTradeRate(testTicker1); + service.subscribeRealTimeTradeRate(testTicker2); + service.subscribeRealTimeTradeRate(testTicker3); + + // then + Set subscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(subscriptions).hasSize(3); + assertThat(subscriptions).containsExactlyInAnyOrder(testTicker1, testTicker2, testTicker3); + + // when - 일부 해지 + service.unsubscribeRealTimeTradeRate(testTicker2); + + // then + Set updatedSubscriptions = service.getAllRealTimeTradeRateSubscribedTickers(); + assertThat(updatedSubscriptions).hasSize(2); + assertThat(updatedSubscriptions).containsExactlyInAnyOrder(testTicker1, testTicker3); + assertThat(updatedSubscriptions).doesNotContain(testTicker2); + } + + // ===== 메서드 시그니처 이슈 테스트 ===== + @Test + @DisplayName("getAllPrevRateSubscribedTickers 메서드의 파라미터는 실제로 사용되지 않는다") + void getAllPrevRateSubscribedTickers_ParameterNotUsed() { + // given + service.subscribePrevRate(testTicker1); + service.subscribePrevRate(testTicker2); + + // when - 다른 파라미터로 호출해도 같은 결과 + Set result1 = service.getAllPrevRateSubscribedTickers(testTicker1); + Set result2 = service.getAllPrevRateSubscribedTickers(testTicker2); + Set result3 = service.getAllPrevRateSubscribedTickers("NONEXISTENT"); + + // then - 모든 호출이 동일한 결과 반환 + assertThat(result1).isEqualTo(result2); + assertThat(result2).isEqualTo(result3); + assertThat(result1).containsExactlyInAnyOrder(testTicker1, testTicker2); + } + + // ===== 비즈니스 로직 일관성 테스트 ===== + @Test + @DisplayName("구독과 해지가 순서에 관계없이 일관되게 동작한다") + void subscriptionLifecycle_ConsistentBehavior() { + // 초기 상태 확인 + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + + // 구독 -> 확인 -> 해지 -> 확인 + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + + service.unsubscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + + // 재구독 + service.subscribeRealTimeTradeRate(testTicker1); + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 구독을 해지해도 예외가 발생하지 않는다") + void unsubscribeNonExistentSubscription_NoException() { + // when & then - 예외 발생하지 않음 + assertThatCode(() -> { + service.unsubscribeRealTimeTradeRate(testTicker1); + service.unsubscribeRealTimeOhlc(testTicker2); + service.unsubscribePrevRate(testTicker3); + }).doesNotThrowAnyException(); + + // 구독 상태는 여전히 false + assertThat(service.isSubscribedToRealTimeTradeRate(testTicker1)).isFalse(); + assertThat(service.isSubscribedToRealTimeOhlc(testTicker2)).isFalse(); + assertThat(service.isSubscribedToPrevRate(testTicker3)).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java new file mode 100644 index 00000000..e56d9800 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeDataPrevRateServiceTest.java @@ -0,0 +1,177 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.PrevRateDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; +import com.cleanengine.coin.chart.repository.RealTimeTradeRepository; +import com.cleanengine.coin.trade.entity.Trade; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RealTimeDataPrevRateService 테스트") +class RealTimeDataPrevRateServiceTest { + + @Mock + private RealTimeTradeRepository tradeRepository; + + @InjectMocks + private RealTimeDataPrevRateService service; + + private TradeEventDto tradeEventDto; + private LocalDateTime currentTime; + private Trade mockTrade; + + @BeforeEach + void setUp() { + tradeEventDto = new TradeEventDto("TRUMP", 0, 150.0, LocalDateTime.now()); + currentTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + mockTrade = createTrade(currentTime); // ✅ 이제 100.0 가격으로 생성됨 + } + + @Test + @DisplayName("전일 거래 데이터가 있을 때 정상적으로 PrevRateDto를 생성한다") + void generatePrevRateData_WithYesterdayTrade_Success() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrade); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto, currentTime); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 + assertThat(result.getChangeRate()).isEqualTo(50.0); // (150-100)/100 * 100 + assertThat(result.getTimestamp()).isEqualTo(currentTime); + } + + @Test + @DisplayName("전일 거래 데이터가 없을 때 기본값으로 PrevRateDto를 생성한다") + void generatePrevRateData_WithoutYesterdayTrade_ReturnsDefault() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(null); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto, currentTime); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(0.0); + assertThat(result.getChangeRate()).isEqualTo(0.0); + assertThat(result.getTimestamp()).isEqualTo(currentTime); + } + + @Test + @DisplayName("현재 시간을 사용하는 오버로드 메서드가 정상 동작한다") + void generatePrevRateData_WithCurrentTime_Success() { + // given + when(tradeRepository.findFirstByTickerAndTradeTimeBetweenOrderByTradeTimeDesc( + eq("TRUMP"), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrade); + + // when + PrevRateDto result = service.generatePrevRateData(tradeEventDto); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getCurrentPrice()).isEqualTo(150.0); + assertThat(result.getPrevClose()).isEqualTo(100.0); // ✅ 성공 + } + + @Test + @DisplayName("변화율이 양수일때 제대로 츨력이 되는지") + void getChangeRate_PriceIncrease_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(120.0, 100.0); + + // then + assertThat(result).isEqualTo(20.0); // (120-100)/100 * 100 + } + + @Test + @DisplayName("변화율이 음수일때 로직이 정상적으로 작동하는지") + void getChangeRate_PriceDecrease_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(80.0, 100.0); + + // then + assertThat(result).isEqualTo(-20.0); // (80-100)/100 * 100 + } + + @Test + @DisplayName("변화율이 0일때 0으로 제대로 출력이 되는지") + void getChangeRate_SamePrice_ReturnsZero() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(100.0, 100.0); + + // then + assertThat(result).isEqualTo(0.0); + } + + @Test + @DisplayName("전일 시간 범위를 올바르게 계산한다") + void getYesterDay_CalculatesCorrectRange() { + // given + LocalDateTime today = LocalDateTime.of(2024, 1, 15, 14, 30, 45); + + // when + RealTimeDataPrevRateService.YesterDay result = RealTimeDataPrevRateService.getYesterDay(today); + + // then + assertThat(result.yesterdayStart()).isEqualTo(LocalDateTime.of(2024, 1, 14, 0, 0, 0)); + assertThat(result.yesterdayEnd()).isEqualTo(LocalDateTime.of(2024, 1, 14, 23, 59, 59)); + } + + @Test + @DisplayName("YesterDay 레코드가 올바르게 동작한다") + void yesterDayRecord_WorksCorrectly() { + // given + LocalDateTime start = LocalDateTime.of(2024, 1, 14, 0, 0, 0); + LocalDateTime end = LocalDateTime.of(2024, 1, 14, 23, 59, 59); + + // when + RealTimeDataPrevRateService.YesterDay yesterDay = new RealTimeDataPrevRateService.YesterDay(start, end); + + // then + assertThat(yesterDay.yesterdayStart()).isEqualTo(start); + assertThat(yesterDay.yesterdayEnd()).isEqualTo(end); + assertThat(yesterDay.toString()).contains("2024-01-14T00:00"); + assertThat(yesterDay.toString()).contains("2024-01-14T23:59:59"); + } + + @Test + @DisplayName("소수점이 있는 가격에서도 변화율이 정확하게 계산이 된다") + void getChangeRate_WithDecimalPrices_CalculatesCorrectly() { + // when + double result = RealTimeDataPrevRateService.getChangeRate(150.75, 100.50); + + // then + double expected = ((150.75 - 100.50) / 100.50) * 100; + assertThat(result).isEqualTo(expected); + } + + //실제 엔티티를 만들어서 목업엔티티로 사용 + private Trade createTrade(LocalDateTime tradeTime) { + return new Trade(null, "BTC", tradeTime, 1, 2, 100.0, 1.0); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java new file mode 100644 index 00000000..b516bed8 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeOhlcServiceTest.java @@ -0,0 +1,422 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.trade.repository.TradeRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RealTimeOhlcService 단위 테스트") +class RealTimeOhlcServiceTest { + + @Mock + private TradeRepository tradeRepository; + + @InjectMocks + private RealTimeOhlcService service; + + private String validTicker; + private LocalDateTime fixedNow; + private List mockTrades; + + @BeforeEach + void setUp() { + validTicker = "BTC"; + fixedNow = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + mockTrades = createMockTrades(); + } + + // ===== getRealTimeOhlc 통합 테스트 ===== + @Test + @DisplayName("정상적인 거래 데이터로 실시간 OHLC를 생성한다") + void getRealTimeOhlc_WithValidTrades_ReturnsOhlcData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrades); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("BTC"); + assertThat(result.getHigh()).isEqualTo(200.0); + assertThat(result.getLow()).isEqualTo(100.0); + assertThat(result.getVolume()).isEqualTo(6.0); // 1+2+3 + } + + @Test + @DisplayName("거래 데이터가 없으면 캐시된 데이터를 반환한다") + void getRealTimeOhlc_NoTrades_ReturnsCachedData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // 캐시에 데이터 미리 저장 + RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + service.updateCache(validTicker, fixedNow, cachedData); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isEqualTo(cachedData); + } + + @Test + @DisplayName("거래 데이터도 캐시도 없으면 null을 반환한다") + void getRealTimeOhlc_NoTradesNoCache_ReturnsNull() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(List.of()); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isNull(); + } + + @Test + @DisplayName("예외 발생 시 캐시된 데이터를 반환한다") + void getRealTimeOhlc_ExceptionOccurs_ReturnsCachedData() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + eq(validTicker), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenThrow(new RuntimeException("DB 연결 오류")); + + RealTimeOhlcDto cachedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + service.updateCache(validTicker, fixedNow, cachedData); + + // when + RealTimeOhlcDto result = service.getRealTimeOhlc(validTicker); + + // then + assertThat(result).isEqualTo(cachedData); + } + + // ===== calculateTimeRange 테스트 ===== + @Test + @DisplayName("첫 번째 호출 시 1초 전부터 현재까지의 범위를 계산한다") + void calculateTimeRange_FirstCall_ReturnsOneSecondRange() { + // when + RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); + + // then + assertThat(result.start()).isEqualTo(fixedNow.minusSeconds(1)); + assertThat(result.end()).isEqualTo(fixedNow); + } + + @Test + @DisplayName("이전 처리 시간이 있으면 그 시간부터 현재까지의 범위를 계산한다") + void calculateTimeRange_WithPreviousTime_ReturnsCustomRange() { + // given + LocalDateTime previousTime = fixedNow.minusSeconds(5); + + // null 대신 더미 데이터 사용 + RealTimeOhlcDto dummyData = new RealTimeOhlcDto( + validTicker, previousTime, 100.0, 100.0, 100.0, 100.0, 1.0 + ); + service.updateCache(validTicker, previousTime, dummyData); // ✅ 유효한 객체 + + // when + RealTimeOhlcService.TimeRange result = service.calculateTimeRange(validTicker, fixedNow); + + // then + assertThat(result.start()).isEqualTo(previousTime); + assertThat(result.end()).isEqualTo(fixedNow); + } + + // ===== getProcessedTradeData 테스트 ===== + @Test + @DisplayName("빈 거래 데이터는 빈 리스트를 반환한다") + void getProcessedTradeData_EmptyTrades_ReturnsEmptyList() { + // given + RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange( + fixedNow.minusSeconds(1), fixedNow); + + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + validTicker, timeRange.start(), timeRange.end())) + .thenReturn(List.of()); + + // when + List result = service.getProcessedTradeData(validTicker, timeRange); + + // then + assertThat(result).isEmpty(); + } + + // ===== updateCache 테스트 ===== + @Test + @DisplayName("캐시를 정상적으로 업데이트한다") + void updateCache_ValidData_UpdatesCorrectly() { + // given + RealTimeOhlcDto ohlcData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); + + // when + service.updateCache(validTicker, fixedNow, ohlcData); + + // then + RealTimeOhlcDto cachedData = service.getCachedData(validTicker); + assertThat(cachedData).isEqualTo(ohlcData); + + // 시간 범위 계산 시 업데이트된 시간이 사용되는지 확인 + RealTimeOhlcService.TimeRange timeRange = service.calculateTimeRange(validTicker, fixedNow.plusSeconds(5)); + assertThat(timeRange.start()).isEqualTo(fixedNow); + } + + // ===== getCachedData 테스트 ===== + @Test + @DisplayName("캐시된 데이터를 정상적으로 조회한다") + void getCachedData_ExistingData_ReturnsData() { + // given + RealTimeOhlcDto expectedData = new RealTimeOhlcDto(validTicker, fixedNow, 100.0, 200.0, 50.0, 150.0, 10.0); + service.updateCache(validTicker, fixedNow, expectedData); + + // when + RealTimeOhlcDto result = service.getCachedData(validTicker); + + // then + assertThat(result).isEqualTo(expectedData); + } + + @Test + @DisplayName("캐시에 데이터가 없으면 null을 반환한다") + void getCachedData_NoData_ReturnsNull() { + // when + RealTimeOhlcDto result = service.getCachedData("NONEXISTENT"); + + // then + assertThat(result).isNull(); + } + + // ===== createOhlcDto 테스트 ===== + @Test + @DisplayName("OHLCV 데이터로 DTO를 생성한다") + void createOhlcDto_ValidData_CreatesCorrectDto() { + // given + RealTimeOhlcService.calculateOhlcv ohlcv = + new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); + + // when + RealTimeOhlcDto result = service.createOhlcDto(validTicker, fixedNow, ohlcv); + + // then + assertThat(result.getTicker()).isEqualTo(validTicker); + assertThat(result.getTimestamp()).isEqualTo(fixedNow); + assertThat(result.getOpen()).isEqualTo(100.0); + assertThat(result.getHigh()).isEqualTo(200.0); + assertThat(result.getLow()).isEqualTo(50.0); + assertThat(result.getClose()).isEqualTo(150.0); + assertThat(result.getVolume()).isEqualTo(10.0); + } + + // ===== getCalculateOhlcv 정적 메서드 테스트 ===== + @Test + @DisplayName("단일 거래로 OHLCV를 계산한다") + void getCalculateOhlcv_SingleTrade_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow, 100.0, 5.0) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(5.0); + } + + @Test + @DisplayName("여러 거래로 OHLCV를 계산한다") + void getCalculateOhlcv_MultipleTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), // open + createTrade(fixedNow.minusSeconds(2), 200.0, 2.0), // high + createTrade(fixedNow.minusSeconds(1), 50.0, 3.0), // low + createTrade(fixedNow, 150.0, 4.0) // close + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(200.0); + assertThat(result.low()).isEqualTo(50.0); + assertThat(result.close()).isEqualTo(150.0); + assertThat(result.volume()).isEqualTo(10.0); // 1+2+3+4 + } + + @Test + @DisplayName("동일한 가격의 거래들로 OHLCV를 계산한다") + void getCalculateOhlcv_SamePriceTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(2), 100.0, 1.0), + createTrade(fixedNow.minusSeconds(1), 100.0, 2.0), + createTrade(fixedNow, 100.0, 3.0) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(6.0); + } + + @Test + @DisplayName("소수점 가격과 거래량으로 정확하게 계산한다") + void getCalculateOhlcv_DecimalValues_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(fixedNow.minusSeconds(1), 100.5, 1.5), + createTrade(fixedNow, 200.75, 2.25) + ); + + // when + RealTimeOhlcService.calculateOhlcv result = + RealTimeOhlcService.getCalculateOhlcv(trades); + + // then + assertThat(result.open()).isEqualTo(100.5); + assertThat(result.high()).isEqualTo(200.75); + assertThat(result.low()).isEqualTo(100.5); + assertThat(result.close()).isEqualTo(200.75); + assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 + } + + // ===== 레코드 객체 테스트 ===== + @Test + @DisplayName("TimeRange 레코드가 올바르게 동작한다") + void timeRangeRecord_WorksCorrectly() { + // given + LocalDateTime start = fixedNow.minusSeconds(1); + LocalDateTime end = fixedNow; + + // when + RealTimeOhlcService.TimeRange timeRange = new RealTimeOhlcService.TimeRange(start, end); + + // then + assertThat(timeRange.start()).isEqualTo(start); + assertThat(timeRange.end()).isEqualTo(end); + assertThat(timeRange.toString()).contains(start.toString(), end.toString()); + } + + @Test + @DisplayName("calculateOhlcv 레코드가 올바르게 동작한다") + void calculateOhlcvRecord_WorksCorrectly() { + // given + RealTimeOhlcService.calculateOhlcv ohlcv = + new RealTimeOhlcService.calculateOhlcv(100.0, 200.0, 50.0, 150.0, 10.0); + + // then + assertThat(ohlcv.open()).isEqualTo(100.0); + assertThat(ohlcv.high()).isEqualTo(200.0); + assertThat(ohlcv.low()).isEqualTo(50.0); + assertThat(ohlcv.close()).isEqualTo(150.0); + assertThat(ohlcv.volume()).isEqualTo(10.0); + assertThat(ohlcv.toString()).contains("100.0", "200.0", "50.0", "150.0", "10.0"); + } + + // ===== 동시성 테스트 ===== + @Test + @DisplayName("여러 티커를 동시에 처리해도 캐시가 올바르게 동작한다") + void concurrentTickers_CacheWorksCorrectly() { + // given + String ticker1 = "BTC"; + String ticker2 = "ETH"; + RealTimeOhlcDto data1 = new RealTimeOhlcDto(ticker1, fixedNow, 100.0, 100.0, 100.0, 100.0, 5.0); + RealTimeOhlcDto data2 = new RealTimeOhlcDto(ticker2, fixedNow, 200.0, 200.0, 200.0, 200.0, 10.0); + + // when + service.updateCache(ticker1, fixedNow, data1); + service.updateCache(ticker2, fixedNow, data2); + + // then + assertThat(service.getCachedData(ticker1)).isEqualTo(data1); + assertThat(service.getCachedData(ticker2)).isEqualTo(data2); + assertThat(service.getCachedData(ticker1)).isNotEqualTo(data2); + } + + // ===== Repository 호출 검증 테스트 ===== + @Test + @DisplayName("Repository가 올바른 파라미터로 호출된다") + void repository_CalledWithCorrectParameters() { + // given + when(tradeRepository.findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + any(String.class), any(LocalDateTime.class), any(LocalDateTime.class))) + .thenReturn(mockTrades); + + // when + service.getRealTimeOhlc(validTicker); + + // then + ArgumentCaptor tickerCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor startCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + ArgumentCaptor endCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + + verify(tradeRepository).findByTickerAndTradeTimeBetweenOrderByTradeTimeAsc( + tickerCaptor.capture(), startCaptor.capture(), endCaptor.capture()); + + assertThat(tickerCaptor.getValue()).isEqualTo(validTicker); + assertThat(startCaptor.getValue()).isBefore(endCaptor.getValue()); + } + + // ===== 헬퍼 메서드들 ===== + private List createMockTrades() { + return List.of( + createTrade(fixedNow.minusSeconds(3), 100.0, 1.0), + createTrade(fixedNow.minusSeconds(2), 150.0, 2.0), + createTrade(fixedNow.minusSeconds(1), 200.0, 3.0) + ); + } + + private Trade createTrade(LocalDateTime tradeTime, Double price, Double size) { + Trade trade = new Trade(); + try { + setField(trade, "tradeTime", tradeTime); + setField(trade, "price", price); + setField(trade, "size", size); + } catch (Exception e) { + throw new RuntimeException("Trade 객체 생성 실패", e); + } + return trade; + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + java.lang.reflect.Field field = Trade.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java new file mode 100644 index 00000000..e8fb109c --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/RealTimeTradeServiceTest.java @@ -0,0 +1,632 @@ +package com.cleanengine.coin.chart.service; + +import com.cleanengine.coin.chart.dto.RealTimeDataDto; +import com.cleanengine.coin.chart.dto.TradeEventDto; +import org.assertj.core.api.AssertProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("RealTimeTradeService 단위 테스트") +class RealTimeTradeServiceTest { + + private RealTimeTradeService service; + private TradeEventDto testTradeEventDto; + private LocalDateTime testTime; + + @BeforeEach + void setUp() { + service = new RealTimeTradeService(); + testTime = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + testTradeEventDto = new TradeEventDto("TRUMP", 1.5, 50000.0, testTime); + } + + @Test + @DisplayName("extractTradeInfo - 정상적인 거래 정보 추출") + void extractTradeInfo_ValidData_ReturnsCorrectTradeInfo() { + // when + RealTimeTradeService.TradeInfo result = service.extractTradeInfo(testTradeEventDto); + + // then + assertThat(result.ticker()).isEqualTo("TRUMP"); + assertThat(result.price()).isEqualTo(50000.0); + assertThat(result.size()).isEqualTo(1.5); + assertThat(result.timestamp()).isEqualTo(testTime); + } + + @Test + @DisplayName("extractTradeInfo - null 타임스탬프 처리") + void extractTradeInfo_NullTimestamp_HandledCorrectly() { + // given + TradeEventDto tradeWithNullTime = new TradeEventDto("TRUMP", 2.0, 3000.0, null); + + // when + RealTimeTradeService.TradeInfo result = service.extractTradeInfo(tradeWithNullTime); + + // then + assertThat(result.ticker()).isEqualTo("TRUMP"); + assertThat(result.price()).isEqualTo(3000.0); + assertThat(result.size()).isEqualTo(2.0); + assertThat(result.timestamp()).isNull(); + } + + // ===== shouldCalculateChangeRate 메서드 테스트 ===== + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래가 null인 경우") + void shouldCalculateChangeRate_PreviousTradeNull_ReturnsFalse() { + // when + boolean result = service.shouldCalculateChangeRate(null, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래 가격이 0인 경우") + void shouldCalculateChangeRate_PreviousPriceZero_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 0.0, testTime); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 이전 거래 가격이 음수인 경우") + void shouldCalculateChangeRate_PreviousPriceNegative_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, -100.0, testTime); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 동일한 객체 참조인 경우") + void shouldCalculateChangeRate_SameObjectReference_ReturnsFalse() { + // when + boolean result = service.shouldCalculateChangeRate(testTradeEventDto, testTradeEventDto); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("shouldCalculateChangeRate - 정상적인 조건인 경우") + void shouldCalculateChangeRate_ValidCondition_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime.minusSeconds(10)); + + // when + boolean result = service.shouldCalculateChangeRate(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + // ===== isNewTrade 메서드 테스트 ===== + @Test + @DisplayName("isNewTrade - 이전 타임스탬프가 null인 경우") + void isNewTrade_PreviousTimestampNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 현재 타임스탬프가 null인 경우") + void isNewTrade_CurrentTimestampNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 둘 다 null인 경우") + void isNewTrade_BothTimestampsNull_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, null); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, null); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("isNewTrade - 동일한 타임스탬프인 경우") + void isNewTrade_SameTimestamp_ReturnsFalse() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime); + TradeEventDto currentTrade = new TradeEventDto("TRUMP", 1.5, 50000.0, testTime); + + // when + boolean result = service.isNewTrade(previousTrade, currentTrade); + + // then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("isNewTrade - 다른 타임스탬프인 경우") + void isNewTrade_DifferentTimestamp_ReturnsTrue() { + // given + TradeEventDto previousTrade = new TradeEventDto("TRUMP", 1.0, 45000.0, testTime.minusSeconds(10)); + + // when + boolean result = service.isNewTrade(previousTrade, testTradeEventDto); + + // then + assertThat(result).isTrue(); + } + + // ===== createCachedTradeDto 메서드 테스트 ===== + @Test + @DisplayName("createCachedTradeDto - 정상적인 복사본 생성") + void createCachedTradeDto_ValidInput_ReturnsCorrectCopy() { + // when + TradeEventDto result = service.createCachedTradeDto(testTradeEventDto); + + // then + assertThat(result.getTicker()).isEqualTo(testTradeEventDto.getTicker()); + assertThat(result.getPrice()).isEqualTo(testTradeEventDto.getPrice()); + assertThat(result.getSize()).isEqualTo(testTradeEventDto.getSize()); + assertThat(result.getTimestamp()).isEqualTo(testTradeEventDto.getTimestamp()); + // 다른 객체임을 확인 + assertThat(result).isNotSameAs(testTradeEventDto); + } + + @Test + @DisplayName("createCachedTradeDto - null 타임스탬프 복사") + void createCachedTradeDto_NullTimestamp_CopiedCorrectly() { + // given + TradeEventDto tradeWithNullTime = new TradeEventDto("TRUMP", 2.0, 3000.0, null); + + // when + TradeEventDto result = service.createCachedTradeDto(tradeWithNullTime); + + // then + assertThat(result.getTimestamp()).isNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + } + + // ===== createRealTimeDataDto 메서드 테스트 ===== + @Test + @DisplayName("createRealTimeDataDto - 정상적인 DTO 생성") + void createRealTimeDataDto_ValidInput_ReturnsCorrectDto() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + double changeRate = 5.5; + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, changeRate); + + // then + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getPrice()).isEqualTo(50000.0); + assertThat(result.getSize()).isEqualTo(1.5); + assertThat(result.getChangeRate()).isEqualTo(5.5); + assertThat(result.getTimestamp()).isEqualTo(testTime); + assertThat(result.getTransactionId()).isNotNull(); + } + + @Test + @DisplayName("createRealTimeDataDto - 0 변동률 처리") + void createRealTimeDataDto_ZeroChangeRate_HandledCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 3000.0, 2.0, testTime); + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, 0.0); + + // then + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("createRealTimeDataDto - 음수 변동률 처리") + void createRealTimeDataDto_NegativeChangeRate_HandledCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 3000.0, 2.0, testTime); + + // when + RealTimeDataDto result = service.createRealTimeDataDto(tradeInfo, -10.5); + + // then + assertThat(result.getChangeRate()).isEqualTo(-10.5); + } + + // ===== generateTransactionId 메서드 테스트 ===== + @Test + @DisplayName("generateTransactionId - 유효한 UUID 생성") + void generateTransactionId_ReturnsValidUUID() { + // when + String result = service.generateTransactionId(); + + // then + assertThat(result).isNotNull(); + assertThat(result).isNotEmpty(); + // UUID 형식 검증 + assertThat((AssertProvider) () -> UUID.fromString(result)).getMostSignificantBits(); + } + + @Test + @DisplayName("generateTransactionId - 호출할 때마다 다른 ID 생성") + void generateTransactionId_GeneratesDifferentIds() { + // when + String id1 = service.generateTransactionId(); + String id2 = service.generateTransactionId(); + + // then + assertThat(id1).isNotEqualTo(id2); + } + + @Test + @DisplayName("generateTransactionId - 연속 호출 시 모두 다른 ID") + void generateTransactionId_MultipleCallsGenerateDifferentIds() { + // when + String id1 = service.generateTransactionId(); + String id2 = service.generateTransactionId(); + String id3 = service.generateTransactionId(); + + // then + assertThat(id1).isNotEqualTo(id2); + assertThat(id2).isNotEqualTo(id3); + assertThat(id1).isNotEqualTo(id3); + } + + // ===== getChangeRate 메서드 테스트 ===== + @Test + @DisplayName("getChangeRate - 가격 상승 케이스") + void getChangeRate_PriceIncrease_CalculatesCorrectly() { + // when + double result = service.getChangeRate(110.0, 100.0); + + // then + assertThat(result).isEqualTo(10.0); + } + + @Test + @DisplayName("getChangeRate - 가격 하락 케이스") + void getChangeRate_PriceDecrease_CalculatesCorrectly() { + // when + double result = service.getChangeRate(90.0, 100.0); + + // then + assertThat(result).isEqualTo(-10.0); + } + + @Test + @DisplayName("getChangeRate - 가격 변동 없음") + void getChangeRate_NoChange_ReturnsZero() { + // when + double result = service.getChangeRate(100.0, 100.0); + + // then + assertThat(result).isEqualTo(0.0); + } + + @Test + @DisplayName("getChangeRate - 소수점 가격 처리") + void getChangeRate_DecimalPrices_CalculatesCorrectly() { + // when + double result = service.getChangeRate(105.50, 100.25); + + // then + double expected = ((105.50 - 100.25) / 100.25) * 100; + assertThat(result).isCloseTo(expected, org.assertj.core.data.Offset.offset(0.0001)); + } + + @Test + @DisplayName("getChangeRate - 큰 변동률 처리") + void getChangeRate_LargeChangeRate_CalculatesCorrectly() { + // when + double result = service.getChangeRate(200.0, 100.0); + + // then + assertThat(result).isEqualTo(100.0); + } + + @Test + @DisplayName("getChangeRate - 매우 작은 가격 변동") + void getChangeRate_VerySmallChange_CalculatesCorrectly() { + // when + double result = service.getChangeRate(100.01, 100.0); + + // then + assertThat(result).isCloseTo(0.01, org.assertj.core.data.Offset.offset(0.0001)); + } + + // ===== Record 클래스 테스트 ===== + @Test + @DisplayName("TradeInfo record - 정상 동작 확인") + void tradeInfo_Record_WorksCorrectly() { + // given + RealTimeTradeService.TradeInfo tradeInfo = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + + // then + assertThat(tradeInfo.ticker()).isEqualTo("TRUMP"); + assertThat(tradeInfo.price()).isEqualTo(50000.0); + assertThat(tradeInfo.size()).isEqualTo(1.5); + assertThat(tradeInfo.timestamp()).isEqualTo(testTime); + } + + @Test + @DisplayName("TradeInfo record - equals와 hashCode 동작") + void tradeInfo_Record_EqualsAndHashCode() { + // given + RealTimeTradeService.TradeInfo tradeInfo1 = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + RealTimeTradeService.TradeInfo tradeInfo2 = new RealTimeTradeService.TradeInfo( + "TRUMP", 50000.0, 1.5, testTime); + + // then + assertThat(tradeInfo1).isEqualTo(tradeInfo2); + assertThat(tradeInfo1.hashCode()).isEqualTo(tradeInfo2.hashCode()); + } + + @Test + @DisplayName("ChangeRateResult record - 정상 동작 확인") + void changeRateResult_Record_WorksCorrectly() { + // given + RealTimeTradeService.ChangeRateResult result = new RealTimeTradeService.ChangeRateResult(5.5, true); + + // then + assertThat(result.changeRate()).isEqualTo(5.5); + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("ChangeRateResult record - false 케이스") + void changeRateResult_Record_FalseCase() { + // given + RealTimeTradeService.ChangeRateResult result = new RealTimeTradeService.ChangeRateResult(0.0, false); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("ChangeRateResult record - equals와 hashCode 동작") + void changeRateResult_Record_EqualsAndHashCode() { + // given + RealTimeTradeService.ChangeRateResult result1 = new RealTimeTradeService.ChangeRateResult(5.5, true); + RealTimeTradeService.ChangeRateResult result2 = new RealTimeTradeService.ChangeRateResult(5.5, true); + + // then + assertThat(result1).isEqualTo(result2); + assertThat(result1.hashCode()).isEqualTo(result2.hashCode()); + } + + + // ===== updateTradeCache 메서드 테스트 ===== + @Test + @DisplayName("updateTradeCache - shouldUpdate가 true일 때 캐시 업데이트") + void updateTradeCache_ShouldUpdateTrue_UpdatesCache() { + // given + RealTimeTradeService.ChangeRateResult changeRateResult = + new RealTimeTradeService.ChangeRateResult(5.0, true); + + // when + service.updateTradeCache(testTradeEventDto, changeRateResult); + + // then - 내부 상태 확인을 위해 다음 호출에서 이전 데이터로 사용되는지 확인 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + assertThat(result.shouldUpdate()).isTrue(); // 이전 데이터가 캐시되었으므로 계산 가능 + } + + @Test + @DisplayName("updateTradeCache - shouldUpdate가 false지만 캐시에 없을 때 업데이트") + void updateTradeCache_ShouldUpdateFalseButNoCachedData_UpdatesCache() { + // given + RealTimeTradeService.ChangeRateResult changeRateResult = + new RealTimeTradeService.ChangeRateResult(0.0, false); + + // when + service.updateTradeCache(testTradeEventDto, changeRateResult); + + // then - 캐시되었는지 확인 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("updateTradeCache - shouldUpdate가 false이고 캐시에 있을 때 업데이트 안함") + void updateTradeCache_ShouldUpdateFalseAndCachedDataExists_DoesNotUpdate() { + // given - 먼저 캐시에 데이터 추가 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(5.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 다른 가격의 거래 데이터 + TradeEventDto differentTrade = new TradeEventDto("TRUMP", 2.0, 60000.0, testTime.plusSeconds(5)); + RealTimeTradeService.ChangeRateResult secondResult = + new RealTimeTradeService.ChangeRateResult(0.0, false); + + // when + service.updateTradeCache(differentTrade, secondResult); + + // then - 원래 캐시된 데이터가 유지되는지 확인 + TradeEventDto thirdTrade = new TradeEventDto("TRUMP", 1.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo thirdTradeInfo = service.extractTradeInfo(thirdTrade); + + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(thirdTrade, thirdTradeInfo); + // 첫 번째 캐시된 데이터(50000.0)와 비교되어야 함 + double expectedChangeRate = ((55000.0 - 50000.0) / 50000.0) * 100; + assertThat(result.changeRate()).isCloseTo(expectedChangeRate, org.assertj.core.data.Offset.offset(0.01)); + } + + // ===== generateRealTimeData 메서드 테스트 ===== + @Test + @DisplayName("generateRealTimeData - 정상적인 실시간 데이터 생성") + void generateRealTimeData_ValidInput_ReturnsCorrectData() { + // when + RealTimeDataDto result = service.generateRealTimeData(testTradeEventDto); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTicker()).isEqualTo("TRUMP"); + assertThat(result.getPrice()).isEqualTo(50000.0); + assertThat(result.getSize()).isEqualTo(1.5); + assertThat(result.getTimestamp()).isEqualTo(testTime); + assertThat(result.getTransactionId()).isNotNull(); + assertThat(result.getChangeRate()).isEqualTo(0.0); // 첫 번째 거래이므로 0 + } + + @Test + @DisplayName("generateRealTimeData - 두 번째 거래에서 변동률 계산") + void generateRealTimeData_SecondTrade_CalculatesChangeRate() { + // given - 첫 번째 거래 + service.generateRealTimeData(testTradeEventDto); + + // 두 번째 거래 (가격 상승) + TradeEventDto secondTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + + // when + RealTimeDataDto result = service.generateRealTimeData(secondTrade); + + // then + assertThat(result.getChangeRate()).isEqualTo(10.0); // (55000-50000)/50000 * 100 + } + + @Test + @DisplayName("generateRealTimeData - 동일한 타임스탬프 거래 처리") + void generateRealTimeData_SameTimestamp_ReturnsZeroChangeRate() { + // given - 첫 번째 거래 + service.generateRealTimeData(testTradeEventDto); + + // 동일한 타임스탬프의 다른 거래 + TradeEventDto sameTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + + // when + RealTimeDataDto result = service.generateRealTimeData(sameTrade); + + // then + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + @Test + @DisplayName("generateRealTimeData - 예외 발생 시 기본값 반환") + void generateRealTimeData_ExceptionOccurs_ReturnsDefaultData() { + // given - null 값으로 예외 유발 가능한 데이터 + TradeEventDto nullTrade = new TradeEventDto(null, 1.0, 50000.0, testTime); + + // when + RealTimeDataDto result = service.generateRealTimeData(nullTrade); + + // then + assertThat(result).isNotNull(); + assertThat(result.getChangeRate()).isEqualTo(0.0); + } + + // ===== calculateChangeRate 메서드 테스트 ===== + @Test + @DisplayName("calculateChangeRate - 이전 거래가 없는 경우") + void calculateChangeRate_NoPreviousTrade_ReturnsZeroWithFalse() { + // given + RealTimeTradeService.TradeInfo tradeInfo = service.extractTradeInfo(testTradeEventDto); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(testTradeEventDto, tradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("calculateChangeRate - 정상적인 변동률 계산") + void calculateChangeRate_ValidPreviousTrade_CalculatesCorrectly() { + // given - 이전 거래 캐시에 저장 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 새로운 거래 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime.plusSeconds(10)); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(10.0); // (55000-50000)/50000 * 100 + assertThat(result.shouldUpdate()).isTrue(); + } + + @Test + @DisplayName("calculateChangeRate - 동일한 타임스탬프 거래") + void calculateChangeRate_SameTimestamp_ReturnsZeroWithFalse() { + // given - 이전 거래 캐시에 저장 + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(testTradeEventDto, firstResult); + + // 동일한 타임스탬프의 거래 + TradeEventDto sameTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + RealTimeTradeService.TradeInfo sameTradeInfo = service.extractTradeInfo(sameTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(sameTrade, sameTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } + + @Test + @DisplayName("calculateChangeRate - 이전 거래 가격이 0인 경우") + void calculateChangeRate_PreviousPriceZero_ReturnsZeroWithFalse() { + // given - 가격이 0인 이전 거래 캐시에 저장 + TradeEventDto zeroPriceTrade = new TradeEventDto("TRUMP", 1.0, 0.0, testTime.minusSeconds(10)); + RealTimeTradeService.ChangeRateResult firstResult = + new RealTimeTradeService.ChangeRateResult(0.0, true); + service.updateTradeCache(zeroPriceTrade, firstResult); + + // 새로운 거래 + TradeEventDto newTrade = new TradeEventDto("TRUMP", 2.0, 55000.0, testTime); + RealTimeTradeService.TradeInfo newTradeInfo = service.extractTradeInfo(newTrade); + + // when + RealTimeTradeService.ChangeRateResult result = service.calculateChangeRate(newTrade, newTradeInfo); + + // then + assertThat(result.changeRate()).isEqualTo(0.0); + assertThat(result.shouldUpdate()).isFalse(); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java b/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java new file mode 100644 index 00000000..4c1b456d --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/WebsocketSendServiceTest.java @@ -0,0 +1,333 @@ +package com.cleanengine.coin.chart.service; + +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.messaging.simp.SimpMessagingTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("WebsocketSendService 단위 테스트") +class WebsocketSendServiceTest { + + @Mock + private SimpMessagingTemplate messagingTemplate; + + @InjectMocks + private WebsocketSendService websocketSendService; + + private Object testData; + private String testTicker; + + @BeforeEach + void setUp() { + testData = new TestDto("BTC", 50000.0, 1.5); + testTicker = "BTC"; + } + + // ===== buildTopic 메서드 테스트 ===== + @Test + @DisplayName("buildTopic - 정상적인 토픽 생성 (realTimeTradeRate)") + void buildTopic_RealTimeTradeRate_ReturnsCorrectTopic() { + // when + String result = websocketSendService.buildTopic("realTimeTradeRate", "BTC"); + + // then + assertThat(result).isEqualTo("/topic/realTimeTradeRate/BTC"); + } + + @Test + @DisplayName("buildTopic - 정상적인 토픽 생성 (prevRate)") + void buildTopic_PrevRate_ReturnsCorrectTopic() { + // when + String result = websocketSendService.buildTopic("prevRate", "ETH"); + + // then + assertThat(result).isEqualTo("/topic/prevRate/ETH"); + } + + @Test + @DisplayName("buildTopic - 다양한 티커로 정상 생성") + void buildTopic_DifferentTickers_ReturnsCorrectTopic() { + // when & then + assertThat(websocketSendService.buildTopic("realTimeTradeRate", "TRUMP")) + .isEqualTo("/topic/realTimeTradeRate/TRUMP"); + assertThat(websocketSendService.buildTopic("prevRate", "BTC-USD")) + .isEqualTo("/topic/prevRate/BTC-USD"); + } + + @Test + @DisplayName("buildTopic - null 티커인 경우 예외 발생") + void buildTopic_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 빈 문자열 티커인 경우 예외 발생") + void buildTopic_EmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 공백만 있는 티커인 경우 예외 발생") + void buildTopic_WhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("buildTopic - 탭과 공백이 섞인 티커인 경우 예외 발생") + void buildTopic_TabAndWhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.buildTopic("realTimeTradeRate", "\t \n")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + // ===== sendMessage 메서드 테스트 ===== + @Test + @DisplayName("sendMessage - 정상적인 메시지 전송") + void sendMessage_ValidInput_SendsMessageCorrectly() { + // given + String topic = "/topic/realTimeTradeRate/BTC"; + + // when + websocketSendService.sendMessage(topic, testData); + + // then + verify(messagingTemplate, times(1)).convertAndSend(topic, testData); + } + + @Test + @DisplayName("sendMessage - 다른 토픽으로 정상 전송") + void sendMessage_DifferentTopic_SendsCorrectly() { + // given + String topic = "/topic/prevRate/ETH"; + Object data = "test data"; + + // when + websocketSendService.sendMessage(topic, data); + + // then + verify(messagingTemplate, times(1)).convertAndSend(topic, data); + } + + @Test + @DisplayName("sendMessage - null 토픽인 경우 예외 발생") + void sendMessage_NullTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(null, testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - 빈 토픽인 경우 예외 발생") + void sendMessage_EmptyTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage("", testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - 공백만 있는 토픽인 경우 예외 발생") + void sendMessage_WhitespaceTopic_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(" ", testData)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("토픽은 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendMessage - null 데이터인 경우 예외 발생") + void sendMessage_NullData_ThrowsException() { + // given + String topic = "/topic/realTimeTradeRate/BTC"; + + // when & then + assertThatThrownBy(() -> websocketSendService.sendMessage(topic, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + // ===== sendChangeRate 메서드 테스트 ===== + @Test + @DisplayName("sendChangeRate - 정상적인 실시간 거래 데이터 전송") + void sendChangeRate_ValidInput_SendsCorrectly() { + // when + websocketSendService.sendChangeRate(testData, testTicker); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Object.class); + + verify(messagingTemplate, times(1)).convertAndSend(topicCaptor.capture(), dataCaptor.capture()); + + assertThat(topicCaptor.getValue()).isEqualTo("/topic/realTimeTradeRate/BTC"); + assertThat(dataCaptor.getValue()).isEqualTo(testData); + } + + @Test + @DisplayName("sendChangeRate - 다양한 티커로 정상 전송") + void sendChangeRate_DifferentTickers_SendsCorrectly() { + // given + Object ethData = new TestDto("ETH", 3000.0, 2.0); + + // when + websocketSendService.sendChangeRate(ethData, "ETH"); + + // then + verify(messagingTemplate).convertAndSend("/topic/realTimeTradeRate/ETH", ethData); + } + + @Test + @DisplayName("sendChangeRate - null 티커인 경우 예외 발생") + void sendChangeRate_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(testData, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendChangeRate - 빈 티커인 경우 예외 발생") + void sendChangeRate_EmptyTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(testData, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendChangeRate - null 데이터인 경우 예외 발생") + void sendChangeRate_NullData_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendChangeRate(null, testTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + // ===== sendPrevRate 메서드 테스트 ===== + @Test + @DisplayName("sendPrevRate - 정상적인 전일 대비 데이터 전송") + void sendPrevRate_ValidInput_SendsCorrectly() { + // when + websocketSendService.sendPrevRate(testData, testTicker); + + // then + ArgumentCaptor topicCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(Object.class); + + verify(messagingTemplate, times(1)).convertAndSend(topicCaptor.capture(), dataCaptor.capture()); + + // prevRate 토픽으로 전송됨 + assertThat(topicCaptor.getValue()).isEqualTo("/topic/prevRate/BTC"); + assertThat(dataCaptor.getValue()).isEqualTo(testData); + } + + @Test + @DisplayName("sendPrevRate - 다양한 티커로 정상 전송") + void sendPrevRate_DifferentTickers_SendsCorrectly() { + // given + Object trumpData = new TestDto("TRUMP", 150.0, 2.5); + + // when + websocketSendService.sendPrevRate(trumpData, "TRUMP"); + + // then + verify(messagingTemplate).convertAndSend("/topic/prevRate/TRUMP", trumpData); + } + + @Test + @DisplayName("sendPrevRate - null 티커인 경우 예외 발생") + void sendPrevRate_NullTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(testData, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendPrevRate - 공백 티커인 경우 예외 발생") + void sendPrevRate_WhitespaceTicker_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(testData, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + @Test + @DisplayName("sendPrevRate - null 데이터인 경우 예외 발생") + void sendPrevRate_NullData_ThrowsException() { + // when & then + assertThatThrownBy(() -> websocketSendService.sendPrevRate(null, testTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("데이터는 null일 수 없습니다"); + + verify(messagingTemplate, never()).convertAndSend((String) any(), (Object) any()); + } + + + // 테스트용 DTO 클래스 + private static class TestDto { + private final String ticker; + private final Double price; + private final Double size; + + public TestDto(String ticker, Double price, Double size) { + this.ticker = ticker; + this.price = price; + this.size = size; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null || getClass() != obj.getClass()) return false; + TestDto testDto = (TestDto) obj; + return ticker.equals(testDto.ticker) && + price.equals(testDto.price) && + size.equals(testDto.size); + } + + @Override + public String toString() { + return String.format("TestDto{ticker='%s', price=%s, size=%s}", ticker, price, size); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java new file mode 100644 index 00000000..93335a3e --- /dev/null +++ b/src/test/java/com/cleanengine/coin/chart/service/minute/MinuteOhlcDataServiceImplTest.java @@ -0,0 +1,333 @@ +package com.cleanengine.coin.chart.service.minute; + +import com.cleanengine.coin.chart.dto.RealTimeOhlcDto; +import com.cleanengine.coin.chart.repository.MinuteOhlcDataRepository; +import com.cleanengine.coin.trade.entity.Trade; +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.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("MinuteOhlcDataServiceImpl 단위 테스트") +class MinuteOhlcDataServiceImplTest { + + @Mock + private MinuteOhlcDataRepository tradeRepository; + + @InjectMocks + private MinuteOhlcDataServiceImpl service; + + private List mockTrades; + private String validTicker; + + @BeforeEach + void setUp() { + validTicker = "BTC"; + mockTrades = createMockTrades(); + } + + // ===== getMinuteOhlcData 테스트 ===== + @Test + @DisplayName("정상적인 티커로 분봉 데이터를 조회한다") + void getMinuteOhlcData_ValidTicker_ReturnsOhlcData() { + // given + when(tradeRepository.findByTickerOrderByTradeTimeAsc(validTicker)) + .thenReturn(mockTrades); + + // when + List result = service.getMinuteOhlcData(validTicker); + + // then + assertThat(result).isNotEmpty(); + assertThat(result).hasSize(2); // 2분간의 데이터 + + RealTimeOhlcDto firstMinute = result.getFirst(); + assertThat(firstMinute.getTicker()).isEqualTo("BTC"); + assertThat(firstMinute.getOpen()).isEqualTo(100.0); + assertThat(firstMinute.getHigh()).isEqualTo(150.0); + assertThat(firstMinute.getLow()).isEqualTo(100.0); + assertThat(firstMinute.getClose()).isEqualTo(150.0); + assertThat(firstMinute.getVolume()).isEqualTo(3.0); // 1.0 + 2.0 + } + + @Test + @DisplayName("거래 데이터가 없으면 빈 리스트를 반환한다") + void getMinuteOhlcData_NoTrades_ReturnsEmptyList() { + // given + when(tradeRepository.findByTickerOrderByTradeTimeAsc(validTicker)) + .thenReturn(List.of()); + + // when + List result = service.getMinuteOhlcData(validTicker); + + // then + assertThat(result).isEmpty(); + } + + // ===== validateTicker 테스트 ===== + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" ", "\t", "\n"}) + @DisplayName("잘못된 티커로 검증하면 예외가 발생한다") + void validateTicker_InvalidTicker_ThrowsException(String invalidTicker) { + // when & then + assertThatThrownBy(() -> service.validateTicker(invalidTicker)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("티커는 비어있을 수 없습니다"); + } + + @Test + @DisplayName("유효한 티커는 검증을 통과한다") + void validateTicker_ValidTicker_Success() { + // when & then (예외가 발생하지 않아야 함) + service.validateTicker("BTC"); + service.validateTicker("ETH-USD"); + service.validateTicker("123"); + } + + // ===== groupTradesByMinute 테스트 ===== + @Test + @DisplayName("거래 데이터를 분 단위로 그룹핑한다") + void groupTradesByMinute_ValidTrades_GroupsCorrectly() { + // when + Map> result = service.groupTradesByMinute(mockTrades); + + // then + assertThat(result).hasSize(2); + + LocalDateTime firstMinute = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + LocalDateTime secondMinute = LocalDateTime.of(2024, 1, 15, 10, 31, 0); + + assertThat(result).containsKey(firstMinute); + assertThat(result).containsKey(secondMinute); + assertThat(result.get(firstMinute)).hasSize(2); + assertThat(result.get(secondMinute)).hasSize(1); + } + + @Test + @DisplayName("빈 거래 리스트는 빈 맵을 반환한다") + void groupTradesByMinute_EmptyTrades_ReturnsEmptyMap() { + // when + Map> result = service.groupTradesByMinute(List.of()); + + // then + assertThat(result).isEmpty(); + } + + // ===== truncateToMinute 테스트 ===== + @Test + @DisplayName("거래 시간을 분 단위로 자른다") + void truncateToMinute_ValidTrade_TruncatesCorrectly() { + // given + Trade trade = createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 45), 100.0, 1.0); + + // when + LocalDateTime result = service.truncateToMinute(trade); + + // then + assertThat(result).isEqualTo(LocalDateTime.of(2024, 1, 15, 10, 30, 0)); + } + + // ===== createOhlcDto 테스트 ===== + @Test + @DisplayName("단일 분 거래 데이터로 OHLC DTO를 생성한다") + void createOhlcDto_ValidTrades_CreatesCorrectDto() { + // given + String ticker = "BTC"; + LocalDateTime minute = LocalDateTime.of(2024, 1, 15, 10, 30, 0); + List trades = List.of( + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 150.0, 2.0) + ); + + // when + RealTimeOhlcDto result = service.createOhlcDto(ticker, minute, trades); + + // then + assertThat(result.getTicker()).isEqualTo("BTC"); + assertThat(result.getTimestamp()).isEqualTo(minute); + assertThat(result.getOpen()).isEqualTo(100.0); + assertThat(result.getHigh()).isEqualTo(150.0); + assertThat(result.getLow()).isEqualTo(100.0); + assertThat(result.getClose()).isEqualTo(150.0); + assertThat(result.getVolume()).isEqualTo(3.0); + } + + // ===== validateTradeList 테스트 ===== + @Test + @DisplayName("null 거래 리스트는 예외를 발생시킨다") + void validateTradeList_NullTrades_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.validateTradeList(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("거래 데이터가 없습니다"); + } + + @Test + @DisplayName("빈 거래 리스트는 예외를 발생시킨다") + void validateTradeList_EmptyTrades_ThrowsException() { + // when & then + assertThatThrownBy(() -> service.validateTradeList(List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("거래 데이터가 없습니다"); + } + + @Test + @DisplayName("유효한 거래 리스트는 검증을 통과한다") + void validateTradeList_ValidTrades_Success() { + // when & then (예외가 발생하지 않아야 함) + service.validateTradeList(mockTrades); + } + + // ===== calculateOhlcData 정적 메서드 테스트 ===== + @Test + @DisplayName("단일 거래 데이터로 OHLC를 계산한다") + void calculateOhlcData_SingleTrade_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.0, 5.0) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(5.0); + } + + @Test + @DisplayName("여러 거래 데이터로 OHLC를 계산한다") + void calculateOhlcData_MultipleTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), // open + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 20), 200.0, 2.0), // high + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 50.0, 3.0), // low + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 40), 150.0, 4.0) // close + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(200.0); + assertThat(result.low()).isEqualTo(50.0); + assertThat(result.close()).isEqualTo(150.0); + assertThat(result.volume()).isEqualTo(10.0); // 1+2+3+4 + } + + @Test + @DisplayName("동일한 가격의 거래들로 OHLC를 계산한다") + void calculateOhlcData_SamePriceTrades_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.0, 1.0), + createTrade(LocalDateTime.now(), 100.0, 2.0), + createTrade(LocalDateTime.now(), 100.0, 3.0) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.0); + assertThat(result.high()).isEqualTo(100.0); + assertThat(result.low()).isEqualTo(100.0); + assertThat(result.close()).isEqualTo(100.0); + assertThat(result.volume()).isEqualTo(6.0); + } + + // ===== OhlcData 레코드 테스트 ===== + @Test + @DisplayName("OhlcData 레코드가 올바르게 동작한다") + void ohlcDataRecord_WorksCorrectly() { + // given + MinuteOhlcDataServiceImpl.OhlcData ohlcData = + new MinuteOhlcDataServiceImpl.OhlcData(100.0, 200.0, 50.0, 150.0, 10.0); + + // then + assertThat(ohlcData.open()).isEqualTo(100.0); + assertThat(ohlcData.high()).isEqualTo(200.0); + assertThat(ohlcData.low()).isEqualTo(50.0); + assertThat(ohlcData.close()).isEqualTo(150.0); + assertThat(ohlcData.volume()).isEqualTo(10.0); + assertThat(ohlcData.toString()).contains("100.0", "200.0", "50.0", "150.0", "10.0"); + } + + // ===== 경계값 테스트 ===== + @Test + @DisplayName("소수점 가격과 거래량으로 정확하게 계산한다") + void calculateOhlcData_DecimalValues_CalculatesCorrectly() { + // given + List trades = List.of( + createTrade(LocalDateTime.now(), 100.5, 1.5), + createTrade(LocalDateTime.now(), 200.75, 2.25) + ); + + // when + MinuteOhlcDataServiceImpl.OhlcData result = + MinuteOhlcDataServiceImpl.calculateOhlcData(trades); + + // then + assertThat(result.open()).isEqualTo(100.5); + assertThat(result.high()).isEqualTo(200.75); + assertThat(result.low()).isEqualTo(100.5); + assertThat(result.close()).isEqualTo(200.75); + assertThat(result.volume()).isEqualTo(3.75); // 1.5 + 2.25 + } + + // =====리플렉션 제거===== + private List createMockTrades() { + return List.of( + // 첫 번째 분 (10:30) + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 10), 100.0, 1.0), + createTrade(LocalDateTime.of(2024, 1, 15, 10, 30, 30), 150.0, 2.0), + + // 두 번째 분 (10:31) + createTrade(LocalDateTime.of(2024, 1, 15, 10, 31, 20), 200.0, 3.0) + ); + } + + /** + * Trade 엔티티 생성 - @AllArgsConstructor 사용하여 리플렉션 제거 + * + * @param tradeTime 거래 시간 + * @param price 가격 + * @param size 거래량 + * @return Trade 객체 + */ + private Trade createTrade(LocalDateTime tradeTime, Double price, Double size) { + return new Trade( + null, // id (자동 생성) 실제 db에 들어가는게 아니기때문에 null로 설정 + "BTC", // ticker + tradeTime, // tradeTime + 1, // buyUserId (더미 값) + 2, // sellUserId (더미 값) + price, // price + size // size + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/order/adapter/in/AssetControllerTest.java b/src/test/java/com/cleanengine/coin/order/adapter/in/AssetControllerTest.java new file mode 100644 index 00000000..6085cd48 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/adapter/in/AssetControllerTest.java @@ -0,0 +1,53 @@ +package com.cleanengine.coin.order.adapter.in; + +import com.cleanengine.coin.base.ControllerTest; +import com.cleanengine.coin.order.application.dto.AssetInfo; +import com.cleanengine.coin.order.application.AssetService; +import com.cleanengine.coin.order.adapter.in.web.asset.AssetController; +import com.cleanengine.coin.tool.annotation.WithCustomMockUser; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AssetController.class) +public class AssetControllerTest extends ControllerTest { + + @MockitoBean + AssetService assetService; + + @Test + @WithCustomMockUser + public void findAll() throws Exception { + when(assetService.getAllAssetInfos()) + .thenReturn(List.of(new AssetInfo("BTC", "비트코인", null))); + + String responseStr = performGet("/api/asset") + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + AssetController.AssetInfos assetInfos = convertAs(responseStr, AssetController.AssetInfos.class); + + assertEquals(1, assetInfos.assets().size()); + assertEquals("비트코인", assetInfos.assets().get(0).name()); + } + + @Test + @WithCustomMockUser + public void findAsset() throws Exception { + when(assetService.getAssetInfo("BTC")) + .thenReturn(new AssetInfo("BTC", "비트코인", null)); + + String responseStr = performGet("/api/asset/BTC") + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + + AssetInfo assetInfo = convertAs(responseStr, AssetInfo.class); + assertEquals("비트코인", assetInfo.name()); + } +} diff --git a/src/test/java/com/cleanengine/coin/order/adapter/out/AssetRepositoryTest.java b/src/test/java/com/cleanengine/coin/order/adapter/out/AssetRepositoryTest.java new file mode 100644 index 00000000..a8c3b9e9 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/order/adapter/out/AssetRepositoryTest.java @@ -0,0 +1,46 @@ +package com.cleanengine.coin.order.adapter.out; + +import com.cleanengine.coin.base.MariaDBAdapterTest; +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.jdbc.Sql; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class AssetRepositoryTest extends MariaDBAdapterTest { + @Autowired + protected AssetRepository assetRepository; + + @Order(3) + @Test + public void saveAsset(){ + assetRepository.save(new Asset("BTC", "비트코인")); + } + + @Order(4) + @Test + public void saveAsset2(){ + assetRepository.save(new Asset("TRUMP", "오피셜 트럼프")); + } + + @Order(1) + @Test + @Sql(scripts = "./AssetRepository/createBTC.sql") + public void findByTicker(){ + Asset asset = assetRepository.findById("BTC").get(); + + assertEquals("비트코인", asset.getName()); + } + + @Order(2) + @Test + @Sql(scripts = "./AssetRepository/createBTC.sql") + public void findAll(){ + assertEquals(1, assetRepository.findAll().size()); + } + + // Repository의 Custom Query 테스트 +} diff --git a/src/test/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerTest.java b/src/test/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerTest.java index 9f21d893..093d9147 100644 --- a/src/test/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerTest.java +++ b/src/test/java/com/cleanengine/coin/order/application/queue/OrderQueueManagerTest.java @@ -1,264 +1,267 @@ -package com.cleanengine.coin.order.application.queue; - -import com.cleanengine.coin.order.domain.BuyOrder; -import com.cleanengine.coin.order.domain.Order; -import com.cleanengine.coin.order.domain.SellOrder; -import static com.cleanengine.coin.order.domain.tool.SellOrderGenerator.LimitSellOrderGenerator.*; -import static com.cleanengine.coin.order.domain.tool.SellOrderGenerator.MarketSellOrderGenerator.*; -import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.LimitBuyOrderGenerator.*; -import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.MarketBuyOrderGenerator.*; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.concurrent.PriorityBlockingQueue; - - -import static org.junit.jupiter.api.Assertions.assertEquals; - -@DisplayName("OrderQueueManager 테스트") -public class OrderQueueManagerTest { - private static final String TICKER = "BTC"; - - private OrderQueueManager orderQueueManager; - - @BeforeEach - public void setUp(){ - orderQueueManager = new OrderQueueManager(TICKER); - } - - private void addOrderToQueueManager(Order order){ - orderQueueManager.addOrder(order); - } - - private void addBuyOrdersToQueueManager(List orders){ - for(Order order : orders){ - orderQueueManager.addOrder(order); - } - } - - private void addSellOrdersToQueueManager(List orders){ - for(Order order : orders){ - orderQueueManager.addOrder(order); - } - } - - @Test - @DisplayName("매수지정가 주문을 삽입시 매수지정가 큐에 올바르게 삽입됨") - void addLimitBuyOrder_limitBuyOrderQueueSizeIncreased() { - BuyOrder limitBuyOrder = createLimitBuyOrderWithRandomPrice(); - - addOrderToQueueManager(limitBuyOrder); - - PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - assertEquals(1, limitBuyOrderQueue.size()); - assertEquals(limitBuyOrder, limitBuyOrderQueue.poll()); - } - - @Test - @DisplayName("매수시장가 주문을 삽입시 매수시장가 큐에 올바르게 삽입됨") - void addMarketBuyOrder_marketBuyOrderQueueSizeIncreased() { - BuyOrder marketBuyOrder = createMarketBuyOrderWithRandomPrice(); - - addOrderToQueueManager(marketBuyOrder); - - PriorityBlockingQueue marketBuyOrderQueue = orderQueueManager.getMarketBuyOrderQueue(); - - assertEquals(1, marketBuyOrderQueue.size()); - assertEquals(marketBuyOrder, marketBuyOrderQueue.poll()); - } - - @Test - @DisplayName("매도지정가 주문을 삽입시 매도지정가 큐에 올바르게 삽입됨") - void addLimitSellOrder_limitSellOrderQueueSizeIncreased() { - SellOrder limitSellOrder = createLimitSellOrderWithRandomPrice(); - - addOrderToQueueManager(limitSellOrder); - - PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - - assertEquals(1, limitSellOrderQueue.size()); - assertEquals(limitSellOrder, limitSellOrderQueue.poll()); - } - - @Test - @DisplayName("매도시장가 주문을 삽입시 매도시장가 큐에 올바르게 삽입됨") - void addMarketSellOrder_marketSellOrderQueueSizeIncreased() { - SellOrder marketSellOrder = createMarketSellOrderWithCreatedTime(LocalDateTime.now()); - - addOrderToQueueManager(marketSellOrder); - - PriorityBlockingQueue marketSellOrderQueue = orderQueueManager.getMarketSellOrderQueue(); - - assertEquals(1, marketSellOrderQueue.size()); - assertEquals(marketSellOrder, marketSellOrderQueue.poll()); - } - - @Test - @DisplayName("매수지정가 주문을 가격이 높은 순서대로 큐에 삽입시 가격이 높은 순서대로 정렬됨") - void addLimitBuyOrders_higherPriceFirst_sortedDescending() { - List limitBuyOrdersAsc = createLimitBuyOrdersWithPrices - (1000.0, 500.0, 100.0); - List limitBuyOrdersDesc = limitBuyOrdersAsc.reversed(); - - addBuyOrdersToQueueManager(limitBuyOrdersDesc); - - PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - assertEquals(limitBuyOrderQueue.poll().getPrice(), 1000.0); - assertEquals(limitBuyOrderQueue.poll().getPrice(), 500.0); - assertEquals(limitBuyOrderQueue.poll().getPrice(), 100.0); - } - - @Test - @DisplayName("매수지정가 주문을 가격이 작은 순서대로 큐에 삽입시 가격이 높은 순서대로 정렬됨") - void addLimitBuyOrders_lowerPriceFirst_sortedDescending() { - List limitBuyOrdersAsc = createLimitBuyOrdersWithPrices - (100.0, 500.0, 1000.0); - - addBuyOrdersToQueueManager(limitBuyOrdersAsc); - - PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - assertEquals(limitBuyOrderQueue.poll().getPrice(), 1000.0); - assertEquals(limitBuyOrderQueue.poll().getPrice(), 500.0); - assertEquals(limitBuyOrderQueue.poll().getPrice(), 100.0); - } - - @Test - @DisplayName("가격이 같은 매수지정가 주문을 시간이 빠른 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") - void addLimitBuyOrdersSamePrice_fasterTimeFirst_sortedFaster() { - List limitBuyOrders = - createLimitBuyOrdersWithDifferentCreatedTimesAsc(); - - addBuyOrdersToQueueManager(limitBuyOrders); - - PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2028); - } - - @Test - @DisplayName("가격이 같은 매수지정가 주문을 시간이 느린 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") - void addLimitBuyOrdersSamePrice_slowerTimeFirst_sortedFaster() { - List limitBuyOrders = - createLimitBuyOrdersWithDifferentCreatedTimesAsc(); - List limitBuyOrdersDesc = - limitBuyOrders.reversed(); - - addBuyOrdersToQueueManager(limitBuyOrdersDesc); - - PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); - - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); - assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2028); - } - - @Test - @DisplayName("매도지정가 주문을 가격이 낮은 순서대로 큐에 삽입시 가격이 낮은 순서대로 정렬됨") - void addLimitSellOrders_lowerPriceFirst_sortedAscending() { - List limitSellOrders = - createLimitSellOrdersWithPrices - (100.0, 500.0, 1000.0); - - addSellOrdersToQueueManager(limitSellOrders); - - PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - - assertEquals(limitSellOrderQueue.poll().getPrice(), 100.0); - assertEquals(limitSellOrderQueue.poll().getPrice(), 500.0); - assertEquals(limitSellOrderQueue.poll().getPrice(), 1000.0); - } - - @Test - @DisplayName("매도지정가 주문을 가격이 높은 순서대로 큐에 삽입시 가격이 낮은 순서대로 정렬됨") - void addLimitSellOrders_highestPriceFirst_sortedAscending() { - List limitSellOrders = - createLimitSellOrdersWithPrices - (1000.0, 500.0, 100.0); - - addSellOrdersToQueueManager(limitSellOrders); - - PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - - assertEquals(limitSellOrderQueue.poll().getPrice(), 100.0); - assertEquals(limitSellOrderQueue.poll().getPrice(), 500.0); - assertEquals(limitSellOrderQueue.poll().getPrice(), 1000.0); - } - - @Test - @DisplayName("가격이 같은 매도지정가 주문을 시간이 빠른 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") - void addLimitSellOrdersSamePrice_fasterTimeFirst_sortedFaster() { - List limitSellOrders = - createLimitSellOrdersWithDifferentCreatedTimesAsc(); - - addSellOrdersToQueueManager(limitSellOrders); - - PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2027); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2028); - } - - @Test - @DisplayName("가격이 같은 매도지정가 주문을 시간이 느린 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") - void addLimitSellOrdersSamePrice_slowerTimeFirst_sortedFaster() { - List limitSellOrders = - createLimitSellOrdersWithDifferentCreatedTimesAsc(); - List limitSellOrdersDesc = - limitSellOrders.reversed(); - - addSellOrdersToQueueManager(limitSellOrdersDesc); - - PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); - - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2027); - assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2028); - } - - @Test - @DisplayName("가격이 다른 매수시장가 주문을 높은 가격 순서대로 삽입시 시간이 빠른 순서대로 정렬됨") - void addMarketBuyOrders_highestPriceFirst_sortedFaster() { - List marketBuyOrders = - List.of(createMarketBuyOrderWithPriceAndCreatedTime(1000.0, LocalDateTime.of(2027, 5, 9, 10, 18, 0)), - createMarketBuyOrderWithPriceAndCreatedTime(500.0, LocalDateTime.of(2026, 5, 9, 10, 18, 0)), - createMarketBuyOrderWithPriceAndCreatedTime(100.0, LocalDateTime.of(2025, 5, 9, 10, 18, 0))); - - addBuyOrdersToQueueManager(marketBuyOrders); - - PriorityBlockingQueue marketBuyOrderQueue = orderQueueManager.getMarketBuyOrderQueue(); - - assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); - } - - @Test - @DisplayName("매도시장가 주문을 시간이 느린 순서대로 삽입시 시간이 빠른 순서대로 정렬됨") - void addMarketSellOrders_slowerTimeFirst_sortedFaster() { - List marketSellOrders = - createMarketSellOrdersWithDifferentCreatedTimesAsc(); - List marketSellOrdersDesc = - marketSellOrders.reversed(); - - addSellOrdersToQueueManager(marketSellOrdersDesc); - - PriorityBlockingQueue marketSellOrderQueue = orderQueueManager.getMarketSellOrderQueue(); - - assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2025); - assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2026); - assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2027); - } -} +//package com.cleanengine.coin.order.application.queue; +// +//import com.cleanengine.coin.order.domain.BuyOrder; +//import com.cleanengine.coin.order.domain.Order; +//import com.cleanengine.coin.order.domain.SellOrder; +//import org.junit.jupiter.api.BeforeEach; +//import org.junit.jupiter.api.Disabled; +//import org.junit.jupiter.api.DisplayName; +//import org.junit.jupiter.api.Test; +// +//import java.time.LocalDateTime; +//import java.util.List; +//import java.util.concurrent.PriorityBlockingQueue; +// +//import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.LimitBuyOrderGenerator.*; +//import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.MarketBuyOrderGenerator.createMarketBuyOrderWithPriceAndCreatedTime; +//import static com.cleanengine.coin.order.domain.tool.BuyOrderGenerator.MarketBuyOrderGenerator.createMarketBuyOrderWithRandomPrice; +//import static com.cleanengine.coin.order.domain.tool.SellOrderGenerator.LimitSellOrderGenerator.*; +//import static com.cleanengine.coin.order.domain.tool.SellOrderGenerator.MarketSellOrderGenerator.createMarketSellOrderWithCreatedTime; +//import static com.cleanengine.coin.order.domain.tool.SellOrderGenerator.MarketSellOrderGenerator.createMarketSellOrdersWithDifferentCreatedTimesAsc; +//import static org.junit.jupiter.api.Assertions.assertEquals; +// +//@Disabled +//@DisplayName("OrderQueueManager 테스트") +//public class OrderQueueManagerTest { +// private static final String TICKER = "BTC"; +// +// private OrderQueueManager orderQueueManager; +// +// @BeforeEach +// public void setUp(){ +// orderQueueManager = new OrderQueueManager(TICKER); +// } +// +// private void addOrderToQueueManager(Order order){ +// orderQueueManager.addOrder(order); +// } +// +// private void addBuyOrdersToQueueManager(List orders){ +// for(Order order : orders){ +// orderQueueManager.addOrder(order); +// } +// } +// +// private void addSellOrdersToQueueManager(List orders){ +// for(Order order : orders){ +// orderQueueManager.addOrder(order); +// } +// } +// +// @Test +// @DisplayName("매수지정가 주문을 삽입시 매수지정가 큐에 올바르게 삽입됨") +// void addLimitBuyOrder_limitBuyOrderQueueSizeIncreased() { +// BuyOrder limitBuyOrder = createLimitBuyOrderWithRandomPrice(); +// +// addOrderToQueueManager(limitBuyOrder); +// +// PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); +// +// assertEquals(1, limitBuyOrderQueue.size()); +// assertEquals(limitBuyOrder, limitBuyOrderQueue.poll()); +// } +// +// @Test +// @DisplayName("매수시장가 주문을 삽입시 매수시장가 큐에 올바르게 삽입됨") +// void addMarketBuyOrder_marketBuyOrderQueueSizeIncreased() { +// BuyOrder marketBuyOrder = createMarketBuyOrderWithRandomPrice(); +// +// addOrderToQueueManager(marketBuyOrder); +// +// PriorityBlockingQueue marketBuyOrderQueue = orderQueueManager.getMarketBuyOrderQueue(); +// +// assertEquals(1, marketBuyOrderQueue.size()); +// assertEquals(marketBuyOrder, marketBuyOrderQueue.poll()); +// } +// +// @Test +// @DisplayName("매도지정가 주문을 삽입시 매도지정가 큐에 올바르게 삽입됨") +// void addLimitSellOrder_limitSellOrderQueueSizeIncreased() { +// SellOrder limitSellOrder = createLimitSellOrderWithRandomPrice(); +// +// addOrderToQueueManager(limitSellOrder); +// +// PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); +// +// assertEquals(1, limitSellOrderQueue.size()); +// assertEquals(limitSellOrder, limitSellOrderQueue.poll()); +// } +// +// @Test +// @DisplayName("매도시장가 주문을 삽입시 매도시장가 큐에 올바르게 삽입됨") +// void addMarketSellOrder_marketSellOrderQueueSizeIncreased() { +// SellOrder marketSellOrder = createMarketSellOrderWithCreatedTime(LocalDateTime.now()); +// +// addOrderToQueueManager(marketSellOrder); +// +// PriorityBlockingQueue marketSellOrderQueue = orderQueueManager.getMarketSellOrderQueue(); +// +// assertEquals(1, marketSellOrderQueue.size()); +// assertEquals(marketSellOrder, marketSellOrderQueue.poll()); +// } +// +// @Test +// @DisplayName("매수지정가 주문을 가격이 높은 순서대로 큐에 삽입시 가격이 높은 순서대로 정렬됨") +// void addLimitBuyOrders_higherPriceFirst_sortedDescending() { +// List limitBuyOrdersAsc = createLimitBuyOrdersWithPrices +// (1000.0, 500.0, 100.0); +// List limitBuyOrdersDesc = limitBuyOrdersAsc.reversed(); +// +// addBuyOrdersToQueueManager(limitBuyOrdersDesc); +// +// PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); +// +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 1000.0); +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 500.0); +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 100.0); +// } +// +// @Test +// @DisplayName("매수지정가 주문을 가격이 작은 순서대로 큐에 삽입시 가격이 높은 순서대로 정렬됨") +// void addLimitBuyOrders_lowerPriceFirst_sortedDescending() { +// List limitBuyOrdersAsc = createLimitBuyOrdersWithPrices +// (100.0, 500.0, 1000.0); +// +// addBuyOrdersToQueueManager(limitBuyOrdersAsc); +// +// PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); +// +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 1000.0); +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 500.0); +// assertEquals(limitBuyOrderQueue.poll().getPrice(), 100.0); +// } +// +// @Test +// @DisplayName("가격이 같은 매수지정가 주문을 시간이 빠른 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") +// void addLimitBuyOrdersSamePrice_fasterTimeFirst_sortedFaster() { +// List limitBuyOrders = +// createLimitBuyOrdersWithDifferentCreatedTimesAsc(); +// +// addBuyOrdersToQueueManager(limitBuyOrders); +// +// PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); +// +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2028); +// } +// +// @Test +// @DisplayName("가격이 같은 매수지정가 주문을 시간이 느린 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") +// void addLimitBuyOrdersSamePrice_slowerTimeFirst_sortedFaster() { +// List limitBuyOrders = +// createLimitBuyOrdersWithDifferentCreatedTimesAsc(); +// List limitBuyOrdersDesc = +// limitBuyOrders.reversed(); +// +// addBuyOrdersToQueueManager(limitBuyOrdersDesc); +// +// PriorityBlockingQueue limitBuyOrderQueue = orderQueueManager.getLimitBuyOrderQueue(); +// +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); +// assertEquals(limitBuyOrderQueue.poll().getCreatedAt().getYear(), 2028); +// } +// +// @Test +// @DisplayName("매도지정가 주문을 가격이 낮은 순서대로 큐에 삽입시 가격이 낮은 순서대로 정렬됨") +// void addLimitSellOrders_lowerPriceFirst_sortedAscending() { +// List limitSellOrders = +// createLimitSellOrdersWithPrices +// (100.0, 500.0, 1000.0); +// +// addSellOrdersToQueueManager(limitSellOrders); +// +// PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); +// +// assertEquals(limitSellOrderQueue.poll().getPrice(), 100.0); +// assertEquals(limitSellOrderQueue.poll().getPrice(), 500.0); +// assertEquals(limitSellOrderQueue.poll().getPrice(), 1000.0); +// } +// +// @Test +// @DisplayName("매도지정가 주문을 가격이 높은 순서대로 큐에 삽입시 가격이 낮은 순서대로 정렬됨") +// void addLimitSellOrders_highestPriceFirst_sortedAscending() { +// List limitSellOrders = +// createLimitSellOrdersWithPrices +// (1000.0, 500.0, 100.0); +// +// addSellOrdersToQueueManager(limitSellOrders); +// +// PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); +// +// assertEquals(limitSellOrderQueue.poll().getPrice(), 100.0); +// assertEquals(limitSellOrderQueue.poll().getPrice(), 500.0); +// assertEquals(limitSellOrderQueue.poll().getPrice(), 1000.0); +// } +// +// @Test +// @DisplayName("가격이 같은 매도지정가 주문을 시간이 빠른 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") +// void addLimitSellOrdersSamePrice_fasterTimeFirst_sortedFaster() { +// List limitSellOrders = +// createLimitSellOrdersWithDifferentCreatedTimesAsc(); +// +// addSellOrdersToQueueManager(limitSellOrders); +// +// PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); +// +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2027); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2028); +// } +// +// @Test +// @DisplayName("가격이 같은 매도지정가 주문을 시간이 느린 순서대로 큐에 삽입시 시간이 빠른 순서대로 정렬됨") +// void addLimitSellOrdersSamePrice_slowerTimeFirst_sortedFaster() { +// List limitSellOrders = +// createLimitSellOrdersWithDifferentCreatedTimesAsc(); +// List limitSellOrdersDesc = +// limitSellOrders.reversed(); +// +// addSellOrdersToQueueManager(limitSellOrdersDesc); +// +// PriorityBlockingQueue limitSellOrderQueue = orderQueueManager.getLimitSellOrderQueue(); +// +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2027); +// assertEquals(limitSellOrderQueue.poll().getCreatedAt().getYear(), 2028); +// } +// +// @Test +// @DisplayName("가격이 다른 매수시장가 주문을 높은 가격 순서대로 삽입시 시간이 빠른 순서대로 정렬됨") +// void addMarketBuyOrders_highestPriceFirst_sortedFaster() { +// List marketBuyOrders = +// List.of(createMarketBuyOrderWithPriceAndCreatedTime(1000.0, LocalDateTime.of(2027, 5, 9, 10, 18, 0)), +// createMarketBuyOrderWithPriceAndCreatedTime(500.0, LocalDateTime.of(2026, 5, 9, 10, 18, 0)), +// createMarketBuyOrderWithPriceAndCreatedTime(100.0, LocalDateTime.of(2025, 5, 9, 10, 18, 0))); +// +// addBuyOrdersToQueueManager(marketBuyOrders); +// +// PriorityBlockingQueue marketBuyOrderQueue = orderQueueManager.getMarketBuyOrderQueue(); +// +// assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(marketBuyOrderQueue.poll().getCreatedAt().getYear(), 2027); +// } +// +// @Test +// @DisplayName("매도시장가 주문을 시간이 느린 순서대로 삽입시 시간이 빠른 순서대로 정렬됨") +// void addMarketSellOrders_slowerTimeFirst_sortedFaster() { +// List marketSellOrders = +// createMarketSellOrdersWithDifferentCreatedTimesAsc(); +// List marketSellOrdersDesc = +// marketSellOrders.reversed(); +// +// addSellOrdersToQueueManager(marketSellOrdersDesc); +// +// PriorityBlockingQueue marketSellOrderQueue = orderQueueManager.getMarketSellOrderQueue(); +// +// assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2025); +// assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2026); +// assertEquals(marketSellOrderQueue.poll().getCreatedAt().getYear(), 2027); +// } +//} 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 26bda593..7a82204d 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,11 +1,11 @@ package com.cleanengine.coin.order.integration.buyorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; +import com.cleanengine.coin.order.adapter.out.persistentce.account.AccountExternalRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; import com.cleanengine.coin.order.application.OrderService; -import com.cleanengine.coin.order.external.adapter.account.AccountExternalRepository; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; +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 org.junit.jupiter.api.DisplayName; 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 6dff93c8..b50a3424 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,10 +1,10 @@ package com.cleanengine.coin.order.integration.sellorder; import com.cleanengine.coin.common.error.DomainValidationException; -import com.cleanengine.coin.order.application.OrderCommand; -import com.cleanengine.coin.order.application.OrderInfo; +import com.cleanengine.coin.order.adapter.out.persistentce.wallet.WalletExternalRepository; import com.cleanengine.coin.order.application.OrderService; -import com.cleanengine.coin.order.external.adapter.wallet.WalletExternalRepository; +import com.cleanengine.coin.order.application.dto.OrderCommand; +import com.cleanengine.coin.order.application.dto.OrderInfo; import com.cleanengine.coin.user.domain.Wallet; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/cleanengine/coin/orderbook/infra/OrderBookUpdatedNotifierAdapterTest.java b/src/test/java/com/cleanengine/coin/orderbook/infra/OrderBookUpdatedNotifierAdapterTest.java new file mode 100644 index 00000000..8059137a --- /dev/null +++ b/src/test/java/com/cleanengine/coin/orderbook/infra/OrderBookUpdatedNotifierAdapterTest.java @@ -0,0 +1,37 @@ +package com.cleanengine.coin.orderbook.infra; + +import com.cleanengine.coin.base.WebSocketTest; +import com.cleanengine.coin.orderbook.dto.OrderBookInfo; +import com.cleanengine.coin.orderbook.dto.OrderBookUnitInfo; +import com.cleanengine.coin.tool.helper.GenericStompFrameHandler; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Disabled +public class OrderBookUpdatedNotifierAdapterTest extends WebSocketTest { + + @Autowired + protected OrderBookUpdatedNotifierAdapter orderBookUpdatedNotifierAdapter; + + @Test + public void getOrderBooks() throws Exception { + OrderBookInfo orderBookInfo = new OrderBookInfo("BTC", + List.of(new OrderBookUnitInfo(1.0, 1.0)), + List.of(new OrderBookUnitInfo( 2.0, 2.0))); + + session.subscribe("/topic/orderbook/BTC", + new GenericStompFrameHandler<>(OrderBookInfo.class, responseQueue)); + + orderBookUpdatedNotifierAdapter.sendOrderBooks(orderBookInfo); + + OrderBookInfo result = (OrderBookInfo) responseQueue.poll(10, TimeUnit.SECONDS); + + assertEquals(orderBookInfo, result); + } +} diff --git a/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java b/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java new file mode 100644 index 00000000..6112304a --- /dev/null +++ b/src/test/java/com/cleanengine/coin/tool/annotation/WithCustomMockUser.java @@ -0,0 +1,13 @@ +package com.cleanengine.coin.tool.annotation; + +import com.cleanengine.coin.tool.helper.WithCustomMockUserSecurityContextFactory; +import org.springframework.security.test.context.support.WithSecurityContext; + +import java.lang.annotation.Retention; + +@Retention(value = java.lang.annotation.RetentionPolicy.RUNTIME) +@WithSecurityContext(factory = WithCustomMockUserSecurityContextFactory.class) +public @interface WithCustomMockUser { + String name() default "user"; + int id() default 1; +} diff --git a/src/test/java/com/cleanengine/coin/tool/extension/MariaDBTestContainerExtension.java b/src/test/java/com/cleanengine/coin/tool/extension/MariaDBTestContainerExtension.java new file mode 100644 index 00000000..c7f2059c --- /dev/null +++ b/src/test/java/com/cleanengine/coin/tool/extension/MariaDBTestContainerExtension.java @@ -0,0 +1,55 @@ +package com.cleanengine.coin.tool.extension; + +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.Extension; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.MariaDBContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.utility.DockerImageName; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * mariadb testcontainer 관련 설정은 다 이 클래스에서 할 수 있도록 하기 + */ +public class MariaDBTestContainerExtension implements Extension, BeforeAllCallback, BeforeEachCallback { + public static JdbcDatabaseContainer container = (JdbcDatabaseContainer) new MariaDBContainer(DockerImageName.parse("mariadb:latest")) + .withDatabaseName("if") + .withUsername("user") + .withPassword("pass") + .withInitScripts("./db/mariadb/schema/init.sql") + .withLogConsumer(new Slf4jLogConsumer(getLogger())) + .withEnv("TZ", "Asia/Seoul"); + + @Override + public void beforeAll(ExtensionContext context) throws Exception { + ExtensionContext.Store store = context.getStore(ExtensionContext.Namespace.GLOBAL); + + if(store.get("mariaDBStarted", Boolean.class) == null) { + container.start(); + store.put("mariaDBStarted", true); + store.put("mariaDBInstance", container); + Runtime.getRuntime().addShutdownHook(new Thread(()->{ + if(container!=null && container.isRunning()) { + container.stop(); + } + })); + } + else{ + container = (JdbcDatabaseContainer) store.get("mariaDBInstance", JdbcDatabaseContainer.class); + } + } + + @Override + public void beforeEach(ExtensionContext context) throws Exception { + assertTrue(container.isRunning()); + } + + private static Logger getLogger() { + return LoggerFactory.getLogger("MariaDBIntegrationTest"); + } +} diff --git a/src/test/java/com/cleanengine/coin/tool/helper/GenericStompFrameHandler.java b/src/test/java/com/cleanengine/coin/tool/helper/GenericStompFrameHandler.java new file mode 100644 index 00000000..741513a3 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/tool/helper/GenericStompFrameHandler.java @@ -0,0 +1,34 @@ +package com.cleanengine.coin.tool.helper; + +import org.springframework.messaging.simp.stomp.StompFrameHandler; +import org.springframework.messaging.simp.stomp.StompHeaders; + +import java.lang.reflect.Type; +import java.util.concurrent.BlockingQueue; + +public class GenericStompFrameHandler implements StompFrameHandler { + private final Class payloadType; + private final BlockingQueue outputQueue; + + public GenericStompFrameHandler(Class payloadType, BlockingQueue queue) { + this.payloadType = payloadType; + this.outputQueue = queue; + } + + @Override + public Type getPayloadType(StompHeaders headers) { + return payloadType; + } + + @Override + public void handleFrame(StompHeaders headers, Object payload) { + if(payload == null){ + return; + } + + if(!payloadType.isInstance(payload)){ + throw new RuntimeException("Unexpected payload type: " + payload.getClass()); + } + outputQueue.add(payload); + } +} diff --git a/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java new file mode 100644 index 00000000..d2636a56 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/tool/helper/WithCustomMockUserSecurityContextFactory.java @@ -0,0 +1,29 @@ +package com.cleanengine.coin.tool.helper; + +import com.cleanengine.coin.tool.annotation.WithCustomMockUser; +import com.cleanengine.coin.user.login.infra.CustomOAuth2User; +import com.cleanengine.coin.user.login.infra.UserOAuthDetails; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.test.context.support.WithSecurityContextFactory; + +public class WithCustomMockUserSecurityContextFactory implements WithSecurityContextFactory { + @Override + public SecurityContext createSecurityContext(WithCustomMockUser annotation) { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + + UserOAuthDetails userOAuthDetails = new UserOAuthDetails(); + userOAuthDetails.setUserId(annotation.id()); + userOAuthDetails.setName(annotation.name()); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(userOAuthDetails); + + Authentication authentication = new UsernamePasswordAuthenticationToken(customOAuth2User, + null, customOAuth2User.getAuthorities()); + context.setAuthentication(authentication); + + return context; + } +} diff --git a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java index 392b0fca..169b961e 100644 --- a/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java +++ b/src/test/java/com/cleanengine/coin/trade/application/TradeQueueManagerTest.java @@ -1,12 +1,13 @@ package com.cleanengine.coin.trade.application; -import com.cleanengine.coin.order.application.queue.OrderQueueManager; -import com.cleanengine.coin.order.application.queue.OrderQueueManagerPool; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.BuyOrderRepository; +import com.cleanengine.coin.order.adapter.out.persistentce.order.command.SellOrderRepository; import com.cleanengine.coin.order.domain.BuyOrder; import com.cleanengine.coin.order.domain.Order; +import com.cleanengine.coin.order.domain.OrderType; import com.cleanengine.coin.order.domain.SellOrder; -import com.cleanengine.coin.order.infra.BuyOrderRepository; -import com.cleanengine.coin.order.infra.SellOrderRepository; +import com.cleanengine.coin.order.domain.spi.WaitingOrders; +import com.cleanengine.coin.order.domain.spi.WaitingOrdersManager; import com.cleanengine.coin.trade.entity.Trade; import com.cleanengine.coin.trade.repository.TradeRepository; import org.junit.jupiter.api.AfterAll; @@ -17,6 +18,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; import java.util.List; @@ -27,6 +29,7 @@ import static org.junit.jupiter.api.Assertions.*; @SpringBootTest +@ActiveProfiles({"dev", "it", "h2-mem"}) @DisplayName("체결 처리 테스트") public class TradeQueueManagerTest { @@ -45,25 +48,27 @@ public class TradeQueueManagerTest { @Autowired TradeBatchProcessor tradeBatchProcessor; @Autowired - private OrderQueueManagerPool orderQueueManagerPool; + private WaitingOrdersManager waitingOrdersManager; + @Autowired + TradeService tradeService; private final String ticker = "BTC"; private void addBuyOrdersToQueueManager(List orders){ if (orders.isEmpty()) return; String ticker = orders.getFirst().getTicker(); - OrderQueueManager orderQueueManager = orderQueueManagerPool.getOrderQueueManager(ticker); + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); for(com.cleanengine.coin.order.domain.Order order : orders){ - orderQueueManager.addOrder(order); + waitingOrders.addOrder(order); } } private void addSellOrdersToQueueManager(List orders){ if (orders.isEmpty()) return; String ticker = orders.getFirst().getTicker(); - OrderQueueManager orderQueueManager = orderQueueManagerPool.getOrderQueueManager(ticker); + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); for(Order order : orders){ - orderQueueManager.addOrder(order); + waitingOrders.addOrder(order); } } @@ -72,11 +77,8 @@ void setUp() { if (staticTradeBatchProcessor == null) { staticTradeBatchProcessor = tradeBatchProcessor; } - OrderQueueManager orderQueueManager = orderQueueManagerPool.getOrderQueueManager(ticker); - orderQueueManager.getMarketSellOrderQueue().clear(); - orderQueueManager.getMarketBuyOrderQueue().clear(); - orderQueueManager.getLimitSellOrderQueue().clear(); - orderQueueManager.getLimitBuyOrderQueue().clear(); + WaitingOrders waitingOrders = waitingOrdersManager.getWaitingOrders(ticker); + waitingOrders.clearAllQueues(); tradeRepository.deleteAll(); buyOrderRepository.deleteAll(); sellOrderRepository.deleteAll(); @@ -107,9 +109,9 @@ public void testLimitToLimitCompleteTrade() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -137,8 +139,8 @@ public void testLimitToLimitCompleteTrade() { assertTrue(tradeOfBuy.getFirst().getSize() - orderSize < MINIMUM_ORDER_SIZE, "체결수량과 주문수량은 같아야 합니다."); assertTrue(tradeOfBuy.getFirst().getPrice() - price < MINIMUM_ORDER_SIZE, "체결단가와 주문단가는 같아야 합니다."); - assertTrue(orderQueueManager.getLimitBuyOrderQueue().isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertTrue(orderQueueManager.getLimitSellOrderQueue().isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); assertTrue(buyOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매수주문의 잔여수량은 없어야 합니다."); assertTrue(sellOrder.getRemainingSize() < MINIMUM_ORDER_SIZE, "매도주문의 잔여수량은 없어야 합니다."); @@ -154,9 +156,9 @@ public void testLimitToLimitPartialTrade1() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -180,8 +182,8 @@ public void testLimitToLimitPartialTrade1() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertTrue(orderQueueManager.getLimitBuyOrderQueue().isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertEquals(1, orderQueueManager.getLimitSellOrderQueue().size(), "지정가 매도 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); } @DisplayName("지정가매수-지정가매도 매수부분체결") @@ -194,9 +196,9 @@ public void testLimitToLimitPartialTrade2() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -220,8 +222,8 @@ public void testLimitToLimitPartialTrade2() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertEquals(1, orderQueueManager.getLimitBuyOrderQueue().size(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(orderQueueManager.getLimitSellOrderQueue().isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); } @DisplayName("시장가매수-지정가매도 완전체결") @@ -234,9 +236,9 @@ public void testMarketToLimitCompleteTrade1() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -260,8 +262,8 @@ public void testMarketToLimitCompleteTrade1() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertTrue(orderQueueManager.getMarketBuyOrderQueue().isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); - assertTrue(orderQueueManager.getLimitSellOrderQueue().isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); } @DisplayName("지정가매수-시장가매도 완전체결") @@ -274,9 +276,9 @@ public void testMarketToLimitCompleteTrade2() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -300,8 +302,8 @@ public void testMarketToLimitCompleteTrade2() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertTrue(orderQueueManager.getLimitBuyOrderQueue().isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); - assertTrue(orderQueueManager.getMarketSellOrderQueue().isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매수 주문이 없어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); } @DisplayName("시장가매수-지정가매도 매도부분체결") @@ -314,9 +316,9 @@ public void testMarketToLimitPartialTrade1() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -340,8 +342,8 @@ public void testMarketToLimitPartialTrade1() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertTrue(orderQueueManager.getMarketBuyOrderQueue().isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); - assertEquals(1, orderQueueManager.getLimitSellOrderQueue().size(), "지정가 매도 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매수 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매도 주문이 1개 남아있어야 합니다."); } @DisplayName("시장가매수-지정가매도 매수부분체결") @@ -354,9 +356,9 @@ public void testMarketToLimitPartialTrade2() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -380,14 +382,14 @@ public void testMarketToLimitPartialTrade2() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertEquals(1, orderQueueManager.getMarketBuyOrderQueue().size(), "시장가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(orderQueueManager.getLimitSellOrderQueue().isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).size(), "시장가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "남은 지정가 매도 주문이 없어야 합니다."); - assert orderQueueManager.getMarketBuyOrderQueue().peek() != null; + assert waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek() != null; assertEquals(1_300_000_000.0 - 130_000_000.0, - orderQueueManager.getMarketBuyOrderQueue().peek().getRemainingDeposit(), + waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek().getRemainingDeposit(), "잔여 예수금이 맞지 않습니다."); - logger.debug("잔여 예수금 : {}", orderQueueManager.getMarketBuyOrderQueue().peek().getRemainingDeposit()); + logger.debug("잔여 예수금 : {}", waitingOrders.getBuyOrderPriorityQueueStore(OrderType.MARKET).peek().getRemainingDeposit()); } @DisplayName("지정가매수-시장가매도 매수부분체결") @@ -400,9 +402,9 @@ public void testMarketToLimitPartialTrade3() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -426,8 +428,8 @@ public void testMarketToLimitPartialTrade3() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertEquals(1, orderQueueManager.getLimitBuyOrderQueue().size(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertTrue(orderQueueManager.getMarketSellOrderQueue().isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); + assertEquals(1, waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).size(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertTrue(waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).isEmpty(), "남은 시장가 매도 주문이 없어야 합니다."); } @DisplayName("지정가매수-시장가매도 매도부분체결") @@ -440,9 +442,9 @@ public void testMarketToLimitPartialTrade4() { sellOrderRepository.save(sellOrder); Map tradeQueueManagers = tradeBatchProcessor.getTradeQueueManagers(); - OrderQueueManager orderQueueManager = tradeQueueManagers.get(ticker).getOrderQueueManager(); - orderQueueManager.addOrder(buyOrder); - orderQueueManager.addOrder(sellOrder); + WaitingOrders waitingOrders = tradeService.getWaitingOrdersManager().getWaitingOrders(ticker); + waitingOrders.addOrder(buyOrder); + waitingOrders.addOrder(sellOrder); // 체결이 완료될 때까지 대기 (최대 3초) await() @@ -466,8 +468,8 @@ public void testMarketToLimitPartialTrade4() { "매수인와 매도인의 거래 내역은 동일한 거래를 가리켜야 합니다" ); - assertTrue(orderQueueManager.getLimitBuyOrderQueue().isEmpty(), "지정가 매수 주문이 1개 남아있어야 합니다."); - assertEquals(1, orderQueueManager.getMarketSellOrderQueue().size(), "남은 시장가 매도 주문이 없어야 합니다."); + assertTrue(waitingOrders.getBuyOrderPriorityQueueStore(OrderType.LIMIT).isEmpty(), "지정가 매수 주문이 1개 남아있어야 합니다."); + assertEquals(1, waitingOrders.getSellOrderPriorityQueueStore(OrderType.MARKET).size(), "남은 시장가 매도 주문이 없어야 합니다."); } // @DisplayName("여러 지정가매수-지정가매도 완전체결") diff --git a/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java new file mode 100644 index 00000000..40dab6a9 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/domain/AccountTest.java @@ -0,0 +1,73 @@ +package com.cleanengine.coin.user.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.*; + +class AccountTest { + + @DisplayName("계좌의 예수금을 5000만큼 증가시킨다.") + @Test + void increaseCash() { + // given + Account account = Account.of(1, 1000.0); + + // when + account.increaseCash(5000.0); + + // then + assertEquals(6000.0, account.getCash()); + } + + @DisplayName("계좌의 예수금을 0만큼 증가시키면 예외가 발생한다.") + @Test + void increaseZeroCash() { + // given + Account account = Account.of(1, 1000.0); + + // when, then + assertThatThrownBy(() -> account.increaseCash(0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Increase amount must be greater than zero."); + } + + @DisplayName("계좌의 예수금을 5000만큼 감소시킨다.") + @Test + void decreaseCash() { + // given + Account account = Account.of(1, 6000.0); + + // when, then + account.decreaseCash(5000.0); + + // then + assertEquals(1000.0, account.getCash()); + } + + @DisplayName("계좌의 예수금을 0만큼 감소시키면 예외가 발생한다.") + @Test + void decreaseZeroCash() { + // given + Account account = Account.of(1, 6000.0); + + // when, then + assertThatThrownBy(() -> account.decreaseCash(0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Decrease amount must be greater than zero."); + } + + @DisplayName("계좌의 예수금보다 많은 금액을 감소시키면 예외가 발생한다.") + @Test + void decreaseCashUnderRemainingCash() { + // given + Account account = Account.of(1, 1000.0); + + // when, then + assertThatThrownBy(() -> account.decreaseCash(5000.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Cannot decrease cash. Available cash: 1000.0, requested: 5000.0"); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java new file mode 100644 index 00000000..35c4b078 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/user/info/application/AccountServiceTest.java @@ -0,0 +1,37 @@ +package com.cleanengine.coin.user.info.application; + +import com.cleanengine.coin.common.CommonValues; +import com.cleanengine.coin.user.domain.Account; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles({"dev", "it", "h2-mem"}) +@SpringBootTest +class AccountServiceTest { + + @Autowired + private AccountService accountService; + + @DisplayName("유저 ID와 예수금으로 신규 계좌를 생성한다.") + @Test + void test() { + // given + int userId = 3; + double cash = CommonValues.INITIAL_USER_CASH; + + // when + accountService.createNewAccount(userId, cash); + Account account = accountService.retrieveAccountByUserId(userId); + + // then + assertThat(account).isNotNull() + .extracting(Account::getUserId, Account::getCash) + .containsExactly(userId, cash); + } + +} \ No newline at end of file diff --git a/src/test/resources/com/cleanengine/coin/order/adapter/out/AssetRepository/createBTC.sql b/src/test/resources/com/cleanengine/coin/order/adapter/out/AssetRepository/createBTC.sql new file mode 100644 index 00000000..eb25ca81 --- /dev/null +++ b/src/test/resources/com/cleanengine/coin/order/adapter/out/AssetRepository/createBTC.sql @@ -0,0 +1 @@ +INSERT INTO asset(ticker, name) VALUES ('BTC', '비트코인'); \ No newline at end of file diff --git a/src/test/resources/db/mariadb/data/data.sql b/src/test/resources/db/mariadb/data/data.sql new file mode 100644 index 00000000..75045f86 --- /dev/null +++ b/src/test/resources/db/mariadb/data/data.sql @@ -0,0 +1,15 @@ +INSERT INTO `if`.users (user_id, created_at) +VALUES (1, '2025-05-16 09:30:00.000000'), + (2, '2025-05-16 09:30:00.000000'); + +INSERT INTO `if`.account (account_id, cash, user_id) +VALUES (1, 0, 1), + (2, 500000000, 2); + +INSERT INTO `if`.asset (ticker, name) +VALUES ('BTC', '비트코인'), + ('TRUMP', '오피셜트럼프'); + +INSERT INTO `if`.wallet (wallet_id, account_id, buy_price, roi, size, ticker) +VALUES (1, 1, 0, 0, 500000000, 'BTC'), + (2, 1, 0, 0, 500000000, 'TRUMP'); diff --git a/src/test/resources/db/mariadb/data/delete.sql b/src/test/resources/db/mariadb/data/delete.sql new file mode 100644 index 00000000..338ae0de --- /dev/null +++ b/src/test/resources/db/mariadb/data/delete.sql @@ -0,0 +1,7 @@ +TRUNCATE TABLE `if`.users; +TRUNCATE TABLE `if`.account; +TRUNCATE TABLE `if`.asset; +TRUNCATE TABLE `if`.wallet; +TRUNCATE TABLE `if`.buy_orders; +TRUNCATE TABLE `if`.sell_orders; +TRUNCATE TABLE `if`.trade; \ No newline at end of file diff --git a/src/test/resources/db/mariadb/schema/init.sql b/src/test/resources/db/mariadb/schema/init.sql new file mode 100644 index 00000000..60bfc8fd --- /dev/null +++ b/src/test/resources/db/mariadb/schema/init.sql @@ -0,0 +1,92 @@ +create table account +( + account_id int auto_increment + primary key, + cash double not null, + user_id int not null +); + +create table asset +( + ticker varchar(10) not null + primary key, + name varchar(100) null, + icon blob null +); + +create table buy_orders +( + buy_order_id bigint auto_increment + primary key, + created_at datetime(6) not null, + is_bot bit not null, + is_marketorder bit not null, + order_size double null, + price double null, + remaining_size double null, + state enum ('CANCELED', 'DONE', 'WAIT') not null, + ticker varchar(10) not null, + user_id int not null, + locked_deposit double not null, + remaining_deposit double not null +); + +create table oauth +( + oauth_id int auto_increment + primary key, + access_token text null, + email varchar(255) null, + expires_at datetime(6) null, + nickname varchar(255) null, + provider varchar(20) not null, + provider_user_id varchar(255) not null, + refresh_token text null, + scope varchar(255) null, + user_id int not null +); + +create table sell_orders +( + sell_order_id bigint auto_increment + primary key, + created_at datetime(6) not null, + is_bot bit not null, + is_marketorder bit not null, + order_size double null, + price double null, + remaining_size double null, + state enum ('CANCELED', 'DONE', 'WAIT') not null, + ticker varchar(10) not null, + user_id int not null +); + +create table trade +( + trade_id int auto_increment + primary key, + buy_user_id int not null, + price double not null, + sell_user_id int not null, + size double not null, + ticker varchar(255) not null, + trade_time datetime(6) not null +); + +create table users +( + user_id int auto_increment + primary key, + created_at datetime(6) not null +); + +create table wallet +( + wallet_id bigint auto_increment + primary key, + account_id int not null, + buy_price double null, + roi double null, + size double not null, + ticker varchar(10) not null +);