From 6132d3730328049959036fa72d0fc765fcbb163d Mon Sep 17 00:00:00 2001 From: lionarius Date: Mon, 18 Nov 2024 11:08:58 +0300 Subject: [PATCH] lab10 --- .../api/command/CancelOrderCommand.java | 16 + .../ru/lionarius/api/command/Command.java | 7 + .../api/command/CreateClientCommand.java | 17 + .../api/command/GetOrderMessagesCommand.java | 20 ++ .../api/command/GetOrdersCommand.java | 20 ++ .../api/command/PlaceOrderCommand.java | 20 ++ .../api/order/message/OrderClosedMessage.java | 4 +- .../api/order/message/OrderMessageType.java | 4 +- .../java/ru/lionarius/impl/OrderBook.java | 96 +++++ .../{ => plain}/PlainCurrencyExchange.java | 96 +---- .../impl/queue/CommandProcessor.java | 154 ++++++++ .../impl/queue/QueueCurrencyExchange.java | 74 ++++ .../java/ConcurrentCurrencyExchangeTest.java | 9 +- .../ConcurrentQueueCurrencyExchangeTest.java | 334 ++++++++++++++++++ src/test/java/CurrencyExchangeTest.java | 11 +- src/test/java/CurrencyQueueExchangeTest.java | 232 ++++++++++++ src/test/java/PerformanceTest.java | 106 ++++++ 17 files changed, 1113 insertions(+), 107 deletions(-) create mode 100644 src/main/java/ru/lionarius/api/command/CancelOrderCommand.java create mode 100644 src/main/java/ru/lionarius/api/command/Command.java create mode 100644 src/main/java/ru/lionarius/api/command/CreateClientCommand.java create mode 100644 src/main/java/ru/lionarius/api/command/GetOrderMessagesCommand.java create mode 100644 src/main/java/ru/lionarius/api/command/GetOrdersCommand.java create mode 100644 src/main/java/ru/lionarius/api/command/PlaceOrderCommand.java create mode 100644 src/main/java/ru/lionarius/impl/OrderBook.java rename src/main/java/ru/lionarius/impl/{ => plain}/PlainCurrencyExchange.java (52%) create mode 100644 src/main/java/ru/lionarius/impl/queue/CommandProcessor.java create mode 100644 src/main/java/ru/lionarius/impl/queue/QueueCurrencyExchange.java create mode 100644 src/test/java/ConcurrentQueueCurrencyExchangeTest.java create mode 100644 src/test/java/CurrencyQueueExchangeTest.java create mode 100644 src/test/java/PerformanceTest.java diff --git a/src/main/java/ru/lionarius/api/command/CancelOrderCommand.java b/src/main/java/ru/lionarius/api/command/CancelOrderCommand.java new file mode 100644 index 0000000..8757d80 --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/CancelOrderCommand.java @@ -0,0 +1,16 @@ +package ru.lionarius.api.command; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public record CancelOrderCommand(UUID clientId, UUID orderId, CompletableFuture result) implements Command { + + public CancelOrderCommand(UUID clientId, UUID orderId) { + this(clientId, orderId, new CompletableFuture<>()); + } + + @Override + public CompletableFuture result() { + return result; + } +} diff --git a/src/main/java/ru/lionarius/api/command/Command.java b/src/main/java/ru/lionarius/api/command/Command.java new file mode 100644 index 0000000..7540806 --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/Command.java @@ -0,0 +1,7 @@ +package ru.lionarius.api.command; + +import java.util.concurrent.CompletableFuture; + +public interface Command { + CompletableFuture result(); +} diff --git a/src/main/java/ru/lionarius/api/command/CreateClientCommand.java b/src/main/java/ru/lionarius/api/command/CreateClientCommand.java new file mode 100644 index 0000000..15bf301 --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/CreateClientCommand.java @@ -0,0 +1,17 @@ +package ru.lionarius.api.command; + +import ru.lionarius.api.client.Client; + +import java.util.concurrent.CompletableFuture; + +public record CreateClientCommand(String name, CompletableFuture result) implements Command { + + public CreateClientCommand(String name) { + this(name, new CompletableFuture<>()); + } + + @Override + public CompletableFuture result() { + return result; + } +} diff --git a/src/main/java/ru/lionarius/api/command/GetOrderMessagesCommand.java b/src/main/java/ru/lionarius/api/command/GetOrderMessagesCommand.java new file mode 100644 index 0000000..fa3617a --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/GetOrderMessagesCommand.java @@ -0,0 +1,20 @@ +package ru.lionarius.api.command; + +import ru.lionarius.api.order.message.OrderMessage; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public record GetOrderMessagesCommand(UUID clientId, UUID orderId, + CompletableFuture> result) implements Command> { + + public GetOrderMessagesCommand(UUID clientId, UUID orderId) { + this(clientId, orderId, new CompletableFuture<>()); + } + + @Override + public CompletableFuture> result() { + return result; + } +} diff --git a/src/main/java/ru/lionarius/api/command/GetOrdersCommand.java b/src/main/java/ru/lionarius/api/command/GetOrdersCommand.java new file mode 100644 index 0000000..aaf403d --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/GetOrdersCommand.java @@ -0,0 +1,20 @@ +package ru.lionarius.api.command; + +import ru.lionarius.api.order.OrderView; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public record GetOrdersCommand(UUID clientId, + CompletableFuture> result) implements Command> { + + public GetOrdersCommand(UUID clientId) { + this(clientId, new CompletableFuture<>()); + } + + @Override + public CompletableFuture> result() { + return result; + } +} diff --git a/src/main/java/ru/lionarius/api/command/PlaceOrderCommand.java b/src/main/java/ru/lionarius/api/command/PlaceOrderCommand.java new file mode 100644 index 0000000..7a497d9 --- /dev/null +++ b/src/main/java/ru/lionarius/api/command/PlaceOrderCommand.java @@ -0,0 +1,20 @@ +package ru.lionarius.api.command; + +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.OrderType; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +public record PlaceOrderCommand(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity, + CompletableFuture result) implements Command { + + public PlaceOrderCommand(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity) { + this(clientId, pair, type, price, quantity, new CompletableFuture<>()); + } + + @Override + public CompletableFuture result() { + return result; + } +} diff --git a/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java b/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java index bfc99e9..aa2998a 100644 --- a/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java +++ b/src/main/java/ru/lionarius/api/order/message/OrderClosedMessage.java @@ -8,7 +8,7 @@ public record OrderClosedMessage(Reason reason) implements OrderMessage { } public enum Reason { - CANCELLED, - FULFILLED + FULFILLED, + CANCELLED } } diff --git a/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java b/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java index 53022a6..e6d1cb1 100644 --- a/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java +++ b/src/main/java/ru/lionarius/api/order/message/OrderMessageType.java @@ -2,6 +2,6 @@ package ru.lionarius.api.order.message; public enum OrderMessageType { CREATED, - CLOSED, - FILLED + FILLED, + CLOSED } diff --git a/src/main/java/ru/lionarius/impl/OrderBook.java b/src/main/java/ru/lionarius/impl/OrderBook.java new file mode 100644 index 0000000..8f93896 --- /dev/null +++ b/src/main/java/ru/lionarius/impl/OrderBook.java @@ -0,0 +1,96 @@ +package ru.lionarius.impl; + +import ru.lionarius.api.order.Order; +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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.TreeMap; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +public 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/main/java/ru/lionarius/impl/PlainCurrencyExchange.java b/src/main/java/ru/lionarius/impl/plain/PlainCurrencyExchange.java similarity index 52% rename from src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java rename to src/main/java/ru/lionarius/impl/plain/PlainCurrencyExchange.java index 7929f0a..dd3eff3 100644 --- a/src/main/java/ru/lionarius/impl/PlainCurrencyExchange.java +++ b/src/main/java/ru/lionarius/impl/plain/PlainCurrencyExchange.java @@ -1,4 +1,4 @@ -package ru.lionarius.impl; +package ru.lionarius.impl.plain; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; @@ -12,23 +12,20 @@ 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 ru.lionarius.impl.InMemoryClientRepository; +import ru.lionarius.impl.OrderBook; import java.util.*; import java.util.concurrent.CompletableFuture; -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 orderBooks; - private final Set allowedCurrencies; private final Set allowedPairs; - public PlainCurrencyExchange(Set allowedCurrencies, Set allowedPairs) { - this.allowedCurrencies = ImmutableSet.copyOf(allowedCurrencies); + public PlainCurrencyExchange(Set allowedPairs) { this.allowedPairs = ImmutableSet.copyOf(allowedPairs); this.orderBooks = allowedPairs.stream() @@ -64,6 +61,9 @@ public class PlainCurrencyExchange implements CurrencyExchange { if (quantity <= 0.0) throw new IllegalArgumentException("Quantity must be positive"); + if (!allowedPairs.contains(pair)) + throw new IllegalArgumentException("Pair is not allowed"); + var orders = clientRepository.getClientOrders(clientId).orElseThrow(); var order = new Order(clientId, type, pair, new OrderData(price, quantity)); @@ -121,86 +121,4 @@ public class PlainCurrencyExchange implements CurrencyExchange { 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/main/java/ru/lionarius/impl/queue/CommandProcessor.java b/src/main/java/ru/lionarius/impl/queue/CommandProcessor.java new file mode 100644 index 0000000..63524d4 --- /dev/null +++ b/src/main/java/ru/lionarius/impl/queue/CommandProcessor.java @@ -0,0 +1,154 @@ +package ru.lionarius.impl.queue; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import ru.lionarius.api.client.ClientRepository; +import ru.lionarius.api.command.*; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.Order; +import ru.lionarius.api.order.OrderData; +import ru.lionarius.api.order.message.OrderClosedMessage; +import ru.lionarius.impl.InMemoryClientRepository; +import ru.lionarius.impl.OrderBook; + +import java.util.AbstractMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +public class CommandProcessor implements Runnable { + private final BlockingQueue> commandQueue; + private final ExecutorService executorService; + + private final ClientRepository clientRepository = new InMemoryClientRepository(); + private final Map orderBooks; + private final Set allowedPairs; + + public CommandProcessor(Set allowedPairs, BlockingQueue> commandQueue) { + this.commandQueue = commandQueue; + this.executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors()); + + this.allowedPairs = ImmutableSet.copyOf(allowedPairs); + this.orderBooks = allowedPairs.stream() + .map(pair -> new AbstractMap.SimpleEntry<>(pair, new OrderBook())) + .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + @Override + public void run() { + while (true) { + try { + var command = commandQueue.take(); + + executorService.submit(() -> processCommand(command)); + } catch (InterruptedException e) { + break; + } + } + } + + public void shutdown() throws InterruptedException { + executorService.shutdown(); + executorService.awaitTermination(5, TimeUnit.SECONDS); + } + + private void processCommand(Command command) { + try { + if (command instanceof CreateClientCommand) { + processCreateClientCommand((CreateClientCommand) command); + } else if (command instanceof PlaceOrderCommand) { + processPlaceOrderCommand((PlaceOrderCommand) command); + } else if (command instanceof CancelOrderCommand) { + processCancelOrderCommand((CancelOrderCommand) command); + } else if (command instanceof GetOrdersCommand) { + processGetOrdersCommand((GetOrdersCommand) command); + } else if (command instanceof GetOrderMessagesCommand) { + processGetOrderMessagesCommand((GetOrderMessagesCommand) command); + } + } catch (Exception e) { + command.result().completeExceptionally(e); + } + } + + private void processCreateClientCommand(CreateClientCommand command) { + if (command.name() == null) + throw new IllegalArgumentException("Name cannot be null"); + + command.result().complete(clientRepository.createClient(command.name())); + } + + private void processPlaceOrderCommand(PlaceOrderCommand command) { + if (command.clientId() == null) + throw new IllegalArgumentException("Client ID cannot be null"); + + if (command.pair() == null) + throw new IllegalArgumentException("Currency pair cannot be null"); + + if (command.type() == null) + throw new IllegalArgumentException("Order type cannot be null"); + + if (command.price() <= 0.0) + throw new IllegalArgumentException("Price must be positive"); + + if (command.quantity() <= 0.0) + throw new IllegalArgumentException("Quantity must be positive"); + + if (!allowedPairs.contains(command.pair())) + throw new IllegalArgumentException("Pair is not allowed"); + + var orders = clientRepository.getClientOrders(command.clientId()).orElseThrow(); + + var order = new Order(command.clientId(), command.type(), command.pair(), new OrderData(command.price(), command.quantity())); + orders.add(order); + + var orderBook = orderBooks.get(command.pair()); + + orderBook.addOrder(order); + orderBook.matchOrders(); + + command.result().complete(order.getId()); + } + + private void processCancelOrderCommand(CancelOrderCommand command) { + if (command.clientId() == null) + throw new IllegalArgumentException("Client ID cannot be null"); + + if (command.orderId() == null) + throw new IllegalArgumentException("Order ID cannot be null"); + + var orders = clientRepository.getClientOrders(command.clientId()).orElseThrow(); + + var order = orders.get(command.orderId()).orElseThrow(); + order.pushMessage(new OrderClosedMessage(OrderClosedMessage.Reason.CANCELLED)); + + command.result().complete(null); + } + + private void processGetOrdersCommand(GetOrdersCommand command) { + if (command.clientId() == null) + throw new IllegalArgumentException("Client ID cannot be null"); + + var orders = clientRepository.getClientOrders(command.clientId()).orElseThrow(); + + var result = orders.get().stream().map(Order::getView).toList(); + + command.result().complete(result); + } + + private void processGetOrderMessagesCommand(GetOrderMessagesCommand command) { + if (command.clientId() == null) + throw new IllegalArgumentException("Client ID cannot be null"); + + if (command.orderId() == null) + throw new IllegalArgumentException("Order ID cannot be null"); + + var orders = clientRepository.getClientOrders(command.clientId()).orElseThrow(); + + var result = orders.get(command.orderId()).map(Order::getMessages).orElseThrow(); + + command.result().complete(result); + } +} diff --git a/src/main/java/ru/lionarius/impl/queue/QueueCurrencyExchange.java b/src/main/java/ru/lionarius/impl/queue/QueueCurrencyExchange.java new file mode 100644 index 0000000..e0ebda3 --- /dev/null +++ b/src/main/java/ru/lionarius/impl/queue/QueueCurrencyExchange.java @@ -0,0 +1,74 @@ +package ru.lionarius.impl.queue; + +import ru.lionarius.api.CurrencyExchange; +import ru.lionarius.api.client.Client; +import ru.lionarius.api.command.*; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.OrderType; +import ru.lionarius.api.order.OrderView; +import ru.lionarius.api.order.message.OrderMessage; + +import java.util.*; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; + +public class QueueCurrencyExchange implements CurrencyExchange { + private final BlockingQueue> commandQueue; + private final CommandProcessor commandProcessor; + private final Thread commandProcessorThread; + + public QueueCurrencyExchange(Set allowedPairs) { + this.commandQueue = new LinkedBlockingQueue<>(); + this.commandProcessor = new CommandProcessor(allowedPairs, commandQueue); + + this.commandProcessorThread = new Thread(commandProcessor); + this.commandProcessorThread.start(); + } + + @Override + public CompletableFuture createClient(String name) { + var command = new CreateClientCommand(name); + commandQueue.add(command); + + return command.result(); + } + + @Override + public CompletableFuture placeOrder(UUID clientId, CurrencyPair pair, OrderType type, double price, double quantity) { + var command = new PlaceOrderCommand(clientId, pair, type, price, quantity); + commandQueue.add(command); + + return command.result(); + } + + @Override + public CompletableFuture cancelOrder(UUID clientId, UUID orderId) { + var command = new CancelOrderCommand(clientId, orderId); + commandQueue.add(command); + + return command.result(); + } + + @Override + public CompletableFuture> getOrders(UUID clientId) { + var command = new GetOrdersCommand(clientId); + commandQueue.add(command); + + return command.result(); + } + + @Override + public CompletableFuture> getOrderMessages(UUID clientId, UUID orderId) { + var command = new GetOrderMessagesCommand(clientId, orderId); + commandQueue.add(command); + + return command.result(); + } + + public void shutdown() throws InterruptedException { + this.commandProcessorThread.interrupt(); + this.commandProcessorThread.join(); + this.commandProcessor.shutdown(); + } +} diff --git a/src/test/java/ConcurrentCurrencyExchangeTest.java b/src/test/java/ConcurrentCurrencyExchangeTest.java index 3fb8340..a581473 100644 --- a/src/test/java/ConcurrentCurrencyExchangeTest.java +++ b/src/test/java/ConcurrentCurrencyExchangeTest.java @@ -6,7 +6,7 @@ import ru.lionarius.api.client.Client; import ru.lionarius.api.currency.Currency; import ru.lionarius.api.currency.CurrencyPair; import ru.lionarius.api.order.OrderType; -import ru.lionarius.impl.PlainCurrencyExchange; +import ru.lionarius.impl.plain.PlainCurrencyExchange; import java.util.*; import java.util.concurrent.*; @@ -16,19 +16,16 @@ import static org.junit.jupiter.api.Assertions.*; class ConcurrentCurrencyExchangeTest { private CurrencyExchange exchange; - private Currency RUB; - private Currency CNY; private CurrencyPair RUB_CNY; private ExecutorService executorService; @BeforeEach void setUp() { - RUB = new Currency("RUB"); - CNY = new Currency("CNY"); + Currency RUB = new Currency("RUB"); + Currency CNY = new Currency("CNY"); RUB_CNY = new CurrencyPair(RUB, CNY); exchange = new PlainCurrencyExchange( - Set.of(RUB, CNY), Set.of(RUB_CNY) ); diff --git a/src/test/java/ConcurrentQueueCurrencyExchangeTest.java b/src/test/java/ConcurrentQueueCurrencyExchangeTest.java new file mode 100644 index 0000000..07aa8c4 --- /dev/null +++ b/src/test/java/ConcurrentQueueCurrencyExchangeTest.java @@ -0,0 +1,334 @@ +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ru.lionarius.api.client.Client; +import ru.lionarius.api.currency.Currency; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.OrderType; +import ru.lionarius.impl.queue.QueueCurrencyExchange; + +import java.util.ArrayList; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConcurrentQueueCurrencyExchangeTest { + private QueueCurrencyExchange exchange; + private CurrencyPair RUB_CNY; + private ExecutorService executorService; + + @BeforeEach + void setUp() { + Currency RUB = new Currency("RUB"); + Currency CNY = new Currency("CNY"); + RUB_CNY = new CurrencyPair(RUB, CNY); + + exchange = new QueueCurrencyExchange( + Set.of(RUB_CNY) + ); + + 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 testConcurrentOrderPlacement() throws InterruptedException { + var numSellers = 10; + var numBuyers = 10; + var ordersPerClient = 20; + var latch = new CountDownLatch(numSellers + numBuyers); + + var sellers = new ArrayList(); + for (int i = 0; i < numSellers; i++) { + var seller = exchange.createClient("Seller " + i).join(); + sellers.add(seller); + } + + var buyers = new ArrayList(); + for (int i = 0; i < numBuyers; i++) { + var buyer = exchange.createClient("Buyer " + i).join(); + buyers.add(buyer); + } + + var sellerFutures = sellers.stream() + .map(seller -> CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < ordersPerClient; i++) { + exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, 10.0, 10.0).join(); + Thread.sleep(10); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService)) + .toList(); + + var buyerFutures = buyers.stream() + .map(buyer -> CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < ordersPerClient; i++) { + exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, 1.2, 10.0).join(); + Thread.sleep(10); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService)) + .toList(); + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf( + Stream.concat(sellerFutures.stream(), buyerFutures.stream()) + .toArray(CompletableFuture[]::new) + ).join(); + + for (var seller : sellers) { + assertTrue((long) exchange.getOrders(seller.id()).join().size() <= ordersPerClient); + } + + for (var buyer : buyers) { + assertTrue((long) exchange.getOrders(buyer.id()).join().size() <= ordersPerClient); + } + } + + @Test + void testConcurrentOrderMatchingWithDifferentPrices() throws InterruptedException { + var numOrders = 50; + var latch = new CountDownLatch(2); + + var seller = exchange.createClient("Seller").join(); + var buyer = exchange.createClient("Buyer").join(); + + var sellerFuture = CompletableFuture.runAsync(() -> { + try { + for (int i = 0; i < numOrders; i++) { + var price = 1.0 + (i * 0.01); + exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, price, 10.0).join(); + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService); + + var buyerFuture = CompletableFuture.runAsync(() -> { + try { + for (var i = 0; i < numOrders; i++) { + var price = 2.0 - (i * 0.01); + exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, price, 10.0).join(); + Thread.sleep(5); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + latch.countDown(); + } + }, executorService); + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(sellerFuture, buyerFuture).join(); + + var sellerOrders = exchange.getOrders(seller.id()).join(); + var buyerOrders = exchange.getOrders(buyer.id()).join(); + + var remainingSellerOrders = sellerOrders.stream().filter(order -> !order.closed()).count(); + var remainingBuyerOrders = buyerOrders.stream().filter(order -> !order.closed()).count(); + + assertTrue(remainingSellerOrders + remainingBuyerOrders < numOrders * 2, + "Some orders should have been matched"); + } + + @Test + void testBalanceConsistencyWithConcurrentTradesSamePrices() throws InterruptedException { + var numTraders = 100; + var numOrders = 1000; + var latch = new CountDownLatch(numTraders * 2); + + var sellers = new ArrayList(); + var buyers = new ArrayList(); + var sellAmount = 100.0; + var buyAmount = 100.0; + + for (var i = 0; i < numTraders; i++) { + var seller = exchange.createClient("Seller " + i).join(); + var buyer = exchange.createClient("Buyer " + i).join(); + + sellers.add(seller); + buyers.add(buyer); + } + + var futures = new ArrayList>(); + + for (var seller : sellers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (var i = 0; i < numOrders; i++) { + exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, buyAmount, sellAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + for (var buyer : buyers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (var i = 0; i < numOrders; i++) { + exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, sellAmount, buyAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + var totalRubMoney = 0.0; + var totalCnyMoney = 0.0; + + for (var seller : sellers) { + var orders = exchange.getOrders(seller.id()).join(); + + totalCnyMoney += orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); + totalRubMoney += orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); + } + + assertEquals(numOrders * numTraders * sellAmount, totalCnyMoney, 0.001); + assertEquals(numOrders * numTraders * buyAmount, totalRubMoney, 0.001); + + for (var buyer : buyers) { + var orders = exchange.getOrders(buyer.id()).join(); + + totalCnyMoney -= orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); + totalRubMoney -= orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); + } + + assertEquals(0.0, totalRubMoney, 0.001); + assertEquals(0.0, totalCnyMoney, 0.001); + } + + @Test + void testBalanceConsistencyWithConcurrentTrades() throws InterruptedException { + var numTraders = 100; + var numOrders = 1000; + var latch = new CountDownLatch(numTraders * 3); + + var sellers = new ArrayList(); + var buyers = new ArrayList(); + var sellAmount = 100.0; + var buyAmount = 50.0; + + for (var i = 0; i < numTraders; i++) { + var seller = exchange.createClient("Seller " + i).join(); + var buyer = exchange.createClient("Buyer " + i).join(); + var buyer2 = exchange.createClient("Buyer " + i).join(); + + sellers.add(seller); + buyers.add(buyer); + buyers.add(buyer2); + } + + var futures = new ArrayList>(); + + for (var seller : sellers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (var i = 0; i < numOrders; i++) { + exchange.placeOrder(seller.id(), RUB_CNY, OrderType.SELL, buyAmount, sellAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + for (var buyer : buyers) { + var future = CompletableFuture.runAsync(() -> { + try { + for (var i = 0; i < numOrders; i++) { + exchange.placeOrder(buyer.id(), RUB_CNY, OrderType.BUY, sellAmount, buyAmount).join(); + } + } finally { + latch.countDown(); + } + }, executorService); + futures.add(future); + } + + latch.await(30, TimeUnit.SECONDS); + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + var totalRubMoney = 0.0; + var totalCnyMoney = 0.0; + + for (var seller : sellers) { + var orders = exchange.getOrders(seller.id()).join(); + + totalCnyMoney += orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); + totalRubMoney += orders.stream().mapToDouble(order -> order.originalData().price() - order.lastData().price()).sum(); + } + + assertEquals(numOrders * numTraders * sellAmount, totalCnyMoney, 0.001); + assertEquals(numOrders * numTraders * buyAmount, totalRubMoney, 0.001); + + for (var buyer : buyers) { + var orders = exchange.getOrders(buyer.id()).join(); + + totalCnyMoney -= orders.stream().mapToDouble(order -> order.originalData().quantity() - order.lastData().quantity()).sum(); + } + + assertEquals(0.0, totalCnyMoney, 0.001); + } + + @AfterEach + void tearDown() { + executorService.shutdown(); + try { + if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + exchange.shutdown(); + } 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 index 31298a8..82c1d35 100644 --- a/src/test/java/CurrencyExchangeTest.java +++ b/src/test/java/CurrencyExchangeTest.java @@ -8,10 +8,8 @@ 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 ru.lionarius.impl.plain.PlainCurrencyExchange; -import java.util.Arrays; -import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutionException; @@ -20,18 +18,15 @@ import static org.junit.jupiter.api.Assertions.*; class CurrencyExchangeTest { private CurrencyExchange exchange; - private Currency RUB; - private Currency CNY; private CurrencyPair RUB_CNY; @BeforeEach void setUp() { - RUB = new Currency("RUB"); - CNY = new Currency("CNY"); + Currency RUB = new Currency("RUB"); + Currency CNY = new Currency("CNY"); RUB_CNY = new CurrencyPair(RUB, CNY); exchange = new PlainCurrencyExchange( - Set.of(RUB, CNY), Set.of(RUB_CNY) ); } diff --git a/src/test/java/CurrencyQueueExchangeTest.java b/src/test/java/CurrencyQueueExchangeTest.java new file mode 100644 index 0000000..e30fbb8 --- /dev/null +++ b/src/test/java/CurrencyQueueExchangeTest.java @@ -0,0 +1,232 @@ +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.queue.QueueCurrencyExchange; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +import static org.junit.jupiter.api.Assertions.*; + +class CurrencyQueueExchangeTest { + private QueueCurrencyExchange exchange; + private CurrencyPair RUB_CNY; + + @BeforeEach + void setUp() { + Currency RUB = new Currency("RUB"); + Currency CNY = new Currency("CNY"); + RUB_CNY = new CurrencyPair(RUB, CNY); + + exchange = new QueueCurrencyExchange( + Set.of(RUB_CNY) + ); + } + + @Test + void testCreateClient() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertNotNull(client); + } + + @Test + void testCreateClientWithNullName() { + assertThrows(ExecutionException.class, () -> exchange.createClient(null).get()); + } + + @Test + void testGetBalancesNonexistentClient() { + var nonexistentId = UUID.randomUUID(); + assertThrows(Exception.class, () -> exchange.getOrders(nonexistentId).get()); + } + + @Test + void testPlaceOrder() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, 120, 100.0).get(); + + exchange.getOrders(client.id()).get().getFirst(); + } + + @Test + void testOrderPriceValidation() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + assertThrows(ExecutionException.class, () -> + exchange.placeOrder(client.id(), RUB_CNY, OrderType.SELL, -1.0, 100.0).get() + ); + } + + @Test + void testOrderQuantityValidation() 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() + ); + } + + + @Test + void testMatchingOrders() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").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(); + + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); + + assertEquals(3, sellOrderMessages.size()); + assertSame(sellOrderMessages.getLast().type(), OrderMessageType.CLOSED); + assertEquals(3, buyOrderMessages.size()); + assertSame(buyOrderMessages.getLast().type(), OrderMessageType.CLOSED); + } + + @Test + void testPartialOrderMatching() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller = exchange.createClient("Seller").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(); + + var buyOrderMessages = exchange.getOrderMessages(buyer.id(), buyOrderId).get(); + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); + + 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 + void testPartialOrderMatchingMultiple() throws ExecutionException, InterruptedException { + var buyer1 = exchange.createClient("Buyer1").get(); + var buyer2 = exchange.createClient("Buyer2").get(); + var seller = exchange.createClient("Seller").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(); + + var sellOrderMessages = exchange.getOrderMessages(seller.id(), sellOrderId).get(); + var buyOrder1Messages = exchange.getOrderMessages(buyer1.id(), buyOrderId1).get(); + var buyOrder2Messages = exchange.getOrderMessages(buyer2.id(), buyOrderId2).get(); + + 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 testPricePriorityInOrderMatching() throws ExecutionException, InterruptedException { + var buyer = exchange.createClient("Buyer").get(); + var seller1 = exchange.createClient("Seller1").get(); + var seller2 = exchange.createClient("Seller2").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(); + + var buyOrderId = 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(); + + 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 + 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(); + + 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(); + + var buyOrderId = 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(); + + 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("Seller").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(); + + var sellOrderId = 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(); + + assertEquals(1, sellOrderMessages.size()); + assertEquals(1, buyOrderMessages.size()); + assertEquals(1, buyOrderMessages2.size()); + } + + @Test + public void testCancelBuyOrder() throws ExecutionException, InterruptedException { + var client = exchange.createClient("Trader").get(); + + 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); + } + + @AfterEach + void tearDown() { + try { + exchange.shutdown(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/PerformanceTest.java b/src/test/java/PerformanceTest.java new file mode 100644 index 0000000..232a73a --- /dev/null +++ b/src/test/java/PerformanceTest.java @@ -0,0 +1,106 @@ +import org.junit.jupiter.api.Test; +import ru.lionarius.api.CurrencyExchange; +import ru.lionarius.api.client.Client; +import ru.lionarius.api.currency.Currency; +import ru.lionarius.api.currency.CurrencyPair; +import ru.lionarius.api.order.OrderType; +import ru.lionarius.impl.plain.PlainCurrencyExchange; +import ru.lionarius.impl.queue.QueueCurrencyExchange; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.*; + +public class PerformanceTest { + + @Test + public void compareQueuePlainPerformance() throws Exception { + var BTC = new Currency("BTC"); + var ETH = new Currency("ETH"); + var USD = new Currency("USD"); + var RUB = new Currency("RUB"); + + var pairs = Set.of( + new CurrencyPair(BTC, USD), + new CurrencyPair(ETH, USD), + new CurrencyPair(USD, BTC), + new CurrencyPair(USD, ETH), + new CurrencyPair(RUB, USD), + new CurrencyPair(USD, RUB), + new CurrencyPair(BTC, RUB), + new CurrencyPair(ETH, RUB), + new CurrencyPair(RUB, BTC), + new CurrencyPair(RUB, ETH) + ); + + var plainExchange = new PlainCurrencyExchange(pairs); + var queueExchange = new QueueCurrencyExchange(pairs); + + var numTraders = 500; + var numOrders = 1000; + + var plainTime = benchmark(numTraders, numOrders, plainExchange, pairs); + var queueTime = benchmark(numTraders, numOrders, queueExchange, pairs); + + queueExchange.shutdown(); + + System.out.printf("Direct implementation: %.2f orders/sec%n", plainTime); + System.out.printf("Queue implementation: %.2f orders/sec%n", queueTime); + } + + double benchmark(int numTraders, int numOrders, CurrencyExchange exchange, Set pairs) throws InterruptedException { + var buyers = new ArrayList(); + var sellers = new ArrayList(); + + initTest(numTraders, buyers, sellers, exchange); + + var start = System.nanoTime(); + var orders = stressTest(buyers, sellers, numOrders, exchange, List.copyOf(pairs)); + + return orders * 1_000_000_000.0 / (System.nanoTime() - start); + } + + void initTest(int numTraders, List buyers, List sellers, CurrencyExchange exchange) { + for (var i = 0; i < numTraders; i++) { + var seller = exchange.createClient("Seller " + i).join(); + var buyer = exchange.createClient("Buyer " + i).join(); + + sellers.add(seller); + buyers.add(buyer); + } + } + + long stressTest(List buyers, List sellers, int numOrders, CurrencyExchange exchange, List pairs) throws InterruptedException { + long orders = 0; + + var futures = new ArrayList>(); + + var random = new Random(); + + for (var seller : sellers) { + for (var i = 0; i < numOrders; i++) { + var randomPair = pairs.get(random.nextInt(pairs.size())); + var buyAmount = random.nextInt(100) + 1; + var sellAmount = random.nextInt(100) + 1; + futures.add(exchange.placeOrder(seller.id(), randomPair, OrderType.SELL, buyAmount, sellAmount)); + orders += 1; + } + } + + for (var buyer : buyers) { + for (var i = 0; i < numOrders; i++) { + var randomPair = pairs.get(random.nextInt(pairs.size())); + var buyAmount = random.nextInt(100) + 1; + var sellAmount = random.nextInt(100) + 1; + futures.add(exchange.placeOrder(buyer.id(), randomPair, OrderType.BUY, sellAmount, buyAmount)); + orders += 1; + } + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + + return orders; + } +}