diff --git a/build.gradle b/build.gradle index f3e70cbb..ecfc5b0a 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ repositories { mavenCentral() } + jacoco { //추가함 toolVersion = "0.8.13" } 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 1753e944..8fa1e380 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/ApiScheduler.java @@ -3,14 +3,13 @@ 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.domain.APIVWAPState; import com.cleanengine.coin.realitybot.dto.Ticks; -import com.cleanengine.coin.realitybot.service.ApiVWAPService; +import com.cleanengine.coin.realitybot.parser.TickParser; import com.cleanengine.coin.realitybot.service.OrderGenerateService; -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.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import java.util.List; @@ -29,38 +28,40 @@ public class ApiScheduler { private final TickServiceManager tickServiceManager; private final Map lastSequentialIdMap = new ConcurrentHashMap<>(); private final AssetRepository assetRepository; + private final CoinoneAPIClient coinoneAPIClient; private String ticker; - @Scheduled(fixedRate = 5000) +// @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); +// 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로 변환 +// String rawJson = getMarketDataWithFallback(ticker); + List gson = tickParser.parseGson(rawJson); //json을 list로 변환 - ApiVWAPService apiVWAPService = tickServiceManager.getService(ticker); + APIVWAPState apiVWAPState = 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() > lastSeqId){ //중복 검증용 - apiVWAPService.addTick(ticks); + apiVWAPState.addTick(ticks); lastSeqId = Math.max(lastSeqId, ticks.getSequential_id()); //중복 id 갱신 } } lastSequentialIdMap.put(ticker,lastSeqId); - double vwap = apiVWAPService.getVWAP(); - double volume = apiVWAPService.getAvgVolumePerOrder(); + double vwap = apiVWAPState.getVWAP(); + double volume = apiVWAPState.getAvgVolumePerOrder(); orderGenerateService.generateOrder(ticker,vwap,volume); //1tick 당 매수/매도 3개씩 제작 // log.info("작동확인 {}의 가격 : {} , 볼륨 : {}",ticker, vwap, volume); } @@ -73,6 +74,23 @@ public void destroy() throws Exception { //담긴 Queue데이터 확인용 // orderQueueManagerService.logAllOrders(); // virtualTradeService.printOrderSummary(); }*/ +/*public String getMarketDataWithFallback(String ticker) { + try { +// String bithumbJson = bithumbAPIClient.get(ticker); + String bithumbJson = null; + // 예외가 없었어도 비정상 응답일 수 있음 → 예: 빈 JSON 또는 에러 코드 + if (bithumbJson == null || bithumbJson.isBlank() || bithumbJson.contains("\"result\":\"error\"")) { + log.warn("Bithumb 응답 비정상, Coinone으로 대체 요청"); + return coinoneAPIClient.get(ticker); + } + + return bithumbJson; + + } catch (Exception e) { + log.error("Bithumb API 오류 발생: {} → Coinone으로 대체 요청", e.getMessage()); + return coinoneAPIClient.get(ticker); + } +}*/ } 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 a0ee5f31..653a08c5 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java +++ b/src/main/java/com/cleanengine/coin/realitybot/api/BithumbAPIClient.java @@ -4,9 +4,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import okhttp3.OkHttpClient; -import okhttp3.*; +import okhttp3.Request; +import okhttp3.Response; import org.springframework.stereotype.Component; -import com.google.gson.Gson; import java.io.IOException; @@ -14,21 +14,31 @@ @RequiredArgsConstructor @Slf4j public class BithumbAPIClient { - private OkHttpClient client; - private Gson gson; + private final OkHttpClient client; private String ticker; public String get(String ticker){ //API를 responseBody에 담아 반환 this.ticker = ticker; - client = new OkHttpClient(); - gson = new Gson(); +// client = new OkHttpClient(); +// gson = new Gson(); + +// try { +// Thread.sleep(2500); +// } catch (InterruptedException e) { +// Thread.currentThread().interrupt(); +// } + + Request request = new Request.Builder() .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()){ + if ((response.code() == 400)){ + log.warn("DB asset 최신화가 필요합니다 : {}",ticker); + } String responseBody = response.body().string(); // return gson.toJson(response.body().string()); log.debug("{}의 Bithumb API 응답 : {}",ticker,responseBody); @@ -37,5 +47,24 @@ public String get(String ticker){ //API를 responseBody에 담아 반환 throw new RuntimeException(e); } } + public String getOpeningPrice(String ticker){ + this.ticker = ticker; + Request request = new Request.Builder() + .url("https://api.bithumb.com/v1/ticker?markets=KRW-"+ticker) + .get() + .addHeader("accept", "application/json") + .build(); + try (Response response = client.newCall(request).execute()){ + if ((response.code() == 400)){ + log.warn("DB asset 최신화가 필요합니다 : {}",ticker); + } + String responseBody = response.body().string(); +// return gson.toJson(response.body().string()); + log.debug("{}의 OpeningPirce 응답 : {}",ticker,responseBody); + return responseBody; + } catch (IOException e) { + throw new RuntimeException("API 요청 중 예외 발생",e); + } + } } diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/CoinoneAPIClient.java b/src/main/java/com/cleanengine/coin/realitybot/api/CoinoneAPIClient.java new file mode 100644 index 00000000..29cf255a --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/api/CoinoneAPIClient.java @@ -0,0 +1,60 @@ +package com.cleanengine.coin.realitybot.api; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CoinoneAPIClient { + private final OkHttpClient client; + private String ticker; + + + public String get(String ticker){ //API를 responseBody에 담아 반환 + this.ticker = ticker; + Request request = new Request.Builder() + .url("https://api.coinone.co.kr/public/v2/trades/KRW/"+ticker+"?size=10") + .get() + .addHeader("accept", "application/json") + .build(); + try (Response response = client.newCall(request).execute()){ + if ((response.code() == 400)){ + log.warn("잘못된 ticker를 입력하였습니다. 입력된 ticker : {}",ticker); + } + String responseBody = response.body().string(); +// return gson.toJson(response.body().string()); + log.info("{}의 Bithumb API 응답 : {}",ticker,responseBody); + return responseBody; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +/* public String getOpeningPrice(String ticker){ + this.ticker = ticker; + Request request = new Request.Builder() + .url("https://api.bithumb.com/v1/ticker?markets=KRW-"+ticker) + .get() + .addHeader("accept", "application/json") + .build(); + try (Response response = client.newCall(request).execute()){ + if ((response.code() == 400)){ + log.warn("잘못된 ticker를 입력하였습니다. 입력된 ticker : {}",ticker); + } + String responseBody = response.body().string(); +// return gson.toJson(response.body().string()); + log.debug("{}의 OpeningPirce 응답 : {}",ticker,responseBody); + return responseBody; + } catch (IOException e) { + throw new RuntimeException("API 요청 중 예외 발생",e); + } + }*/ + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java b/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java new file mode 100644 index 00000000..db3f2c8f --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresher.java @@ -0,0 +1,64 @@ +package com.cleanengine.coin.realitybot.api; + +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; +import com.cleanengine.coin.realitybot.dto.OpeningPrice; +import com.cleanengine.coin.realitybot.parser.OpeningPriceParser; +import com.cleanengine.coin.realitybot.vo.UnitPricePolicy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UnitPriceRefresher implements ApplicationRunner { + private final UnitPricePolicy unitPricePolicy; + private final AssetRepository assetRepository; + private final BithumbAPIClient bithumbAPIClient; + private final OpeningPriceParser openingPriceParser; + private final Map unitPriceCache = new ConcurrentHashMap<>(); + + @Override + public void run(ApplicationArguments args){ + log.info("Running Unit Price Refresher..."); + initializeUnitPrices(); + } + + public void initializeUnitPrices() { + List tickers = assetRepository.findAll(); + for (Asset ticker : tickers){ + double unitPrice = fetchOpeningPriceFromAPI(ticker.getTicker()); + unitPriceCache.put(ticker.getTicker(),unitPrice); + } + } + + @Scheduled(cron = "${bot-handler.corn}") + public void refreshUnitPrices() { + initializeUnitPrices(); +// List tickers = assetRepository.findAll(); +// for (Asset ticker : tickers){ +// double unitPrice = fetchOpeningPriceFromAPI(ticker.getTicker()); +// unitPriceCache.put(ticker.getTicker(),unitPrice); +// } + + } + + private double fetchOpeningPriceFromAPI(String ticker) { + String rawJson = bithumbAPIClient.getOpeningPrice(ticker); //api raw데이터 + OpeningPrice json = openingPriceParser.parseGson(rawJson); //json을 list로 변환 + double unitprice = unitPricePolicy.getUnitPrice(json.getOpening_price()); + return unitprice; + } + + public double getUnitPriceByTicker(String ticker){ + return unitPriceCache.get(ticker); + } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/config/ApiClientConfig.java b/src/main/java/com/cleanengine/coin/realitybot/config/ApiClientConfig.java index fcbae119..8574944a 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/config/ApiClientConfig.java +++ b/src/main/java/com/cleanengine/coin/realitybot/config/ApiClientConfig.java @@ -16,9 +16,4 @@ public OkHttpClient okHttpClient() { // .addInterceptor() .build(); } - - @Bean - public Queue ticksQueue(){ //공통화 시킴 - return new LinkedList<>(); - } } diff --git a/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java b/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java new file mode 100644 index 00000000..c26118a1 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/config/SchedulerConfig.java @@ -0,0 +1,40 @@ +package com.cleanengine.coin.realitybot.config; + +import com.cleanengine.coin.realitybot.api.ApiScheduler; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import java.time.Duration; + +@Configuration +@EnableScheduling +//@RequiredArgsConstructor +public class SchedulerConfig implements SchedulingConfigurer { + + //멀티쓰레드 환경 x +// @Autowired +// private TaskScheduler apiScheduler; + private final ApiScheduler apiScheduler; + @Value("${bot-handler.fixed-rate}") + private final Duration fixedRate; + + protected SchedulerConfig(ApiScheduler apiScheduler, @Value("${bot-handler.fixed-rate}") Duration fixedRate) { + this.apiScheduler = apiScheduler; + this.fixedRate = fixedRate; + } + + @Override + public void configureTasks(ScheduledTaskRegistrar registrar) { +// registrar.setScheduler(apiScheduler); //멀티 쓰레드 x + registrar.addFixedRateTask(() -> { + try { + apiScheduler.MarketAllRequest(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + }, fixedRate); + } +} 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 6de9a720..2aa87c81 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java +++ b/src/main/java/com/cleanengine/coin/realitybot/controller/ApiController.java @@ -1,20 +1,19 @@ package com.cleanengine.coin.realitybot.controller; -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.RestController; @RestController @RequestMapping("/api") public class ApiController { - private final BithumbAPIClient bithumbAPIClient; - public ApiController(BithumbAPIClient bithumbAPIClient) { - this.bithumbAPIClient = bithumbAPIClient; - } - @GetMapping("/test") - public String getApiData(){ -// return bithumbAPIClient.get(ticekr); - return null; - - }} + //초기 api 작동 확인용 -> realitybot controller로 전환 필요 +// private final BithumbAPIClient bithumbAPIClient; +// public ApiController(BithumbAPIClient bithumbAPIClient) { +// this.bithumbAPIClient = bithumbAPIClient; +// } +// @GetMapping("/test/{tickerName}") +// public String getApiData(@PathVariable String tickerName) { +// System.out.println("tickername 출력"+tickerName); +// return bithumbAPIClient.get(tickerName); +// } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java b/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java new file mode 100644 index 00000000..fa22e48b --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/domain/APIVWAPState.java @@ -0,0 +1,43 @@ +package com.cleanengine.coin.realitybot.domain; + + +import com.cleanengine.coin.realitybot.dto.Ticks; +import lombok.Getter; + +import java.util.LinkedList; +import java.util.Queue; + +@Getter +public class APIVWAPState { + private final Queue ticksQueue = new LinkedList<>(); + private VWAPCalculator calculator = new VWAPCalculator(); + private int maxQueueSize = 10; + + public void addTick(Ticks tick){ + if (ticksQueue.size() >= maxQueueSize) { + //10개 이상이 되면 선착순으로 제거해나감 + Ticks removed = ticksQueue.poll(); + calculator.removeTrade(removed.getTrade_price(), removed.getTrade_volume()); + } + //초기엔 들어온 갯수에 따라 증가시켜서 계산함 + ticksQueue.add(tick); + calculator.recordTrade(tick.getTrade_price(),tick.getTrade_volume()); + //갯수 만큼 계산하기 때문에 정상 작동 +// calculator.getVWAP(); + } + + + //n초마다 5회 주문 , api 체결 내역에서 10종목씩 비교 + public double getAvgVolumePerOrder() { + return calculator.getTotalVolume() / 50; + }//todo 에러 인젝션으로 50일때와 5일때 복귀 속도 알아보기 + + public double getVWAP(){ + return calculator.getVWAP(); + } + + public int getTickSize() { + return ticksQueue.size(); + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPState.java b/src/main/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPState.java new file mode 100644 index 00000000..b9deb3b4 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPState.java @@ -0,0 +1,33 @@ +package com.cleanengine.coin.realitybot.domain; + +import com.cleanengine.coin.trade.entity.Trade; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@Getter +@Setter +@Slf4j +public class PlatformVWAPState { + + public PlatformVWAPState(String ticker) { + this.ticker = ticker; + } + + private String ticker; + private final VWAPCalculator calculator = new VWAPCalculator(); +// private final Queue tradeQueue = new LinkedList<>(); //테스트를 위한 큐 -> 체결 db에서 데이터 조회 +// private int maxQueueSize = 10; + + public void addTrades(List trades) { + for (Trade trade : trades) { + double price = trade.getPrice(); + double volume = trade.getSize(); + calculator.recordTrade(price,volume); + } + } + public double getVWAP(){ + return calculator.getVWAP(); + }} diff --git a/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPCalculator.java b/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPCalculator.java new file mode 100644 index 00000000..9afb29e7 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/domain/VWAPCalculator.java @@ -0,0 +1,25 @@ +package com.cleanengine.coin.realitybot.domain; + + +import lombok.Getter; + +@Getter +public class VWAPCalculator { + private double totalPriceVolume; + private double totalVolume; + + public void recordTrade(double price, double volume) { + totalPriceVolume += price * volume; + totalVolume += volume; + } + + public void removeTrade(double price, double volume) { + totalPriceVolume -= price * volume; + totalVolume -= volume; + } + + public double getVWAP() { + return (totalVolume == 0) ? 0.0 : totalPriceVolume / totalVolume; + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/dto/CoinoneTicksResponse.java b/src/main/java/com/cleanengine/coin/realitybot/dto/CoinoneTicksResponse.java new file mode 100644 index 00000000..aa345a53 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/dto/CoinoneTicksResponse.java @@ -0,0 +1,29 @@ +package com.cleanengine.coin.realitybot.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class CoinoneTicksResponse { + private String result; + private String error_code; + private long server_time; + private String quote_currency; + private String target_currency; + private List trades; + + @Getter + @Setter + public static class Trades { + private String id; + private long timestamp; + private String price; + private String qty; + private boolean is_seller_maker; + } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/dto/OpeningPrice.java b/src/main/java/com/cleanengine/coin/realitybot/dto/OpeningPrice.java new file mode 100644 index 00000000..c14313e1 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/dto/OpeningPrice.java @@ -0,0 +1,23 @@ +package com.cleanengine.coin.realitybot.dto; + +import lombok.*; + +@Getter +@Setter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OpeningPrice { + private String market; + private double opening_price; + private double trade_price; + + @Override + public String toString() { + return "OpeningPrice{" + + "market='" + market + '\'' + + ", OpeningPrice=" + opening_price + + ", tradePrice=" + trade_price + + '}'; + } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/dto/TestOrder.java b/src/main/java/com/cleanengine/coin/realitybot/dto/TestOrder.java deleted file mode 100644 index 3c1bda4a..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/dto/TestOrder.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.cleanengine.coin.realitybot.dto; - -import lombok.*; - -@AllArgsConstructor -@Data -@Builder -@Getter -@Setter -public class TestOrder { - public enum Type{BUY,SELL} - private final Type type; - private double price; - private double volume; - private long timestamp; - - - @Override - public String toString() { - return "TestOrder{" + - "type=" + type + - ", price=" + price + - ", volum=" + volume + - ", timestamp=" + timestamp + - '}'; - } -} diff --git a/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java b/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java index aaa0f3fb..9513aab6 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java +++ b/src/main/java/com/cleanengine/coin/realitybot/dto/Ticks.java @@ -2,10 +2,6 @@ import lombok.*; -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalTime; - @Getter @Setter @Builder diff --git a/src/main/java/com/cleanengine/coin/realitybot/parser/CoinoneTicksAdapter.java b/src/main/java/com/cleanengine/coin/realitybot/parser/CoinoneTicksAdapter.java new file mode 100644 index 00000000..47598a77 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/parser/CoinoneTicksAdapter.java @@ -0,0 +1,23 @@ +package com.cleanengine.coin.realitybot.parser; + +import com.cleanengine.coin.realitybot.dto.CoinoneTicksResponse; +import com.cleanengine.coin.realitybot.dto.Ticks; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class CoinoneTicksAdapter { + public List convertToTicks(CoinoneTicksResponse response, String market){ + return response.getTrades().stream() + .map(tx -> Ticks.builder() + .market(market) + .timestamp(String.valueOf(tx.getTimestamp())) + .trade_price(Float.parseFloat(tx.getPrice())) + .trade_volume(Double.parseDouble(tx.getQty())) + .ask_bid(tx.is_seller_maker() ? "ASK" : "BID") + .sequential_id(Long.parseLong(tx.getId())) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParser.java b/src/main/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParser.java new file mode 100644 index 00000000..5c4328a4 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParser.java @@ -0,0 +1,26 @@ +package com.cleanengine.coin.realitybot.parser; + +import com.cleanengine.coin.realitybot.dto.OpeningPrice; +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 OpeningPriceParser { + private final Gson gson = new Gson(); + + public OpeningPrice parseGson(String json) { + List list = gson.fromJson(json, new TypeToken>() {}.getType()); + return list.get(0); + } + + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java b/src/main/java/com/cleanengine/coin/realitybot/parser/TickParser.java similarity index 55% rename from src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java rename to src/main/java/com/cleanengine/coin/realitybot/parser/TickParser.java index c82f6e84..30289ea9 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/TickParser.java +++ b/src/main/java/com/cleanengine/coin/realitybot/parser/TickParser.java @@ -1,5 +1,6 @@ -package com.cleanengine.coin.realitybot.service; +package com.cleanengine.coin.realitybot.parser; +import com.cleanengine.coin.realitybot.dto.CoinoneTicksResponse; import com.cleanengine.coin.realitybot.dto.Ticks; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; @@ -15,9 +16,15 @@ @Slf4j @Getter public class TickParser { - private static final Gson gson = new Gson(); + private final Gson gson = new Gson(); +// private final CoinoneTicksAdapter coinoneAdapter; +// private final TickParser tickParser; - public static List parseGson(String json) { + public List parseGson(String json) { +// if (exchange.equalsIgnoreCase("coinone") || json.contains("transactions")) { +// CoinoneTicksResponse response = gson.fromJson(json, CoinoneTicksResponse.class); +// return coinoneAdapter.convertToTicks(response, "KRW-" + ticker.toUpperCase()); +// } else return gson.fromJson(json, new TypeToken>() {}.getType()); } /* diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java b/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java deleted file mode 100644 index e1a9b0b3..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/ApiVWAPService.java +++ /dev/null @@ -1,47 +0,0 @@ -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 849d769b..4d2c878a 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/OrderGenerateService.java @@ -1,7 +1,10 @@ package com.cleanengine.coin.realitybot.service; -import com.cleanengine.coin.common.error.DomainValidationException; import com.cleanengine.coin.order.application.OrderService; +import com.cleanengine.coin.realitybot.api.UnitPriceRefresher; +import com.cleanengine.coin.realitybot.vo.DeviationPricePolicy; +import com.cleanengine.coin.realitybot.vo.OrderPricePolicy; +import com.cleanengine.coin.realitybot.vo.OrderVolumePolicy; import com.cleanengine.coin.order.adapter.out.persistentce.account.OrderAccountRepository; import com.cleanengine.coin.order.adapter.out.persistentce.wallet.OrderWalletRepository; import com.cleanengine.coin.trade.entity.Trade; @@ -10,6 +13,7 @@ import com.cleanengine.coin.user.domain.Wallet; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; @@ -25,12 +29,17 @@ @Order(5) @RequiredArgsConstructor public class OrderGenerateService { - private final int[] orderLevels = {1,2,3}; - private final int unitPrice = 10; //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 + @Value("${bot-handler.order-level}") + private int[] orderLevels; //체결 강도 + private double unitPrice = 0; //TODO : 거래쌍 시세에 따른 호가 정책 개발 필요 + private final UnitPriceRefresher unitPriceRefresher; private final PlatformVWAPService platformVWAPService; private final OrderService orderService; private final TradeRepository tradeRepository; private final VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; + private final OrderPricePolicy orderPricePolicy; + private final DeviationPricePolicy deviationPricePolicy; + private final OrderVolumePolicy orderVolumePolicy; private final OrderWalletRepository orderWalletRepository; private final OrderAccountRepository accountExternalRepository; private String ticker; @@ -39,6 +48,9 @@ public class OrderGenerateService { public void generateOrder(String ticker, double apiVWAP, double avgVolume) {//기준 주문금액, 주문량 받기 (tick당 계산되어 들어옴) this.ticker = ticker; + //호가 정책 적용 + this.unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); + //최근 체결 내역 가져오기 List trades = tradeRepository.findTop10ByTickerOrderByTradeTimeDesc(ticker); @@ -48,90 +60,19 @@ public void generateOrder(String ticker, double apiVWAP, double avgVolume) {// //편차 계산 (vwap 기준) double trendLineRate = (platformVWAP - apiVWAP)/ apiVWAP; - //편차가 +-1% 이상 발생하면 true 반환 - boolean isWithinRange = Math.abs(trendLineRate) <= 0.001; //TODO 호가 단위에 따른 편차 보정 필요 for(int level : orderLevels) { //1주문당 3회 매수매도 처리 - 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; - - //1단계 밀집 주문 - if (level == 1){ //1level일 경우 주문이 겹치도록 설정 - double basePrice = normalizeToUnit(platformVWAP); //기준 가격 (호가 단위 정규화) - //체결을 위해 매수가 올리고, 매도가 내리는 계산 적용 - sellPrice = normalizeToUnit(basePrice - randomOffset); - buyPrice = normalizeToUnit(basePrice + randomOffset); - } - //2~3 단계 : orderbook 단위 주문 - else { - randomOffset = level1TradeMaker(platformVWAP,0.01); - //체결 확률 증가용 코드 - sellPrice = normalizeToUnit(platformVWAP + priceOffset - randomOffset); - buyPrice = normalizeToUnit(platformVWAP - priceOffset + randomOffset); - //안정적인 스프레드 유지 -// sellPrice = normalizeToUnit(platformVWAP + priceOffset); -// buyPrice = normalizeToUnit(platformVWAP - priceOffset); - } + OrderPricePolicy.OrderPrice basePrice = orderPricePolicy.calculatePrice(level,platformVWAP,unitPrice,trendLineRate); + DeviationPricePolicy.AdjustPrice adjustPrice = deviationPricePolicy.adjust( + basePrice.sell(), basePrice.buy(), trendLineRate, apiVWAP, unitPrice); - //주문 실행 - double sellVolume = getRandomVolum(avgVolume); - double buyVolume = getRandomVolum(avgVolume); - - if (platformVWAP != 0){ - if (isWithinRange){ - if (trendLineRate > 0){ - sellVolume *=1.5; - buyVolume *= 0.7; - } else { - sellVolume *=0.7; - buyVolume *= 1.5; - } - } - double correctionRate = 0.1; - if (trendLineRate < -0.01) { // platformVWAP이 너무 낮음 - 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)); // 매수 싸게 - //platform vwap -> vwap으로 변환 - } - - - // 편차에 따라 강도 조절 - if (deviation > 0.01) { - double power = trendLineRate * 100; // 3% → 3 - if (trendLineRate < 0) { - 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 { - 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(ticker,false, sellVolume,sellPrice); - createOrderWithFallback(ticker,true, buyVolume,buyPrice); - -// queueManager.addSellOrder(sellPrice, sellVolume); -// queueManager.addBuyOrder(buyPrice, buyVolume); //Queue 추가 - } else { - - //스위치 시켜야 할까? - createOrderWithFallback(ticker,false, sellVolume,sellPrice); - createOrderWithFallback(ticker,true, buyVolume,buyPrice); - -// queueManager.addSellOrder(sellPrice, sellVolume); -// queueManager.addBuyOrder(buyPrice, buyVolume); + double sellVolume = orderVolumePolicy.calculateVolume(avgVolume,trendLineRate,false); + double buyVolume = orderVolumePolicy.calculateVolume(avgVolume,trendLineRate,true); + double sellPrice = adjustPrice.sell(); + double buyPrice = adjustPrice.buy(); - } + createOrderWithFallback(ticker,false, sellVolume, sellPrice); + createOrderWithFallback(ticker,true, buyVolume, buyPrice); try { @@ -141,36 +82,37 @@ public void generateOrder(String ticker, double apiVWAP, double avgVolume) {// } // vwaPerrorInJectionScheduler.enableInjection(); //에러 발생기 비활성화 - /* //모니터링용 - System.out.println("sellPrice = " + sellPrice); - System.out.println("sellVolume = " + sellVolume); + /* DecimalFormat df = new DecimalFormat("#,##0.00"); + DecimalFormat dfv = new DecimalFormat("#,###.########"); //모니터링용 - System.out.println("buyPrice = " + buyPrice); - System.out.println("buyVolume = " + buyVolume); + System.out.println("sellPrice = " + df.format(sellPrice)); + System.out.println("sellVolume = " + dfv.format(sellVolume)); + //모니터링용 + System.out.println("buyPrice = " + df.format(buyPrice)); + System.out.println("buyVolume = " + dfv.format(buyVolume)); System.out.println("===================================="); - DecimalFormat df = new DecimalFormat("#,##0.00"); - System.out.println(ticker+"의 현재 시장 vwap :"+df.format(apiVWAP)+" | 현재 플랫폼 vwap :"+df.format(platformVWAP));*/ - + System.out.println(ticker+"의 현재 시장 vwap :"+df.format(apiVWAP)+" | 현재 플랫폼 vwap :"+df.format(platformVWAP)); +*/ } - /* System.out.println("📦"+ticker+" [체결 기록 Top 10]"); + /*System.out.println("📦"+ticker+" [체결 기록 Top 10]"); trades.forEach(t -> System.out.printf("🕒 %s | 가격: %.0f | 수량: %.8f | 매수: #%d ↔ 매도: #%d%n", t.getTradeTime(), t.getPrice(), t.getSize(), t.getBuyUserId(), t.getSellUserId()) );*/ } - private void createOrderWithFallback(String ticker,boolean isBuy, double volume, double price ) { + private void createOrderWithFallback(String ticker,boolean isBuy, double volume, double price ) throws IllegalArgumentException { 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) { + } catch (IllegalArgumentException e) { log.debug("잔량 부족: {}", e.getMessage()); try { resetBot(ticker); @@ -198,32 +140,4 @@ protected void resetBot(String ticker){ accountExternalRepository.save(account2); } - //==================================order 정규화용 ============================================ - - private double level1TradeMaker(double platformVWAP, double maxRate){ - //시장가에 해당하는 호가는 거래 체결 강하게 하기 위함 - double percent = (Math.random() * 2-1)*maxRate; - 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; - } - private double getRandomVolum(double avgVolum){ //볼륨 랜덤 입력 - double rawVolume = avgVolum * (0.5+Math.random()); - //호가 단위에 따라 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 deleted file mode 100644 index f737db01..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/OrderQueueManagerService.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.cleanengine.coin.realitybot.service; - -import com.cleanengine.coin.realitybot.dto.TestOrder; -import lombok.Getter; - -import java.util.Comparator; -import java.util.PriorityQueue; - -@Getter -//@Component -public class OrderQueueManagerService { - /*해당 코드는 초기 개별 모듈로 작업할 때 가상의 체결을 만드는 코드였습니다. - * 이젠 쓰이지 않는 코드이나 어떤 에러가 발생할 때 재사용하기 위한 용도로 삭제하지 않았습니다. - * */ - - //체결용 출력 - private final PriorityQueue buyqueue = new PriorityQueue<>(new Comparator() { - @Override - public int compare(TestOrder o1, TestOrder o2) { - return Double.compare(o2.getPrice(), o1.getPrice());//가격이 높은 순 - } - }); - private final PriorityQueue sellqueue = new PriorityQueue<>(new Comparator() { - @Override - public int compare(TestOrder o1, TestOrder o2) { - return Double.compare(o1.getPrice(), o2.getPrice());//가격이 낮은 순 - } - }); - - //generator로부터 입력 - public void addBuyOrder(double price, double volume){ - buyqueue.offer(new TestOrder(TestOrder.Type.BUY,price,volume,System.currentTimeMillis())); - } - public void addSellOrder(double price, double volume){ - sellqueue.offer(new TestOrder(TestOrder.Type.SELL,price,volume,System.currentTimeMillis())); - } - - //큐 로그 확인용 - public void logAllOrders(){ - System.out.println("== BUY QUEUE =="); - buyqueue.forEach(System.out::println); - System.out.println("== SELL QUEUE =="); - sellqueue.forEach(System.out::println); - } - -} 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 d3b0821f..b6544433 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/PlatformVWAPService.java @@ -1,6 +1,6 @@ package com.cleanengine.coin.realitybot.service; -import com.cleanengine.coin.realitybot.vo.VWAPState; +import com.cleanengine.coin.realitybot.domain.PlatformVWAPState; import com.cleanengine.coin.trade.entity.Trade; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -12,15 +12,15 @@ @Service @Slf4j public class PlatformVWAPService {//TODO 가상 시장 조회용 사라질 예정임 - Map vwapMap = new ConcurrentHashMap<>(); + Map vwapMap = new ConcurrentHashMap<>(); public double calculateVWAPbyTrades(String ticker,List trades,double apiVWAP) { - VWAPState state = vwapMap.computeIfAbsent(ticker, VWAPState::new); + PlatformVWAPState state = vwapMap.computeIfAbsent(ticker, PlatformVWAPState::new); if (trades.size() < 10){ //체결 내역이 10개 이하일 경우 자체 계산 return generateVWAP(apiVWAP); } - state.calculateVWAPbyTrades(trades); + state.addTrades(trades); return state.getVWAP(); } diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java b/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java index a49ab9c6..d65f5b3d 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/TickServiceManager.java @@ -1,5 +1,6 @@ package com.cleanengine.coin.realitybot.service; +import com.cleanengine.coin.realitybot.domain.APIVWAPState; import org.springframework.stereotype.Service; import java.util.Map; @@ -11,8 +12,8 @@ public class TickServiceManager { * 초기엔 전역에서 vwap을 계산하거나 sequentialid를 변수에 담았으나 인스턴스가 종목별로 생성되어야 해서 작성되었습니다. * ConcurrentHashMap을 통해 중복 검사 후 종목명으로 만들어진 게 없다면 새로 만듭니다. * */ - private final Map tickServiceMap = new ConcurrentHashMap<>(); - public ApiVWAPService getService(String ticker) { - return tickServiceMap.computeIfAbsent(ticker, t -> new ApiVWAPService()); + private final Map tickServiceMap = new ConcurrentHashMap<>(); + public APIVWAPState getService(String ticker) { + return tickServiceMap.computeIfAbsent(ticker, t -> new APIVWAPState()); } } 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 289144e5..059e24d1 100644 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java +++ b/src/main/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionScheduler.java @@ -25,7 +25,7 @@ public void enableInjection() { this.shouldInject = true; } - @Scheduled(fixedRate = 60000) // 혹은 따로 수동 호출도 가능 + @Scheduled(fixedRate = 30000) // 혹은 따로 수동 호출도 가능 public void injectFakeTrade() { if (!shouldInject || hasInjected) return; diff --git a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java b/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java deleted file mode 100644 index a32983d5..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/service/VirtualTradeService.java +++ /dev/null @@ -1,118 +0,0 @@ -package com.cleanengine.coin.realitybot.service; - -import com.cleanengine.coin.realitybot.dto.TestOrder; - -import java.util.*; - -//@Service -public class VirtualTradeService { - private final OrderQueueManagerService queueManager; - private final PlatformVWAPService platformVWAPService; - - public VirtualTradeService(OrderQueueManagerService queueManager, PlatformVWAPService platformVWAPService) { - this.platformVWAPService = platformVWAPService; - this.queueManager = queueManager; - } - /*해당 코드는 초기 개별 모듈로 작업할 때 가상의 체결을 만드는 코드였습니다. - * 이젠 쓰이지 않는 코드이나 어떤 에러가 발생할 때 재사용하기 위한 용도로 삭제하지 않았습니다. - * */ - - //가상 주문 매칭 및 체결 처리를 담당하는 서비스 - public void matchOrder(){ - //매수, 매도 주문 큐 관리 - PriorityQueue buyQueue = queueManager.getBuyqueue(); - PriorityQueue sellQueue = queueManager.getSellqueue(); - - while(!buyQueue.isEmpty() && !sellQueue.isEmpty()){ - //주문 추출 - TestOrder buyOrder = buyQueue.peek(); //가장 높은 매수 주문 - TestOrder sellOrder = sellQueue.peek(); // 가장 낮은 매도 주문 - - //체결 조건 부여 : 현재 느슨한 체결 (1:1은 문제 발생/어짜피 매서드 호출 힘너무 쓰면 안됨) - //매수 희망가 >= 매도 희망가 - if ((long)buyOrder.getPrice() == (long)sellOrder.getPrice()){ //매도벽이 크게 세워짐.. - - //체결 가격을 중간값으로 설정 -// double matchedPrice = (buyOrder.getPrice() + sellOrder.getPrice())/2; // 느슨한 체결 조건 쓰니깐 문제 발생 - - //현재 매도가 기준 - double matchedPrice = sellOrder.getPrice(); - double matchedVolume = Math.min(buyOrder.getVolume(), sellOrder.getVolume()); //적은쪽으로 물량 설정 -// System.out.println("=== 체결 진행 - 가격 :"+matchedPrice+", 수량 : "+matchedVolume); - - //잔량 처리 - buyOrder.setVolume(buyOrder.getVolume() - matchedVolume); - sellOrder.setVolume(sellOrder.getVolume() - matchedVolume); - - //잔량 0 이하 주문 제거 - if (buyOrder.getVolume() <= 0) buyQueue.poll(); - if (sellOrder.getVolume() <= 0) sellQueue.poll(); - -// platformVWAPService.recordTrade(matchedPrice,matchedVolume); - } - else { - break; - } - - } - } - public void matchOrderbyIterator(){ - //queue 를 list로 변환 - List buyOrders = new ArrayList<>(queueManager.getBuyqueue()); - List sellOrders = new ArrayList<>(queueManager.getSellqueue()); - - // - buyOrders.sort(Comparator.comparing(TestOrder::getPrice).reversed()); - sellOrders.sort(Comparator.comparing(TestOrder::getPrice)); - - List excutedBuy = new ArrayList<>(); - List excutedSell = new ArrayList<>(); - - for (TestOrder buyOrder : buyOrders){ - for (TestOrder sellOrder : sellOrders){ - if ((int)buyOrder.getPrice() >= (int)sellOrder.getPrice()){ - double matchVolume = Math.min(buyOrder.getVolume(), sellOrder.getVolume()); - if (matchVolume <=0) continue; - buyOrder.setVolume(buyOrder.getVolume() - matchVolume); - sellOrder.setVolume(sellOrder.getVolume() - matchVolume); - - if (buyOrder.getVolume() <= 0){ - excutedBuy.add(buyOrder); - break; - } - if (sellOrder.getVolume() <= 0){ - excutedSell.add(sellOrder); -// break; - } - } - - } - } - queueManager.getBuyqueue().removeAll(excutedBuy); - queueManager.getSellqueue().removeAll(excutedSell); - - } - - //매서드 종료 시 호가창 요약 - private void printSummary(PriorityQueue queue, Comparator sortOrder) { - Map summary = new TreeMap<>(sortOrder); - - for (TestOrder order : queue) { - int price = (int) order.getPrice(); // 호가 기준 - double volume = order.getVolume(); - summary.put(price, summary.getOrDefault(price, 0.0) + volume); - } - - for (Map.Entry entry : summary.entrySet()) { - System.out.printf("호가 %d원 : %.4f 개%n", entry.getKey(), entry.getValue()); - } - } - - //전체 호가창 콘솔 출력 - public void printOrderSummary() { - System.out.println("=== SELL ORDER SUMMARY ==="); - printSummary(queueManager.getSellqueue(), Comparator.reverseOrder()); // ⬇ 고가 → 저가 - System.out.println("=== BUY ORDER SUMMARY ==="); - printSummary(queueManager.getBuyqueue(), Comparator.reverseOrder()); // ⬆ 저가 → 고가 - } -} diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java b/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java deleted file mode 100644 index 04458350..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/vo/APITicker.java +++ /dev/null @@ -1,14 +0,0 @@ -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/DeviationPricePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java new file mode 100644 index 00000000..523015a0 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicy.java @@ -0,0 +1,80 @@ +package com.cleanengine.coin.realitybot.vo; + +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@NoArgsConstructor +public class DeviationPricePolicy { + /** + * 편차율이 클 경우 가격과 수량을 강하게 보정합니다. + * + * @param platformSell 계산 된 플랫폼 기준 매도 가격 + * @param platformBuy 계산 된 플랫폼 기준 매수 가격 + * @param trendLineRate (platformVWAP - apiVWAP) / apiVWAP + * @param apiVWAP 외부 기준 가격 + * @return 추가 선형 보정된 가격쌍 (sell, buy) + */ + + public AdjustPrice adjust(double platformSell,double platformBuy, double trendLineRate, double apiVWAP, double unitPrice){ + double deviation = Math.abs(trendLineRate); + + if (deviation <= 0.01){ + return new AdjustPrice(platformSell,platformBuy); + } + double weight = getCorrectionWeight(deviation); + double closeness = 0.5 + (weight * 0.3); // 보간 가중치: 0.7 ~ 1.0 -> 0.5 + +// double targetVWAP = (trendLineRate > 0) //만약 closeness 를 0.5 입력시 중간값 +// ? apiVWAP + (platformSell - apiVWAP) * closeness // 고평가 → platformSell(25000) → apiVWAP(16000) 사이 가중치 %로 유도 +// : apiVWAP - (apiVWAP - platformBuy) * closeness; // 저평가 → platformBuy(12000) ← apiVWAP(16000) 사이 가중치 %로 유도 + double sellTarget, buyTarget; + if (trendLineRate > 0) { + // sell은 platformSell에서 apiVWAP 쪽으로 낮춤 + sellTarget = apiVWAP + (platformSell - apiVWAP) * closeness; + buyTarget = apiVWAP + (platformBuy - apiVWAP) * closeness; + } else { + // 저평가일 경우: 가격을 올림 + sellTarget = apiVWAP - (apiVWAP - platformSell) * closeness; + buyTarget = apiVWAP - (apiVWAP - platformBuy) * closeness; + } + + double adjustedSell = normalizeToUnit(interpolate(platformSell,sellTarget ,weight),unitPrice); + double adjustedBuy = normalizeToUnit(interpolate(platformBuy,buyTarget ,weight),unitPrice); + + return new AdjustPrice(adjustedSell,adjustedBuy); + + } + + /*private double getCorrentionRate(double deviation) { 3단계 보정에서 선형보정 + if (deviation <= 0.01){ + return 0.05; //5% 약보정 + } else if (deviation <= 0.03){ + return 0.10; //10% 의 중보정 + } else return 0.15; //15%의 강보정 + }*/ + + /** + * 1% 미만은 보정 X, 10% 이상은 거의 전면 보정. + * 중간값은 비례적으로 weight 증가 + */ + private double getCorrectionWeight(double deviation) { + double start = 0.01; // 보정 시작 기준 (1%) + double end = 0.10; // 보정 최댓값 기준 (10%) + + double weight = (deviation - start) / (end - start); + return Math.min(1.0, Math.max(0.0, weight)); // 0 ~ 1 사이로 제한 + } + + /** + * 선형 보간 함수: platformPrice → apiVWAP 사이 보간 + */ + private double interpolate(double platformPrice, double apiVWAP, double weight) { + return platformPrice * (1 - weight) + apiVWAP * weight; + } + private double normalizeToUnit(double price, double unitPrice) { + return Math.round(price / unitPrice) * unitPrice; + } + + public record AdjustPrice(double sell, double buy){} +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicy.java new file mode 100644 index 00000000..fefa8582 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicy.java @@ -0,0 +1,61 @@ +package com.cleanengine.coin.realitybot.vo; + +import org.springframework.stereotype.Component; + +@Component +public class OrderPricePolicy { + /** + * 레벨에 따라 매수/매도 가격을 계산합니다. + * @param level 주문 강도 (1~5) + * @param platformVWAP 플랫폼 기준 평균 체결 가격 + * @param unitPrice 호가 단위 + * @param trendLineRate 플랫폼과 API VWAP의 편차율 + * @return PricePair (매도/매수 가격) + */ + public OrderPrice calculatePrice(int level, + double platformVWAP, + double unitPrice, + double trendLineRate) { + double priceOffset = unitPrice * level; + double sellPrice, buyPrice; + double randomOffset = Math.abs(getRandomOffset(platformVWAP,getDynamicMaxRate(trendLineRate))); + double basePrice = normalizeToUnit(platformVWAP, unitPrice); //기준 가격 (호가 단위 정규화) + + + if (level == 1){ //1level일 경우 주문이 겹치도록 설정 + //체결을 위해 매수가 올리고, 매도가 내리는 계산 적용 + sellPrice = normalizeToUnit(basePrice - randomOffset,unitPrice); + buyPrice = normalizeToUnit(basePrice + randomOffset,unitPrice); + } + //2~3 단계 : orderbook 단위 주문 + else { + randomOffset = getRandomOffset(platformVWAP,0.001); + //체결 확률 증가용 코드 + sellPrice = normalizeToUnit(platformVWAP + priceOffset - randomOffset,unitPrice); + buyPrice = normalizeToUnit(platformVWAP - priceOffset + randomOffset,unitPrice); + //안정적인 스프레드 유지 +// sellPrice = normalizeToUnit(platformVWAP + priceOffset); +// buyPrice = normalizeToUnit(platformVWAP - priceOffset); + } + return new OrderPrice(sellPrice, buyPrice); + } + + private double getRandomOffset(double basePrice, double maxRate){ + //시장가에 해당하는 호가는 거래 체결 강하게 하기 위함 + double percent = (Math.random() * 2-1)*maxRate; + return basePrice * 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, double unitPrice){ //호가단위로 변환 + return (int) ((double)(Math.round(price / unitPrice)) * unitPrice); + } + + public record OrderPrice(double sell, double buy){} +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java new file mode 100644 index 00000000..3f43eacf --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/OrderVolumePolicy.java @@ -0,0 +1,54 @@ +package com.cleanengine.coin.realitybot.vo; + +import lombok.NoArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@NoArgsConstructor +public class OrderVolumePolicy { + + /** + * 평균 거래량과 추세 편차율을 기반으로 랜덤 거래량을 생성합니다. + * + * @param avgVolume 평균 거래량 + * @param trendLineRate platformVWAP - apiVWAP 편차율 (e.g., 0.03 = +3%) + * @param isBuy 매수면 true, 매도면 false + * @return 생성된 거래량 + */ + + public double calculateVolume(double avgVolume, double trendLineRate, boolean isBuy){ + //기본 랜덤 거래량 (0.5~1.5) + double rawVolume = avgVolume *(0.5*Math.random()); + + //편차에 따른 거래량 보정 3% -> 최대 2.5배 증가 + double deviation = Math.abs(trendLineRate); //절댓값 반환 + if (deviation>0.01){ //1% 초과할 경우 + double power = deviation * 100; //0.03 -> 3% +// double multiplier = 1.0 + (power * 0.5); //2.5배 (max로 사용) + double multiplier = Math.pow(1.1,power); //2.5배 (max로 사용) + rawVolume *= multiplier; //강한 추세 -> 강한 보정 + } + + //매수-매도 비중 조정 + if (deviation <=0.001) //0.1%일 경우 안정권 , 추가적인 보정 x + return volumeExpansion(rawVolume); + if (trendLineRate > 0){ + //시장이 상승하면 매도 강세보정 + return isBuy? volumeExpansion(rawVolume* 0.7) //소극적 매수 + : volumeExpansion(rawVolume*1.5); //적극적 매도 + } else { + //시장이 하락하면 매수 강세보정 + return isBuy? volumeExpansion(rawVolume*1.5) //적극적 매도 + : volumeExpansion(rawVolume*0.7); //소극적 매수 + } + } + + private double volumeExpansion(double rawVolume){ + double resultVolume = Math.round(rawVolume * 10000.0)/10000.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/vo/UnitPricePolicy.java b/src/main/java/com/cleanengine/coin/realitybot/vo/UnitPricePolicy.java new file mode 100644 index 00000000..59939a66 --- /dev/null +++ b/src/main/java/com/cleanengine/coin/realitybot/vo/UnitPricePolicy.java @@ -0,0 +1,41 @@ +package com.cleanengine.coin.realitybot.vo; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.NavigableMap; +import java.util.TreeMap; + +@Slf4j +@Service +public class UnitPricePolicy { + private final NavigableMap unitPriceRules = new TreeMap(); + + @PostConstruct + public void initRules() { + unitPriceRules.put(0.0001,0.00000001); + unitPriceRules.put(0.001,0.0000001); + unitPriceRules.put(0.01,0.000001); + unitPriceRules.put(0.1,0.00001); + unitPriceRules.put(1.0,0.0001); + unitPriceRules.put(10.0,0.001); + unitPriceRules.put(100.0,0.01); + unitPriceRules.put(1_000.0,0.1); + unitPriceRules.put(10_000.0,1.0); + unitPriceRules.put(100_000.0,10.0); + unitPriceRules.put(500_000.0,50.0); + unitPriceRules.put(1_000_000.0,100.0); + unitPriceRules.put(2_000_000.0,500.0); + unitPriceRules.put(Double.MAX_VALUE,1_000.0); + } + + public double getUnitPrice(double apiTradePrice){ + if (apiTradePrice <=0){ + log.warn("api의 opening_price가 음수입니다. 0원으로 치환됩니다."); + return unitPriceRules.higherEntry(0.0).getValue(); + } + return unitPriceRules.higherEntry(apiTradePrice).getValue(); + } + +} diff --git a/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java b/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java deleted file mode 100644 index 8f7e6e7f..00000000 --- a/src/main/java/com/cleanengine/coin/realitybot/vo/VWAPState.java +++ /dev/null @@ -1,78 +0,0 @@ -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/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2fbd62f9..6ec6fe43 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,4 +26,8 @@ spring: secure: false frontend: - url: "http://localhost:5173/callback" \ No newline at end of file + url: "http://localhost:5173/callback" +bot-handler: + fixed-rate: 5000 # 5초마다 실행 + corn : "0 0 0 * * *" # 매일 자정마다 호가 + order-level : 1,2,3,4,5 #오더북 단계 설정 - 주문량 증가 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 18466b76..7d298092 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -42,4 +42,9 @@ spring: order: tickers: BTC, TRUMP server: - forward-headers-strategy: native \ No newline at end of file + forward-headers-strategy: native + +bot-handler: + fixed-rate: 5000 # 5초마다 실행 + corn : "0 0 0 * * *" # 매일 자정마다 호가 + order-level : 1,2,3,4,5 #오더북 단계 설정 - 주문량 증가 diff --git a/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java b/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java new file mode 100644 index 00000000..74bad746 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/RealitybotCoreTestSuite.java @@ -0,0 +1,48 @@ +package com.cleanengine.coin.realitybot; + +import com.cleanengine.coin.realitybot.api.ApiSchedulerTest; +import com.cleanengine.coin.realitybot.api.BithumbAPIClientTest; +import com.cleanengine.coin.realitybot.api.RefresherRunnerTest; +import com.cleanengine.coin.realitybot.api.UnitPriceRefresherTest; +import com.cleanengine.coin.realitybot.config.ApiClientConfigTest; +import com.cleanengine.coin.realitybot.config.SchedulerConfigTest; +import com.cleanengine.coin.realitybot.domain.APIVWAPStateTest; +import com.cleanengine.coin.realitybot.domain.PlatformVWAPStateTest; +import com.cleanengine.coin.realitybot.domain.VWAPCalculatorTest; +import com.cleanengine.coin.realitybot.dto.OpeningPriceTest; +import com.cleanengine.coin.realitybot.dto.TicksTest; +import com.cleanengine.coin.realitybot.parser.OpeningPriceParserTest; +import com.cleanengine.coin.realitybot.parser.TickParserTest; +import com.cleanengine.coin.realitybot.service.PlatformVWAPServiceTest; +import com.cleanengine.coin.realitybot.service.TickServiceManagerTest; +import com.cleanengine.coin.realitybot.service.VWAPerrorInJectionSchedulerTest; +import com.cleanengine.coin.realitybot.vo.DeviationPricePolicyTest; +import com.cleanengine.coin.realitybot.vo.OrderPricePolicyTest; +import com.cleanengine.coin.realitybot.vo.UnitPricePolicyTest; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + RefresherRunnerTest.class, + BithumbAPIClientTest.class, + UnitPriceRefresherTest.class, + OpeningPriceTest.class, + PlatformVWAPStateTest.class, + UnitPricePolicyTest.class, + SchedulerConfigTest.class, + ApiSchedulerTest.class, + ApiClientConfigTest.class, + PlatformVWAPServiceTest.class, + TickServiceManagerTest.class, + VWAPCalculatorTest.class, + APIVWAPStateTest.class, + TicksTest.class, + TickParserTest.class, + OpeningPriceParserTest.class, + VWAPerrorInJectionSchedulerTest.class, + OrderPricePolicyTest.class, + DeviationPricePolicyTest.class +}) +public class RealitybotCoreTestSuite { +} diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/ApiSchedulerTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/ApiSchedulerTest.java new file mode 100644 index 00000000..1eaca43f --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/api/ApiSchedulerTest.java @@ -0,0 +1,78 @@ +package com.cleanengine.coin.realitybot.api; + +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; +import com.cleanengine.coin.realitybot.domain.APIVWAPState; +import com.cleanengine.coin.realitybot.dto.Ticks; +import com.cleanengine.coin.realitybot.parser.TickParser; +import com.cleanengine.coin.realitybot.service.OrderGenerateService; +import com.cleanengine.coin.realitybot.service.TickServiceManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class ApiSchedulerTest { + + @InjectMocks + private ApiScheduler apiScheduler; + + @Mock + private BithumbAPIClient apiClient; + @Mock + private TickParser tickParser; + @Mock + private OrderGenerateService orderGenerateService; + @Mock + APIVWAPState apiVWAPState; + @Mock + private TickServiceManager tickServiceManager; + @Mock + private AssetRepository assetRepository; + + + + @Test + void marketAllRequestCallsAllTickers() throws InterruptedException { + List assets = List.of( + new Asset("BTC", "비트코인", null), + new Asset("TRUMP", "트럼프", null), + new Asset("ETH", "이더리움", null), + new Asset("DOGE", "도지코인", null), + new Asset("USDT", "테더", null), + new Asset("PEPE", "페페", null), + new Asset("XRP", "리플", null), + new Asset("SOL", "솔라나", null), + new Asset("SUI", "수이", null), + new Asset("WLD", "월드코인", null) + ); + List testTicks = List.of( + new Ticks("BTC","2025-06-01","11:30:25","2025-06-01T11:30:25.123Z",95730000.0f,0.0082,95000000.0f,730000.0,"ASK",100001L), + new Ticks("ETH","2025-06-01","11:31:10","2025-06-01T11:31:10.456Z",4850000.0f,1.25,4800000.0f,50000.0,"BID",100002L), + new Ticks("DOGE","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",185.5f,9500.0,180.0f,5.5,"ASK", 100003L)); + Ticks ticks = new Ticks("BTC","2025-06-01","11:30:25","2025-06-01T11:30:25.123Z",95730000.0f,0.0082,95000000.0f,730000.0,"ASK",100001L); + //given + when(assetRepository.findAll()).thenReturn(assets); + when(apiClient.get(anyString())).thenReturn("[{data:...}]"); + when(tickParser.parseGson(anyString())).thenReturn(testTicks); + when(tickServiceManager.getService(anyString())).thenReturn(apiVWAPState); + doNothing().when(apiVWAPState).addTick(any()); + System.out.println(assets.size()); + //when + apiScheduler.MarketAllRequest(); + + //then +// verify(apiScheduler,times(assets.size())).MarketDataRequest(anyString()); + verify(orderGenerateService,times(assets.size())).generateOrder(anyString(),anyDouble(),anyDouble()); + } + + @Test + void marketDataRequest() { + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/BithumbAPIClientTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/BithumbAPIClientTest.java new file mode 100644 index 00000000..7dac6fdc --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/api/BithumbAPIClientTest.java @@ -0,0 +1,191 @@ +package com.cleanengine.coin.realitybot.api; + +import okhttp3.*; +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.io.IOException; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +@ExtendWith(MockitoExtension.class) +public class BithumbAPIClientTest { + + @Mock + private OkHttpClient client; + @Mock + private Call call; + @InjectMocks + BithumbAPIClient bithumbAPIClient; + + private String ticker = "BTC"; + private String tradeJson = " {\n" + + " \"market\": \"KRW-BTC\",\n" + + " \"trade_date_utc\": \"2018-04-18\",\n" + + " \"trade_time_utc\": \"10:19:58\",\n" + + " \"timestamp\": 1524046798000,\n" + + " \"trade_price\": 8616000,\n" + + " \"trade_volume\": 0.03060688,\n" + + " \"prev_closing_price\": 8450000,\n" + + " \"chane_price\": 166000,\n" + + " \"ask_bid\": \"ASK\"\n" + + " }"; + private String openingJson = "{\n" + + " \"market\": \"KRW-BTC\",\n" + + " \"trade_date\": \"20180418\",\n" + + " \"trade_time\": \"102340\",\n" + + " \"trade_date_kst\": \"20180418\",\n" + + " \"trade_time_kst\": \"192340\",\n" + + " \"trade_timestamp\": 1524047020000,\n" + + " \"opening_price\": 8450000,\n" + + " \"high_price\": 8679000,\n" + + " \"low_price\": 8445000,\n" + + " \"trade_price\": 8621000,\n" + + " \"prev_closing_price\": 8450000,\n" + + " \"change\": \"RISE\",\n" + + " \"change_price\": 171000,\n" + + " \"change_rate\": 0.0202366864,\n" + + " \"signed_change_price\": 171000,\n" + + " \"signed_change_rate\": 0.0202366864,\n" + + " \"trade_volume\": 0.02467802,\n" + + " \"acc_trade_price\": 108024804862.58253,\n" + + " \"acc_trade_price_24h\": 232702901371.09308,\n" + + " \"acc_trade_volume\": 12603.53386105,\n" + + " \"acc_trade_volume_24h\": 27181.31137002,\n" + + " \"highest_52_week_price\": 28885000,\n" + + " \"highest_52_week_date\": \"2018-01-06\",\n" + + " \"lowest_52_week_price\": 4175000,\n" + + " \"lowest_52_week_date\": \"2017-09-25\",\n" + + " \"timestamp\": 1524047026072\n" + + " }"; + private String failJson = "{}"; + + @DisplayName("실행시 API의 response에 trade 값이 들어오는 지") + @Test + void callTradePrice() throws IOException { + //given + ResponseBody responseBody = ResponseBody.create(tradeJson, MediaType.get("application/json")); + Request mockrequest = new Request.Builder().url("http://localhost").build(); + Response mockresponse = new Response.Builder() + .request(mockrequest) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody) + .build(); + + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(mockresponse); + + //when + String response = bithumbAPIClient.get(ticker); + + //then + assertTrue(response.contains("trade_price")); + assertEquals(tradeJson, response); + } + + @DisplayName("실행시 API의 response에 Opening_price 값이 들어오는 지") + @Test + void callOpeningPrice() throws IOException { + //given + ResponseBody responseBody = ResponseBody.create(openingJson, MediaType.get("application/json")); + Request mockrequest = new Request.Builder().url("http://localhost").build(); + Response mockresponse = new Response.Builder() + .request(mockrequest) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody) + .build(); + + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(mockresponse); + + //when + String response = bithumbAPIClient.getOpeningPrice(ticker); + + //then + assertTrue(response.contains("opening_price")); + assertEquals(openingJson, response); + } + @DisplayName("ticker가 잘못된 요청이 들어갔을 때 log를 띄우는 지") + @Test + void callFailbyWrongTicker() throws IOException { + //given + + ResponseBody responseBody = ResponseBody.create(failJson, MediaType.get("application/json")); + Request mockrequest = new Request.Builder().url("http://localhost").build(); + Response mockresponse = new Response.Builder() + .request(mockrequest) + .protocol(Protocol.HTTP_1_1) + .code(400) + .message("OK") + .body(responseBody) + .build(); + + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(mockresponse); + + //when + String response = bithumbAPIClient.getOpeningPrice(ticker); + + //then + assertTrue(response.contains("{}")); + } + + //무응답도 대응필요함 + @DisplayName("실행시 API의 response가 실패할 경우 에러를 던지는 지") + @Test + void callOpeningPriceFails() throws IOException { + //given + //when + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenThrow(new IOException("API 요청 중 예외 발생")); + + //then + assertThrows(RuntimeException.class, () -> bithumbAPIClient.getOpeningPrice(ticker)); + } + //무응답도 대응필요함 + @DisplayName("실행시 API의 response가 실패할 경우 에러를 던지는 지") + @Test + void callTradePriceFails() throws IOException { + //given + //when + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenThrow(new IOException("API 요청 중 예외 발생")); + + //then + assertThrows(RuntimeException.class, () -> bithumbAPIClient.get(ticker)); + } + + @DisplayName("ticker가 잘못된 요청이 들어갔을 때 log를 띄우는 지") + @Test + void callFailbyWrongTickertoGet() throws IOException { + //given + + ResponseBody responseBody = ResponseBody.create(failJson, MediaType.get("application/json")); + Request mockrequest = new Request.Builder().url("http://localhost").build(); + Response mockresponse = new Response.Builder() + .request(mockrequest) + .protocol(Protocol.HTTP_1_1) + .code(400) + .message("OK") + .body(responseBody) + .build(); + + when(client.newCall(any())).thenReturn(call); + when(call.execute()).thenReturn(mockresponse); + + //when + String response = bithumbAPIClient.get(ticker); + + //then + assertTrue(response.contains("{}")); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java new file mode 100644 index 00000000..cfb7bf79 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/api/RefresherRunnerTest.java @@ -0,0 +1,27 @@ +package com.cleanengine.coin.realitybot.api; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@SpringBootTest +public class RefresherRunnerTest { + @SpyBean + private UnitPriceRefresher unitPriceRefresher; + + @DisplayName("어플리케이션 실행 시 호가 단위 수집") + @Test + public void runwithrefrecher(){ + verify(unitPriceRefresher,times(1)).run(any(ApplicationArguments.class)); + verify(unitPriceRefresher,times(1)).initializeUnitPrices(); + } + +// @DisplayName(" ") +} diff --git a/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java b/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java new file mode 100644 index 00000000..38fcb970 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/api/UnitPriceRefresherTest.java @@ -0,0 +1,60 @@ +package com.cleanengine.coin.realitybot.api; + +import com.cleanengine.coin.order.adapter.out.persistentce.asset.AssetRepository; +import com.cleanengine.coin.order.domain.Asset; +import com.cleanengine.coin.realitybot.dto.OpeningPrice; +import com.cleanengine.coin.realitybot.parser.OpeningPriceParser; +import com.cleanengine.coin.realitybot.vo.UnitPricePolicy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class UnitPriceRefresherTest { + @InjectMocks + private UnitPriceRefresher unitPriceRefresher; + + @Mock + private AssetRepository assetRepository; + + @Mock + private BithumbAPIClient bithumbAPIClient; + + @Mock + private OpeningPriceParser openingPriceParser; + + @Mock + private UnitPricePolicy unitPricePolicy; + + @DisplayName("run 시작시 호가 단위를 불러오는 지 여부") + @Test + void testRefresherUnitPrice() { + //given + String ticker = "BTC"; + Asset btc = new Asset(ticker,"비트코인",null); + + String json = "[{\"market\": \"KRW-BTC\", \"opening_price\": 1000000, \"trade_price\": 1010000}]"; + OpeningPrice parsed = new OpeningPrice(); + parsed.setOpening_price(1000000); + + when(assetRepository.findAll()).thenReturn(List.of(btc)); + when(bithumbAPIClient.getOpeningPrice(ticker)).thenReturn(json); + when(openingPriceParser.parseGson(json)).thenReturn(parsed); + when(unitPricePolicy.getUnitPrice(1000000)).thenReturn(100.0); + //when + unitPriceRefresher.refreshUnitPrices(); + + //then + double unitPrice = unitPriceRefresher.getUnitPriceByTicker(ticker); + assertEquals(100.0, unitPrice); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/config/ApiClientConfigTest.java b/src/test/java/com/cleanengine/coin/realitybot/config/ApiClientConfigTest.java new file mode 100644 index 00000000..79f5ab21 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/config/ApiClientConfigTest.java @@ -0,0 +1,26 @@ +package com.cleanengine.coin.realitybot.config; + +import okhttp3.OkHttpClient; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ApiClientConfig.class) +public class ApiClientConfigTest { + + @Autowired + OkHttpClient okHttpClient; + + @DisplayName("Bean 정상 등록 여부") + @Test + void CreateokHttpClientBean() { + assertNotNull(okHttpClient); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java b/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java new file mode 100644 index 00000000..3b573a5a --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/config/SchedulerConfigTest.java @@ -0,0 +1,82 @@ +package com.cleanengine.coin.realitybot.config; + +import com.cleanengine.coin.realitybot.api.ApiScheduler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class SchedulerConfigTest { + + @Mock + ApiScheduler apiScheduler; + + @Mock + ScheduledTaskRegistrar scheduledTaskRegistrar; + + @InjectMocks + SchedulerConfig schedulerConfig; + + @BeforeEach + void setUp() { + schedulerConfig = new SchedulerConfig(apiScheduler,Duration.ofMillis(500)); + } + + + @DisplayName("fixedrate를 적용 후 정상 작동하는 지") + @Test + void testConfigureTasksOnFixedRate() throws InterruptedException { + //when + schedulerConfig.configureTasks(scheduledTaskRegistrar); + + //then + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class);//스케줄러에 등록된 작업 + ArgumentCaptor intervalCaptor = ArgumentCaptor.forClass(Duration.class);//실행 주기 + //mock에게 전달 된 인자를 캡처해서 확인가능하게 해줌 + //내부 속성을 확인할 때, 동적으로 생성된 값을 검증 할 때 + + verify(scheduledTaskRegistrar).addFixedRateTask(taskCaptor.capture(), intervalCaptor.capture()); + //addFixedRateTask를 실행 시 인자 두개를 캡쳐함 + + Runnable task = taskCaptor.getValue(); + //그 캡처된 인자중 task는 실제 실행하는 작업 (schedulerconfig에 구현한 것 -> apiScheduler.MarketAllRequest();) + task.run(); //가져와서 동적으로 실행할 수 있게 됨 -> apiScheduler.MarketAllRequest(); + + verify(apiScheduler).MarketAllRequest(); //작동 검증 + + Duration interval = intervalCaptor.getValue(); + assertEquals(Duration.ofMillis(500), interval); + } + @DisplayName("marketallrequest가 예외 발생 시 에러를 던지는 지 확인") + @Test + void testCheckErrorbyMarketallRequest() throws InterruptedException { + //given + doThrow(new InterruptedException()).when(apiScheduler).MarketAllRequest(); + //메서드 실행 시 에러 던지도록 셋팅 + + //when + schedulerConfig.configureTasks(scheduledTaskRegistrar); + + //then + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class);//스케줄러에 등록된 작업 + verify(scheduledTaskRegistrar).addFixedRateTask(taskCaptor.capture(), any(Duration.class)); + + Runnable task = taskCaptor.getValue(); + assertThrows(RuntimeException.class, () -> task.run()); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/domain/APIVWAPStateTest.java b/src/test/java/com/cleanengine/coin/realitybot/domain/APIVWAPStateTest.java new file mode 100644 index 00000000..adb1b450 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/domain/APIVWAPStateTest.java @@ -0,0 +1,73 @@ +package com.cleanengine.coin.realitybot.domain; + +import com.cleanengine.coin.realitybot.dto.Ticks; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +public class APIVWAPStateTest { + + @Mock + VWAPCalculator calculator; + @InjectMocks + APIVWAPState apivwapState; + + @DisplayName("ticks가 10개 이하 일 경우 ticks 갯수만큼 record 작동") + @Test + void testAddTicksUnder10() { + //given + for (int i = 0; i < 5; i++) { + apivwapState.addTick(new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",100,i,180.0f,5.5,"ASK", 100003L)); + } + //when + + //then + assertEquals(5,apivwapState.getTickSize()); + } + + @DisplayName("ticks가 10개 초과 시 오래된 tick 제거 후 size 유지") + @Test + void testAddTicksOver10(){ + APIVWAPState apivwapState = new APIVWAPState();//mock이 아니라 진짜 객체 생성 + //given + Ticks firstTick = new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",1000,10,180.0f,5.5,"ASK", 100003L); + apivwapState.addTick(firstTick); + for (int i = 0; i < 10; i++) { + apivwapState.addTick(new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",100,i,180.0f,5.5,"ASK", 100003L)); + } + assertEquals(100,apivwapState.getVWAP()); + + Ticks lastTick = new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",999,9,180.0f,5.5,"ASK", 100003L); + apivwapState.addTick(lastTick); + //when + + //then + assertEquals(10,apivwapState.getTickSize()); + assertEquals(249.83,apivwapState.getVWAP(),0.1); + } + + + @DisplayName("평균 주문 갯수로 계산한다.") + @Test + void testGetAvgVolume(){ + APIVWAPState apivwapState = new APIVWAPState();//mock이 아니라 진짜 객체 생성 + //given + Ticks firstTick = new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",1000,10,180.0f,5.5,"ASK", 100003L); + apivwapState.addTick(firstTick); + for (int i = 0; i < 10; i++) { + apivwapState.addTick(new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",i,10,180.0f,5.5,"ASK", 100003L)); + } + Ticks lastTick = new Ticks("BTC","2025-06-01","11:32:45","2025-06-01T11:32:45.789Z",999,10,180.0f,5.5,"ASK", 100003L); + apivwapState.addTick(lastTick); + //when + + //then + assertEquals(2,apivwapState.getAvgVolumePerOrder()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPStateTest.java b/src/test/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPStateTest.java new file mode 100644 index 00000000..244ec318 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/domain/PlatformVWAPStateTest.java @@ -0,0 +1,52 @@ +package com.cleanengine.coin.realitybot.domain; + +import com.cleanengine.coin.trade.entity.Trade; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class PlatformVWAPStateTest { + +/* @DisplayName("10개 이상의 거래를 보낼 경우 최신 10개만 계산하는 지") + @Test + void testVWAPStacksOver10Trades(){ + String ticker = "BTC"; + VWAPState vwapState = new VWAPState(ticker); + for (int i = 1; i < 16; i++) { + double price = i*100; + System.out.println(price); + vwapState.recordTrade(price,1); + } + System.out.println(vwapState.getTotalPriceVolume()); + System.out.println(vwapState.getTotalVolume()); + System.out.println(vwapState.getVWAP()); + assertEquals(vwapState.getVWAP(), 0); + }*/ + + @DisplayName("10개 이상의 모든 거래를 계산한다.") + @Test + void TestcalculateVWAPbyTrades() { + String ticker = "BTC"; + PlatformVWAPState platformVwapState = new PlatformVWAPState(ticker); + List trades = List.of( + new Trade(1, "BTC", LocalDateTime.now(), 2, 1, 10000.0, 10.0), // 100000 + new Trade(2, "BTC", LocalDateTime.now(), 2, 1, 11000.0, 10.0), // 110000 + new Trade(3, "BTC", LocalDateTime.now(), 2, 1, 12000.0, 10.0), // 120000 + new Trade(4, "BTC", LocalDateTime.now(), 2, 1, 13000.0, 10.0), // 130000 + new Trade(5, "BTC", LocalDateTime.now(), 2, 1, 14000.0, 10.0), // 140000 + new Trade(6, "BTC", LocalDateTime.now(), 2, 1, 15000.0, 10.0), // 150000 + new Trade(7, "BTC", LocalDateTime.now(), 2, 1, 16000.0, 10.0), // 160000 + new Trade(8, "BTC", LocalDateTime.now(), 2, 1, 17000.0, 10.0), // 170000 + new Trade(9, "BTC", LocalDateTime.now(), 2, 1, 18000.0, 10.0), // 180000 + new Trade(10, "BTC", LocalDateTime.now(), 2, 1, 19000.0, 10.0), // 190000 + new Trade(11, "BTC", LocalDateTime.now(), 2, 1, 20000.0, 10.0) // 200000 + ); + platformVwapState.addTrades(trades); + System.out.println(platformVwapState.getVWAP()); + assertEquals(platformVwapState.getVWAP(),15000.0); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/domain/VWAPCalculatorTest.java b/src/test/java/com/cleanengine/coin/realitybot/domain/VWAPCalculatorTest.java new file mode 100644 index 00000000..0320b493 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/domain/VWAPCalculatorTest.java @@ -0,0 +1,92 @@ +package com.cleanengine.coin.realitybot.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class VWAPCalculatorTest { + + private final VWAPCalculator calculator = new VWAPCalculator(); + + @DisplayName("trade 한 건으로 vwap 계산한다.") + @Test + void testVWAPwithSingleTrade() { + + calculator.recordTrade(18000.0,10.0); + assertEquals(calculator.getTotalPriceVolume(), 180000.0); + assertEquals(calculator.getTotalVolume(), 10.0); + assertEquals(calculator.getVWAP(), 18000.0); + } + @DisplayName("수량이 0 일 경우 계산되지 않는다.") + @Test + void testVWAPwith0VolumeTrade() { + calculator.recordTrade(18000.0,0); + assertEquals(calculator.getTotalPriceVolume(), 0); + assertEquals(calculator.getTotalVolume(), 0); + assertEquals(calculator.getVWAP(), 0); + } + @DisplayName("금액이 0원일 경우 계산되지 않는다.") + @Test + void testVWAPwith0PriceTrade() { + String ticker = "BTC"; + PlatformVWAPState platformVwapState = new PlatformVWAPState(ticker); + calculator.recordTrade(0,100); + assertEquals(calculator.getTotalPriceVolume(), 0); + assertEquals(calculator.getTotalVolume(), 100); + assertEquals(calculator.getVWAP(), 0); + } + @DisplayName("trade 건별로 누적되어 vwap 계산한다.") + @Test + void testVWAPStackTrades() { + calculator.recordTrade(18000, 100); + calculator.recordTrade(17000, 70); + calculator.recordTrade(16000, 50); + calculator.recordTrade(15000, 30); + calculator.recordTrade(14000, 10); + assertEquals(calculator.getTotalPriceVolume(), 4380000.0); + assertEquals(calculator.getTotalVolume(), 260.0); + assertEquals(calculator.getVWAP(), 16846.1538, 0.0001); + } + @DisplayName("수량이 0일 경우 체결 건이 있어도 적용되지 않는다.") + @Test + void testVWAPStackTradesWith0Volumes() { + calculator.recordTrade(18000, 0); + calculator.recordTrade(17000, 0); + calculator.recordTrade(16000, 0); + calculator.recordTrade(15000, 0); + calculator.recordTrade(14000, 0); + assertEquals(calculator.getTotalPriceVolume(), 0); + assertEquals(calculator.getTotalVolume(), 0); + assertEquals(calculator.getVWAP(), 0.0); + } + @DisplayName("여러 체결 건 중 한 건만 수량이 있을 경우 그 건만 적용된다.") + @Test + void testVWAPStack1Trades(){ + calculator.recordTrade(18000,0); + calculator.recordTrade(17000,0); + calculator.recordTrade(16000,0); + calculator.recordTrade(15000,0); + calculator.recordTrade(14000,1); + assertEquals(calculator.getTotalPriceVolume(), 14000.0); + assertEquals(calculator.getTotalVolume(), 1); + assertEquals(calculator.getVWAP(), 14000.0); + } + @DisplayName("쌓인 주문에서 일부를 제거한다.") + @Test + void testVWAPRemoveTrades() { + calculator.recordTrade(18000, 100); + calculator.recordTrade(17000, 70); + calculator.recordTrade(16000, 50); + calculator.recordTrade(15000, 30); + calculator.recordTrade(14000, 10); + calculator.removeTrade(18000, 100); + calculator.removeTrade(17000, 70); + calculator.removeTrade(16000, 50); + calculator.removeTrade(15000, 30); + assertEquals(calculator.getTotalPriceVolume(), 140000.0); + assertEquals(calculator.getTotalVolume(), 10.0); + assertEquals(calculator.getVWAP(), 14000.0); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/dto/OpeningPriceTest.java b/src/test/java/com/cleanengine/coin/realitybot/dto/OpeningPriceTest.java new file mode 100644 index 00000000..7731ac34 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/dto/OpeningPriceTest.java @@ -0,0 +1,54 @@ +package com.cleanengine.coin.realitybot.dto; + +import com.google.gson.Gson; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class OpeningPriceTest { + + OpeningPrice openingPrice = new OpeningPrice(); + + @Test + void testGsonParsing(){ + String json = "{\n" + + " \"market\": \"KRW-BTC\",\n" + + " \"trade_date\": \"20180418\",\n" + + " \"trade_time\": \"102340\",\n" + + " \"trade_date_kst\": \"20180418\",\n" + + " \"trade_time_kst\": \"192340\",\n" + + " \"trade_timestamp\": 1524047020000,\n" + + " \"opening_price\": 8450000,\n" + + " \"high_price\": 8679000,\n" + + " \"low_price\": 8445000,\n" + + " \"trade_price\": 8621000,\n" + + " \"prev_closing_price\": 8450000,\n" + + " \"change\": \"RISE\",\n" + + " \"change_price\": 171000,\n" + + " \"change_rate\": 0.0202366864,\n" + + " \"signed_change_price\": 171000,\n" + + " \"signed_change_rate\": 0.0202366864,\n" + + " \"trade_volume\": 0.02467802,\n" + + " \"acc_trade_price\": 108024804862.58253,\n" + + " \"acc_trade_price_24h\": 232702901371.09308,\n" + + " \"acc_trade_volume\": 12603.53386105,\n" + + " \"acc_trade_volume_24h\": 27181.31137002,\n" + + " \"highest_52_week_price\": 28885000,\n" + + " \"highest_52_week_date\": \"2018-01-06\",\n" + + " \"lowest_52_week_price\": 4175000,\n" + + " \"lowest_52_week_date\": \"2017-09-25\",\n" + + " \"timestamp\": 1524047026072\n" + + " }"; + + Gson gson = new Gson(); + openingPrice = gson.fromJson(json, OpeningPrice.class); + String actual = openingPrice.toString(); + String expected = "OpeningPrice{market='KRW-BTC', OpeningPrice=8450000.0, tradePrice=8621000.0}"; + + assertEquals("KRW-BTC", openingPrice.getMarket()); + assertEquals(8450000, openingPrice.getOpening_price()); + assertEquals(8621000,openingPrice.getTrade_price()); + + assertEquals(expected, actual); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/dto/TicksTest.java b/src/test/java/com/cleanengine/coin/realitybot/dto/TicksTest.java new file mode 100644 index 00000000..a3d3a5c9 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/dto/TicksTest.java @@ -0,0 +1,43 @@ +package com.cleanengine.coin.realitybot.dto; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class TicksTest { + @Test + void testToString() { + // given + Ticks tick = Ticks.builder() + .market("KRW-BTC") + .trade_date_utc("2025-06-01") + .trade_time_utc("11:32:45") + .timestamp("2025-06-01T11:32:45.789Z") + .trade_price(1000.0f) + .trade_volume(5.0) + .prev_closing_price(980.0f) + .change_price(20.0) + .ask_bid("ASK") + .sequential_id(1000001L) + .build(); + + // when + String actual = tick.toString(); + + // then + String expected = "Ticks{" + + "market='KRW-BTC', " + + "trade_date_utc='2025-06-01', " + + "trade_time_utc='11:32:45', " + + "timestamp=2025-06-01T11:32:45.789Z, " + + "trade_price=1000.0, " + + "trade_volume=5.0, " + + "prev_closing_price=980.0, " + + "change_price=20.0, " + + "ask_bid='ASK', " + + "sequential_id=1000001" + + "}"; + + assertEquals(expected, actual); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParserTest.java b/src/test/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParserTest.java new file mode 100644 index 00000000..d8d89844 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/parser/OpeningPriceParserTest.java @@ -0,0 +1,31 @@ +package com.cleanengine.coin.realitybot.parser; + +import com.cleanengine.coin.realitybot.dto.OpeningPrice; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class OpeningPriceParserTest { + private OpeningPriceParser openingPriceParser; + + @BeforeEach + void setUp() { + openingPriceParser = new OpeningPriceParser(); + } + + @Test + @DisplayName("json이 주어지면 openingprice객체로 반환한다.") + void testParseGson(){ + //given + String json = "[{\"market\":\"BTC\", \"opening_price\":10000.0,\"trade_price\":15000.0}]"; + + //when + OpeningPrice openingPrice = openingPriceParser.parseGson(json); + + //then + assertEquals("BTC", openingPrice.getMarket()); + assertEquals(10000.0,openingPrice.getOpening_price()); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/parser/TickParserTest.java b/src/test/java/com/cleanengine/coin/realitybot/parser/TickParserTest.java new file mode 100644 index 00000000..4cdd18f4 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/parser/TickParserTest.java @@ -0,0 +1,36 @@ +package com.cleanengine.coin.realitybot.parser; + +import com.cleanengine.coin.realitybot.dto.Ticks; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class TickParserTest { + private TickParser tickParser; + + @BeforeEach + void setUp() { + tickParser = new TickParser(); + } + + @Test + @DisplayName("json을 주면 ticks 객체를 반환한다.") + void parse() { + //given + String json = "[{\"market\":\"BTC\",\"trade_date_utc\":\"2025-06-03\",\"trade_time_utc\":\"10:00:00\",\"timestamp\":\"2025-06-03T10:00:00.000Z\",\"trade_price\":45000.0,\"trade_volume\":0.5,\"prev_closing_price\":44000.0,\"change_price\":1000.0,\"ask_bid\":\"ASK\",\"sequential_id\":123456}]"; + //when + List ticks = tickParser.parseGson(json); + //then + assertEquals(1, ticks.size()); + Ticks tick = ticks.get(0); + assertEquals("BTC", tick.getMarket()); + assertEquals(45000.0, tick.getTrade_price()); + assertEquals(0.5, tick.getTrade_volume()); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/OrderGenerateServiceTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/OrderGenerateServiceTest.java new file mode 100644 index 00000000..913df305 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/service/OrderGenerateServiceTest.java @@ -0,0 +1,7 @@ +package com.cleanengine.coin.realitybot.service; + +import static org.junit.jupiter.api.Assertions.*; + +public class OrderGenerateServiceTest { + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java new file mode 100644 index 00000000..b4138452 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/service/PlatformVWAPServiceTest.java @@ -0,0 +1,78 @@ +package com.cleanengine.coin.realitybot.service; + +import com.cleanengine.coin.realitybot.domain.PlatformVWAPState; +import com.cleanengine.coin.trade.entity.Trade; +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 java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class PlatformVWAPServiceTest { + + @InjectMocks + private PlatformVWAPService platformVWAPService; + + @Mock + private PlatformVWAPState platformVwapState; + + @DisplayName("10개 이하일 때, APIVWAP 기준으로 랜덤값이 반환되는 지") + @Test + void testCalculateVWAPLessThan10Trades() { + //give + String ticker = "BTC"; + List trades = List.of( + new Trade(1, "BTC", LocalDateTime.now(), 2, 1, 10000.0, 10.0), // 100000 + new Trade(2, "BTC", LocalDateTime.now(), 2, 1, 11000.0, 10.0), // 110000 + new Trade(3, "BTC", LocalDateTime.now(), 2, 1, 12000.0, 10.0) // 120000 + );//이게 적용되면 10000원대 + double apiVWAP = 1000.0; //0.1%의 보정값 + + //when + double result = platformVWAPService.calculateVWAPbyTrades(ticker, trades, apiVWAP); + + //than + assertEquals(apiVWAP, result,1); + assertTrue(result>=999.0 && result<=1001.0); + } + + @DisplayName("10개 이상일 때, trades 기준으로 계산되는 지") + @Test + void testCalculateVWAPMoreThan10Trades() { + //given + String ticker = "BTC"; + List trades = List.of( + new Trade(1, "BTC", LocalDateTime.now(), 2, 1, 10000.0, 10.0), // 100000 + new Trade(2, "BTC", LocalDateTime.now(), 2, 1, 11000.0, 10.0), // 110000 + new Trade(3, "BTC", LocalDateTime.now(), 2, 1, 12000.0, 10.0), // 120000 + new Trade(4, "BTC", LocalDateTime.now(), 2, 1, 13000.0, 10.0), // 130000 + new Trade(5, "BTC", LocalDateTime.now(), 2, 1, 14000.0, 10.0), // 140000 + new Trade(6, "BTC", LocalDateTime.now(), 2, 1, 15000.0, 10.0), // 150000 + new Trade(7, "BTC", LocalDateTime.now(), 2, 1, 16000.0, 10.0), // 160000 + new Trade(8, "BTC", LocalDateTime.now(), 2, 1, 17000.0, 10.0), // 170000 + new Trade(9, "BTC", LocalDateTime.now(), 2, 1, 18000.0, 10.0), // 180000 + new Trade(10, "BTC", LocalDateTime.now(), 2, 1, 19000.0, 10.0), // 190000 + new Trade(11, "BTC", LocalDateTime.now(), 2, 1, 20000.0, 10.0) // 200000 + ); + double apiVWAP = 1000.0; + when(platformVwapState.getVWAP()).thenReturn(15000.0); + platformVWAPService.vwapMap.put(ticker, platformVwapState); + //when + double result = platformVWAPService.calculateVWAPbyTrades(ticker, trades, apiVWAP); + + //then + verify(platformVwapState).addTrades(trades); + verify(platformVwapState).getVWAP(); + assertEquals( 15000.0,result); + } + //todo generatevwap null확인안함 +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/TickServiceManagerTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/TickServiceManagerTest.java new file mode 100644 index 00000000..3e69d393 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/service/TickServiceManagerTest.java @@ -0,0 +1,46 @@ +package com.cleanengine.coin.realitybot.service; + +import com.cleanengine.coin.realitybot.domain.APIVWAPState; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class TickServiceManagerTest { +// @InjectMocks + private TickServiceManager tickServiceManager = new TickServiceManager(); + + @DisplayName("최초 ticker 입력시 not null이여야 함") + @Test + void getNewService() { + //given + String ticker = "BTC"; + //when + APIVWAPState service = tickServiceManager.getService(ticker); + //then + assertNotNull(service); + } + @DisplayName("같은 ticker일 경우 동일 객체 반환") + @Test + void checksDuplication() { + //given + String ticker = "BTC"; + //when + APIVWAPState service1 = tickServiceManager.getService(ticker); + APIVWAPState service2 = tickServiceManager.getService(ticker); + //then + assertSame(service1, service2); + } + + @DisplayName("다른 ticker일 경우 다른 인스턴스 반환") + @Test + void checksOthers() { + //given + //when + APIVWAPState service1 = tickServiceManager.getService("BTC"); + APIVWAPState service2 = tickServiceManager.getService("TRUMP"); + //then + assertNotSame(service1, service2); + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java new file mode 100644 index 00000000..2432d626 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/service/VWAPerrorInJectionSchedulerTest.java @@ -0,0 +1,59 @@ +package com.cleanengine.coin.realitybot.service; + +import com.cleanengine.coin.trade.entity.Trade; +import com.cleanengine.coin.trade.repository.TradeRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class VWAPerrorInJectionSchedulerTest { + + @Mock + TradeRepository tradeRepository; + + @InjectMocks + VWAPerrorInJectionScheduler vwaPerrorInJectionScheduler; + + @Test + @DisplayName("enableInjection() 호출 전에는 작동 안한다") + void doNotingInjection(){ + vwaPerrorInJectionScheduler.injectFakeTrade(); + verify(tradeRepository,never()).save(any()); + } + + @Test + @DisplayName("호출 후에 fateTrade 삽입") + void injectOnceAfterEnable(){ + vwaPerrorInJectionScheduler.enableInjection(); + vwaPerrorInJectionScheduler.injectFakeTrade(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Trade.class); + verify(tradeRepository,times(1)).save(captor.capture()); + + Trade trade = captor.getValue(); + assertEquals("TRUMP",trade.getTicker()); + } + @Test + @DisplayName("한 번 삽입 이후 재삽입 되지 않음") + void onlyOnecInject(){ + vwaPerrorInJectionScheduler.enableInjection(); + verify(tradeRepository, never()).save(any()); + vwaPerrorInJectionScheduler.injectFakeTrade(); + verify(tradeRepository,times(1)).save(any()); + + vwaPerrorInJectionScheduler.injectFakeTrade(); + vwaPerrorInJectionScheduler.enableInjection(); + verify(tradeRepository,times(1)).save(any()); + + } + +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicyTest.java b/src/test/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicyTest.java new file mode 100644 index 00000000..43407525 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/vo/DeviationPricePolicyTest.java @@ -0,0 +1,39 @@ +package com.cleanengine.coin.realitybot.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.junit.jupiter.api.Assertions.*; +@SpringBootTest +public class DeviationPricePolicyTest { + private final DeviationPricePolicy policy = new DeviationPricePolicy(); + + @Test + @DisplayName("1% 이하 편차라면 보정안한다.") + void noAjustWhenDeviationLessThan1(){ + DeviationPricePolicy.AdjustPrice result = policy.adjust(15000,14000, 0,14500,100); + + assertEquals(15000,result.sell()); + assertEquals(14000,result.buy()); + } + + @Test + @DisplayName("시장이 고평가일 때 보정한다.") + void adjustWhenOverValue(){ + var result = policy.adjust(25000,24000, 0.05,19000,1000); + System.out.println(result.sell()); + System.out.println(result.buy()); + assertTrue(result.sell() < 25000); + assertTrue(result.buy() < 24000); + } + @Test + @DisplayName("시장이 저평가일 때 보정한다.") + void adjustWhenUnderValue(){ + var result = policy.adjust(15000,14000, -0.05,19000,1000); + System.out.println(result.sell()); + System.out.println(result.buy()); + assertTrue(result.sell() > 15000); + assertTrue(result.buy() > 14000); + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicyTest.java b/src/test/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicyTest.java new file mode 100644 index 00000000..2658a975 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/vo/OrderPricePolicyTest.java @@ -0,0 +1,68 @@ +package com.cleanengine.coin.realitybot.vo; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +public class OrderPricePolicyTest { + + + private final OrderPricePolicy policy = new OrderPricePolicy(); + + @Test + @DisplayName("Level 1: 매수는 VWAP 이상, 매도는 VWAP 이하이어야 함") + void testLevel1() { + double platformVWAP = 10000.0; + double unitPrice = 10.0; + double trendLineRate = 0.03; + + OrderPricePolicy.OrderPrice price = policy.calculatePrice(1, platformVWAP, unitPrice, trendLineRate); + + assertTrue(price.buy() > platformVWAP, "Level 1: 매수 가격은 VWAP보다 커야 함"); + assertTrue(price.sell() < platformVWAP, "Level 1: 매도 가격은 VWAP보다 작아야 함"); + assertEquals(0, price.buy() % unitPrice, "호가 단위로 정규화되어야 함"); + assertEquals(0, price.sell() % unitPrice, "호가 단위로 정규화되어야 함"); + } + + @Test + @DisplayName("Level 5: 가격 차이가 충분히 커야 함") + void testLevel5Spread() { + int level = 5; + double platformVWAP = 30000.0; + double unitPrice = 100.0; + double trendLineRate = 0.0; + + OrderPricePolicy.OrderPrice price = policy.calculatePrice(level, platformVWAP, unitPrice, trendLineRate); + + double minExpectedDiff = unitPrice * 5 * 0.8; // 랜덤 보정 고려해도 80% 이상 차이 기대 + assertTrue(price.sell() - price.buy() >= minExpectedDiff, + "레벨 5는 충분한 가격 차이를 가져야 함"); + } + + @RepeatedTest(5) + @DisplayName("Level 2~3: 가격 차이는 허용 범위 내, 호가 단위로 정규화됨") + void testLevel2To3_priceDiffWithinRange() { + for (int level = 2; level <= 3; level++) { + double platformVWAP = 20000.0; + double unitPrice = 50.0; + double trendLineRate = -0.02; + + OrderPricePolicy.OrderPrice price = policy.calculatePrice(level, platformVWAP, unitPrice, trendLineRate); + + double priceDiff = price.sell() - price.buy(); + double priceOffset = unitPrice * level; + double maxRandomOffset = platformVWAP * 0.01; + double maxAllowedDiff = (priceOffset + maxRandomOffset) * 2; + + System.out.printf("level=%d, sell=%.1f, buy=%.1f, diff=%.1f, maxAllowed=%.1f%n", + level, price.sell(), price.buy(), priceDiff, maxAllowedDiff); + + assertTrue(Math.abs(priceDiff) <= maxAllowedDiff, + String.format("가격 차이 %.1f 이 최대 허용 범위 %.1f 초과", priceDiff, maxAllowedDiff)); + assertEquals(0, price.sell() % unitPrice, "매도 가격은 호가 단위여야 함"); + assertEquals(0, price.buy() % unitPrice, "매수 가격은 호가 단위여야 함"); + } + } +} \ No newline at end of file diff --git a/src/test/java/com/cleanengine/coin/realitybot/vo/UnitPricePolicyTest.java b/src/test/java/com/cleanengine/coin/realitybot/vo/UnitPricePolicyTest.java new file mode 100644 index 00000000..7debc977 --- /dev/null +++ b/src/test/java/com/cleanengine/coin/realitybot/vo/UnitPricePolicyTest.java @@ -0,0 +1,41 @@ +package com.cleanengine.coin.realitybot.vo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class UnitPricePolicyTest { + + private UnitPricePolicy unitPricePolicy; + + @BeforeEach + void setUp(){ + unitPricePolicy = new UnitPricePolicy(); + unitPricePolicy.initRules(); + } + + + @DisplayName("opening_price가 단위 가격이 정확이 매핑되는 지 테스트") + @Test + void testGetUnitPrice(){ + + //then + //0원이 입력 될 경우 + assertEquals(0.00000001,unitPricePolicy.getUnitPrice(0)); + + //최저가 보다 낮은 금액을 입력했을 때 + assertEquals(0.00000001,unitPricePolicy.getUnitPrice(0.0000999)); + assertEquals(0.0000001,unitPricePolicy.getUnitPrice(0.000999)); + + //최고가 보다 높은 금액을 입력했을 때 + assertEquals(500,unitPricePolicy.getUnitPrice(1_999_999.9999999)); + assertEquals(1_000,unitPricePolicy.getUnitPrice(2_000_000.0000009)); + } + @DisplayName("opening_price는 음수보다 높아야 합니다.") + @Test + void testNegativeValueThrowsException(){ + assertEquals(0.00000001,unitPricePolicy.getUnitPrice(-15)); + } +} \ No newline at end of file