From 858fce0797aa237fcd2d3a3f91e43b5a7142476d Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:25:19 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(settlement):=20BUY=20=EC=B8=A1=20?= =?UTF-8?q?=EC=A0=95=EC=82=B0=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20Order.fill()=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/coinflow/order/domain/Order.java | 17 ++++++++++---- .../coinflow/order/service/OrderService.java | 23 +++++++++++++++---- .../com/coinflow/wallet/domain/Wallet.java | 4 ++++ 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/coinflow/order/domain/Order.java b/src/main/java/com/coinflow/order/domain/Order.java index 40d6ccf..3b9e629 100644 --- a/src/main/java/com/coinflow/order/domain/Order.java +++ b/src/main/java/com/coinflow/order/domain/Order.java @@ -5,6 +5,7 @@ import lombok.NoArgsConstructor; import java.math.BigDecimal; +import java.math.RoundingMode; import java.time.LocalDateTime; @Getter @@ -89,17 +90,22 @@ public boolean isCancelable() { } public BigDecimal releasableAmount() { - if (side == OrderSide.BUY) { - return lockedAmount.subtract(executedQuoteAmount); - } - return remainingQuantity; + return lockedAmount; } - public void fill(BigDecimal quantity, BigDecimal quoteAmount) { + public void fill(BigDecimal quantity, BigDecimal quoteAmount, int amountScale) { this.executedQuantity = this.executedQuantity.add(quantity); this.executedQuoteAmount = this.executedQuoteAmount.add(quoteAmount); this.remainingQuantity = this.remainingQuantity.subtract(quantity); + if (this.side == OrderSide.BUY) { + this.lockedAmount = (this.remainingQuantity.compareTo(BigDecimal.ZERO) == 0) + ? BigDecimal.ZERO + : this.price.multiply(this.remainingQuantity).setScale(amountScale, RoundingMode.CEILING); + } else { + this.lockedAmount = this.lockedAmount.subtract(quantity); + } + if (this.remainingQuantity.compareTo(BigDecimal.ZERO) == 0) { this.status = OrderStatus.FILLED; this.closedAt = LocalDateTime.now(); @@ -110,6 +116,7 @@ public void fill(BigDecimal quantity, BigDecimal quoteAmount) { public void cancel() { this.status = OrderStatus.CANCELED; + this.lockedAmount = BigDecimal.ZERO; this.closedAt = LocalDateTime.now(); } diff --git a/src/main/java/com/coinflow/order/service/OrderService.java b/src/main/java/com/coinflow/order/service/OrderService.java index e8fbd1c..4b75a22 100644 --- a/src/main/java/com/coinflow/order/service/OrderService.java +++ b/src/main/java/com/coinflow/order/service/OrderService.java @@ -226,17 +226,30 @@ private List settle(Market market, Order taker, List matchRe for (MatchResult result : matchResults) { Order maker = orderRepository.findById(result.makerOrderId()).orElseThrow(); - maker.fill(result.quantity(), result.quoteAmount()); - taker.fill(result.quantity(), result.quoteAmount()); + boolean takerIsBuy = taker.getSide() == OrderSide.BUY; + Order buyOrder = takerIsBuy ? taker : maker; + + // Capture buy order's locked amount BEFORE fill so we can compute what was released + BigDecimal oldBuyLocked = buyOrder.getLockedAmount(); + + maker.fill(result.quantity(), result.quoteAmount(), market.getAmountScale()); + taker.fill(result.quantity(), result.quoteAmount(), market.getAmountScale()); + + // buyerReleased = portion of locked consumed this fill (quoteAmount paid + any rounding refund) + BigDecimal buyerReleased = oldBuyLocked.subtract(buyOrder.getLockedAmount()); + BigDecimal buyerRefund = buyerReleased.subtract(result.quoteAmount()); Wallet buyerBaseWallet = walletRepository.findByUserIdAndAssetWithLock(result.buyUserId(), market.getBaseAsset()).orElseThrow(); Wallet sellerQuoteWallet = walletRepository.findByUserIdAndAssetWithLock(result.sellUserId(), market.getQuoteAsset()).orElseThrow(); Wallet sellerBaseWallet = walletRepository.findByUserIdAndAssetWithLock(result.sellUserId(), market.getBaseAsset()).orElseThrow(); Wallet buyerQuoteWallet = walletRepository.findByUserIdAndAssetWithLock(result.buyUserId(), market.getQuoteAsset()).orElseThrow(); - buyerQuoteWallet.unlock(result.quoteAmount()); + buyerQuoteWallet.consumeLocked(buyerReleased); + if (buyerRefund.compareTo(BigDecimal.ZERO) > 0) { + buyerQuoteWallet.deposit(buyerRefund); + } buyerBaseWallet.deposit(result.quantity()); - sellerBaseWallet.unlock(result.quantity()); + sellerBaseWallet.consumeLocked(result.quantity()); sellerQuoteWallet.deposit(result.quoteAmount()); Trade trade = Trade.create( @@ -254,7 +267,7 @@ private List settle(Market market, Order taker, List matchRe walletLedgerRepository.save(WalletLedger.create( buyerQuoteWallet, LedgerType.TRADE_BUY_QUOTE_SETTLE, - result.quoteAmount(), result.quoteAmount().negate(), + buyerRefund, buyerReleased.negate(), buyOrderId, tradeId )); walletLedgerRepository.save(WalletLedger.create( diff --git a/src/main/java/com/coinflow/wallet/domain/Wallet.java b/src/main/java/com/coinflow/wallet/domain/Wallet.java index 8e5f810..ba01cb9 100644 --- a/src/main/java/com/coinflow/wallet/domain/Wallet.java +++ b/src/main/java/com/coinflow/wallet/domain/Wallet.java @@ -49,6 +49,10 @@ public void unlock(BigDecimal amount) { this.availableBalance = this.availableBalance.add(amount); } + public void consumeLocked(BigDecimal amount) { + this.lockedBalance = this.lockedBalance.subtract(amount); + } + @PrePersist public void prePersist() { LocalDateTime now = LocalDateTime.now(); From fa70a21719fd646688397849918d16f0a922b99e Mon Sep 17 00:00:00 2001 From: ohhalim Date: Thu, 14 May 2026 12:25:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?test(integration):=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=C2=B7=EC=A0=95=EC=82=B0=20=ED=86=B5=ED=95=A9=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(SET-001/001b/002/005?= =?UTF-8?q?,=20CAN-002,=20MAT-001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/MatchingSettlementTest.java | 359 ++++++++++++++++++ 1 file changed, 359 insertions(+) create mode 100644 src/test/java/com/coinflow/integration/MatchingSettlementTest.java diff --git a/src/test/java/com/coinflow/integration/MatchingSettlementTest.java b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java new file mode 100644 index 0000000..a70dc40 --- /dev/null +++ b/src/test/java/com/coinflow/integration/MatchingSettlementTest.java @@ -0,0 +1,359 @@ +package com.coinflow.integration; + +import com.coinflow.auth.repository.UserRepository; +import com.coinflow.order.matching.MatchingEngine; +import com.coinflow.support.TestcontainersConfig; +import com.coinflow.wallet.domain.Wallet; +import com.coinflow.wallet.repository.WalletRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.context.annotation.Import; +import org.springframework.http.*; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SuppressWarnings("rawtypes") +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestcontainersConfig.class) +class MatchingSettlementTest { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private UserRepository userRepository; + @Autowired private WalletRepository walletRepository; + @Autowired private MatchingEngine matchingEngine; + + @BeforeEach + void setUp() { + matchingEngine.clearAll(); + } + + // ── SET-001: BUY taker 가격차이 환불 ───────────────────────────── + // 매도가(98,000,000) < 매수가(100,000,000), 0.0001 BTC 체결 + // locked=10,000 KRW, quoteAmount=9,800 KRW → 환불 200 KRW + + @Test + void SET_001_BUY_taker_체결가_낮으면_잔여_KRW_환불() { + String buyerToken = signupAndLogin("set001-buyer@example.com"); + String sellerToken = signupAndLogin("set001-seller@example.com"); + depositKrw("set001-buyer@example.com", new BigDecimal("10000")); + depositBtc("set001-seller@example.com", new BigDecimal("0.001")); + + // maker: SELL at 98,000,000 + createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.0001", null); + + // taker: BUY at 100,000,000 → locks 10,000 KRW, matches at 9,800 KRW + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); + + var buyer = userRepository.findByEmail("set001-buyer@example.com").orElseThrow(); + var seller = userRepository.findByEmail("set001-seller@example.com").orElseThrow(); + + var buyerKrw = findWallet(buyer.getId(), "KRW"); + var buyerBtc = findWallet(buyer.getId(), "BTC"); + var sellerKrw = findWallet(seller.getId(), "KRW"); + var sellerBtc = findWallet(seller.getId(), "BTC"); + + // 환불 200 KRW 확인 + assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("200"); + assertThat(buyerKrw.getLockedBalance()).isEqualByComparingTo("0"); + assertThat(buyerBtc.getAvailableBalance()).isEqualByComparingTo("0.0001"); + + // seller는 9,800 KRW 수령 + assertThat(sellerKrw.getAvailableBalance()).isEqualByComparingTo("9800"); + assertThat(sellerBtc.getLockedBalance()).isEqualByComparingTo("0"); + + // 자산 보존 불변식: buyer 소비 KRW = seller 수령 KRW + buyer 환불 KRW + // 10,000 = 9,800 + 200 ✅ + } + + // ── SET-001b: 부분 체결 반복 시 rounding 누적 ──────────────────── + // maker1 SELL 98,000,000 / maker2 SELL 99,000,000 + // taker BUY 100,000,000 qty=0.0002 → 두 번 체결 + // 1차 locked 차감: 20,000→10,000 released 10,000 refund 200 + // 2차 locked 차감: 10,000→0 released 10,000 refund 100 + // 최종 buyer KRW available = 300, BTC = 0.0002 + + @Test + void SET_001b_부분_체결_반복_환불_누적() { + String buyerToken = signupAndLogin("set001b-buyer@example.com"); + String seller1Token = signupAndLogin("set001b-seller1@example.com"); + String seller2Token = signupAndLogin("set001b-seller2@example.com"); + depositKrw("set001b-buyer@example.com", new BigDecimal("20000")); + depositBtc("set001b-seller1@example.com", new BigDecimal("0.001")); + depositBtc("set001b-seller2@example.com", new BigDecimal("0.001")); + + // maker1: cheaper → matched first + createOrder(seller1Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.0001", null); + // maker2: slightly more expensive + createOrder(seller2Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "99000000", "0.0001", null); + + // taker BUY 0.0002 → fully filled across two makers + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0002", null); + + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); + + var trades = (List) buyResponse.getBody().get("trades"); + assertThat(trades).hasSize(2); + + var buyer = userRepository.findByEmail("set001b-buyer@example.com").orElseThrow(); + var buyerKrw = findWallet(buyer.getId(), "KRW"); + var buyerBtc = findWallet(buyer.getId(), "BTC"); + + // 총 지출: 9,800 + 9,900 = 19,700 → 환불: 300 KRW + assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("300"); + assertThat(buyerKrw.getLockedBalance()).isEqualByComparingTo("0"); + assertThat(buyerBtc.getAvailableBalance()).isEqualByComparingTo("0.0002"); + } + + // ── SET-002: SELL taker 정산 ───────────────────────────────────── + + @Test + void SET_002_SELL_taker_정산_정확성() { + String buyerToken = signupAndLogin("set002-buyer@example.com"); + String sellerToken = signupAndLogin("set002-seller@example.com"); + depositKrw("set002-buyer@example.com", new BigDecimal("10000")); + depositBtc("set002-seller@example.com", new BigDecimal("0.001")); + + // maker: BUY + createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + // taker: SELL → 전량 체결 + var sellResponse = createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + + assertThat(sellResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(sellResponse.getBody().get("status")).isEqualTo("FILLED"); + + var buyer = userRepository.findByEmail("set002-buyer@example.com").orElseThrow(); + var seller = userRepository.findByEmail("set002-seller@example.com").orElseThrow(); + + var buyerKrw = findWallet(buyer.getId(), "KRW"); + var buyerBtc = findWallet(buyer.getId(), "BTC"); + var sellerKrw = findWallet(seller.getId(), "KRW"); + var sellerBtc = findWallet(seller.getId(), "BTC"); + + // 동일 가격이라 환불 없음 + assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("0"); + assertThat(buyerKrw.getLockedBalance()).isEqualByComparingTo("0"); + assertThat(buyerBtc.getAvailableBalance()).isEqualByComparingTo("0.0001"); + + assertThat(sellerKrw.getAvailableBalance()).isEqualByComparingTo("10000"); + assertThat(sellerBtc.getAvailableBalance()).isEqualByComparingTo("0.0009"); + assertThat(sellerBtc.getLockedBalance()).isEqualByComparingTo("0"); + } + + // ── SET-005: Self-trade mixed candidates ───────────────────────── + // user2 SELL seq=1 (price 99,000,000) + user1 SELL seq=2 (price 100,000,000) + // user1 BUY at 100,000,000 → hasSelfTrade가 user1의 SELL을 탐지 → 전체 거절 + + @Test + void SET_005_호가창에_타인_주문_있어도_자기체결_후보_있으면_거절() { + String user1Token = signupAndLogin("set005-user1@example.com"); + String user2Token = signupAndLogin("set005-user2@example.com"); + depositKrw("set005-user1@example.com", new BigDecimal("10000000")); + depositBtc("set005-user1@example.com", new BigDecimal("0.001")); + depositBtc("set005-user2@example.com", new BigDecimal("0.001")); + + // user2 SELL at 99,000,000 (더 싸서 매칭 우선) + createOrder(user2Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "99000000", "0.0001", null); + // user1 SELL at 100,000,000 (자기 주문) + createOrder(user1Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + + // user1 BUY at 100,000,000 → user1 자신의 SELL과 가격 교차 → 거절 + var buyResponse = createOrder(user1Token, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(buyResponse.getBody().get("code")).isEqualTo("SELF_TRADE_NOT_ALLOWED"); + + // user1 KRW 잔고 변동 없음 (주문 거절 → lock 없음) + var user1 = userRepository.findByEmail("set005-user1@example.com").orElseThrow(); + var user1Krw = findWallet(user1.getId(), "KRW"); + assertThat(user1Krw.getLockedBalance()).isEqualByComparingTo("0"); + assertThat(user1Krw.getAvailableBalance()).isEqualByComparingTo("10000000"); + } + + // ── CAN-002: 부분 체결 후 취소 ─────────────────────────────────── + // BUY 0.0002 중 0.0001 체결 → PARTIALLY_FILLED + // 취소 시 남은 locked(10,000 KRW) 반환 + + @Test + void CAN_002_부분_체결_후_취소_lockedAmount_정확() { + String buyerToken = signupAndLogin("can002-buyer@example.com"); + String sellerToken = signupAndLogin("can002-seller@example.com"); + depositKrw("can002-buyer@example.com", new BigDecimal("20000")); + depositBtc("can002-seller@example.com", new BigDecimal("0.001")); + + // maker: SELL 0.0001 at 100,000,000 + createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + + // taker: BUY 0.0002 → 0.0001만 체결, 나머지 OPEN + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0002", null); + Long buyOrderId = ((Number) buyResponse.getBody().get("orderId")).longValue(); + assertThat(buyResponse.getBody().get("status")).isEqualTo("PARTIALLY_FILLED"); + + var buyer = userRepository.findByEmail("can002-buyer@example.com").orElseThrow(); + var buyerKrw = findWallet(buyer.getId(), "KRW"); + // 체결 후: available=0 locked=10,000 (남은 0.0001 BTC 분 KRW) + assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("0"); + assertThat(buyerKrw.getLockedBalance()).isEqualByComparingTo("10000"); + + // 취소 + var cancelResponse = cancelOrder(getToken("can002-buyer@example.com"), buyOrderId); + assertThat(cancelResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(cancelResponse.getBody().get("status")).isEqualTo("CANCELED"); + + buyerKrw = findWallet(buyer.getId(), "KRW"); + // 취소 후: locked 10,000 → available 복귀 + assertThat(buyerKrw.getAvailableBalance()).isEqualByComparingTo("10000"); + assertThat(buyerKrw.getLockedBalance()).isEqualByComparingTo("0"); + assertThat(findWallet(buyer.getId(), "BTC").getAvailableBalance()).isEqualByComparingTo("0.0001"); + } + + // ── MAT-001: 가격 우선순위 ──────────────────────────────────────── + // 3개의 SELL이 다른 가격으로 등록 → BUY는 가장 낮은 가격 SELL과 체결 + + @Test + void MAT_001_가격_우선순위_가장_낮은_SELL과_체결() { + String buyerToken = signupAndLogin("mat001-buyer@example.com"); + String seller1Token = signupAndLogin("mat001-seller1@example.com"); + String seller2Token = signupAndLogin("mat001-seller2@example.com"); + String seller3Token = signupAndLogin("mat001-seller3@example.com"); + depositKrw("mat001-buyer@example.com", new BigDecimal("10000")); + depositBtc("mat001-seller1@example.com", new BigDecimal("0.001")); + depositBtc("mat001-seller2@example.com", new BigDecimal("0.001")); + depositBtc("mat001-seller3@example.com", new BigDecimal("0.001")); + + createOrder(seller1Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "99000000", "0.0001", null); + createOrder(seller2Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "100000000", "0.0001", null); + createOrder(seller3Token, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.0001", null); // 최저가 + + // BUY at 100,000,000 → seller3(98,000,000)과 체결 + var buyResponse = createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.0001", null); + + assertThat(buyResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(buyResponse.getBody().get("status")).isEqualTo("FILLED"); + + var trades = (List) buyResponse.getBody().get("trades"); + assertThat(trades).hasSize(1); + var trade = (Map) trades.get(0); + assertThat(trade.get("price")).isEqualTo("98000000"); // 최저가 체결 + + // buyer: 9,800 KRW 지출, 200 환불, 0.0001 BTC 수령 + var buyer = userRepository.findByEmail("mat001-buyer@example.com").orElseThrow(); + assertThat(findWallet(buyer.getId(), "KRW").getAvailableBalance()).isEqualByComparingTo("200"); + assertThat(findWallet(buyer.getId(), "BTC").getAvailableBalance()).isEqualByComparingTo("0.0001"); + + // seller3 (98,000,000): 9,800 KRW 수령 + var seller3 = userRepository.findByEmail("mat001-seller3@example.com").orElseThrow(); + assertThat(findWallet(seller3.getId(), "KRW").getAvailableBalance()).isEqualByComparingTo("9800"); + + // seller1, seller2: 변동 없음 (체결 안 됨) + var seller1 = userRepository.findByEmail("mat001-seller1@example.com").orElseThrow(); + var seller2 = userRepository.findByEmail("mat001-seller2@example.com").orElseThrow(); + assertThat(findWallet(seller1.getId(), "KRW").getAvailableBalance()).isEqualByComparingTo("0"); + assertThat(findWallet(seller2.getId(), "KRW").getAvailableBalance()).isEqualByComparingTo("0"); + } + + // ── 불변식: wallet 잔고 음수 불가 ──────────────────────────────── + + @Test + void INVARIANT_체결_후_모든_지갑_잔고_음수_불가() { + String buyerToken = signupAndLogin("inv001-buyer@example.com"); + String sellerToken = signupAndLogin("inv001-seller@example.com"); + depositKrw("inv001-buyer@example.com", new BigDecimal("98000")); + depositBtc("inv001-seller@example.com", new BigDecimal("0.01")); + + createOrder(sellerToken, "BTC-KRW", "SELL", "LIMIT", "GTC", "98000000", "0.001", null); + createOrder(buyerToken, "BTC-KRW", "BUY", "LIMIT", "GTC", "100000000", "0.001", null); + + var buyer = userRepository.findByEmail("inv001-buyer@example.com").orElseThrow(); + var seller = userRepository.findByEmail("inv001-seller@example.com").orElseThrow(); + + walletRepository.findAllByUserId(buyer.getId()).forEach(w -> { + assertThat(w.getAvailableBalance()).isGreaterThanOrEqualTo(BigDecimal.ZERO); + assertThat(w.getLockedBalance()).isGreaterThanOrEqualTo(BigDecimal.ZERO); + }); + walletRepository.findAllByUserId(seller.getId()).forEach(w -> { + assertThat(w.getAvailableBalance()).isGreaterThanOrEqualTo(BigDecimal.ZERO); + assertThat(w.getLockedBalance()).isGreaterThanOrEqualTo(BigDecimal.ZERO); + }); + } + + // ── helpers ─────────────────────────────────────────────────────── + + private final Map tokenCache = new java.util.HashMap<>(); + + private String signupAndLogin(String email) { + restTemplate.postForEntity( + "/api/v1/auth/signup", + Map.of("email", email, "password", "password1234", "nickname", "tester"), + Map.class + ); + var loginResponse = restTemplate.postForEntity( + "/api/v1/auth/login", + Map.of("email", email, "password", "password1234"), + Map.class + ); + String token = (String) loginResponse.getBody().get("accessToken"); + tokenCache.put(email, token); + return token; + } + + private String getToken(String email) { + return tokenCache.get(email); + } + + private Wallet findWallet(Long userId, String asset) { + return walletRepository.findAllByUserId(userId).stream() + .filter(w -> w.getAsset().equals(asset)) + .findFirst().orElseThrow(); + } + + private void depositKrw(String email, BigDecimal amount) { + deposit(email, "KRW", amount); + } + + private void depositBtc(String email, BigDecimal amount) { + deposit(email, "BTC", amount); + } + + private void deposit(String email, String asset, BigDecimal amount) { + var user = userRepository.findByEmail(email).orElseThrow(); + Wallet wallet = walletRepository.findAllByUserId(user.getId()).stream() + .filter(w -> w.getAsset().equals(asset)) + .findFirst().orElseThrow(); + wallet.deposit(amount); + walletRepository.save(wallet); + } + + private ResponseEntity createOrder(String token, String market, String side, String type, + String timeInForce, String price, String quantity, + String clientOrderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + var body = new java.util.HashMap(); + body.put("market", market); + body.put("side", side); + body.put("type", type); + body.put("timeInForce", timeInForce); + body.put("price", price); + body.put("quantity", quantity); + if (clientOrderId != null) body.put("clientOrderId", clientOrderId); + return restTemplate.exchange("/api/v1/orders", HttpMethod.POST, new HttpEntity<>(body, headers), Map.class); + } + + private ResponseEntity cancelOrder(String token, Long orderId) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(token); + return restTemplate.exchange("/api/v1/orders/" + orderId + "/cancel", HttpMethod.POST, new HttpEntity<>(headers), Map.class); + } +}