1167 lines
62 KiB
Python
1167 lines
62 KiB
Python
from decimal import Decimal
|
||
import logging
|
||
from unittest.mock import ANY
|
||
import pytest
|
||
|
||
from connection.tcp_connection_manager import TcpConnectionManager
|
||
from proto.common_pb2 import Instrument, Side
|
||
|
||
from tests.common.mock_expectations import CallExpectationsManager
|
||
from tests.test_client.admin_test_client import AdminClientConnectionHandlerFactory, AdminTestClient
|
||
from tests.test_client.order_book_test_client import (
|
||
OrderBookClientConnectionHandlerFactory, OrderBookCreatedExpectation, OrderBookTestClient)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TestOrderBookSystem:
|
||
PROTOCOL = "order_book"
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def setup(self, request: pytest.FixtureRequest) -> None:
|
||
self.test_name = request.node.name
|
||
logger.info(f"Setting up test: {self.test_name}")
|
||
self.tcp_connection_manager = TcpConnectionManager()
|
||
self.call_expectations_manager = CallExpectationsManager()
|
||
self.call_expectations_manager.setup_network(self.tcp_connection_manager)
|
||
self.order_book_client_factory = OrderBookClientConnectionHandlerFactory(self.call_expectations_manager)
|
||
self._admin_factory = AdminClientConnectionHandlerFactory(self.call_expectations_manager)
|
||
self._admin_client: AdminTestClient | None = None
|
||
self._next_instrument_id = 1
|
||
|
||
def _connect_unauthenticated(self) -> OrderBookTestClient:
|
||
return self.tcp_connection_manager.connect(self.server_address, self.order_book_client_factory)
|
||
|
||
def _get_admin_client(self) -> AdminTestClient:
|
||
if self._admin_client is None:
|
||
admin_address = self.orchestrator.get_server_address("admin")
|
||
self._admin_client = self.tcp_connection_manager.connect(admin_address, self._admin_factory)
|
||
return self._admin_client
|
||
|
||
def _create_order_book_via_admin(self, order_book_client: OrderBookTestClient,
|
||
tick_size: Decimal) -> OrderBookCreatedExpectation:
|
||
on_created = order_book_client.expect_on_order_book_created_event(tick_size=tick_size)
|
||
|
||
instrument = Instrument(
|
||
symbol=f"TEST.{self._next_instrument_id}",
|
||
description="Test instrument",
|
||
currency="USD",
|
||
multiplier=1.0)
|
||
self._next_instrument_id += 1
|
||
|
||
admin_client = self._get_admin_client()
|
||
admin_client.test_create_instrument(instrument, tick_size)
|
||
|
||
assert on_created.fulfilled, "OnOrderBookCreated not received"
|
||
order_book_client._last_order_book_id = on_created.get_message().order_book_id
|
||
|
||
return on_created
|
||
|
||
def test_create_order_book(self) -> None:
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_insert_order(self) -> None:
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
client.test_insert_order(side=Side.BUY, price=Decimal("100.0"), quantity=10, username=self.test_name)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_cancel_order(self) -> None:
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
client.test_insert_order(side=Side.BUY, price=Decimal("100.0"), quantity=10, username=self.test_name)
|
||
client.test_cancel_order()
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
@pytest.mark.parametrize(
|
||
"buy_price, buy_quantity, sell_price, sell_quantity, aggressor_side, expected_trade_price, expected_trade_quantity",
|
||
[
|
||
pytest.param(Decimal("100.0"), 10, Decimal("100.0"), 10, Side.BUY, Decimal("100.0"), 10, id="same_price_same_quantity_buyer_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("100.0"), 10, Side.SELL, Decimal("100.0"), 10, id="same_price_same_quantity_seller_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("100.0"), 5, Side.BUY, Decimal("100.0"), 5, id="same_price_different_quantity_buyer_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("100.0"), 5, Side.SELL, Decimal("100.0"), 5, id="same_price_different_quantity_seller_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("99.99"), 10, Side.BUY, Decimal("99.99"), 10, id="different_price_same_quantity_buyer_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("99.99"), 10, Side.SELL, Decimal("100.0"), 10, id="different_price_same_quantity_seller_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("99.99"), 5, Side.BUY, Decimal("99.99"), 5, id="different_price_different_quantity_buyer_hit"),
|
||
pytest.param(Decimal("100.0"), 10, Decimal("99.99"), 5, Side.SELL, Decimal("100.0"), 5, id="different_price_different_quantity_seller_hit"),
|
||
]
|
||
)
|
||
def test_match_single_order(
|
||
self, buy_price: Decimal, buy_quantity: int, sell_price: Decimal, sell_quantity: int, aggressor_side: Side.ValueType,
|
||
expected_trade_price: Decimal, expected_trade_quantity: int) -> None:
|
||
if aggressor_side == Side.BUY:
|
||
aggressive_price = buy_price
|
||
aggressive_quantity = buy_quantity
|
||
passive_price = sell_price
|
||
passive_quantity = sell_quantity
|
||
else:
|
||
aggressive_price = sell_price
|
||
aggressive_quantity = sell_quantity
|
||
passive_price = buy_price
|
||
passive_quantity = buy_quantity
|
||
|
||
logger.info("Connecting first client and creating order book")
|
||
self.client_passive = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(self.client_passive, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
logger.info("Inserting passive order with first client")
|
||
passive_side = Side.BUY if aggressor_side == Side.SELL else Side.SELL
|
||
passive_order_response_expectation = self.client_passive.test_insert_order(
|
||
side=passive_side, price=passive_price, quantity=passive_quantity, order_book_id=order_book_id,
|
||
username=f"{self.test_name}_1")
|
||
passive_order_id = passive_order_response_expectation.get_response().order_id
|
||
|
||
logger.info("Connecting second client and expecting existing orders to be published")
|
||
self.client_aggressor = self._connect_unauthenticated()
|
||
|
||
self.client_aggressor.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
self.client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=passive_order_id, side=passive_side, price=passive_price, quantity=passive_quantity)
|
||
# Verify that the second client received the full state of the order book
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
logger.info("Inserting aggressive order with second client and expecting trade to be published")
|
||
aggressive_order_response_expectation = self.client_aggressor.test_insert_order(
|
||
side=aggressor_side, price=aggressive_price, quantity=aggressive_quantity, order_book_id=order_book_id,
|
||
username=f"{self.test_name}_2")
|
||
aggressive_order_response = aggressive_order_response_expectation.get_response()
|
||
aggressive_order_id = aggressive_order_response.order_id
|
||
assert len(aggressive_order_response.trade_ids) == 1
|
||
trade_id = aggressive_order_response.trade_ids[0]
|
||
assert aggressive_order_response.traded_quantity == expected_trade_quantity
|
||
|
||
# expect order from client2 to be published to client1
|
||
self.client_passive.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=aggressive_order_id, side=aggressor_side, price=aggressive_price,
|
||
quantity=aggressive_quantity)
|
||
|
||
# expect trade to be published to both clients
|
||
buy_order_id = passive_order_id if passive_side == Side.BUY else aggressive_order_id
|
||
sell_order_id = aggressive_order_id if passive_side == Side.BUY else passive_order_id
|
||
self.client_passive.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id, buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=aggressor_side, price=expected_trade_price, quantity=expected_trade_quantity)
|
||
self.client_aggressor.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id, buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=aggressor_side, price=expected_trade_price, quantity=expected_trade_quantity)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_order_cancellation_after_partial_fill(self) -> None:
|
||
logger.info("Start matching orders with partial fill")
|
||
self.test_match_single_order(
|
||
buy_price=Decimal("100.0"), buy_quantity=10,
|
||
sell_price=Decimal("100.0"), sell_quantity=5,
|
||
aggressor_side=Side.BUY,
|
||
expected_trade_price=Decimal("100.0"), expected_trade_quantity=5)
|
||
|
||
assert self.client_aggressor._last_order_id is not None
|
||
order_id = self.client_aggressor._last_order_id
|
||
order_book_id = self.client_passive._last_order_book_id
|
||
self.client_aggressor.test_cancel_order(order_book_id=order_book_id)
|
||
self.client_passive.expect_on_order_cancelled_event(order_id=order_id)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
def test_order_cancellation_after_full_fill(self) -> None:
|
||
logger.info("Start matching orders fully")
|
||
self.test_match_single_order(
|
||
buy_price=Decimal("100.0"), buy_quantity=10,
|
||
sell_price=Decimal("100.0"), sell_quantity=10,
|
||
aggressor_side=Side.BUY,
|
||
expected_trade_price=Decimal("100.0"), expected_trade_quantity=10)
|
||
|
||
order_book_id = self.client_passive._last_order_book_id
|
||
self.client_aggressor.test_cancel_order(order_book_id=order_book_id, expect_success=False)
|
||
self.client_passive.test_cancel_order(order_book_id=order_book_id, expect_success=False)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
@pytest.mark.parametrize(
|
||
"num_levels, orders_per_level",
|
||
[
|
||
pytest.param(5, 1, id="single_order_per_level"),
|
||
pytest.param(1, 5, id="single_level_multiple_orders"),
|
||
pytest.param(5, 5, id="multiple_levels_multiple_orders"),
|
||
pytest.param(50, 5, id="lots_of_levels_multiple_orders"),
|
||
pytest.param(50, 20, id="lots_of_levels_lots_of_orders"),
|
||
]
|
||
)
|
||
def test_match_single_aggressive_order_on_multiple_levels(self, num_levels: int, orders_per_level: int) -> None:
|
||
tick_size = Decimal("0.01")
|
||
best_bid = Decimal("100.0")
|
||
bid_quantity = 10
|
||
|
||
logger.info("Connecting first client and creating order book")
|
||
self.client_passive = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(self.client_passive, tick_size=tick_size)
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
logger.info("Inserting passive orders with first client")
|
||
logger.info(f"Inserting {orders_per_level} passive orders per level, at {num_levels} levels")
|
||
passive_order_response_expectations = []
|
||
bid_prices = [best_bid - i * tick_size for i in range(num_levels)]
|
||
for price in bid_prices:
|
||
for _ in range(orders_per_level):
|
||
passive_order_response_expectations.append(self.client_passive.test_insert_order(
|
||
side=Side.BUY, price=price, quantity=bid_quantity, order_book_id=order_book_id,
|
||
username=f"{self.test_name}_1"))
|
||
|
||
logger.info("Connecting second client and expecting existing orders to be published")
|
||
self.client_aggressor = self._connect_unauthenticated()
|
||
|
||
self.client_aggressor.expect_on_order_book_created_event(
|
||
tick_size=tick_size, order_book_id=order_book_id)
|
||
buy_order_ids = []
|
||
for passive_order_response_expectation in passive_order_response_expectations:
|
||
passive_order_response = passive_order_response_expectation.get_response()
|
||
buy_order_ids.append(passive_order_response.order_id)
|
||
self.client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=passive_order_response.order_id, side=Side.BUY,
|
||
price=ANY, quantity=bid_quantity)
|
||
# Verify that the second client received the full state of the order book
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
worst_bid = min(bid_prices)
|
||
total_bid_quantity = num_levels * orders_per_level * bid_quantity
|
||
|
||
logger.info("Inserting aggressive order with second client and expecting to trade all levels")
|
||
aggressive_order_response_expectation = self.client_aggressor.test_insert_order(
|
||
side=Side.SELL, price=worst_bid, quantity=total_bid_quantity, order_book_id=order_book_id,
|
||
username=f"{self.test_name}_2")
|
||
aggressive_order_response = aggressive_order_response_expectation.get_response()
|
||
sell_order_id = aggressive_order_response.order_id
|
||
assert len(aggressive_order_response.trade_ids) == num_levels * orders_per_level
|
||
aggressive_order_response_trade_ids = set(aggressive_order_response.trade_ids)
|
||
|
||
# expect order from client2 to be published (also) to client1
|
||
self.client_passive.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=ANY, side=Side.SELL, price=worst_bid, quantity=total_bid_quantity)
|
||
|
||
# expect trades to be published to both clients
|
||
on_trade_expectations = []
|
||
for price in bid_prices:
|
||
for _ in range(orders_per_level):
|
||
on_trade_expectations.append(self.client_passive.expect_on_trade_event(
|
||
trade_id=ANY, order_book_id=order_book_id, buy_order_id=ANY, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=price, quantity=bid_quantity))
|
||
on_trade_expectations.append(self.client_aggressor.expect_on_trade_event(
|
||
trade_id=ANY, order_book_id=order_book_id, buy_order_id=ANY, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=price, quantity=bid_quantity))
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# verify that all trade ids from the aggressive order response are present in the trade events
|
||
trade_ids = set(trade_expectation.get_message().trade_id for trade_expectation in on_trade_expectations)
|
||
assert trade_ids == aggressive_order_response_trade_ids
|
||
|
||
# =========================================================================
|
||
# State publishing to new clients
|
||
# =========================================================================
|
||
|
||
def test_new_client_receives_existing_order_book(self) -> None:
|
||
"""A client connecting after an order book is created should receive OnOrderBookCreated."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_new_client_receives_existing_orders(self) -> None:
|
||
"""A client connecting after orders are placed should receive OnOrderInserted for each resting order."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
buy_resp = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
buy_order_id = buy_resp.get_response().order_id
|
||
|
||
sell_resp = client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("101.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
sell_order_id = sell_resp.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=buy_order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("101.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_new_client_receives_multiple_order_books(self) -> None:
|
||
"""A client connecting should receive OnOrderBookCreated for all existing order books."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created_1 = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
ob_id_1 = on_created_1.get_message().order_book_id
|
||
on_created_2 = self._create_order_book_via_admin(client1, tick_size=Decimal("0.05"))
|
||
ob_id_2 = on_created_2.get_message().order_book_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.01"), order_book_id=ob_id_1)
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.05"), order_book_id=ob_id_2)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_new_client_does_not_receive_cancelled_orders(self) -> None:
|
||
"""Cancelled orders must not appear in the state published to a newly connecting client."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
buy_resp = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("99.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
buy_order_id = buy_resp.get_response().order_id
|
||
client1.test_cancel_order(order_id=buy_order_id, order_book_id=order_book_id)
|
||
|
||
sell_resp = client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("101.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
sell_order_id = sell_resp.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("101.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_new_client_receives_partially_filled_orders(self) -> None:
|
||
"""A partially filled order should still appear in state published to a newly connecting client."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
buy_resp = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_buyer")
|
||
buy_order_id = buy_resp.get_response().order_id
|
||
|
||
sell_resp = client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_seller")
|
||
sell_response = sell_resp.get_response()
|
||
assert sell_response.traded_quantity == 5
|
||
trade_id = sell_response.trade_ids[0]
|
||
|
||
client1.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_response.order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=buy_order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Error handling
|
||
# =========================================================================
|
||
|
||
def test_insert_order_on_nonexistent_order_book(self) -> None:
|
||
"""Inserting on an order_book_id that does not exist should return an error response."""
|
||
client = self._connect_unauthenticated()
|
||
client._last_order_book_id = 99999
|
||
client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=99999, username=self.test_name,
|
||
expect_success=False, expect_public_feed=False)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_cancel_nonexistent_order(self) -> None:
|
||
"""Cancelling an order_id that does not exist should return an error response."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
client.test_cancel_order(
|
||
order_id=99999, expect_success=False, expect_public_feed=False)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_cancel_same_order_twice(self) -> None:
|
||
"""Cancelling the same order a second time should return an error response."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
resp = client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10, username=self.test_name)
|
||
order_id = resp.get_response().order_id
|
||
|
||
client.test_cancel_order(order_id=order_id)
|
||
client.test_cancel_order(
|
||
order_id=order_id, expect_success=False, expect_public_feed=False)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
@pytest.mark.parametrize(
|
||
"tick_size, valid_price, invalid_price",
|
||
[
|
||
pytest.param(Decimal("0.01"), Decimal("100.01"), Decimal("100.005"), id="tick_0.01"),
|
||
pytest.param(Decimal("0.05"), Decimal("100.05"), Decimal("100.01"), id="tick_0.05"),
|
||
pytest.param(Decimal("0.50"), Decimal("100.50"), Decimal("100.25"), id="tick_0.50"),
|
||
pytest.param(Decimal("1.00"), Decimal("100.0"), Decimal("100.50"), id="tick_1.00"),
|
||
]
|
||
)
|
||
def test_insert_order_price_must_be_multiple_of_tick_size(
|
||
self, tick_size: Decimal, valid_price: Decimal, invalid_price: Decimal) -> None:
|
||
"""Orders with a price that is not a multiple of the tick size should be rejected."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=tick_size)
|
||
|
||
client.test_insert_order(
|
||
side=Side.BUY, price=invalid_price, quantity=10,
|
||
username=self.test_name,
|
||
expect_success=False, expect_public_feed=False)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
client.test_insert_order(
|
||
side=Side.BUY, price=valid_price, quantity=10,
|
||
username=self.test_name)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Floating-point price precision
|
||
# =========================================================================
|
||
|
||
@pytest.mark.parametrize(
|
||
"tick_size, sell_price, buy_price, expected_trade_price",
|
||
[
|
||
pytest.param(
|
||
Decimal("0.01"), Decimal("100.03"), Decimal("100.03"), Decimal("100.03"),
|
||
id="0.03_is_not_exact_in_float"),
|
||
pytest.param(
|
||
Decimal("0.01"), Decimal("100.07"), Decimal("100.07"), Decimal("100.07"),
|
||
id="0.07_is_not_exact_in_float"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("100.0001"), Decimal("100.0001"), Decimal("100.0001"),
|
||
id="4dp_exact_match"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("99.9999"), Decimal("99.9999"), Decimal("99.9999"),
|
||
id="4dp_just_below_round_number"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("100.0003"), Decimal("100.0007"), Decimal("100.0003"),
|
||
id="4dp_cross_trades_at_passive_ask"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("99.99999"), Decimal("99.99999"), Decimal("99.99999"),
|
||
id="5dp_just_below_round_number"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("100.00003"), Decimal("100.00007"), Decimal("100.0000"),
|
||
id="5dp_crossing_outside_precision"),
|
||
pytest.param(
|
||
Decimal("0.0001"), Decimal("0.0001"), Decimal("0.0001"), Decimal("0.0001"),
|
||
id="smallest_possible_price"),
|
||
pytest.param(
|
||
Decimal("0.01"), Decimal("0.10"), Decimal("0.10"), Decimal("0.10"),
|
||
id="0.1_is_not_exact_in_float"),
|
||
pytest.param(
|
||
Decimal("0.01"), Decimal("33.33"), Decimal("33.33"), Decimal("33.33"),
|
||
id="repeating_decimal_in_float"),
|
||
]
|
||
)
|
||
def test_price_precision_through_float_protocol(
|
||
self, tick_size: Decimal, sell_price: Decimal, buy_price: Decimal,
|
||
expected_trade_price: Decimal) -> None:
|
||
"""Prices use float (double) on the wire. Implementations must not lose precision up to 4 decimal places."""
|
||
client_passive = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client_passive, tick_size=tick_size)
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
passive_resp = client_passive.test_insert_order(
|
||
side=Side.SELL, price=sell_price, quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_seller")
|
||
passive_order_id = passive_resp.get_response().order_id
|
||
|
||
client_aggressor = self._connect_unauthenticated()
|
||
client_aggressor.expect_on_order_book_created_event(
|
||
tick_size=tick_size, order_book_id=order_book_id)
|
||
client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=passive_order_id,
|
||
side=Side.SELL, price=sell_price, quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client_aggressor._last_order_book_id = order_book_id
|
||
|
||
agg_resp = client_aggressor.test_insert_order(
|
||
side=Side.BUY, price=buy_price, quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_buyer")
|
||
agg_response = agg_resp.get_response()
|
||
assert len(agg_response.trade_ids) == 1, \
|
||
f"Expected a trade at sell={sell_price} buy={buy_price}, but got none"
|
||
assert agg_response.traded_quantity == 10
|
||
trade_id = agg_response.trade_ids[0]
|
||
agg_order_id = agg_response.order_id
|
||
|
||
client_passive.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=agg_order_id,
|
||
side=Side.BUY, price=buy_price, quantity=10)
|
||
client_passive.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=agg_order_id, sell_order_id=passive_order_id,
|
||
aggressive_side=Side.BUY, price=expected_trade_price, quantity=10)
|
||
client_aggressor.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=agg_order_id, sell_order_id=passive_order_id,
|
||
aggressive_side=Side.BUY, price=expected_trade_price, quantity=10)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Response field validation
|
||
# =========================================================================
|
||
|
||
def test_insert_order_response_no_trades_when_no_match(self) -> None:
|
||
"""InsertOrderResponse should have empty trade_ids and zero traded_quantity when no match occurs."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
resp = client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10, username=self.test_name)
|
||
response = resp.get_response()
|
||
assert len(response.trade_ids) == 0, \
|
||
f"Expected no trades, got trade_ids={list(response.trade_ids)}"
|
||
assert response.traded_quantity == 0, \
|
||
f"Expected traded_quantity=0, got {response.traded_quantity}"
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_cancel_order_response_remaining_quantity(self) -> None:
|
||
"""CancelOrderResponse should report the full remaining (unfilled) quantity."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
resp = client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10, username=self.test_name)
|
||
order_id = resp.get_response().order_id
|
||
|
||
cancel_resp = client.test_cancel_order(order_id=order_id)
|
||
cancel_response = cancel_resp.get_response()
|
||
assert cancel_response.remaining_quantity == 10, \
|
||
f"Expected remaining_quantity=10, got {cancel_response.remaining_quantity}"
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_trade_ids_are_unique_across_matches(self) -> None:
|
||
"""Every trade executed must receive a globally unique trade_id."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
order_book_id = client._last_order_book_id
|
||
|
||
all_trade_ids: set[int] = set()
|
||
for i in range(5):
|
||
client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_{i}_buy")
|
||
sell_resp = client.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_{i}_sell")
|
||
sell_response = sell_resp.get_response()
|
||
assert len(sell_response.trade_ids) == 1
|
||
trade_id = sell_response.trade_ids[0]
|
||
assert trade_id not in all_trade_ids, f"Duplicate trade_id {trade_id} on iteration {i}"
|
||
all_trade_ids.add(trade_id)
|
||
|
||
for trade_id in all_trade_ids:
|
||
client.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=ANY, sell_order_id=ANY,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Matching – no cross
|
||
# =========================================================================
|
||
|
||
def test_no_match_when_orders_do_not_cross(self) -> None:
|
||
"""A buy below the best ask should not trigger a trade."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
order_book_id = client._last_order_book_id
|
||
|
||
client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("99.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_buyer")
|
||
sell_resp = client.test_insert_order(
|
||
side=Side.SELL, price=Decimal("101.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_seller")
|
||
|
||
sell_response = sell_resp.get_response()
|
||
assert len(sell_response.trade_ids) == 0, \
|
||
"Orders should not match when buy price < sell price"
|
||
assert sell_response.traded_quantity == 0
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Matching – price priority
|
||
# =========================================================================
|
||
|
||
@pytest.mark.parametrize(
|
||
"aggressor_side",
|
||
[
|
||
pytest.param(Side.BUY, id="aggressive_buy_fills_best_ask_first"),
|
||
pytest.param(Side.SELL, id="aggressive_sell_fills_best_bid_first"),
|
||
]
|
||
)
|
||
def test_match_price_priority(self, aggressor_side: Side.ValueType) -> None:
|
||
"""The best-priced resting order must fill before worse-priced ones (price priority)."""
|
||
client_passive = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client_passive, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
if aggressor_side == Side.BUY:
|
||
passive_side = Side.SELL
|
||
worse_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=Decimal("101.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_worse")
|
||
better_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_better")
|
||
aggressive_price = Decimal("101.0")
|
||
expected_trade_price = Decimal("100.0")
|
||
else:
|
||
passive_side = Side.BUY
|
||
worse_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=Decimal("99.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_worse")
|
||
better_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_better")
|
||
aggressive_price = Decimal("99.0")
|
||
expected_trade_price = Decimal("100.0")
|
||
|
||
worse_order_id = worse_resp.get_response().order_id
|
||
better_order_id = better_resp.get_response().order_id
|
||
|
||
client_aggressor = self._connect_unauthenticated()
|
||
client_aggressor.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=worse_order_id,
|
||
side=passive_side, price=ANY, quantity=5)
|
||
client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=better_order_id,
|
||
side=passive_side, price=ANY, quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client_aggressor._last_order_book_id = order_book_id
|
||
|
||
agg_resp = client_aggressor.test_insert_order(
|
||
side=aggressor_side, price=aggressive_price, quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_agg")
|
||
agg_response = agg_resp.get_response()
|
||
assert len(agg_response.trade_ids) == 1, \
|
||
"Should match exactly one order (the better-priced one)"
|
||
assert agg_response.traded_quantity == 5
|
||
trade_id = agg_response.trade_ids[0]
|
||
|
||
if aggressor_side == Side.BUY:
|
||
expected_buy_id = agg_response.order_id
|
||
expected_sell_id = better_order_id
|
||
else:
|
||
expected_buy_id = better_order_id
|
||
expected_sell_id = agg_response.order_id
|
||
|
||
client_passive.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=agg_response.order_id,
|
||
side=aggressor_side, price=aggressive_price, quantity=5)
|
||
client_passive.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=expected_buy_id, sell_order_id=expected_sell_id,
|
||
aggressive_side=aggressor_side, price=expected_trade_price, quantity=5)
|
||
client_aggressor.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=expected_buy_id, sell_order_id=expected_sell_id,
|
||
aggressive_side=aggressor_side, price=expected_trade_price, quantity=5)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Matching – time priority (FIFO at the same price)
|
||
# =========================================================================
|
||
|
||
@pytest.mark.parametrize(
|
||
"aggressor_side",
|
||
[
|
||
pytest.param(Side.BUY, id="aggressive_buy_matches_oldest_ask"),
|
||
pytest.param(Side.SELL, id="aggressive_sell_matches_oldest_bid"),
|
||
]
|
||
)
|
||
def test_match_time_priority_at_same_price(self, aggressor_side: Side.ValueType) -> None:
|
||
"""Among resting orders at the same price, the earliest-inserted must fill first (FIFO)."""
|
||
client_passive = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client_passive, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
passive_side = Side.SELL if aggressor_side == Side.BUY else Side.BUY
|
||
passive_price = Decimal("100.0")
|
||
|
||
first_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=passive_price, quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_first")
|
||
first_order_id = first_resp.get_response().order_id
|
||
|
||
second_resp = client_passive.test_insert_order(
|
||
side=passive_side, price=passive_price, quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_second")
|
||
second_order_id = second_resp.get_response().order_id
|
||
|
||
client_aggressor = self._connect_unauthenticated()
|
||
client_aggressor.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=first_order_id,
|
||
side=passive_side, price=passive_price, quantity=5)
|
||
client_aggressor.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=second_order_id,
|
||
side=passive_side, price=passive_price, quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client_aggressor._last_order_book_id = order_book_id
|
||
|
||
agg_resp = client_aggressor.test_insert_order(
|
||
side=aggressor_side, price=passive_price, quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_agg")
|
||
agg_response = agg_resp.get_response()
|
||
assert len(agg_response.trade_ids) == 1
|
||
assert agg_response.traded_quantity == 5
|
||
trade_id = agg_response.trade_ids[0]
|
||
|
||
if aggressor_side == Side.BUY:
|
||
expected_buy_id = agg_response.order_id
|
||
expected_sell_id = first_order_id
|
||
else:
|
||
expected_buy_id = first_order_id
|
||
expected_sell_id = agg_response.order_id
|
||
|
||
client_passive.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=agg_response.order_id,
|
||
side=aggressor_side, price=passive_price, quantity=5)
|
||
client_passive.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=expected_buy_id, sell_order_id=expected_sell_id,
|
||
aggressive_side=aggressor_side, price=passive_price, quantity=5)
|
||
client_aggressor.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=expected_buy_id, sell_order_id=expected_sell_id,
|
||
aggressive_side=aggressor_side, price=passive_price, quantity=5)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Matching – multi-client scenarios
|
||
# =========================================================================
|
||
|
||
def test_match_multiple_passive_orders_from_different_clients(self) -> None:
|
||
"""An aggressive order should sweep passive orders placed by different clients."""
|
||
client_a = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client_a, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
resp_a = client_a.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_a")
|
||
order_id_a = resp_a.get_response().order_id
|
||
|
||
client_b = self._connect_unauthenticated()
|
||
client_b.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client_b.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=order_id_a,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client_b._last_order_book_id = order_book_id
|
||
|
||
resp_b = client_b.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_b")
|
||
order_id_b = resp_b.get_response().order_id
|
||
|
||
client_a.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=order_id_b,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
client_c = self._connect_unauthenticated()
|
||
client_c.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client_c.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=order_id_a,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
client_c.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=order_id_b,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client_c._last_order_book_id = order_book_id
|
||
|
||
agg_resp = client_c.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=20,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_c")
|
||
agg_response = agg_resp.get_response()
|
||
assert len(agg_response.trade_ids) == 2, \
|
||
f"Expected 2 trades, got {len(agg_response.trade_ids)}"
|
||
assert agg_response.traded_quantity == 20
|
||
sell_order_id = agg_response.order_id
|
||
|
||
for client in [client_a, client_b]:
|
||
client.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=20)
|
||
|
||
for trade_id in agg_response.trade_ids:
|
||
for client in [client_a, client_b, client_c]:
|
||
client.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=ANY, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_multiple_sequential_aggressive_orders_match_same_passive(self) -> None:
|
||
"""Multiple small aggressors should each partially fill a single large passive order."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
buy_resp = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=30,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_buyer")
|
||
buy_order_id = buy_resp.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=buy_order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=30)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client2._last_order_book_id = order_book_id
|
||
|
||
all_trade_ids: list[int] = []
|
||
for i in range(3):
|
||
sell_resp = client2.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_{i}")
|
||
sell_response = sell_resp.get_response()
|
||
assert len(sell_response.trade_ids) == 1
|
||
assert sell_response.traded_quantity == 10
|
||
all_trade_ids.append(sell_response.trade_ids[0])
|
||
sell_order_id = sell_response.order_id
|
||
|
||
client1.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client1.expect_on_trade_event(
|
||
trade_id=sell_response.trade_ids[0], order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client2.expect_on_trade_event(
|
||
trade_id=sell_response.trade_ids[0], order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
assert len(set(all_trade_ids)) == 3, "Each trade should have a unique ID"
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Matching – aggressive partial fill and remainder behaviour
|
||
# =========================================================================
|
||
|
||
def test_aggressive_partial_fill_then_match_remainder(self) -> None:
|
||
"""An aggressive order that partially fills should have its remainder matched by a later counterparty."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
sell_resp = client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
sell_order_id = sell_resp.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client2._last_order_book_id = order_book_id
|
||
|
||
buy_resp = client2.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_2")
|
||
buy_response = buy_resp.get_response()
|
||
assert len(buy_response.trade_ids) == 1
|
||
assert buy_response.traded_quantity == 5
|
||
buy_order_id = buy_response.order_id
|
||
first_trade_id = buy_response.trade_ids[0]
|
||
|
||
client1.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=buy_order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
client1.expect_on_trade_event(
|
||
trade_id=first_trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.BUY, price=Decimal("100.0"), quantity=5)
|
||
client2.expect_on_trade_event(
|
||
trade_id=first_trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.BUY, price=Decimal("100.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
sell_resp_2 = client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
sell_response_2 = sell_resp_2.get_response()
|
||
assert len(sell_response_2.trade_ids) == 1
|
||
assert sell_response_2.traded_quantity == 5
|
||
sell_order_id_2 = sell_response_2.order_id
|
||
second_trade_id = sell_response_2.trade_ids[0]
|
||
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id_2,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
client1.expect_on_trade_event(
|
||
trade_id=second_trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id_2,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
client2.expect_on_trade_event(
|
||
trade_id=second_trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id_2,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_aggressive_partial_fill_then_cancel_remainder(self) -> None:
|
||
"""After a partial fill, cancelling the remainder should succeed with the correct remaining_quantity."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
client1.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_1")
|
||
sell_order_id = client1._last_order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client2._last_order_book_id = order_book_id
|
||
|
||
buy_resp = client2.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_2")
|
||
buy_response = buy_resp.get_response()
|
||
buy_order_id = buy_response.order_id
|
||
assert buy_response.traded_quantity == 5
|
||
trade_id = buy_response.trade_ids[0]
|
||
|
||
client1.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=buy_order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
client1.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.BUY, price=Decimal("100.0"), quantity=5)
|
||
client2.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=order_book_id,
|
||
buy_order_id=buy_order_id, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.BUY, price=Decimal("100.0"), quantity=5)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
cancel_resp = client2.test_cancel_order(
|
||
order_id=buy_order_id, order_book_id=order_book_id)
|
||
cancel_response = cancel_resp.get_response()
|
||
assert cancel_response.remaining_quantity == 5, \
|
||
f"Expected remaining_quantity=5, got {cancel_response.remaining_quantity}"
|
||
|
||
client1.expect_on_order_cancelled_event(order_id=buy_order_id)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Cancel prevents match
|
||
# =========================================================================
|
||
|
||
def test_cancel_passive_prevents_future_match(self) -> None:
|
||
"""Cancelling a passive order must prevent it from being matched by a later crossing order."""
|
||
client = self._connect_unauthenticated()
|
||
self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
order_book_id = client._last_order_book_id
|
||
|
||
buy_resp = client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_buyer")
|
||
client.test_cancel_order(
|
||
order_id=buy_resp.get_response().order_id, order_book_id=order_book_id)
|
||
|
||
sell_resp = client.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=f"{self.test_name}_seller")
|
||
sell_response = sell_resp.get_response()
|
||
assert len(sell_response.trade_ids) == 0, \
|
||
"No trade should occur after the passive was cancelled"
|
||
assert sell_response.traded_quantity == 0
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Multiple order books – isolation
|
||
# =========================================================================
|
||
|
||
def test_orders_on_different_order_books_do_not_interact(self) -> None:
|
||
"""Crossing orders in separate order books must not trigger a trade."""
|
||
client = self._connect_unauthenticated()
|
||
on_created_1 = self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
ob_id_1 = on_created_1.get_message().order_book_id
|
||
on_created_2 = self._create_order_book_via_admin(client, tick_size=Decimal("0.01"))
|
||
ob_id_2 = on_created_2.get_message().order_book_id
|
||
|
||
buy_resp = client.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_1, username=f"{self.test_name}_1")
|
||
assert buy_resp.get_response().traded_quantity == 0
|
||
|
||
sell_resp = client.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_2, username=f"{self.test_name}_2")
|
||
sell_response = sell_resp.get_response()
|
||
assert len(sell_response.trade_ids) == 0, \
|
||
"Orders in different order books should not match"
|
||
assert sell_response.traded_quantity == 0
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
def test_matching_independent_across_order_books(self) -> None:
|
||
"""A trade in one order book must not affect orders in another order book."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created_1 = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
ob_id_1 = on_created_1.get_message().order_book_id
|
||
on_created_2 = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
ob_id_2 = on_created_2.get_message().order_book_id
|
||
|
||
buy_resp_1 = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_1, username=f"{self.test_name}_1")
|
||
buy_order_1 = buy_resp_1.get_response().order_id
|
||
buy_resp_2 = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_2, username=f"{self.test_name}_1")
|
||
buy_order_2 = buy_resp_2.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=ob_id_1)
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=ob_id_2)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=ob_id_1, order_id=buy_order_1,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=ob_id_2, order_id=buy_order_2,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
client2._last_order_book_id = ob_id_1
|
||
|
||
sell_resp = client2.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_1, username=f"{self.test_name}_2")
|
||
sell_response = sell_resp.get_response()
|
||
assert sell_response.traded_quantity == 10
|
||
trade_id = sell_response.trade_ids[0]
|
||
sell_order_id = sell_response.order_id
|
||
|
||
client1.expect_on_order_inserted_event(
|
||
order_book_id=ob_id_1, order_id=sell_order_id,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client1.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=ob_id_1,
|
||
buy_order_id=buy_order_1, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client2.expect_on_trade_event(
|
||
trade_id=trade_id, order_book_id=ob_id_1,
|
||
buy_order_id=buy_order_1, sell_order_id=sell_order_id,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
self.call_expectations_manager.verify_expectations()
|
||
|
||
sell_resp_2 = client2.test_insert_order(
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=ob_id_2, username=f"{self.test_name}_2")
|
||
sell_response_2 = sell_resp_2.get_response()
|
||
assert sell_response_2.traded_quantity == 10, \
|
||
"Order on book 2 should still be available for matching"
|
||
sell_order_id_2 = sell_response_2.order_id
|
||
trade_id_2 = sell_response_2.trade_ids[0]
|
||
|
||
client1.expect_on_order_inserted_event(
|
||
order_book_id=ob_id_2, order_id=sell_order_id_2,
|
||
side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client1.expect_on_trade_event(
|
||
trade_id=trade_id_2, order_book_id=ob_id_2,
|
||
buy_order_id=buy_order_2, sell_order_id=sell_order_id_2,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
client2.expect_on_trade_event(
|
||
trade_id=trade_id_2, order_book_id=ob_id_2,
|
||
buy_order_id=buy_order_2, sell_order_id=sell_order_id_2,
|
||
aggressive_side=Side.SELL, price=Decimal("100.0"), quantity=10)
|
||
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|
||
|
||
# =========================================================================
|
||
# Field propagation
|
||
# =========================================================================
|
||
|
||
def test_order_inserted_event_contains_username(self) -> None:
|
||
"""OnOrderInserted events must propagate the correct username."""
|
||
client1 = self._connect_unauthenticated()
|
||
on_created = self._create_order_book_via_admin(client1, tick_size=Decimal("0.01"))
|
||
order_book_id = on_created.get_message().order_book_id
|
||
|
||
test_username = f"{self.test_name}_user"
|
||
resp = client1.test_insert_order(
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
order_book_id=order_book_id, username=test_username)
|
||
order_id = resp.get_response().order_id
|
||
|
||
client2 = self._connect_unauthenticated()
|
||
client2.expect_on_order_book_created_event(
|
||
tick_size=Decimal("0.01"), order_book_id=order_book_id)
|
||
client2.expect_on_order_inserted_event(
|
||
order_book_id=order_book_id, order_id=order_id,
|
||
side=Side.BUY, price=Decimal("100.0"), quantity=10,
|
||
username=test_username)
|
||
self.call_expectations_manager.verify_expectations()
|
||
self.call_expectations_manager.verify_no_unexpected_calls()
|