gtat-tech-career-kickstarte.../solution/tests/test_info_system.py

942 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()