diff --git a/build.gradle b/build.gradle index 8121a72..8a32d62 100644 --- a/build.gradle +++ b/build.gradle @@ -12,6 +12,8 @@ repositories { dependencies { testImplementation platform('org.junit:junit-bom:5.10.0') testImplementation 'org.junit.jupiter:junit-jupiter' + + implementation 'com.google.guava:guava:33.2.1-jre' } test { diff --git a/src/main/java/ru/lionarius/api/CurrencyExchange.java b/src/main/java/ru/lionarius/api/CurrencyExchange.java new file mode 100644 index 0000000..aa97ffd --- /dev/null +++ b/src/main/java/ru/lionarius/api/CurrencyExchange.java @@ -0,0 +1,28 @@ +package ru.lionarius.api; + +import ru.lionarius.api.client.Client; +import ru.lionarius.api.currency.Currency; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.Order; +import ru.lionarius.api.order.OrderType; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public interface CurrencyExchange { + CompletableFuture createClient(String name); + + CompletableFuture deposit(UUID clientId, Currency currency, double amount); + + CompletableFuture withdraw(UUID clientId, Currency currency, double amount); + + CompletableFuture> placeOrder(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity); + + CompletableFuture cancelOrder(UUID clientId, UUID orderId); + + CompletableFuture> getActiveOrders(UUID clientId); + + CompletableFuture> getBalances(UUID clientId); +} diff --git a/src/main/java/ru/lionarius/api/client/Balance.java b/src/main/java/ru/lionarius/api/client/Balance.java new file mode 100644 index 0000000..8a33316 --- /dev/null +++ b/src/main/java/ru/lionarius/api/client/Balance.java @@ -0,0 +1,27 @@ +package ru.lionarius.api.client; + +import com.google.common.collect.ImmutableMap; +import ru.lionarius.api.currency.Currency; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class Balance { + private final Map balances = new ConcurrentHashMap<>(); + + public Map getBalances() { + return ImmutableMap.copyOf(balances); + } + + public double getBalance(Currency currency) { + return balances.getOrDefault(currency, 0.0); + } + + public void deposit(Currency currency, double amount) { + balances.compute(currency, (k, v) -> v == null ? amount : v + amount); + } + + public void withdraw(Currency currency, double amount) { + balances.compute(currency, (k, v) -> v == null ? -amount : v - amount); + } +} diff --git a/src/main/java/ru/lionarius/api/client/Client.java b/src/main/java/ru/lionarius/api/client/Client.java new file mode 100644 index 0000000..1f39e24 --- /dev/null +++ b/src/main/java/ru/lionarius/api/client/Client.java @@ -0,0 +1,23 @@ +package ru.lionarius.api.client; + +import java.util.UUID; + +public record Client(UUID id, String name) { + public Client(String name) { + this(UUID.randomUUID(), name); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Client client) { + return id.equals(client.id); + } + + return false; + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/src/main/java/ru/lionarius/api/client/ClientRepository.java b/src/main/java/ru/lionarius/api/client/ClientRepository.java new file mode 100644 index 0000000..f31921f --- /dev/null +++ b/src/main/java/ru/lionarius/api/client/ClientRepository.java @@ -0,0 +1,12 @@ +package ru.lionarius.api.client; + +import java.util.Optional; +import java.util.UUID; + +public interface ClientRepository { + Client createClient(String name); + + Optional getClient(UUID clientId); + + Optional getClientBalance(UUID clientId); +} diff --git a/src/main/java/ru/lionarius/api/currency/Currency.java b/src/main/java/ru/lionarius/api/currency/Currency.java new file mode 100644 index 0000000..4ef3397 --- /dev/null +++ b/src/main/java/ru/lionarius/api/currency/Currency.java @@ -0,0 +1,4 @@ +package ru.lionarius.api.currency; + +public record Currency(String name) { +} diff --git a/src/main/java/ru/lionarius/api/currency/CurrencyPair.java b/src/main/java/ru/lionarius/api/currency/CurrencyPair.java new file mode 100644 index 0000000..3ed7d35 --- /dev/null +++ b/src/main/java/ru/lionarius/api/currency/CurrencyPair.java @@ -0,0 +1,4 @@ +package ru.lionarius.api.currency; + +public record CurrencyPair(Currency base, Currency quote) { +} diff --git a/src/main/java/ru/lionarius/api/order/Order.java b/src/main/java/ru/lionarius/api/order/Order.java new file mode 100644 index 0000000..8a13e16 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/Order.java @@ -0,0 +1,32 @@ +package ru.lionarius.api.order; + +import ru.lionarius.api.client.Client; +import ru.lionarius.api.currency.CurrencyPair; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record Order(UUID id, Client client, OrderType type, CurrencyPair pair, double price, double quantity, + LocalDateTime placedAt) { + public Order(Client client, OrderType type, CurrencyPair pair, double price, double quantity) { + this(UUID.randomUUID(), client, type, pair, price, quantity, LocalDateTime.now()); + } + + public double rate() { + return price() / quantity(); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof Order order) { + return id.equals(order.id); + } + + return false; + } + + @Override + public int hashCode() { + return id.hashCode(); + } +} diff --git a/src/main/java/ru/lionarius/api/order/OrderType.java b/src/main/java/ru/lionarius/api/order/OrderType.java new file mode 100644 index 0000000..2cc16e3 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/OrderType.java @@ -0,0 +1,6 @@ +package ru.lionarius.api.order; + +public enum OrderType { + BUY, + SELL +} diff --git a/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java b/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java new file mode 100644 index 0000000..f66423e --- /dev/null +++ b/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java @@ -0,0 +1,35 @@ +package ru.lionarius.impl; + +import ru.lionarius.api.client.Balance; +import ru.lionarius.api.client.Client; +import ru.lionarius.api.client.ClientRepository; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class InMemoryClientRepository implements ClientRepository { + private final Map clients = new ConcurrentHashMap<>(); + private final Map balances = new ConcurrentHashMap<>(); + + @Override + public Client createClient(String name) { + var client = new Client(name); + + clients.put(client.id(), client); + balances.put(client, new Balance()); + + return client; + } + + @Override + public Optional getClient(UUID clientId) { + return Optional.ofNullable(clients.get(clientId)); + } + + @Override + public Optional getClientBalance(UUID clientId) { + return Optional.ofNullable(balances.get(clients.get(clientId))); + } +} diff --git a/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java b/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java new file mode 100644 index 0000000..89568ea --- /dev/null +++ b/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java @@ -0,0 +1,362 @@ +package ru.lionarius.impl; + +import com.google.common.collect.ImmutableList; +import ru.lionarius.api.CurrencyExchange; +import ru.lionarius.api.client.Balance; +import ru.lionarius.api.client.Client; +import ru.lionarius.api.client.ClientRepository; +import ru.lionarius.api.currency.Currency; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.Order; +import ru.lionarius.api.order.OrderType; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public class PlainCurrencyExchange implements CurrencyExchange { + private final ClientRepository clientRepository = new InMemoryClientRepository(); + private final Map> buyOrders = new ConcurrentHashMap<>(); + private final Map> sellOrders = new ConcurrentHashMap<>(); + + private final ReadWriteLock ordersLock = new ReentrantReadWriteLock(); + + private final List allowedCurrencies; + private final List allowedPairs; + + public PlainCurrencyExchange(List allowedCurrencies, List allowedPairs) { + this.allowedCurrencies = ImmutableList.copyOf(allowedCurrencies); + this.allowedPairs = ImmutableList.copyOf(allowedPairs); + } + + @Override + public CompletableFuture createClient(String name) { + return CompletableFuture.supplyAsync(() -> { + if (name == null) + throw new IllegalArgumentException("Name cannot be null"); + + return clientRepository.createClient(name); + }, Runnable::run); + } + + @Override + public CompletableFuture deposit(UUID clientId, Currency currency, double amount) { + return CompletableFuture.runAsync(() -> { + validateDepositWithdrawInputs(currency, amount); + + var client = clientRepository.getClient(clientId).orElseThrow(); + var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + + balance.deposit(currency, amount); + }, Runnable::run); + } + + @Override + public CompletableFuture withdraw(UUID clientId, Currency currency, double amount) { + return CompletableFuture.runAsync(() -> { + validateDepositWithdrawInputs(currency, amount); + + var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + + var balanceForCurrency = balance.getBalance(currency); + if (balanceForCurrency < amount) + throw new IllegalArgumentException("Not enough balance"); + + balance.withdraw(currency, amount); + }, Runnable::run); + } + + private void validateDepositWithdrawInputs(Currency currency, double amount) { + if (currency == null) + throw new IllegalArgumentException("Currency cannot be null"); + + if (amount < 0) + throw new IllegalArgumentException("Amount cannot be negative"); + + if (!allowedCurrencies.contains(currency)) + throw new IllegalArgumentException("Currency is not allowed"); + } + + @Override + public CompletableFuture> placeOrder(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity) { + return CompletableFuture.supplyAsync(() -> { + validateOrderInputs(pair, type, price, quantity); + + var client = clientRepository.getClient(clientId).orElseThrow(); + var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + + reserveFundsForOrder(balance, pair, type, price, quantity); + + var newOrder = new Order(client, type, pair, price, quantity); + Optional orderToAdd = Optional.empty(); + + ordersLock.writeLock().lock(); + try { + // Try to match the order + if (type == OrderType.BUY) { + orderToAdd = matchBuyOrder(newOrder); + } else { + orderToAdd = matchSellOrder(newOrder); + } + + // If order is not fully filled, add to order books + if (orderToAdd.isPresent()) { + if (type == OrderType.BUY) { + buyOrders.computeIfAbsent(pair, k -> new ArrayList<>()).add(orderToAdd.get()); + } else { + sellOrders.computeIfAbsent(pair, k -> new ArrayList<>()).add(orderToAdd.get()); + } + } + + return orderToAdd; + } finally { + ordersLock.writeLock().unlock(); + } + }, Runnable::run); + } + + @Override + public CompletableFuture cancelOrder(UUID clientId, UUID orderId) { + return CompletableFuture.runAsync(() -> { + if (clientId == null || orderId == null) + throw new IllegalArgumentException("ClientId and orderId cannot be null"); + + var client = clientRepository.getClient(clientId).orElseThrow(); + var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + + ordersLock.writeLock().lock(); + var toCancel = new ArrayList(); + try { + for (var orderList : buyOrders.values()) { + if (orderList != null) { + for (var order : orderList) { + if (order.id().equals(orderId) && order.client().id().equals(client.id())) { + toCancel.add(order); + } + } + } + } + for (var orderList : sellOrders.values()) { + for (var order : orderList) { + if (order.id().equals(orderId) && order.client().id().equals(client.id())) { + toCancel.add(order); + } + } + } + + for (var order : toCancel) { + if (order.type() == OrderType.BUY) + buyOrders.get(order.pair()).remove(order); + else + sellOrders.get(order.pair()).remove(order); + + refundOrder(balance, order); + } + } finally { + ordersLock.writeLock().unlock(); + } + }, Runnable::run); + } + + private void reserveFundsForOrder(Balance balance, CurrencyPair pair, OrderType type, double price, double quantity) { + if (type == OrderType.BUY && balance.getBalance(pair.base()) < price) { + throw new IllegalArgumentException("Insufficient funds for buy order"); + } else if (type == OrderType.SELL && balance.getBalance(pair.quote()) < quantity) { + throw new IllegalArgumentException("Insufficient funds for sell order"); + } + // Deduct funds for order placement + balance.withdraw(type == OrderType.BUY ? pair.base() : pair.quote(), type == OrderType.BUY ? price : quantity); + } + + private void refundOrder(Balance balance, Order order) { + if (order.type() == OrderType.BUY) { + balance.deposit(order.pair().base(), order.price()); + } else { + balance.deposit(order.pair().quote(), order.quantity()); + } + } + + private void validateOrderInputs(CurrencyPair pair, OrderType type, double price, double quantity) { + if (pair == null || type == null || price < 0 || quantity < 0) { + throw new IllegalArgumentException("Invalid order parameters"); + } + if (!allowedPairs.contains(pair)) { + throw new IllegalArgumentException("Pair is not allowed"); + } + } + + private List getMatchingOrders(Order order) { + List oppositeOrders = order.type() == OrderType.BUY ? + sellOrders.getOrDefault(order.pair(), new ArrayList<>()) : + buyOrders.getOrDefault(order.pair(), new ArrayList<>()); + + double orderRate = order.rate(); + + return oppositeOrders.stream() + .filter(oppositeOrder -> { + double oppositeRate = oppositeOrder.rate(); + return order.type() == OrderType.BUY ? + oppositeRate <= orderRate : + oppositeRate >= orderRate; + }) + .sorted((o1, o2) -> { + double rate1 = o1.rate(); + double rate2 = o2.rate(); + int rateCompare = order.type() == OrderType.BUY ? + Double.compare(rate1, rate2) : // For buy orders - ascending rate + Double.compare(rate2, rate1); // For sell orders - descending rate + return rateCompare != 0 ? rateCompare : o1.placedAt().compareTo(o2.placedAt()); + }) + .toList(); + } + + private Optional matchBuyOrder(Order buyOrder) { + var remainingBuyOrder = buyOrder; + List matchingOrders = getMatchingOrders(buyOrder); + + for (Order sellOrder : matchingOrders) { + if (remainingBuyOrder.quantity() <= 0.0) break; + + // Calculate matching quantity based on remaining quote currency amount + double matchQuantity = Math.min(remainingBuyOrder.quantity(), sellOrder.quantity()); + // Calculate corresponding base currency amount using sell order's rate + double matchPrice = matchQuantity * sellOrder.rate(); + + // Create new orders with reduced quantities + var newBuyOrder = new Order( + remainingBuyOrder.client(), + remainingBuyOrder.type(), + remainingBuyOrder.pair(), + remainingBuyOrder.price() - matchQuantity * buyOrder.rate(), + remainingBuyOrder.quantity() - matchQuantity + ); + + var newSellOrder = new Order( + sellOrder.client(), + sellOrder.type(), + sellOrder.pair(), + sellOrder.price() - matchPrice, + sellOrder.quantity() - matchQuantity + ); + + // Execute the trade + executeTrade(remainingBuyOrder, sellOrder, matchQuantity); + + // Update orders in the books + remainingBuyOrder = newBuyOrder; + sellOrders.get(buyOrder.pair()).remove(sellOrder); + if (newSellOrder.quantity() > 0) { + sellOrders.get(buyOrder.pair()).add(newSellOrder); + } + } + + if (remainingBuyOrder.quantity() > 0) { + return Optional.of(remainingBuyOrder); + } + + return Optional.empty(); + } + + private Optional matchSellOrder(Order sellOrder) { + var remainingSellOrder = sellOrder; + List matchingOrders = getMatchingOrders(sellOrder); + + for (Order buyOrder : matchingOrders) { + if (remainingSellOrder.quantity() <= 0.0) break; + + // Calculate matching quantity based on remaining quote currency amount + double matchQuantity = Math.min(remainingSellOrder.quantity(), buyOrder.quantity()); + // Calculate corresponding base currency amount using buy order's rate + double matchPrice = (matchQuantity * buyOrder.price()) / buyOrder.quantity(); + + // Create new orders with reduced quantities + var newSellOrder = new Order( + remainingSellOrder.client(), + remainingSellOrder.type(), + remainingSellOrder.pair(), + remainingSellOrder.price() - matchPrice, + remainingSellOrder.quantity() - matchQuantity + ); + + var newBuyOrder = new Order( + buyOrder.client(), + buyOrder.type(), + buyOrder.pair(), + buyOrder.price() - matchPrice, + buyOrder.quantity() - matchQuantity + ); + + // Execute the trade + executeTrade(buyOrder, remainingSellOrder, matchQuantity); + + // Update orders in the books + remainingSellOrder = newSellOrder; + buyOrders.get(sellOrder.pair()).remove(buyOrder); + if (newBuyOrder.quantity() > 0) { + buyOrders.get(sellOrder.pair()).add(newBuyOrder); + } + } + + if (remainingSellOrder.quantity() > 0) { + return Optional.of(remainingSellOrder); + } + + return Optional.empty(); + } + + private void executeTrade(Order buyOrder, Order sellOrder, double quantity) { + var price = quantity * sellOrder.rate(); + + // Get client balances + var buyerBalance = clientRepository.getClientBalance(buyOrder.client().id()).orElseThrow(); + var sellerBalance = clientRepository.getClientBalance(sellOrder.client().id()).orElseThrow(); + + // Transfer currencies + buyerBalance.deposit(buyOrder.pair().quote(), quantity); // Buyer receives quote currency + sellerBalance.deposit(buyOrder.pair().base(), price); // Seller receives base currency + + buyerBalance.deposit(buyOrder.pair().base(), quantity * buyOrder.rate() - price); // Return price difference to buyer + } + + @Override + public CompletableFuture> getActiveOrders(UUID clientId) { + return CompletableFuture.supplyAsync(() -> { + var client = clientRepository.getClient(clientId).orElseThrow(); + + ordersLock.readLock().lock(); + try { + List clientOrders = new ArrayList<>(); + + // Collect all buy orders for the client + buyOrders.values().forEach(orders -> + orders.stream() + .filter(order -> order.client().id().equals(clientId)) + .forEach(clientOrders::add) + ); + + // Collect all sell orders for the client + sellOrders.values().forEach(orders -> + orders.stream() + .filter(order -> order.client().id().equals(clientId)) + .forEach(clientOrders::add) + ); + + return clientOrders; + } finally { + ordersLock.readLock().unlock(); + } + }, Runnable::run); + } + + @Override + public CompletableFuture> getBalances(UUID clientId) { + return CompletableFuture.supplyAsync(() -> { + var client = clientRepository.getClient(clientId).orElseThrow(); + var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + + return balance.getBalances(); + }, Runnable::run); + } +} diff --git a/src/test/java/ConcurrentCurrencyExchangeTest.java b/src/test/java/ConcurrentCurrencyExchangeTest.java new file mode 100644 index 0000000..27261f7 --- /dev/null +++ b/src/test/java/ConcurrentCurrencyExchangeTest.java @@ -0,0 +1,437 @@ +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.Order; +import ru.lionarius.api.order.OrderType; +import ru.lionarius.impl.PlainCurrencyExchange; + +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.junit.jupiter.api.Assertions.*; + +class ConcurrentCurrencyExchangeTest { + private CurrencyExchange exchange; + private Currency USD; + private Currency EUR; + private CurrencyPair USD_EUR; + private ExecutorService executorService; + + @BeforeEach + void setUp() { + USD = new Currency("USD"); + EUR = new Currency("EUR"); + USD_EUR = new CurrencyPair(USD, EUR); + + exchange = new PlainCurrencyExchange( + Arrays.asList(USD, EUR), + Arrays.asList(USD_EUR) + ); + + executorService = Executors.newFixedThreadPool(10); + } + + @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 testConcurrentDeposits() throws InterruptedException { + int numThreads = 10; + int depositsPerThread = 100; + double depositAmount = 10.0; + var latch = new CountDownLatch(numThreads); + + var client = exchange.createClient("Test Client").join(); + + var futures = new ArrayList>(); + + for (int i = 0; i < numThreads; i++) { + var future = CompletableFuture.runAsync(() -> { + try { + for (int j = 0; j < depositsPerThread; j++) { + exchange.deposit(client.id(), USD, depositAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + var balances = exchange.getBalances(client.id()).join(); + assertEquals(numThreads * depositsPerThread * depositAmount, balances.get(USD)); + } + + @Test + void testConcurrentWithdrawals() throws InterruptedException { + var numThreads = 5; + var latch = new CountDownLatch(numThreads); + var initialBalance = 1000.0; + var withdrawalAmount = 10.0; + + var client = exchange.createClient("Test Client").join(); + exchange.deposit(client.id(), USD, initialBalance).join(); + + var futures = new ArrayList>(); + var successfulWithdrawals = new AtomicInteger(0); + + for (int i = 0; i < numThreads; i++) { + var future = CompletableFuture.runAsync(() -> { + try { + while (true) { + try { + exchange.withdraw(client.id(), USD, withdrawalAmount).join(); + successfulWithdrawals.incrementAndGet(); + } catch (CompletionException e) { + break; + } + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + var finalBalance = exchange.getBalances(client.id()).join(); + assertEquals( + initialBalance, + successfulWithdrawals.get() * withdrawalAmount + finalBalance.get(USD), + 0.001 + ); + } + + @Test + void testConcurrentDepositsAndWithdrawals() throws InterruptedException { + var numThreads = 10; + var initialBalance = 1000.0; + var transactionAmount = 10.0; + var latch = new CountDownLatch(numThreads * 2); + + var client = exchange.createClient("Test Client").join(); + exchange.deposit(client.id(), USD, initialBalance).join(); + + var futures = new ArrayList>(); + var successfulWithdrawals = new AtomicInteger(0); + + for (int i = 0; i < numThreads; i++) { + futures.add(CompletableFuture.runAsync(() -> { + try { + for (int j = 0; j < 100; j++) { + exchange.deposit(client.id(), USD, transactionAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService)); + } + + for (int i = 0; i < numThreads; i++) { + futures.add(CompletableFuture.runAsync(() -> { + try { + while (true) { + try { + exchange.withdraw(client.id(), USD, transactionAmount).join(); + successfulWithdrawals.incrementAndGet(); + } catch (CompletionException e) { + break; + } + } + } finally { + latch.countDown(); + } + }, executorService)); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + var finalBalances = exchange.getBalances(client.id()).join(); + assertEquals( + initialBalance + (numThreads * 100 * transactionAmount) - (successfulWithdrawals.get() * transactionAmount), + finalBalances.get(USD), + 0.001 + ); + } + + @Test + void testConcurrentOrderPlacement() throws InterruptedException { + var numSellers = 5; + var numBuyers = 5; + 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(); + exchange.deposit(seller.id(), EUR, 1000.0).join(); + sellers.add(seller); + } + + var buyers = new ArrayList(); + for (int i = 0; i < numBuyers; i++) { + var buyer = exchange.createClient("Buyer " + i).join(); + exchange.deposit(buyer.id(), USD, 1200.0).join(); + buyers.add(buyer); + } + + var sellerFutures = sellers.stream() + .map(seller -> CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < ordersPerClient; i++) { + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 1.2, 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(), USD_EUR, 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(StreamSupport.stream( + exchange.getActiveOrders(seller.id()).join().spliterator(), false + ).count() <= ordersPerClient); + } + + for (var buyer : buyers) { + assertTrue(StreamSupport.stream( + exchange.getActiveOrders(buyer.id()).join().spliterator(), false + ).count() <= 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(); + + exchange.deposit(seller.id(), EUR, 1000.0).join(); + exchange.deposit(buyer.id(), USD, 2000.0).join(); + + var sellerFuture = CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < numOrders; i++) { + var price = 1.0 + (i * 0.01); + exchange.placeOrder(seller.id(), USD_EUR, 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(), USD_EUR, 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.getActiveOrders(seller.id()).join(); + var buyerOrders = exchange.getActiveOrders(buyer.id()).join(); + + var remainingSellerOrders = StreamSupport.stream(sellerOrders.spliterator(), false).count(); + var remainingBuyerOrders = StreamSupport.stream(buyerOrders.spliterator(), false).count(); + + assertTrue(remainingSellerOrders + remainingBuyerOrders < numOrders * 2, + "Some orders should have been matched"); + } + + @Test + void testConcurrentBalanceCheck() throws InterruptedException { + var numThreads = 10; + var startLatch = new CountDownLatch(1); + var completionLatch = new CountDownLatch(numThreads); + + var client = exchange.createClient("Test Client").join(); + var initialBalance = 1000.0; + exchange.deposit(client.id(), USD, initialBalance).join(); + + var successfulChecks = new AtomicInteger(0); + var futures = new ArrayList>(); + + for (int i = 0; i < numThreads; i++) { + var future = CompletableFuture.runAsync(() -> { + try { + startLatch.await(); + var balance = exchange.getBalances(client.id()).join(); + if (Math.abs(balance.get(USD) - initialBalance) < 0.001) { + successfulChecks.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + completionLatch.countDown(); + } + }, executorService); + futures.add(future); + } + + startLatch.countDown(); + completionLatch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + assertEquals(numThreads, successfulChecks.get(), + "All concurrent balance checks should return the correct balance"); + } + + @Test + void testBalanceConsistencyWithConcurrentTradesAndWithdrawals() throws InterruptedException { + var numTraders = 5; + var latch = new CountDownLatch(numTraders * 2); + + var sellers = new ArrayList(); + var buyers = new ArrayList(); + var initialAmount = 1000.0; + + for (var i = 0; i < numTraders; i++) { + var seller = exchange.createClient("Seller " + i).join(); + var buyer = exchange.createClient("Buyer " + i).join(); + + exchange.deposit(seller.id(), EUR, initialAmount).join(); + exchange.deposit(buyer.id(), USD, initialAmount).join(); + + sellers.add(seller); + buyers.add(buyer); + } + + var futures = new ArrayList>(); + + for (var seller : sellers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < 10; i++) { + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 1.2, 10.0).join(); + try { + exchange.withdraw(seller.id(), EUR, 1.0).join(); + } catch (CompletionException ignored) { + + } + Thread.sleep(10); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + for (var buyer : buyers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < 10; i++) { + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 1.2, 10.0).join(); + try { + exchange.withdraw(buyer.id(), USD, 1.0).join(); + } catch (CompletionException ignored) { + } + Thread.sleep(10); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + for (var client : Stream.concat(sellers.stream(), buyers.stream()).toList()) { + var balances = exchange.getBalances(client.id()).join(); + assertTrue(balances.get(USD) >= 0, "USD balance should not be negative"); + assertTrue(balances.get(EUR) >= 0, "EUR balance should not be negative"); + } + } + + @AfterEach + void tearDown() { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} \ No newline at end of file diff --git a/src/test/java/CurrencyExchangeTest.java b/src/test/java/CurrencyExchangeTest.java new file mode 100644 index 0000000..5df649c --- /dev/null +++ b/src/test/java/CurrencyExchangeTest.java @@ -0,0 +1,493 @@ +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.lionarius.api.CurrencyExchange; +import ru.lionarius.api.currency.Currency; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.OrderType; +import ru.lionarius.impl.PlainCurrencyExchange; + +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.*; + +class CurrencyExchangeTest { + private CurrencyExchange exchange; + private Currency USD; + private Currency EUR; + private CurrencyPair USD_EUR; + + @BeforeEach + void setUp() { + USD = new Currency("USD"); + EUR = new Currency("EUR"); + USD_EUR = new CurrencyPair(USD, EUR); + + exchange = new PlainCurrencyExchange( + Arrays.asList(USD, EUR), + Arrays.asList(USD_EUR) + ); + } + + // Client Tests + @Test + void testCreateClient() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertNotNull(client); + assertNotNull(client.id()); + assertEquals("Trader", client.name()); + } + + @Test + void testCreateClientWithNullName() { + assertThrows(ExecutionException.class, () -> exchange.createClient(null).get()); + } + + @Test + void testGetBalancesNonexistentClient() { + var nonexistentId = UUID.randomUUID(); + assertThrows(Exception.class, () -> exchange.getBalances(nonexistentId).get()); + } + + // Deposit Tests + @Test + void testDeposit() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), USD, 1000.0).get(); + + var balances = exchange.getBalances(client.id()).get(); + assertEquals(1000.0, balances.get(USD)); + } + + @Test + void testDepositNegativeAmount() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertThrows(ExecutionException.class, () -> exchange.deposit(client.id(), USD, -100.0).get()); + } + + @Test + void testDepositWithNullCurrency() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertThrows(ExecutionException.class, () -> exchange.deposit(client.id(), null, 100.0).get()); + } + + // Withdraw Tests + @Test + void testWithdraw() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), USD, 1000.0).get(); + exchange.withdraw(client.id(), USD, 500.0).get(); + + var balances = exchange.getBalances(client.id()).get(); + assertEquals(500.0, balances.get(USD)); + } + + @Test + void testWithdrawInsufficientFunds() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), USD, 100.0).get(); + + assertThrows(ExecutionException.class, () -> exchange.withdraw(client.id(), USD, 200.0).get()); + } + + @Test + void testSequentialDepositWithdraw() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), USD, 500.0).get(); + var balancesAfterDeposit = exchange.getBalances(client.id()).get(); + assertEquals(500.0, balancesAfterDeposit.get(USD)); // Trader has 500 USD + + exchange.withdraw(client.id(), USD, 200.0).get(); + var balancesAfterWithdraw = exchange.getBalances(client.id()).get(); + assertEquals(300.0, balancesAfterWithdraw.get(USD)); // Trader has 300 USD + + assertThrows(ExecutionException.class, () -> exchange.withdraw(client.id(), USD, 400.0).get()); // Trader has only 300 USD left + } + + // Order Tests + @Test + void testPlaceOrder() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), EUR, 1000.0).get(); + exchange.placeOrder(client.id(), USD_EUR, OrderType.SELL, 120, 100.0).get(); + + var activeOrders = exchange.getActiveOrders(client.id()).get(); + assertTrue(activeOrders.iterator().hasNext()); + } + + @Test + void testPlaceOrderInsufficientFunds() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(client.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get() + ); + } + + @Test + void testOrderPriceValidation() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), EUR, 100.0).get(); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(client.id(), USD_EUR, OrderType.SELL, -1.0, 100.0).get() + ); + } + + @Test + void testOrderQuantityValidation() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.deposit(client.id(), EUR, 100.0).get(); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(client.id(), USD_EUR, OrderType.SELL, 120.0, -100.0).get() + ); + } + + // Order Matching Tests + @Test + void testMatchingOrders() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer.id(), USD, 120.0).get(); + exchange.deposit(seller.id(), EUR, 100.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 120.0, 100.0).get(); + + Map buyerBalances = exchange.getBalances(buyer.id()).get(); + Map sellerBalances = exchange.getBalances(seller.id()).get(); + + assertEquals(100.0, buyerBalances.get(EUR)); // Buyer bought 100 EUR + assertEquals(0.0, buyerBalances.get(USD)); // Buyer sold 120 USD so he has no USD left + assertEquals(120.0, sellerBalances.get(USD)); // Seller bought 120 USD + assertEquals(0.0, sellerBalances.get(EUR)); // Seller sold 100 EUR so he has no EUR left + } + + @Test + void testPartialOrderMatching() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer.id(), USD, 120.0).get(); + exchange.deposit(seller.id(), EUR, 100.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 60.0, 50.0).get(); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + var sellerBalances = exchange.getBalances(seller.id()).get(); + + assertEquals(50.0, buyerBalances.get(EUR)); // Buyer bought 50 EUR + assertEquals(60.0, sellerBalances.get(USD)); // Seller bought 60 USD + + var sellerOrders = exchange.getActiveOrders(seller.id()).get(); + var remainingOrder = sellerOrders.iterator().next(); + + // Seller has 60/50 USD/EUR order left + assertEquals(USD_EUR, remainingOrder.pair()); + assertEquals(50.0, remainingOrder.quantity()); + assertEquals(60.0, remainingOrder.price()); + } + + @Test + void testPartialOrderMatchingMultiple() throws ExecutionException, InterruptedException { + var buyer1 = exchange.createClient("Buyer1").get(); + var buyer2 = exchange.createClient("Buyer2").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer1.id(), USD, 120.0).get(); + exchange.deposit(buyer2.id(), USD, 120.0).get(); + exchange.deposit(seller.id(), EUR, 100.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + exchange.placeOrder(buyer1.id(), USD_EUR, OrderType.BUY, 60.0, 50.0).get(); + exchange.placeOrder(buyer2.id(), USD_EUR, OrderType.BUY, 60.0, 50.0).get(); + + var buyer1Balances = exchange.getBalances(buyer1.id()).get(); + var buyer2Balances = exchange.getBalances(buyer2.id()).get(); + var sellerBalances = exchange.getBalances(seller.id()).get(); + + assertEquals(50.0, buyer1Balances.get(EUR)); // Buyer1 bought 50 EUR + assertEquals(50.0, buyer2Balances.get(EUR)); // Buyer2 bought 50 EUR + assertEquals(120.0, sellerBalances.get(USD)); // Seller bought 120 USD + + var sellerOrders = exchange.getActiveOrders(seller.id()).get(); + assertFalse(sellerOrders.iterator().hasNext()); // Seller has no orders left + } + + @Test + void testSequentialOrderPlacementAndMatching() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer.id(), USD, 300.0).get(); + exchange.deposit(seller.id(), EUR, 200.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 120.0, 100.0).get(); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + var sellerBalances = exchange.getBalances(seller.id()).get(); + + assertEquals(100.0, buyerBalances.get(EUR)); // Buyer bought 100 EUR + assertEquals(180.0, buyerBalances.get(USD)); // Buyer spends 120 USD + assertEquals(120.0, sellerBalances.get(USD)); // Seller bought 120 USD + assertEquals(100.0, sellerBalances.get(EUR)); // Seller retains remaining EUR + } + + @Test + void testSequentialMultipleClientsWithPartialMatches() throws ExecutionException, InterruptedException { + var buyer1 = exchange.createClient("Buyer1").get(); + var buyer2 = exchange.createClient("Buyer2").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer1.id(), USD, 60.0).get(); + exchange.deposit(buyer2.id(), USD, 60.0).get(); + exchange.deposit(seller.id(), EUR, 100.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + + exchange.placeOrder(buyer1.id(), USD_EUR, OrderType.BUY, 36.0, 30.0).get(); + exchange.placeOrder(buyer2.id(), USD_EUR, OrderType.BUY, 36.0, 30.0).get(); + + var buyer1Balances = exchange.getBalances(buyer1.id()).get(); + var buyer2Balances = exchange.getBalances(buyer2.id()).get(); + var sellerBalances = exchange.getBalances(seller.id()).get(); + + assertEquals(30.0, buyer1Balances.get(EUR)); // Buyer1 bought 30 EUR + assertEquals(24.0, buyer1Balances.get(USD)); // Buyer1 sold 36 EUR + + assertEquals(30.0, buyer2Balances.get(EUR)); // Buyer2 bought 30 EUR + assertEquals(24.0, buyer2Balances.get(USD)); // Buyer2 sold 36 EUR + + var sellerOrders = exchange.getActiveOrders(seller.id()).get(); + var sellerOrder = sellerOrders.iterator().next(); + + assertEquals(40.0, sellerOrder.quantity()); // 40 EUR reserved + assertEquals(48.0, sellerOrder.price()); // 1.2 USD per EUR + assertEquals(72.0, sellerBalances.get(USD)); // 72 USD earned + } + + @Test + void testSequentialScenarioWithInsufficientFunds() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer.id(), USD, 100.0).get(); + exchange.deposit(seller.id(), EUR, 50.0).get(); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get() + ); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 240.0, 200.0).get() + ); + + var buyerOrders = exchange.getActiveOrders(buyer.id()).get(); + var sellerOrders = exchange.getActiveOrders(seller.id()).get(); + assertFalse(buyerOrders.iterator().hasNext()); + assertFalse(sellerOrders.iterator().hasNext()); + } + + @Test + void testPricePriorityInOrderMatching() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller1 = exchange.createClient("Seller1").get(); + var seller2 = exchange.createClient("Seller2").get(); + + exchange.deposit(buyer.id(), USD, 200.0).get(); + exchange.deposit(seller1.id(), EUR, 50.0).get(); + exchange.deposit(seller2.id(), EUR, 50.0).get(); + + exchange.placeOrder(seller1.id(), USD_EUR, OrderType.SELL, 75, 50.0).get(); // Higher price + exchange.placeOrder(seller2.id(), USD_EUR, OrderType.SELL, 60, 50.0).get(); // Lower price + + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 75, 50.0).get(); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + var seller1Balances = exchange.getBalances(seller1.id()).get(); + var seller2Balances = exchange.getBalances(seller2.id()).get(); + + assertEquals(50.0, buyerBalances.get(EUR)); // Buyer gets 50 EUR from the lower-priced seller + assertEquals(140.0, buyerBalances.get(USD)); // Buyer spends 60 USD + + assertEquals(60.0, seller2Balances.get(USD)); // Seller2 sells at 60 price + assertEquals(0.0, seller2Balances.get(EUR)); // Seller2 has no EUR left + + assertNull(seller1Balances.get(USD)); // Seller1 remains untouched + assertEquals(0.0, seller1Balances.get(EUR)); // Seller1 still has their EUR reserved + } + + @Test + void testPricePriorityWithMultipleOrders() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller1 = exchange.createClient("Seller1").get(); + var seller2 = exchange.createClient("Seller2").get(); + var seller3 = exchange.createClient("Seller3").get(); + + exchange.deposit(buyer.id(), USD, 300.0).get(); + exchange.deposit(seller1.id(), EUR, 50.0).get(); + exchange.deposit(seller2.id(), EUR, 50.0).get(); + exchange.deposit(seller3.id(), EUR, 50.0).get(); + + exchange.placeOrder(seller1.id(), USD_EUR, OrderType.SELL, 75.0, 50.0).get(); // Highest price + exchange.placeOrder(seller2.id(), USD_EUR, OrderType.SELL, 65.0, 50.0).get(); // Medium price + exchange.placeOrder(seller3.id(), USD_EUR, OrderType.SELL, 60.0, 50.0).get(); // Lowest price + + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 150.0, 100.0).get(); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + var seller1Balances = exchange.getBalances(seller1.id()).get(); + var seller2Balances = exchange.getBalances(seller2.id()).get(); + var seller3Balances = exchange.getBalances(seller3.id()).get(); + + assertEquals(100.0, buyerBalances.get(EUR)); // Buyer receives 100 EUR (50 from seller3 and 50 from seller2) + assertEquals(175.0, buyerBalances.get(USD)); // Buyer spends 125 USD (60 + 65) + + assertEquals(60.0, seller3Balances.get(USD)); // Seller3 sells at 60 price + assertEquals(0.0, seller3Balances.get(EUR)); // Seller3's EUR is reduced to 0 + + assertEquals(65.0, seller2Balances.get(USD)); // Seller2 sells at 65 price + assertEquals(0.0, seller2Balances.get(EUR)); // Seller2's EUR is reduced to 0 + + assertNull(seller1Balances.get(USD)); // Seller1 remains untouched + assertEquals(0.0, seller1Balances.get(EUR)); // Seller1 still has their EUR reserved + } + + @Test + void testTimePriorityWithEqualPrices() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller1 = exchange.createClient("Seller1").get(); + var seller2 = exchange.createClient("Seller2").get(); + + exchange.deposit(buyer.id(), USD, 200.0).get(); + exchange.deposit(seller1.id(), EUR, 50.0).get(); + exchange.deposit(seller2.id(), EUR, 50.0).get(); + + exchange.placeOrder(seller1.id(), USD_EUR, OrderType.SELL, 60, 50.0).get(); // Placed first + exchange.placeOrder(seller2.id(), USD_EUR, OrderType.SELL, 60, 50.0).get(); // Placed second + + exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 60, 50.0).get(); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + var seller1Balances = exchange.getBalances(seller1.id()).get(); + var seller2Balances = exchange.getBalances(seller2.id()).get(); + + assertEquals(50.0, buyerBalances.get(EUR)); // Buyer gets 50 EUR + assertEquals(140.0, buyerBalances.get(USD)); // Buyer spends 60 USD + + assertEquals(60.0, seller1Balances.get(USD)); // Seller1 sells first + assertEquals(0.0, seller1Balances.get(EUR)); // Seller1's EUR is reduced to 0 + + assertNull(seller2Balances.get(USD)); // Seller2 remains untouched + } + + @Test + void testPriceOrderNotMatching() throws ExecutionException, InterruptedException { + var buyer1 = exchange.createClient("Buyer1").get(); + var buyer2 = exchange.createClient("Buyer2").get(); + var seller = exchange.createClient("Seller2").get(); + + exchange.deposit(buyer1.id(), USD, 200.0).get(); + exchange.deposit(buyer2.id(), USD, 200.0).get(); + exchange.deposit(seller.id(), EUR, 50.0).get(); + + exchange.placeOrder(buyer1.id(), USD_EUR, OrderType.BUY, 80.0, 50.0).get(); + exchange.placeOrder(buyer2.id(), USD_EUR, OrderType.BUY, 75.0, 50.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 85, 50.0).get(); + + var buyer1Balances = exchange.getBalances(buyer1.id()).get(); + var buyer2Balances = exchange.getBalances(buyer2.id()).get(); + var sellerBalances = exchange.getBalances(seller.id()).get(); + + assertNull(sellerBalances.get(USD)); // Seller did not sell anything + assertEquals(0.0, sellerBalances.get(EUR)); // Seller has no EUR left + + assertNull(buyer1Balances.get(EUR)); // Buyer1 did not buy anything + assertEquals(120.0, buyer1Balances.get(USD)); + + assertNull(buyer2Balances.get(EUR)); // Buyer2 did not buy anything + assertEquals(125.0, buyer2Balances.get(USD)); + } + + @Test + public void testCancelBuyOrder() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Client1").get(); + + exchange.deposit(client.id(), USD, 100.0); + + var order = exchange.placeOrder(client.id(), USD_EUR, OrderType.BUY, 100.0, 200.0).get().orElseThrow(); + + var balances = exchange.getBalances(client.id()).get(); + assertEquals(0.0, balances.get(USD)); + + exchange.cancelOrder(client.id(), order.id()).get(); + + balances = exchange.getBalances(client.id()).get(); + assertEquals(100.0, balances.get(USD)); + var orders = exchange.getActiveOrders(client.id()).get(); + + assertFalse(orders.iterator().hasNext()); + } + + @Test + public void testCancelSellOrder() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Client1").get(); + + exchange.deposit(client.id(), EUR, 200.0); + + var order = exchange.placeOrder(client.id(), USD_EUR, OrderType.SELL, 100.0, 200.0).get().orElseThrow(); + + var balances = exchange.getBalances(client.id()).get(); + assertEquals(0.0, balances.get(EUR)); + + exchange.cancelOrder(client.id(), order.id()).get(); + + balances = exchange.getBalances(client.id()).get(); + assertEquals(200.0, balances.get(EUR)); + var orders = exchange.getActiveOrders(client.id()).get(); + + assertFalse(orders.iterator().hasNext()); + } + + @Test + public void testCancelPartialOrder() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").get(); + + exchange.deposit(buyer.id(), USD, 1000.0).get(); + exchange.deposit(seller.id(), EUR, 500.0).get(); + + exchange.placeOrder(seller.id(), USD_EUR, OrderType.SELL, 120.0, 100.0).get(); + + var order = exchange.placeOrder(buyer.id(), USD_EUR, OrderType.BUY, 200.0, 120.0).get().orElseThrow(); + + var buyerOrders = exchange.getActiveOrders(buyer.id()).get(); + var sellerOrders = exchange.getActiveOrders(seller.id()).get(); + + assertTrue(buyerOrders.iterator().hasNext()); + assertFalse(sellerOrders.iterator().hasNext()); + + exchange.cancelOrder(buyer.id(), order.id()); + + var buyerBalances = exchange.getBalances(buyer.id()).get(); + assertEquals(100.0, buyerBalances.get(EUR), 0.001); + assertEquals(880.0, buyerBalances.get(USD), 0.001); + } +}