from decimal import Decimal import logging import math import random from unittest.mock import ANY import pytest from connection.tcp_connection_manager import TcpConnectionManager from proto.common_pb2 import Instrument, Side from proto.info_pb2 import PriceLevel, SubscriptionType from tests.test_client.admin_test_client import AdminClientConnectionHandlerFactory, AdminTestClient from tests.test_client.info_test_client import InfoClientConnectionHandlerFactory, InfoTestClient from tests.test_client.order_book_test_client import OrderBookClientConnectionHandlerFactory, OrderBookTestClient from tests.common.auth_tests import AuthenticationTests from tests.common.mock_expectations import CallExpectationsManager logger = logging.getLogger(__name__) class TestInfoSystem(AuthenticationTests): PROTOCOL = "info" @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.info_client_factory = InfoClientConnectionHandlerFactory(self.call_expectations_manager) self._admin_factory = AdminClientConnectionHandlerFactory(self.call_expectations_manager) self._order_book_factory = OrderBookClientConnectionHandlerFactory(self.call_expectations_manager) self._admin_client: AdminTestClient | None = None self._order_book_client: OrderBookTestClient | None = None self._next_instrument_id = 1 def _connect_unauthenticated(self) -> InfoTestClient: return self.tcp_connection_manager.connect(self.server_address, self.info_client_factory) def _connect_and_login(self, username: str | None = None) -> InfoTestClient: """Connects to info, logs in (if auth required), and expects OnInstrument for existing instruments.""" client = self._connect_unauthenticated() if self.auth_required: client.test_login(username=username or self.test_name) if self.instrument is not None: client.expect_on_instrument_event(self.instrument, tick_size=self.tick_size, order_book_id=self.order_book_id) return client 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 _get_order_book_client(self) -> OrderBookTestClient: if self._order_book_client is None: ob_address = self.orchestrator.get_server_address("order_book") self._order_book_client = self.tcp_connection_manager.connect(ob_address, self._order_book_factory) if self.order_book_id is not None: self._order_book_client.expect_on_order_book_created_event( tick_size=self.tick_size, order_book_id=self.order_book_id) self.call_expectations_manager.verify_expectations(assert_no_pending_calls=False) self._order_book_client._last_order_book_id = self.order_book_id return self._order_book_client def _create_instrument_via_admin(self, instrument: Instrument | None = None, tick_size: Decimal = Decimal("0.01")) -> tuple[Instrument, Decimal, int]: """Creates a test instrument via admin. Sets self.instrument, self.tick_size, self.order_book_id. Returns (instrument, tick_size, order_book_id) for convenience in multi-instrument tests.""" self.tick_size = tick_size if instrument is None: self.instrument = Instrument( symbol=f"TEST.{self._next_instrument_id}", description="Test instrument", currency="USD", multiplier=1.0) self._next_instrument_id += 1 else: self.instrument = instrument admin_client = self._get_admin_client() response_expectation = admin_client.test_create_instrument(self.instrument, self.tick_size) response = response_expectation.get_response() self.order_book_id = response.order_book_id return self.instrument, self.tick_size, self.order_book_id def _expect_book_update(self, client: InfoTestClient, subscription_type, instrument_symbol: str, expected_bids: dict[Decimal, int], expected_asks: dict[Decimal, int]) -> None: """Set up the correct TOB or PDB expectation based on subscription type.""" if subscription_type == SubscriptionType.TOP_OF_BOOK: best_bid = None best_ask = None if expected_bids: best_price = max(expected_bids) best_bid = PriceLevel(price=float(best_price), quantity=expected_bids[best_price]) if expected_asks: best_price = min(expected_asks) best_ask = PriceLevel(price=float(best_price), quantity=expected_asks[best_price]) client.expect_on_top_of_book_event(instrument_symbol, best_bid=best_bid, best_ask=best_ask) elif subscription_type == SubscriptionType.PRICE_DEPTH_BOOK: client.expect_on_price_depth_book_event( instrument_symbol, bids=[PriceLevel(price=float(p), quantity=q) for p, q in expected_bids.items()], asks=[PriceLevel(price=float(p), quantity=q) for p, q in expected_asks.items()]) def _insert_orders(self, side: Side.ValueType | None, orders_per_level: int, number_of_levels: int, best_bid_price: Decimal, best_ask_price: Decimal, quantity_per_order: int, add_cancellation: bool = False) -> tuple[dict[Decimal, int], dict[Decimal, int]]: """Insert orders into the real order book and return the expected price level state.""" order_book_client = self._get_order_book_client() def next_side(i: int) -> Side.ValueType: if side is not None: return side return Side.BUY if i % 2 == 0 else Side.SELL def next_price_adjustment(i: int) -> Decimal: # if side is None we always alternate between buy and sell, so the adjustment needs to be divided by 2 tick_adjustment = math.floor(i / 2) if side is None else i return self.tick_size * tick_adjustment price_levels_by_side: dict[Side.ValueType, dict[Decimal, int]] = {Side.BUY: {}, Side.SELL: {}} for level_index in range(number_of_levels): level_side = next_side(level_index) price_adjustment = next_price_adjustment(level_index) if level_side == Side.BUY: level_price = best_bid_price - price_adjustment else: level_price = best_ask_price + price_adjustment price_levels_by_side[level_side][level_price] = quantity_per_order * orders_per_level for i in range(orders_per_level): order_book_client.test_insert_order( side=level_side, price=level_price, quantity=quantity_per_order, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") is_top_of_book_level = price_adjustment == 0 if add_cancellation and not is_top_of_book_level and (level_index + i) % 2 == 0: order_book_client.test_cancel_order(order_book_id=self.order_book_id) price_levels_by_side[level_side][level_price] -= quantity_per_order if price_levels_by_side[level_side][level_price] == 0: del price_levels_by_side[level_side][level_price] bids = price_levels_by_side[Side.BUY] asks = price_levels_by_side[Side.SELL] return bids, asks def test_create_instrument(self) -> None: self._create_instrument_via_admin( instrument=Instrument(symbol="AAPL", description="Apple", currency="USD", multiplier=1)) client = self._connect_and_login(username="test_create_instrument") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() @pytest.mark.parametrize( "subscription_type, side, orders_per_level, number_of_levels", [ pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 1, 1, id="TOB 1 buy order"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 1, 1, id="TOB 1 sell order"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 2, 1, id="TOB 2 buy orders, 1 level"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 2, 1, id="TOB 2 sell orders, 1 level"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 1, 2, id="TOB 1 buy order by 2 levels"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 1, 2, id="TOB 1 sell order by 2 levels"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 1, 2, id="TOB 1 order by level, both sides"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 2, 2, id="TOB 2 orders by 2 levels, both sides"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 5, 2, id="TOB 5 orders by 2 levels, both sides (unbalanced)"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 1, 1, id="PDB 1 buy order"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 1, 1, id="PDB 1 sell order"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 2, 1, id="PDB 2 buy orders, 1 level"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 2, 1, id="PDB 2 sell orders, 1 level"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 1, 2, id="PDB 1 buy order by 2 levels"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 1, 2, id="PDB 1 sell order by 2 levels"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 1, 2, id="PDB 1 order by level, both sides"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 2, 2, id="PDB 2 orders by 2 levels, both sides"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 5, 2, id="PDB 5 orders by 2 levels, both sides (unbalanced)"), ] ) def test_order_book_on_subscribe_with_insert(self, subscription_type: SubscriptionType.ValueType, side: Side.ValueType | None, orders_per_level: int, number_of_levels: int) -> None: self._create_instrument_via_admin() client = self._connect_and_login() best_bid_price = random.randint(100, 10000) * self.tick_size best_ask_price = best_bid_price + self.tick_size quantity_per_order = random.randint(1, 1000) expected_bids, expected_asks = self._insert_orders( side, orders_per_level, number_of_levels, best_bid_price, best_ask_price, quantity_per_order) logger.info("Checking that no updates were sent before subscribing") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() if subscription_type == SubscriptionType.TOP_OF_BOOK: best_bid = PriceLevel(price=float(best_bid_price), quantity=expected_bids[best_bid_price]) if side is None or side == Side.BUY else None best_ask = PriceLevel(price=float(best_ask_price), quantity=expected_asks[best_ask_price]) if side is None or side == Side.SELL else None client.test_subscribe_to_order_book(instrument_symbol=self.instrument.symbol, type=SubscriptionType.TOP_OF_BOOK) client.expect_on_top_of_book_event(self.instrument.symbol, best_bid=best_bid, best_ask=best_ask) elif subscription_type == SubscriptionType.PRICE_DEPTH_BOOK: client.test_subscribe_to_order_book(instrument_symbol=self.instrument.symbol, type=SubscriptionType.PRICE_DEPTH_BOOK) client.expect_on_price_depth_book_event( self.instrument.symbol, bids=[PriceLevel(price=float(price), quantity=quantity) for price, quantity in expected_bids.items()], asks=[PriceLevel(price=float(price), quantity=quantity) for price, quantity in expected_asks.items()]) else: raise ValueError(f"Unexpected subscription type: {subscription_type}") self.call_expectations_manager.verify_expectations() @pytest.mark.parametrize( "subscription_type, side, orders_per_level, number_of_levels", [ pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 1, 1, id="TOB 1 buy order"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 1, 1, id="TOB 1 sell order"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 2, 1, id="TOB 2 buy orders, 1 level"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 2, 1, id="TOB 2 sell orders, 1 level"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.BUY, 1, 2, id="TOB 1 buy order by 2 levels"), pytest.param(SubscriptionType.TOP_OF_BOOK, Side.SELL, 1, 2, id="TOB 1 sell order by 2 levels"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 1, 2, id="TOB 1 order by level, both sides"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 2, 2, id="TOB 2 orders by 2 levels, both sides"), pytest.param(SubscriptionType.TOP_OF_BOOK, None, 5, 2, id="TOB 5 orders by 2 levels, both sides (unbalanced)"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 1, 1, id="PDB 1 buy order"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 1, 1, id="PDB 1 sell order"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 2, 1, id="PDB 2 buy orders, 1 level"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 2, 1, id="PDB 2 sell orders, 1 level"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.BUY, 1, 2, id="PDB 1 buy order by 2 levels"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, Side.SELL, 1, 2, id="PDB 1 sell order by 2 levels"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 1, 2, id="PDB 1 order by level, both sides"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 2, 2, id="PDB 2 orders by 2 levels, both sides"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, None, 5, 2, id="PDB 5 orders by 2 levels, both sides (unbalanced)"), ] ) def test_order_book_on_subscribe_with_insert_and_cancel(self, subscription_type: SubscriptionType.ValueType, side: Side.ValueType | None, orders_per_level: int, number_of_levels: int) -> None: self._create_instrument_via_admin() client = self._connect_and_login() best_bid_price = random.randint(100, 10000) * self.tick_size best_ask_price = best_bid_price + self.tick_size quantity_per_order = random.randint(1, 1000) expected_bids, expected_asks = self._insert_orders( side, orders_per_level, number_of_levels, best_bid_price, best_ask_price, quantity_per_order, add_cancellation=True) logger.info("Checking that no updates were sent before subscribing") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() if subscription_type == SubscriptionType.TOP_OF_BOOK: best_bid = PriceLevel(price=float(best_bid_price), quantity=expected_bids[best_bid_price]) if side is None or side == Side.BUY else None best_ask = PriceLevel(price=float(best_ask_price), quantity=expected_asks[best_ask_price]) if side is None or side == Side.SELL else None client.test_subscribe_to_order_book(instrument_symbol=self.instrument.symbol, type=SubscriptionType.TOP_OF_BOOK) client.expect_on_top_of_book_event(self.instrument.symbol, best_bid=best_bid, best_ask=best_ask) elif subscription_type == SubscriptionType.PRICE_DEPTH_BOOK: client.test_subscribe_to_order_book(instrument_symbol=self.instrument.symbol, type=SubscriptionType.PRICE_DEPTH_BOOK) client.expect_on_price_depth_book_event( self.instrument.symbol, bids=[PriceLevel(price=float(price), quantity=quantity) for price, quantity in expected_bids.items()], asks=[PriceLevel(price=float(price), quantity=quantity) for price, quantity in expected_asks.items()]) else: raise ValueError(f"Unexpected subscription type: {subscription_type}") self.call_expectations_manager.verify_expectations() @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_order_book_on_subscribe_in_cross_then_trade(self, subscription_type: SubscriptionType.ValueType) -> None: self._create_instrument_via_admin() client = self._connect_and_login() order_book_client = self._get_order_book_client() logger.info("Inserting passive buy order") crossing_price = Decimal("100.0") order_book_client.test_insert_order( side=Side.BUY, price=crossing_price, quantity=10, order_book_id=self.order_book_id, username=f"{self.test_name}_buyer") logger.info("Checking that no updates were sent before subscribing") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() client.test_subscribe_to_order_book(instrument_symbol=self.instrument.symbol, type=subscription_type) if subscription_type == SubscriptionType.TOP_OF_BOOK: client.expect_on_top_of_book_event(self.instrument.symbol, best_bid=PriceLevel(price=float(crossing_price), quantity=10), best_ask=None) elif subscription_type == SubscriptionType.PRICE_DEPTH_BOOK: client.expect_on_price_depth_book_event(self.instrument.symbol, bids=[PriceLevel(price=float(crossing_price), quantity=10)], asks=[]) else: raise ValueError(f"Unexpected subscription type: {subscription_type}") self.call_expectations_manager.verify_expectations() logger.info("Inserting crossing sell order (triggers trade)") order_book_client.test_insert_order( side=Side.SELL, price=crossing_price, quantity=10, order_book_id=self.order_book_id, username=f"{self.test_name}_seller") order_book_client.expect_on_trade_event( trade_id=ANY, order_book_id=self.order_book_id, buy_order_id=ANY, sell_order_id=ANY, aggressive_side=Side.SELL, price=float(crossing_price), quantity=10) if subscription_type == SubscriptionType.TOP_OF_BOOK: client.expect_on_top_of_book_event(self.instrument.symbol, best_bid=None, best_ask=None) elif subscription_type == SubscriptionType.PRICE_DEPTH_BOOK: client.expect_on_price_depth_book_event(self.instrument.symbol, bids=[], asks=[]) else: raise ValueError(f"Unexpected subscription type: {subscription_type}") self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Instrument lifecycle – push to already-connected clients # ---------------------------------------------------------------- def test_instrument_pushed_to_already_connected_client(self) -> None: """Client connects (and logs in if auth required) before any instrument exists, then instrument is created via admin. The info component should push OnInstrument.""" client = self._connect_unauthenticated() if self.auth_required: client.test_login(username=self.test_name) self.call_expectations_manager.verify_no_unexpected_calls() self._create_instrument_via_admin() client.expect_on_instrument_event( self.instrument, tick_size=self.tick_size, order_book_id=self.order_book_id) self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() def test_multiple_instruments_received_on_login(self) -> None: """Create several instruments, then connect (and log in if auth required) a new client. All instruments should be delivered as OnInstrument events.""" instruments: list[tuple[Instrument, Decimal, int]] = [] for _ in range(3): instruments.append(self._create_instrument_via_admin()) client = self._connect_unauthenticated() if self.auth_required: client.test_login(username=self.test_name) for inst, tick, ob_id in instruments: client.expect_on_instrument_event(inst, tick_size=tick, order_book_id=ob_id) self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() def test_instrument_pushed_to_multiple_connected_clients(self) -> None: """Two clients are connected before the instrument is created. Both should receive the OnInstrument event.""" client1 = self._connect_unauthenticated() if self.auth_required: client1.test_login(username=self.test_name) client2 = self._connect_unauthenticated() if self.auth_required: client2.test_login(username=f"{self.test_name}_2") self.call_expectations_manager.verify_no_unexpected_calls() self._create_instrument_via_admin() client1.expect_on_instrument_event( self.instrument, tick_size=self.tick_size, order_book_id=self.order_book_id) client2.expect_on_instrument_event( self.instrument, tick_size=self.tick_size, order_book_id=self.order_book_id) self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Subscription edge cases # ---------------------------------------------------------------- def test_subscribe_to_nonexistent_instrument(self) -> None: """Subscribing to an unknown symbol should return an error.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol="NONEXISTENT", type=SubscriptionType.TOP_OF_BOOK, expect_success=False) # ---------------------------------------------------------------- # Live updates – order insert # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_on_order_insert(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe to an empty order book, then insert an order. The subscriber should receive a book update with the new order.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") quantity = 10 self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Live updates – order cancel # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_on_order_cancel(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe, insert an order (verify update), then cancel it. The book should go back to empty.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") quantity = 10 self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={}, expected_asks={}) order_book_client.test_cancel_order(order_book_id=self.order_book_id) self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Live updates – full trade clears book # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_after_full_trade(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe, insert a passive buy, then a crossing sell that fully trades. The book should end up empty.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") quantity = 10 self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_buyer") self.call_expectations_manager.verify_expectations() self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={}, expected_asks={}) order_book_client.test_insert_order( side=Side.SELL, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_seller") order_book_client.expect_on_trade_event( trade_id=ANY, order_book_id=self.order_book_id, buy_order_id=ANY, sell_order_id=ANY, aggressive_side=Side.SELL, price=float(price), quantity=quantity) self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Live updates – partial trade # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_after_partial_trade(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe, insert a large passive buy (qty 10), then a smaller crossing sell (qty 4). After the partial fill the remaining buy quantity (6) should be reflected in the book update.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") buy_qty = 10 sell_qty = 4 remaining = buy_qty - sell_qty self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: buy_qty}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=buy_qty, order_book_id=self.order_book_id, username=f"{self.test_name}_buyer") self.call_expectations_manager.verify_expectations() self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: remaining}, expected_asks={}) order_book_client.test_insert_order( side=Side.SELL, price=price, quantity=sell_qty, order_book_id=self.order_book_id, username=f"{self.test_name}_seller") order_book_client.expect_on_trade_event( trade_id=ANY, order_book_id=self.order_book_id, buy_order_id=ANY, sell_order_id=ANY, aggressive_side=Side.SELL, price=float(price), quantity=sell_qty) self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Live updates – multiple sequential inserts # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_multiple_sequential_inserts(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe then insert several orders one by one, verifying the book update after each insert.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) order_book_client = self._get_order_book_client() cumulative_bids: dict[Decimal, int] = {} prices = [Decimal("100.0"), Decimal("99.99"), Decimal("99.98")] quantity = 5 for price in prices: cumulative_bids[price] = quantity self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids=dict(cumulative_bids), expected_asks={}) order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() # ---------------------------------------------------------------- # Multi-client – both subscribers receive updates # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_multiple_clients_receive_updates(self, subscription_type: SubscriptionType.ValueType) -> None: """Two clients subscribe to the same instrument. Both should receive book updates when an order is inserted.""" self._create_instrument_via_admin() client1 = self._connect_and_login(username=self.test_name) client2 = self._connect_and_login(username=f"{self.test_name}_2") self.call_expectations_manager.verify_expectations() client1.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) client2.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") quantity = 10 self._expect_book_update(client1, subscription_type, self.instrument.symbol, expected_bids={price: quantity}, expected_asks={}) self._expect_book_update(client2, subscription_type, self.instrument.symbol, expected_bids={price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Multi-client – late subscriber gets current snapshot # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_late_subscriber_gets_current_snapshot(self, subscription_type: SubscriptionType.ValueType) -> None: """Client A subscribes and receives updates as orders are placed. Client B subscribes later and should receive a snapshot reflecting the current book state, including all previous inserts.""" self._create_instrument_via_admin() client_a = self._connect_and_login(username=self.test_name) self.call_expectations_manager.verify_expectations() client_a.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) bid_price = Decimal("100.0") ask_price = Decimal("100.01") quantity = 10 self._expect_book_update(client_a, subscription_type, self.instrument.symbol, expected_bids={bid_price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=bid_price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self._expect_book_update(client_a, subscription_type, self.instrument.symbol, expected_bids={bid_price: quantity}, expected_asks={ask_price: quantity}) order_book_client.test_insert_order( side=Side.SELL, price=ask_price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() logger.info("Late subscriber (client B) connects and subscribes") client_b = self._connect_and_login(username=f"{self.test_name}_2") self.call_expectations_manager.verify_expectations() self._expect_book_update(client_b, subscription_type, self.instrument.symbol, expected_bids={bid_price: quantity}, expected_asks={ask_price: quantity}) client_b.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Multi-client – mixed subscription types on the same instrument # ---------------------------------------------------------------- @pytest.mark.parametrize( "side", [ pytest.param(Side.BUY, id="BUY"), pytest.param(Side.SELL, id="SELL"), ] ) def test_mixed_subscription_types_same_instrument(self, side: Side.ValueType) -> None: """One client subscribes with TOP_OF_BOOK and another with PRICE_DEPTH_BOOK to the same instrument. Each client should receive the appropriate update format when an order is inserted.""" self._create_instrument_via_admin() self._tob_client = self._connect_and_login(username=self.test_name) self._pdb_client = self._connect_and_login(username=f"{self.test_name}_2") self.call_expectations_manager.verify_expectations() self._tob_client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=SubscriptionType.TOP_OF_BOOK) self._pdb_client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=SubscriptionType.PRICE_DEPTH_BOOK) self._mixed_best_price = Decimal("100.0") self._mixed_quantity = 10 best_bid = PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity) if side == Side.BUY else None best_ask = PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity) if side == Side.SELL else None expected_bids = [PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity)] if side == Side.BUY else [] expected_asks = [PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity)] if side == Side.SELL else [] self._tob_client.expect_on_top_of_book_event( self.instrument.symbol, best_bid=best_bid, best_ask=best_ask) self._pdb_client.expect_on_price_depth_book_event( self.instrument.symbol, bids=expected_bids, asks=expected_asks) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=side, price=self._mixed_best_price, quantity=self._mixed_quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() @pytest.mark.parametrize( "side", [ pytest.param(Side.BUY, id="BUY"), pytest.param(Side.SELL, id="SELL"), ] ) def test_mixed_subscriptions_tob_unchanged_after_non_best_insert(self, side: Side.ValueType) -> None: """After the first order establishes the best price, insert a second order at a worse price. The PDB subscriber should see a new level added while the TOB subscriber sees the same best price/quantity.""" self.test_mixed_subscription_types_same_instrument(side) worse_price = (self._mixed_best_price - self.tick_size) if side == Side.BUY else (self._mixed_best_price + self.tick_size) worse_qty = 5 best_bid = PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity) if side == Side.BUY else None best_ask = PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity) if side == Side.SELL else None self._tob_client.expect_on_top_of_book_event( self.instrument.symbol, best_bid=best_bid, best_ask=best_ask) if side == Side.BUY: self._pdb_client.expect_on_price_depth_book_event( self.instrument.symbol, bids=[ PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity), PriceLevel(price=float(worse_price), quantity=worse_qty), ], asks=[]) else: self._pdb_client.expect_on_price_depth_book_event( self.instrument.symbol, bids=[], asks=[ PriceLevel(price=float(self._mixed_best_price), quantity=self._mixed_quantity), PriceLevel(price=float(worse_price), quantity=worse_qty), ]) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=side, price=worse_price, quantity=worse_qty, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Subscription isolation – different instruments # ---------------------------------------------------------------- def test_subscriptions_to_different_instruments_are_isolated(self) -> None: """Subscribe to instrument A only. Insert orders for both A and B. The subscriber should only receive updates for instrument A.""" inst_a, tick_a, ob_id_a = self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=inst_a.symbol, type=SubscriptionType.TOP_OF_BOOK) order_book_client = self._get_order_book_client() price_a = Decimal("100.0") client.expect_on_top_of_book_event( inst_a.symbol, best_bid=PriceLevel(price=float(price_a), quantity=10), best_ask=None) order_book_client.test_insert_order( side=Side.BUY, price=price_a, quantity=10, order_book_id=ob_id_a, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() order_book_client.expect_on_order_book_created_event( tick_size=Decimal("0.02"), order_book_id=None) _, _, ob_id_b = self._create_instrument_via_admin(tick_size=Decimal("0.02")) inst_b = self.instrument client.expect_on_instrument_event( inst_b, tick_size=self.tick_size, order_book_id=ob_id_b) self.call_expectations_manager.verify_expectations() order_book_client.test_insert_order( side=Side.BUY, price=Decimal("200.0"), quantity=20, order_book_id=ob_id_b, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() # ---------------------------------------------------------------- # Unsubscribed client – should not get order book updates # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_unsubscribed_client_receives_no_updates(self, subscription_type: SubscriptionType.ValueType) -> None: """A client that is logged in but has not subscribed should not receive any TOB or PDB updates when the order book changes.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=Decimal("100.0"), quantity=10, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() # ---------------------------------------------------------------- # Live update – new instrument created while subscribed to another # ---------------------------------------------------------------- def test_new_instrument_while_subscribed_to_another(self) -> None: """While subscribed to instrument A, a new instrument B is created. The client should receive OnInstrument for B but no spurious book updates for A.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=SubscriptionType.TOP_OF_BOOK) order_book_client = self._get_order_book_client() order_book_client.expect_on_order_book_created_event( tick_size=Decimal("0.05"), order_book_id=None) self._create_instrument_via_admin(tick_size=Decimal("0.05")) client.expect_on_instrument_event( self.instrument, tick_size=self.tick_size, order_book_id=self.order_book_id) self.call_expectations_manager.verify_expectations() self.call_expectations_manager.verify_no_unexpected_calls() # ---------------------------------------------------------------- # Live updates – both sides of the book # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_with_both_sides(self, subscription_type: SubscriptionType.ValueType) -> None: """Subscribe, insert a buy then a sell at different prices. The book should reflect orders on both sides.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) bid_price = Decimal("100.0") ask_price = Decimal("100.01") quantity = 10 self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={bid_price: quantity}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=bid_price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={bid_price: quantity}, expected_asks={ask_price: quantity}) order_book_client.test_insert_order( side=Side.SELL, price=ask_price, quantity=quantity, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() # ---------------------------------------------------------------- # Live updates – aggregation at same price level # ---------------------------------------------------------------- @pytest.mark.parametrize( "subscription_type", [ pytest.param(SubscriptionType.TOP_OF_BOOK, id="TOB"), pytest.param(SubscriptionType.PRICE_DEPTH_BOOK, id="PDB"), ] ) def test_live_update_quantity_aggregation_at_same_price(self, subscription_type: SubscriptionType.ValueType) -> None: """Insert two orders at the same price after subscribing. The reported quantity should be the sum of both orders.""" self._create_instrument_via_admin() client = self._connect_and_login() self.call_expectations_manager.verify_expectations() client.test_subscribe_to_order_book( instrument_symbol=self.instrument.symbol, type=subscription_type) price = Decimal("100.0") qty1 = 10 qty2 = 15 self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: qty1}, expected_asks={}) order_book_client = self._get_order_book_client() order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=qty1, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations() self._expect_book_update(client, subscription_type, self.instrument.symbol, expected_bids={price: qty1 + qty2}, expected_asks={}) order_book_client.test_insert_order( side=Side.BUY, price=price, quantity=qty2, order_book_id=self.order_book_id, username=f"{self.test_name}_orders") self.call_expectations_manager.verify_expectations()