package business; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import ru.lionarius.api.CurrencyExchange; import ru.lionarius.api.client.Client; import ru.lionarius.api.currency.Currency; import ru.lionarius.api.currency.CurrencyPair; import ru.lionarius.api.order.OrderType; import java.util.ArrayList; import java.util.Set; import java.util.UUID; import java.util.concurrent.*; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.*; public abstract class ConcurrentCurrencyExchangeTest { protected abstract CurrencyExchange createExchange(Set pairs); protected abstract void shutdownExchange(CurrencyExchange exchange); private CurrencyExchange exchange; private CurrencyPair RUB_CNY; private ExecutorService executorService; @BeforeEach void setUp() { Currency RUB = new Currency("RUB"); Currency CNY = new Currency("CNY"); RUB_CNY = new CurrencyPair(RUB, CNY); exchange = createExchange(Set.of(RUB_CNY)); executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); } @Test void testConcurrentClientCreation() throws InterruptedException { int numClients = 100; var latch = new CountDownLatch(numClients); var futures = new ArrayList>(); for (int i = 0; i < numClients; i++) { var future = CompletableFuture.supplyAsync(() -> { try { return exchange.createClient("Client " + UUID.randomUUID()).join(); } finally { latch.countDown(); } }, executorService); futures.add(future); } latch.await(30, TimeUnit.SECONDS); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); assertEquals(numClients, futures.stream().map(CompletableFuture::join).distinct().count(), "All created clients should be unique"); } @Test void testConcurrentOrderPlacement() throws InterruptedException { var numSellers = 10; var numBuyers = 10; var ordersPerClient = 20; var latch = new CountDownLatch(numSellers + numBuyers); var sellers = new ArrayList(); for (int i = 0; i < numSellers; i++) { var seller = exchange.createClient("Seller " + i).join(); sellers.add(seller); } var buyers = new ArrayList(); for (int i = 0; i < numBuyers; i++) { var buyer = exchange.createClient("Buyer " + i).join(); buyers.add(buyer); } var sellerFutures = sellers.stream() .map(seller -> CompletableFuture.runAsync(() -> { try { for (int i = 0; i < ordersPerClient; i++) { exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 10.0, 10.0).join(); Thread.sleep(10); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }, executorService)) .toList(); var buyerFutures = buyers.stream() .map(buyer -> CompletableFuture.runAsync(() -> { try { for (int i = 0; i < ordersPerClient; i++) { exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 1.2, 10.0).join(); Thread.sleep(10); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }, executorService)) .toList(); latch.await(30, TimeUnit.SECONDS); CompletableFuture.allOf( Stream.concat(sellerFutures.stream(), buyerFutures.stream()) .toArray(CompletableFuture[]::new) ).join(); for (var seller : sellers) { assertTrue((long) exchange.getOrders(seller.id()).join().size() <= ordersPerClient); } for (var buyer : buyers) { assertTrue((long) exchange.getOrders(buyer.id()).join().size() <= ordersPerClient); } } @Test void testConcurrentOrderMatchingWithDifferentPrices() throws InterruptedException { var numOrders = 50; var latch = new CountDownLatch(2); var seller = exchange.createClient("Seller").join(); var buyer = exchange.createClient("Buyer").join(); var sellerFuture = CompletableFuture.runAsync(() -> { try { for (int i = 0; i < numOrders; i++) { var price = 1.0 + (i * 0.01); exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, price, 10.0).join(); Thread.sleep(5); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }, executorService); var buyerFuture = CompletableFuture.runAsync(() -> { try { for (var i = 0; i < numOrders; i++) { var price = 2.0 - (i * 0.01); exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, price, 10.0).join(); Thread.sleep(5); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { latch.countDown(); } }, executorService); latch.await(30, TimeUnit.SECONDS); CompletableFuture.allOf(sellerFuture, buyerFuture).join(); var sellerOrders = exchange.getOrders(seller.id()).join(); var buyerOrders = exchange.getOrders(buyer.id()).join(); var remainingSellerOrders = sellerOrders.stream().filter(order -> !order.closed()).count(); var remainingBuyerOrders = buyerOrders.stream().filter(order -> !order.closed()).count(); assertTrue(remainingSellerOrders + remainingBuyerOrders < numOrders * 2, "Some orders should have been matched"); } @Test void testBalanceConsistencyWithConcurrentTradesSamePrices() throws InterruptedException { var numTraders = 100; var numOrders = 1000; var latch = new CountDownLatch(numTraders * 2); var sellers = new ArrayList(); var buyers = new ArrayList(); var sellAmount = 100.0; var buyAmount = 100.0; for (var i = 0; i < numTraders; i++) { var seller = exchange.createClient("Seller " + i).join(); var buyer = exchange.createClient("Buyer " + i).join(); sellers.add(seller); buyers.add(buyer); } var futures = new ArrayList>(); for (var seller : sellers) { var future = CompletableFuture.runAsync(() -> { try { for (var i = 0; i < numOrders; i++) { exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, buyAmount, sellAmount).join(); } } finally { latch.countDown(); } }, executorService); futures.add(future); } for (var buyer : buyers) { var future = CompletableFuture.runAsync(() -> { try { for (var i = 0; i < numOrders; i++) { exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, sellAmount, buyAmount).join(); } } finally { latch.countDown(); } }, executorService); futures.add(future); } latch.await(30, TimeUnit.SECONDS); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); var totalRubMoney = 0.0; var totalCnyMoney = 0.0; for (var seller : sellers) { var orders = exchange.getOrders(seller.id()).join(); totalCnyMoney += orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); totalRubMoney += orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); } assertEquals(numOrders * numTraders * sellAmount, totalCnyMoney, 0.001); assertEquals(numOrders * numTraders * buyAmount, totalRubMoney, 0.001); for (var buyer : buyers) { var orders = exchange.getOrders(buyer.id()).join(); totalCnyMoney -= orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); totalRubMoney -= orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); } assertEquals(0.0, totalRubMoney, 0.001); assertEquals(0.0, totalCnyMoney, 0.001); } @Test void testBalanceConsistencyWithConcurrentTrades() throws InterruptedException { var numTraders = 100; var numOrders = 1000; var latch = new CountDownLatch(numTraders * 3); var sellers = new ArrayList(); var buyers = new ArrayList(); var sellAmount = 100.0; var buyAmount = 50.0; for (var i = 0; i < numTraders; i++) { var seller = exchange.createClient("Seller " + i).join(); var buyer = exchange.createClient("Buyer " + i).join(); var buyer2 = exchange.createClient("Buyer " + i).join(); sellers.add(seller); buyers.add(buyer); buyers.add(buyer2); } var futures = new ArrayList>(); for (var seller : sellers) { var future = CompletableFuture.runAsync(() -> { try { for (var i = 0; i < numOrders; i++) { exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, buyAmount, sellAmount).join(); } } finally { latch.countDown(); } }, executorService); futures.add(future); } for (var buyer : buyers) { var future = CompletableFuture.runAsync(() -> { try { for (var i = 0; i < numOrders; i++) { exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, sellAmount, buyAmount).join(); } } finally { latch.countDown(); } }, executorService); futures.add(future); } latch.await(30, TimeUnit.SECONDS); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); var totalRubMoney = 0.0; var totalCnyMoney = 0.0; for (var seller : sellers) { var orders = exchange.getOrders(seller.id()).join(); totalCnyMoney += orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); totalRubMoney += orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); } assertEquals(numOrders * numTraders * sellAmount, totalCnyMoney, 0.001); assertEquals(numOrders * numTraders * buyAmount, totalRubMoney, 0.001); for (var buyer : buyers) { var orders = exchange.getOrders(buyer.id()).join(); totalCnyMoney -= orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); totalRubMoney -= orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); } assertEquals(0.0, totalCnyMoney, 0.001); assertEquals(0.0, totalRubMoney, 0.001); } @AfterEach void tearDown() { executorService.shutdown(); try { if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException e) { executorService.shutdownNow(); } shutdownExchange(exchange); } }