diff --git a/src/main/java/ru/lionarius/api/CurrencyExchange.java b/src/main/java/ru/lionarius/api/CurrencyExchange.java index aa97ffd..36e65a2 100644 --- a/src/main/java/ru/lionarius/api/CurrencyExchange.java +++ b/src/main/java/ru/lionarius/api/CurrencyExchange.java @@ -1,28 +1,23 @@ 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 ru.lionarius.api.order.OrderView; +import ru.lionarius.api.order.message.OrderMessage; -import java.util.Map; -import java.util.Optional; +import java.util.List; 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 placeOrder(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity); CompletableFuture cancelOrder(UUID clientId, UUID orderId); - CompletableFuture> getActiveOrders(UUID clientId); + CompletableFuture> getOrders(UUID clientId); - CompletableFuture> getBalances(UUID clientId); + CompletableFuture> getOrderMessages(UUID clientId, UUID orderId); } diff --git a/src/main/java/ru/lionarius/api/client/Balance.java b/src/main/java/ru/lionarius/api/client/Balance.java deleted file mode 100644 index 8a33316..0000000 --- a/src/main/java/ru/lionarius/api/client/Balance.java +++ /dev/null @@ -1,27 +0,0 @@ -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/ClientRepository.java b/src/main/java/ru/lionarius/api/client/ClientRepository.java index f31921f..708e0a9 100644 --- a/src/main/java/ru/lionarius/api/client/ClientRepository.java +++ b/src/main/java/ru/lionarius/api/client/ClientRepository.java @@ -1,5 +1,8 @@ package ru.lionarius.api.client; + +import ru.lionarius.api.order.OrderList; + import java.util.Optional; import java.util.UUID; @@ -8,5 +11,5 @@ public interface ClientRepository { Optional getClient(UUID clientId); - Optional getClientBalance(UUID clientId); + Optional getClientOrders(UUID clientId); } diff --git a/src/main/java/ru/lionarius/api/order/Order.java b/src/main/java/ru/lionarius/api/order/Order.java index 8a13e16..7ccd8d3 100644 --- a/src/main/java/ru/lionarius/api/order/Order.java +++ b/src/main/java/ru/lionarius/api/order/Order.java @@ -1,19 +1,119 @@ package ru.lionarius.api.order; -import ru.lionarius.api.client.Client; +import com.google.common.collect.ImmutableList; import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.message.OrderCreatedMessage; +import ru.lionarius.api.order.message.OrderFilledMessage; +import ru.lionarius.api.order.message.OrderMessage; +import ru.lionarius.api.order.message.OrderMessageType; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; -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 class Order { + private final UUID id = UUID.randomUUID(); + private final UUID clientId; + private final OrderType type; + private final CurrencyPair pair; + private final OrderData originalData; + + private OrderData lastData; + private boolean closed = false; + private final List messages = new ArrayList<>(); + + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + public Order(UUID clientId, OrderType type, CurrencyPair pair, OrderData data) { + this.clientId = clientId; + this.type = type; + this.pair = pair; + this.originalData = data; + + pushMessage(new OrderCreatedMessage(data, LocalDateTime.now())); } - public double rate() { - return price() / quantity(); + public OrderView getView() { + lock.readLock().lock(); + try { + return new OrderView(id, clientId, type, pair, originalData, lastData, closed); + } finally { + lock.readLock().unlock(); + } + } + + public UUID getId() { + return id; + } + + public UUID getClientId() { + return clientId; + } + + public OrderType getType() { + return type; + } + + public CurrencyPair getPair() { + return pair; + } + + public boolean isClosed() { + lock.readLock().lock(); + try { + return closed; + } finally { + lock.readLock().unlock(); + } + } + + public OrderData getLastData() { + lock.readLock().lock(); + try { + return lastData; + } finally { + lock.readLock().unlock(); + } + } + + public void pushMessage(OrderMessage message) { + if (closed) + throw new IllegalStateException("Order is closed"); + + lock.writeLock().lock(); + try { + if (message.type() == OrderMessageType.CREATED) { + if (lastData != null) + throw new IllegalStateException("Order already has data"); + + lastData = ((OrderCreatedMessage) message).data(); + } else if (message.type() == OrderMessageType.FILLED) { + lastData = ((OrderFilledMessage) message).newData(); + } else if (message.type() == OrderMessageType.CLOSED) { + closed = true; + } + + messages.add(message); + } finally { + lock.writeLock().unlock(); + } + } + + public List getMessages() { + lock.readLock().lock(); + try { + return ImmutableList.copyOf(messages); + } finally { + lock.readLock().unlock(); + } + } + + @Override + public int hashCode() { + return id.hashCode(); } @Override @@ -24,9 +124,4 @@ public record Order(UUID id, Client client, OrderType type, CurrencyPair pair, d return false; } - - @Override - public int hashCode() { - return id.hashCode(); - } } diff --git a/src/main/java/ru/lionarius/api/order/OrderData.java b/src/main/java/ru/lionarius/api/order/OrderData.java new file mode 100644 index 0000000..f947362 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/OrderData.java @@ -0,0 +1,7 @@ +package ru.lionarius.api.order; + +public record OrderData(double price, double quantity) { + public double rate() { + return price() / quantity(); + } +} diff --git a/src/main/java/ru/lionarius/api/order/OrderList.java b/src/main/java/ru/lionarius/api/order/OrderList.java new file mode 100644 index 0000000..27893b3 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/OrderList.java @@ -0,0 +1,13 @@ +package ru.lionarius.api.order; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface OrderList { + void add(Order order); + + Optional get(UUID orderId); + + List get(); +} diff --git a/src/main/java/ru/lionarius/api/order/OrderView.java b/src/main/java/ru/lionarius/api/order/OrderView.java new file mode 100644 index 0000000..585fff4 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/OrderView.java @@ -0,0 +1,9 @@ +package ru.lionarius.api.order; + +import ru.lionarius.api.currency.CurrencyPair; + +import java.util.UUID; + +public record OrderView(UUID id, UUID clientId, OrderType type, CurrencyPair pair, OrderData originalData, + OrderData lastData, boolean closed) { +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java b/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java new file mode 100644 index 0000000..bfc99e9 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java @@ -0,0 +1,14 @@ +package ru.lionarius.api.order.message; + +public record OrderClosedMessage(Reason reason) implements OrderMessage { + + @Override + public OrderMessageType type() { + return OrderMessageType.CLOSED; + } + + public enum Reason { + CANCELLED, + FULFILLED + } +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderCreatedMessage.java b/src/main/java/ru/lionarius/api/order/message/OrderCreatedMessage.java new file mode 100644 index 0000000..6251579 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/message/OrderCreatedMessage.java @@ -0,0 +1,13 @@ +package ru.lionarius.api.order.message; + +import ru.lionarius.api.order.OrderData; + +import java.time.LocalDateTime; + +public record OrderCreatedMessage(OrderData data, LocalDateTime timestamp) implements OrderMessage { + + @Override + public OrderMessageType type() { + return OrderMessageType.CREATED; + } +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderFilledMessage.java b/src/main/java/ru/lionarius/api/order/message/OrderFilledMessage.java new file mode 100644 index 0000000..81a0a9d --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/message/OrderFilledMessage.java @@ -0,0 +1,12 @@ +package ru.lionarius.api.order.message; + +import ru.lionarius.api.order.OrderData; + +import java.util.UUID; + +public record OrderFilledMessage(UUID otherOrderId, OrderData newData) implements OrderMessage { + @Override + public OrderMessageType type() { + return OrderMessageType.FILLED; + } +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderMessage.java b/src/main/java/ru/lionarius/api/order/message/OrderMessage.java new file mode 100644 index 0000000..8eb2064 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/message/OrderMessage.java @@ -0,0 +1,5 @@ +package ru.lionarius.api.order.message; + +public interface OrderMessage { + OrderMessageType type(); +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java b/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java new file mode 100644 index 0000000..53022a6 --- /dev/null +++ b/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java @@ -0,0 +1,7 @@ +package ru.lionarius.api.order.message; + +public enum OrderMessageType { + CREATED, + CLOSED, + FILLED +} diff --git a/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java b/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java index f66423e..c191e88 100644 --- a/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java +++ b/src/main/java/ru/lionarius/impl/InMemoryClientRepository.java @@ -1,24 +1,24 @@ package ru.lionarius.impl; -import ru.lionarius.api.client.Balance; +import com.google.common.collect.ImmutableList; import ru.lionarius.api.client.Client; import ru.lionarius.api.client.ClientRepository; +import ru.lionarius.api.order.OrderList; +import ru.lionarius.api.order.Order; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class InMemoryClientRepository implements ClientRepository { private final Map clients = new ConcurrentHashMap<>(); - private final Map balances = new ConcurrentHashMap<>(); + private final Map orders = new ConcurrentHashMap<>(); @Override public Client createClient(String name) { var client = new Client(name); clients.put(client.id(), client); - balances.put(client, new Balance()); + orders.put(client.id(), new OrdersList()); return client; } @@ -29,7 +29,23 @@ public class InMemoryClientRepository implements ClientRepository { } @Override - public Optional getClientBalance(UUID clientId) { - return Optional.ofNullable(balances.get(clients.get(clientId))); + public Optional getClientOrders(UUID clientId) { + return Optional.ofNullable(orders.get(clientId)); + } + + private static class OrdersList implements OrderList { + private final Map orders = new ConcurrentHashMap<>(); + + public void add(Order order) { + orders.put(order.getId(), order); + } + + public Optional get(UUID orderId) { + return Optional.ofNullable(orders.get(orderId)); + } + + public List get() { + return ImmutableList.copyOf(orders.values()); + } } } diff --git a/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java b/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java index d9ee596..7929f0a 100644 --- a/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java +++ b/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java @@ -1,34 +1,39 @@ package ru.lionarius.impl; -import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; 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.OrderData; import ru.lionarius.api.order.OrderType; +import ru.lionarius.api.order.OrderView; +import ru.lionarius.api.order.message.OrderClosedMessage; +import ru.lionarius.api.order.message.OrderFilledMessage; +import ru.lionarius.api.order.message.OrderMessage; 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 Map orderBooks; - private final ReadWriteLock ordersLock = new ReentrantReadWriteLock(); + private final Set allowedCurrencies; + private final Set allowedPairs; - private final List allowedCurrencies; - private final List allowedPairs; + public PlainCurrencyExchange(Set allowedCurrencies, Set allowedPairs) { + this.allowedCurrencies = ImmutableSet.copyOf(allowedCurrencies); + this.allowedPairs = ImmutableSet.copyOf(allowedPairs); - public PlainCurrencyExchange(List allowedCurrencies, List allowedPairs) { - this.allowedCurrencies = ImmutableList.copyOf(allowedCurrencies); - this.allowedPairs = ImmutableList.copyOf(allowedPairs); + this.orderBooks = allowedPairs.stream() + .map(pair -> new AbstractMap.SimpleEntry<>(pair, new OrderBook())) + .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)); } @Override @@ -42,322 +47,160 @@ public class PlainCurrencyExchange implements CurrencyExchange { } @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) { + public CompletableFuture placeOrder(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity) { return CompletableFuture.supplyAsync(() -> { - validateOrderInputs(pair, type, price, quantity); + if (clientId == null) + throw new IllegalArgumentException("Client ID cannot be null"); - var client = clientRepository.getClient(clientId).orElseThrow(); - var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + if (pair == null) + throw new IllegalArgumentException("Currency pair cannot be null"); - reserveFundsForOrder(balance, pair, type, price, quantity); + if (type == null) + throw new IllegalArgumentException("Order type cannot be null"); - var newOrder = new Order(client, type, pair, price, quantity); - Optional orderToAdd = Optional.empty(); + if (price <= 0.0) + throw new IllegalArgumentException("Price must be positive"); - ordersLock.writeLock().lock(); - try { - // Try to match the order - if (type == OrderType.BUY) { - orderToAdd = matchBuyOrder(newOrder); - } else { - orderToAdd = matchSellOrder(newOrder); - } + if (quantity <= 0.0) + throw new IllegalArgumentException("Quantity must be positive"); - // 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()); - } - } + var orders = clientRepository.getClientOrders(clientId).orElseThrow(); - return orderToAdd; - } finally { - ordersLock.writeLock().unlock(); - } + var order = new Order(clientId, type, pair, new OrderData(price, quantity)); + orders.add(order); + + var orderBook = orderBooks.get(pair); + + orderBook.addOrder(order); + orderBook.matchOrders(); + + return order.getId(); }, 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"); + if (clientId == null) + throw new IllegalArgumentException("Client ID cannot be null"); - var client = clientRepository.getClient(clientId).orElseThrow(); - var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + if (orderId == null) + throw new IllegalArgumentException("Order ID cannot be null"); - 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); - } - } - } + var orders = clientRepository.getClientOrders(clientId).orElseThrow(); - 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; - }) - .filter(oppositeOrder -> oppositeOrder.client() != order.client()) - .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(); - } + orders.get(orderId).ifPresent(order -> { + order.pushMessage(new OrderClosedMessage(OrderClosedMessage.Reason.CANCELLED)); + }); }, Runnable::run); } @Override - public CompletableFuture> getBalances(UUID clientId) { + public CompletableFuture> getOrders(UUID clientId) { return CompletableFuture.supplyAsync(() -> { - var client = clientRepository.getClient(clientId).orElseThrow(); - var balance = clientRepository.getClientBalance(clientId).orElseThrow(); + if (clientId == null) + throw new IllegalArgumentException("Client ID cannot be null"); - return balance.getBalances(); + var orders = clientRepository.getClientOrders(clientId).orElseThrow(); + + return orders.get().stream().map(Order::getView).toList(); }, Runnable::run); } + + @Override + public CompletableFuture> getOrderMessages(UUID clientId, UUID orderId) { + return CompletableFuture.supplyAsync(() -> { + if (clientId == null) + throw new IllegalArgumentException("Client ID cannot be null"); + + if (orderId == null) + throw new IllegalArgumentException("Order ID cannot be null"); + + var orders = clientRepository.getClientOrders(clientId).orElseThrow(); + + return orders.get(orderId).map(Order::getMessages).orElseThrow(); + }, Runnable::run); + } + + private static class OrderBook { + private final TreeMap> buyOrders = new TreeMap<>(Collections.reverseOrder()); + private final TreeMap> sellOrders = new TreeMap<>(); + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + public void addOrder(Order order) { + var orders = order.getType() == OrderType.BUY ? buyOrders : sellOrders; + + lock.writeLock().lock(); + try { + orders.computeIfAbsent(order.getLastData().rate(), (k) -> new ArrayList<>()).add(order); + } finally { + lock.writeLock().unlock(); + } + } + + public void matchOrders() { + lock.writeLock().lock(); + try { + while (!buyOrders.isEmpty() && !sellOrders.isEmpty()) { + var bestBuyEntry = buyOrders.firstEntry(); + var bestSellEntry = sellOrders.firstEntry(); + + var bestBuy = bestBuyEntry.getValue().getFirst(); + var bestSell = bestSellEntry.getValue().getFirst(); + + if (bestBuy.isClosed()) { + bestBuyEntry.getValue().remove(bestBuy); + if (bestBuyEntry.getValue().isEmpty()) + buyOrders.remove(bestBuyEntry.getKey()); + + continue; + } + + if (bestSell.isClosed()) { + bestSellEntry.getValue().remove(bestSell); + if (bestSellEntry.getValue().isEmpty()) + sellOrders.remove(bestSellEntry.getKey()); + + continue; + } + + if (bestBuy.getLastData().rate() < bestSell.getLastData().rate()) + break; + + var matchQuantity = Math.min(bestBuy.getLastData().quantity(), bestSell.getLastData().quantity()); + + var newBuyOrderData = new OrderData( + bestBuy.getLastData().price() - matchQuantity * bestBuy.getLastData().rate(), + bestBuy.getLastData().quantity() - matchQuantity + ); + + var newSellOrderData = new OrderData( + bestSell.getLastData().price() - matchQuantity * bestSell.getLastData().rate(), + bestSell.getLastData().quantity() - matchQuantity + ); + + bestBuy.pushMessage(new OrderFilledMessage(bestSell.getId(), newBuyOrderData)); + bestSell.pushMessage(new OrderFilledMessage(bestBuy.getId(), newSellOrderData)); + + if (bestBuy.getLastData().quantity() <= 0) { + bestBuyEntry.getValue().remove(bestBuy); + bestBuy.pushMessage(new OrderClosedMessage(OrderClosedMessage.Reason.FULFILLED)); + + if (bestBuyEntry.getValue().isEmpty()) + buyOrders.remove(bestBuyEntry.getKey()); + } + + if (bestSell.getLastData().quantity() <= 0) { + bestSellEntry.getValue().remove(bestSell); + bestSell.pushMessage(new OrderClosedMessage(OrderClosedMessage.Reason.FULFILLED)); + + if (bestSellEntry.getValue().isEmpty()) + sellOrders.remove(bestSellEntry.getKey()); + } + } + } finally { + lock.writeLock().unlock(); + } + } + } } diff --git a/src/test/java/ConcurrentCurrencyExchangeTest.java b/src/test/java/ConcurrentCurrencyExchangeTest.java index f491f58..3fb8340 100644 --- a/src/test/java/ConcurrentCurrencyExchangeTest.java +++ b/src/test/java/ConcurrentCurrencyExchangeTest.java @@ -5,15 +5,12 @@ 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.*; @@ -31,8 +28,8 @@ class ConcurrentCurrencyExchangeTest { RUB_CNY = new CurrencyPair(RUB, CNY); exchange = new PlainCurrencyExchange( - Arrays.asList(RUB, CNY), - Arrays.asList(RUB_CNY) + Set.of(RUB, CNY), + Set.of(RUB_CNY) ); executorService = Executors.newFixedThreadPool(10); @@ -63,132 +60,6 @@ class ConcurrentCurrencyExchangeTest { } - @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(), RUB, 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(RUB)); - } - - @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(), RUB, 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(), RUB, 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(RUB), - 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(), RUB, 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(), RUB, 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(), RUB, 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(RUB), - 0.001 - ); - } - @Test void testConcurrentOrderPlacement() throws InterruptedException { var numSellers = 10; @@ -199,14 +70,12 @@ class ConcurrentCurrencyExchangeTest { var sellers = new ArrayList(); for (int i = 0; i < numSellers; i++) { var seller = exchange.createClient("Seller " + i).join(); - exchange.deposit(seller.id(), CNY, 1000000.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(), RUB, 1200000.0).join(); buyers.add(buyer); } @@ -247,15 +116,11 @@ class ConcurrentCurrencyExchangeTest { ).join(); for (var seller : sellers) { - assertTrue(StreamSupport.stream( - exchange.getActiveOrders(seller.id()).join().spliterator(), false - ).count() <= ordersPerClient); + assertTrue((long) exchange.getOrders(seller.id()).join().size() <= ordersPerClient); } for (var buyer : buyers) { - assertTrue(StreamSupport.stream( - exchange.getActiveOrders(buyer.id()).join().spliterator(), false - ).count() <= ordersPerClient); + assertTrue((long) exchange.getOrders(buyer.id()).join().size() <= ordersPerClient); } } @@ -267,9 +132,6 @@ class ConcurrentCurrencyExchangeTest { var seller = exchange.createClient("Seller").join(); var buyer = exchange.createClient("Buyer").join(); - exchange.deposit(seller.id(), CNY, 1000.0).join(); - exchange.deposit(buyer.id(), RUB, 2000.0).join(); - var sellerFuture = CompletableFuture.runAsync(() -> { try { for (int i = 0; i < numOrders; i++) { @@ -301,57 +163,19 @@ class ConcurrentCurrencyExchangeTest { 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 sellerOrders = exchange.getOrders(seller.id()).join(); + var buyerOrders = exchange.getOrders(buyer.id()).join(); - var remainingSellerOrders = StreamSupport.stream(sellerOrders.spliterator(), false).count(); - var remainingBuyerOrders = StreamSupport.stream(buyerOrders.spliterator(), false).count(); + 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 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(), RUB, 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(RUB) - 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 testBalanceConsistencyWithConcurrentTrades() throws InterruptedException { - var numTraders = 10; + void testBalanceConsistencyWithConcurrentTradesSamePrices() throws InterruptedException { + var numTraders = 100; var numOrders = 1000; var latch = new CountDownLatch(numTraders * 2); @@ -359,14 +183,11 @@ class ConcurrentCurrencyExchangeTest { 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(); - exchange.deposit(seller.id(), CNY, sellAmount * numOrders).join(); - exchange.deposit(buyer.id(), RUB, buyAmount * numOrders).join(); - sellers.add(seller); buyers.add(buyer); } @@ -376,8 +197,7 @@ class ConcurrentCurrencyExchangeTest { for (var seller : sellers) { var future = CompletableFuture.runAsync(() -> { try { - for (var i = 0; i < numOrders; i++) - { + for (var i = 0; i < numOrders; i++) { exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, buyAmount, sellAmount).join(); } } finally { @@ -390,8 +210,7 @@ class ConcurrentCurrencyExchangeTest { for (var buyer : buyers) { var future = CompletableFuture.runAsync(() -> { try { - for (var i = 0; i < numOrders; i++) - { + for (var i = 0; i < numOrders; i++) { exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, sellAmount, buyAmount).join(); } } finally { @@ -405,20 +224,99 @@ class ConcurrentCurrencyExchangeTest { CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); var totalRubMoney = 0.0; var totalCnyMoney = 0.0; - + for (var seller : sellers) { - var balances = exchange.getBalances(seller.id()).join(); - totalCnyMoney += balances.get(CNY); - totalRubMoney += balances.get(RUB); + 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 balances = exchange.getBalances(buyer.id()).join(); - totalCnyMoney += balances.get(CNY); - totalRubMoney += balances.get(RUB); + 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(totalRubMoney, sellAmount * numOrders * numTraders, 0.001); - assertEquals(totalCnyMoney, sellAmount * numOrders * numTraders, 0.001); + + 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(); + } + + assertEquals(0.0, totalCnyMoney, 0.001); } @AfterEach diff --git a/src/test/java/CurrencyExchangeTest.java b/src/test/java/CurrencyExchangeTest.java index 379b84c..31298a8 100644 --- a/src/test/java/CurrencyExchangeTest.java +++ b/src/test/java/CurrencyExchangeTest.java @@ -3,11 +3,16 @@ 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.OrderData; import ru.lionarius.api.order.OrderType; +import ru.lionarius.api.order.message.OrderClosedMessage; +import ru.lionarius.api.order.message.OrderFilledMessage; +import ru.lionarius.api.order.message.OrderMessageType; import ru.lionarius.impl.PlainCurrencyExchange; import java.util.Arrays; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -26,18 +31,16 @@ class CurrencyExchangeTest { RUB_CNY = new CurrencyPair(RUB, CNY); exchange = new PlainCurrencyExchange( - Arrays.asList(RUB, CNY), - Arrays.asList(RUB_CNY) + Set.of(RUB, CNY), + Set.of(RUB_CNY) ); } - + @Test void testCreateClient() throws ExecutionException, InterruptedException { var client = exchange.createClient("Trader").get(); assertNotNull(client); - assertNotNull(client.id()); - assertEquals("Trader", client.name()); } @Test @@ -48,97 +51,22 @@ class CurrencyExchangeTest { @Test void testGetBalancesNonexistentClient() { var nonexistentId = UUID.randomUUID(); - assertThrows(Exception.class, () -> exchange.getBalances(nonexistentId).get()); + assertThrows(Exception.class, () -> exchange.getOrders(nonexistentId).get()); } - - @Test - void testDeposit() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Trader").get(); - - exchange.deposit(client.id(), RUB, 1000.0).get(); - - var balances = exchange.getBalances(client.id()).get(); - assertEquals(1000.0, balances.get(RUB)); - } - - @Test - void testDepositNegativeAmount() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Trader").get(); - - assertThrows(ExecutionException.class, () -> exchange.deposit(client.id(), RUB, -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()); - } - - - @Test - void testWithdraw() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Trader").get(); - - exchange.deposit(client.id(), RUB, 1000.0).get(); - exchange.withdraw(client.id(), RUB, 500.0).get(); - - var balances = exchange.getBalances(client.id()).get(); - assertEquals(500.0, balances.get(RUB)); - } - - @Test - void testWithdrawInsufficientFunds() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Trader").get(); - - exchange.deposit(client.id(), RUB, 100.0).get(); - - assertThrows(ExecutionException.class, () -> exchange.withdraw(client.id(), RUB, 200.0).get()); - } - - @Test - void testSequentialDepositWithdraw() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Trader").get(); - - exchange.deposit(client.id(), RUB, 500.0).get(); - var balancesAfterDeposit = exchange.getBalances(client.id()).get(); - assertEquals(500.0, balancesAfterDeposit.get(RUB)); - - exchange.withdraw(client.id(), RUB, 200.0).get(); - var balancesAfterWithdraw = exchange.getBalances(client.id()).get(); - assertEquals(300.0, balancesAfterWithdraw.get(RUB)); - - assertThrows(ExecutionException.class, () -> exchange.withdraw(client.id(), RUB, 400.0).get()); - } - - @Test void testPlaceOrder() throws ExecutionException, InterruptedException { var client = exchange.createClient("Trader").get(); - exchange.deposit(client.id(), CNY, 1000.0).get(); exchange.placeOrder(client.id(), RUB_CNY, 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(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get() - ); + exchange.getOrders(client.id()).get().getFirst(); } @Test void testOrderPriceValidation() throws ExecutionException, InterruptedException { var client = exchange.createClient("Trader").get(); - exchange.deposit(client.id(), CNY, 100.0).get(); - assertThrows(ExecutionException.class, () -> exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, -1.0, 100.0).get() ); @@ -148,32 +76,27 @@ class CurrencyExchangeTest { void testOrderQuantityValidation() throws ExecutionException, InterruptedException { var client = exchange.createClient("Trader").get(); - exchange.deposit(client.id(), CNY, 100.0).get(); - assertThrows(ExecutionException.class, () -> exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, 120.0, -100.0).get() ); } - + @Test void testMatchingOrders() throws ExecutionException, InterruptedException { var buyer = exchange.createClient("Buyer").get(); var seller = exchange.createClient("Seller").get(); - exchange.deposit(buyer.id(), RUB, 120.0).get(); - exchange.deposit(seller.id(), CNY, 100.0).get(); + var sellOrderId = exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); + var buyOrderId = exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 120.0, 100.0).get(); - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 120.0, 100.0).get(); + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); - Map buyerBalances = exchange.getBalances(buyer.id()).get(); - Map sellerBalances = exchange.getBalances(seller.id()).get(); - - assertEquals(100.0, buyerBalances.get(CNY)); - assertEquals(0.0, buyerBalances.get(RUB)); - assertEquals(120.0, sellerBalances.get(RUB)); - assertEquals(0.0, sellerBalances.get(CNY)); + assertEquals(3, sellOrderMessages.size()); + assertSame(sellOrderMessages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, buyOrderMessages.size()); + assertSame(buyOrderMessages.getLast().type(), OrderMessageType.CLOSED); } @Test @@ -181,25 +104,17 @@ class CurrencyExchangeTest { var buyer = exchange.createClient("Buyer").get(); var seller = exchange.createClient("Seller").get(); - exchange.deposit(buyer.id(), RUB, 120.0).get(); - exchange.deposit(seller.id(), CNY, 100.0).get(); + var sellOrderId = exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); + var buyOrderId = exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); - var buyerBalances = exchange.getBalances(buyer.id()).get(); - var sellerBalances = exchange.getBalances(seller.id()).get(); - - assertEquals(50.0, buyerBalances.get(CNY)); - assertEquals(60.0, sellerBalances.get(RUB)); - - var sellerOrders = exchange.getActiveOrders(seller.id()).get(); - var remainingOrder = sellerOrders.iterator().next(); - - - assertEquals(RUB_CNY, remainingOrder.pair()); - assertEquals(50.0, remainingOrder.quantity()); - assertEquals(60.0, remainingOrder.price()); + assertEquals(2, sellOrderMessages.size()); + assertSame(sellOrderMessages.getLast().type(), OrderMessageType.FILLED); + assertEquals(new OrderData(60.0, 50.0), ((OrderFilledMessage) sellOrderMessages.getLast()).newData()); + assertEquals(3, buyOrderMessages.size()); + assertSame(buyOrderMessages.getLast().type(), OrderMessageType.CLOSED); } @Test @@ -208,100 +123,22 @@ class CurrencyExchangeTest { var buyer2 = exchange.createClient("Buyer2").get(); var seller = exchange.createClient("Seller").get(); - exchange.deposit(buyer1.id(), RUB, 120.0).get(); - exchange.deposit(buyer2.id(), RUB, 120.0).get(); - exchange.deposit(seller.id(), CNY, 100.0).get(); + var sellOrderId = exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); + var buyOrderId1 = exchange.placeOrder(buyer1.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); + var buyOrderId2 = exchange.placeOrder(buyer2.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - exchange.placeOrder(buyer1.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); - exchange.placeOrder(buyer2.id(), RUB_CNY, OrderType.BUY, 60.0, 50.0).get(); + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); + var buyOrder1Messages = exchange.getOrderMessages(buyer1.id(), buyOrderId1).get(); + var buyOrder2Messages = exchange.getOrderMessages(buyer2.id(), buyOrderId2).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(CNY)); - assertEquals(50.0, buyer2Balances.get(CNY)); - assertEquals(120.0, sellerBalances.get(RUB)); - - var sellerOrders = exchange.getActiveOrders(seller.id()).get(); - assertFalse(sellerOrders.iterator().hasNext()); + assertEquals(4, sellOrderMessages.size()); + assertSame(sellOrderMessages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, buyOrder1Messages.size()); + assertSame(buyOrder1Messages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, buyOrder2Messages.size()); + assertSame(buyOrder2Messages.getLast().type(), OrderMessageType.CLOSED); } - @Test - void testSequentialOrderPlacementAndMatching() throws ExecutionException, InterruptedException { - var buyer = exchange.createClient("Buyer").get(); - var seller = exchange.createClient("Seller").get(); - - exchange.deposit(buyer.id(), RUB, 300.0).get(); - exchange.deposit(seller.id(), CNY, 200.0).get(); - - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - exchange.placeOrder(buyer.id(), RUB_CNY, 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(CNY)); - assertEquals(180.0, buyerBalances.get(RUB)); - assertEquals(120.0, sellerBalances.get(RUB)); - assertEquals(100.0, sellerBalances.get(CNY)); - } - - @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(), RUB, 60.0).get(); - exchange.deposit(buyer2.id(), RUB, 60.0).get(); - exchange.deposit(seller.id(), CNY, 100.0).get(); - - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - - exchange.placeOrder(buyer1.id(), RUB_CNY, OrderType.BUY, 36.0, 30.0).get(); - exchange.placeOrder(buyer2.id(), RUB_CNY, 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(CNY)); - assertEquals(24.0, buyer1Balances.get(RUB)); - - assertEquals(30.0, buyer2Balances.get(CNY)); - assertEquals(24.0, buyer2Balances.get(RUB)); - - var sellerOrders = exchange.getActiveOrders(seller.id()).get(); - var sellerOrder = sellerOrders.iterator().next(); - - assertEquals(40.0, sellerOrder.quantity()); - assertEquals(48.0, sellerOrder.price()); - assertEquals(72.0, sellerBalances.get(RUB)); - } - - @Test - void testSequentialScenarioWithInsufficientFunds() throws ExecutionException, InterruptedException { - var buyer = exchange.createClient("Buyer").get(); - var seller = exchange.createClient("Seller").get(); - - exchange.deposit(buyer.id(), RUB, 100.0).get(); - exchange.deposit(seller.id(), CNY, 50.0).get(); - - assertThrows(ExecutionException.class, () -> - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get() - ); - - assertThrows(ExecutionException.class, () -> - exchange.placeOrder(buyer.id(), RUB_CNY, 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 { @@ -309,27 +146,21 @@ class CurrencyExchangeTest { var seller1 = exchange.createClient("Seller1").get(); var seller2 = exchange.createClient("Seller2").get(); - exchange.deposit(buyer.id(), RUB, 200.0).get(); - exchange.deposit(seller1.id(), CNY, 50.0).get(); - exchange.deposit(seller2.id(), CNY, 50.0).get(); + var sellOrderId1 = exchange.placeOrder(seller1.id(), RUB_CNY, OrderType.SELL, 75, 50.0).get(); + var sellOrderId2 = exchange.placeOrder(seller2.id(), RUB_CNY, OrderType.SELL, 60, 50.0).get(); - exchange.placeOrder(seller1.id(), RUB_CNY, OrderType.SELL, 75, 50.0).get(); - exchange.placeOrder(seller2.id(), RUB_CNY, OrderType.SELL, 60, 50.0).get(); + var buyOrderId = exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 75, 50.0).get(); - exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 75, 50.0).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); + var sellOrder1Messages = exchange.getOrderMessages(seller1.id(), sellOrderId1).get(); + var sellOrder2Messages = exchange.getOrderMessages(seller2.id(), sellOrderId2).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(CNY)); - assertEquals(140.0, buyerBalances.get(RUB)); - - assertEquals(60.0, seller2Balances.get(RUB)); - assertEquals(0.0, seller2Balances.get(CNY)); - - assertNull(seller1Balances.get(RUB)); - assertEquals(0.0, seller1Balances.get(CNY)); + assertEquals(1, sellOrder1Messages.size()); + assertSame(sellOrder1Messages.getLast().type(), OrderMessageType.CREATED); + assertEquals(3, sellOrder2Messages.size()); + assertSame(sellOrder2Messages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, buyOrderMessages.size()); + assertSame(buyOrderMessages.getLast().type(), OrderMessageType.CLOSED); } @Test @@ -339,173 +170,59 @@ class CurrencyExchangeTest { var seller2 = exchange.createClient("Seller2").get(); var seller3 = exchange.createClient("Seller3").get(); - exchange.deposit(buyer.id(), RUB, 300.0).get(); - exchange.deposit(seller1.id(), CNY, 50.0).get(); - exchange.deposit(seller2.id(), CNY, 50.0).get(); - exchange.deposit(seller3.id(), CNY, 50.0).get(); + var sellOrderId1 = exchange.placeOrder(seller1.id(), RUB_CNY, OrderType.SELL, 75.0, 50.0).get(); + var sellOrderId2 = exchange.placeOrder(seller2.id(), RUB_CNY, OrderType.SELL, 65.0, 50.0).get(); + var sellOrderId3 = exchange.placeOrder(seller3.id(), RUB_CNY, OrderType.SELL, 60.0, 50.0).get(); - exchange.placeOrder(seller1.id(), RUB_CNY, OrderType.SELL, 75.0, 50.0).get(); - exchange.placeOrder(seller2.id(), RUB_CNY, OrderType.SELL, 65.0, 50.0).get(); - exchange.placeOrder(seller3.id(), RUB_CNY, OrderType.SELL, 60.0, 50.0).get(); + var buyOrderId = exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 150.0, 100.0).get(); - exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 150.0, 100.0).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); + var sellOrder1Messages = exchange.getOrderMessages(seller1.id(), sellOrderId1).get(); + var sellOrder2Messages = exchange.getOrderMessages(seller2.id(), sellOrderId2).get(); + var sellOrder3Messages = exchange.getOrderMessages(seller3.id(), sellOrderId3).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(CNY)); - assertEquals(175.0, buyerBalances.get(RUB)); - - assertEquals(60.0, seller3Balances.get(RUB)); - assertEquals(0.0, seller3Balances.get(CNY)); - - assertEquals(65.0, seller2Balances.get(RUB)); - assertEquals(0.0, seller2Balances.get(CNY)); - - assertNull(seller1Balances.get(RUB)); - assertEquals(0.0, seller1Balances.get(CNY)); - } - - @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(), RUB, 200.0).get(); - exchange.deposit(seller1.id(), CNY, 50.0).get(); - exchange.deposit(seller2.id(), CNY, 50.0).get(); - - exchange.placeOrder(seller1.id(), RUB_CNY, OrderType.SELL, 60, 50.0).get(); - exchange.placeOrder(seller2.id(), RUB_CNY, OrderType.SELL, 60, 50.0).get(); - - exchange.placeOrder(buyer.id(), RUB_CNY, 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(CNY)); - assertEquals(140.0, buyerBalances.get(RUB)); - - assertEquals(60.0, seller1Balances.get(RUB)); - assertEquals(0.0, seller1Balances.get(CNY)); - - assertNull(seller2Balances.get(RUB)); + assertEquals(1, sellOrder1Messages.size()); + assertSame(sellOrder1Messages.getLast().type(), OrderMessageType.CREATED); + assertEquals(3, sellOrder2Messages.size()); + assertSame(sellOrder2Messages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, sellOrder3Messages.size()); + assertSame(sellOrder3Messages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(4, buyOrderMessages.size()); + assertSame(buyOrderMessages.getLast().type(), OrderMessageType.CLOSED); } @Test void testPriceOrderNotMatching() throws ExecutionException, InterruptedException { var buyer1 = exchange.createClient("Buyer1").get(); var buyer2 = exchange.createClient("Buyer2").get(); - var seller = exchange.createClient("Seller2").get(); + var seller = exchange.createClient("Seller").get(); - exchange.deposit(buyer1.id(), RUB, 200.0).get(); - exchange.deposit(buyer2.id(), RUB, 200.0).get(); - exchange.deposit(seller.id(), CNY, 50.0).get(); + var buyOrderId1 = exchange.placeOrder(buyer1.id(), RUB_CNY, OrderType.BUY, 80.0, 50.0).get(); + var buyOrderId2 = exchange.placeOrder(buyer2.id(), RUB_CNY, OrderType.BUY, 75.0, 50.0).get(); - exchange.placeOrder(buyer1.id(), RUB_CNY, OrderType.BUY, 80.0, 50.0).get(); - exchange.placeOrder(buyer2.id(), RUB_CNY, OrderType.BUY, 75.0, 50.0).get(); + var sellOrderId = exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 85, 50.0).get(); - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 85, 50.0).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer1.id(), buyOrderId1).get(); + var buyOrderMessages2 = exchange.getOrderMessages(buyer2.id(), buyOrderId2).get(); + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).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(RUB)); - assertEquals(0.0, sellerBalances.get(CNY)); - - assertNull(buyer1Balances.get(CNY)); - assertEquals(120.0, buyer1Balances.get(RUB)); - - assertNull(buyer2Balances.get(CNY)); - assertEquals(125.0, buyer2Balances.get(RUB)); + assertEquals(1, sellOrderMessages.size()); + assertEquals(1, buyOrderMessages.size()); + assertEquals(1, buyOrderMessages2.size()); } @Test public void testCancelBuyOrder() throws ExecutionException, InterruptedException { - var client = exchange.createClient("Client1").get(); - - exchange.deposit(client.id(), RUB, 100.0); - - var order = exchange.placeOrder(client.id(), RUB_CNY, OrderType.BUY, 100.0, 200.0).get().orElseThrow(); - - var balances = exchange.getBalances(client.id()).get(); - assertEquals(0.0, balances.get(RUB)); - - exchange.cancelOrder(client.id(), order.id()).get(); - - balances = exchange.getBalances(client.id()).get(); - assertEquals(100.0, balances.get(RUB)); - 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(), CNY, 200.0); - - var order = exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, 100.0, 200.0).get().orElseThrow(); - - var balances = exchange.getBalances(client.id()).get(); - assertEquals(0.0, balances.get(CNY)); - - exchange.cancelOrder(client.id(), order.id()).get(); - - balances = exchange.getBalances(client.id()).get(); - assertEquals(200.0, balances.get(CNY)); - 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(), RUB, 1000.0).get(); - exchange.deposit(seller.id(), CNY, 500.0).get(); - - exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 120.0, 100.0).get(); - - var order = exchange.placeOrder(buyer.id(), RUB_CNY, 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(CNY), 0.001); - assertEquals(880.0, buyerBalances.get(RUB), 0.001); - } - - @Test - public void testSameClientSellBuy() throws ExecutionException, InterruptedException { var client = exchange.createClient("Trader").get(); - - exchange.deposit(client.id(), RUB, 1000.0).get(); - exchange.deposit(client.id(), CNY, 1000.0).get(); - - exchange.placeOrder(client.id(), RUB_CNY, OrderType.BUY, 100.0, 100.0).get(); - exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, 90.0, 100.0).get(); - - var balances = exchange.getBalances(client.id()).get(); - assertEquals(900.0, balances.get(RUB), 0.001); - assertEquals(900.0, balances.get(CNY), 0.001); - - var orders = exchange.getActiveOrders(client.id()).get(); - assertNotNull(orders.iterator().next()); - assertNotNull(orders.iterator().next()); + + var orderId = exchange.placeOrder(client.id(), RUB_CNY, OrderType.BUY, 100.0, 200.0).get(); + + exchange.cancelOrder(client.id(), orderId).get(); + + var orderMessages = exchange.getOrderMessages(client.id(), orderId).get(); + + assertEquals(2, orderMessages.size()); + assertSame(orderMessages.getLast().type(), OrderMessageType.CLOSED); + assertSame(((OrderClosedMessage) orderMessages.getLast()).reason(), OrderClosedMessage.Reason.CANCELLED); } }