625 lines
31 KiB
Python
625 lines
31 KiB
Python
from decimal import Decimal
|
||
import logging
|
||
|
||
import pytest
|
||
|
||
from connection.tcp_connection_manager import TcpConnectionManager
|
||
from proto.common_pb2 import Instrument, Side
|
||
from proto.risk_limits_pb2 import (
|
||
InstrumentRiskLimits, RollingWindowLimit, UserRiskLimits,
|
||
)
|
||
|
||
from tests.conftest import DEFAULT_PASSWORD
|
||
from tests.common.auth_tests import AuthenticationTests
|
||
from tests.common.mock_expectations import CallExpectationsManager
|
||
from tests.test_client.admin_test_client import (
|
||
AdminClientConnectionHandlerFactory, AdminTestClient,
|
||
)
|
||
from tests.test_client.risk_gateway_test_client import (
|
||
RiskGatewayClientConnectionHandlerFactory, RiskGatewayTestClient,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class TestRiskGatewaySystem(AuthenticationTests):
|
||
PROTOCOL = "risk_limits"
|
||
|
||
@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._client_factory = RiskGatewayClientConnectionHandlerFactory(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) -> RiskGatewayTestClient:
|
||
return self.tcp_connection_manager.connect(self.server_address, self._client_factory)
|
||
|
||
def _connect_and_login(self, username: str | None = None) -> RiskGatewayTestClient:
|
||
"""Connect to the risk gateway, log in (if auth required), and return the client."""
|
||
client: RiskGatewayTestClient = self.tcp_connection_manager.connect(self.server_address, self._client_factory)
|
||
if self.auth_required:
|
||
client.test_login(username=username or self.test_name, password=DEFAULT_PASSWORD)
|
||
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 _create_instrument_via_admin(self, tick_size: Decimal = Decimal("0.01")) -> str:
|
||
"""Create an instrument via admin and return its symbol."""
|
||
symbol = f"TEST.{self._next_instrument_id}"
|
||
self._next_instrument_id += 1
|
||
instrument = Instrument(
|
||
symbol=symbol, description="Test instrument",
|
||
currency="USD", multiplier=1.0)
|
||
admin = self._get_admin_client()
|
||
admin.test_create_instrument(instrument, tick_size)
|
||
return symbol
|
||
|
||
# =========================================================================
|
||
# Risk limits CRUD – user level
|
||
# =========================================================================
|
||
|
||
def test_set_and_get_user_risk_limits(self) -> None:
|
||
client = self._connect_and_login()
|
||
limits = UserRiskLimits(max_outstanding_quantity=500)
|
||
client.test_set_user_risk_limits(limits)
|
||
|
||
resp = client.test_get_user_risk_limits()
|
||
response = resp.get_response()
|
||
assert response.user_risk_limits.max_outstanding_quantity == 500
|
||
|
||
def test_get_user_risk_limits_when_not_set(self) -> None:
|
||
client = self._connect_and_login()
|
||
client.test_get_user_risk_limits(expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Risk limits CRUD – instrument level
|
||
# =========================================================================
|
||
|
||
def test_set_and_get_instrument_risk_limits(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
limits = InstrumentRiskLimits(max_outstanding_quantity=100)
|
||
client.test_set_instrument_risk_limits(symbol, limits)
|
||
|
||
resp = client.test_get_instrument_risk_limits()
|
||
response = resp.get_response()
|
||
assert symbol in response.risk_limits_by_instrument
|
||
assert response.risk_limits_by_instrument[symbol].max_outstanding_quantity == 100
|
||
|
||
def test_get_instrument_risk_limits_when_not_set(self) -> None:
|
||
client = self._connect_and_login()
|
||
resp = client.test_get_instrument_risk_limits()
|
||
response = resp.get_response()
|
||
assert len(response.risk_limits_by_instrument) == 0
|
||
|
||
def test_set_instrument_risk_limits_for_multiple_instruments(self) -> None:
|
||
sym_a = self._create_instrument_via_admin()
|
||
sym_b = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
|
||
client.test_set_instrument_risk_limits(
|
||
sym_a, InstrumentRiskLimits(max_outstanding_quantity=100))
|
||
client.test_set_instrument_risk_limits(
|
||
sym_b, InstrumentRiskLimits(max_outstanding_quantity=200))
|
||
|
||
resp = client.test_get_instrument_risk_limits()
|
||
by_instrument = resp.get_response().risk_limits_by_instrument
|
||
assert by_instrument[sym_a].max_outstanding_quantity == 100
|
||
assert by_instrument[sym_b].max_outstanding_quantity == 200
|
||
|
||
# =========================================================================
|
||
# User max outstanding quantity
|
||
# =========================================================================
|
||
|
||
def test_user_max_outstanding_quantity_accepts_within_limit(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=20))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
|
||
def test_user_max_outstanding_quantity_rejects_over_limit(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=20))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_user_max_outstanding_quantity_freed_by_cancellation(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=20))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
client.test_cancel_order(symbol)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
|
||
def test_user_max_outstanding_quantity_freed_by_full_trade(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=20))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 20)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
|
||
def test_user_max_outstanding_quantity_freed_by_partial_trade(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=20))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 10)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
def test_user_max_outstanding_quantity_spans_all_instruments(self) -> None:
|
||
"""The user-level limit applies across all instruments combined."""
|
||
sym_a = self._create_instrument_via_admin()
|
||
sym_b = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=30))
|
||
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 20)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 10)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Instrument max outstanding quantity
|
||
# =========================================================================
|
||
|
||
def test_instrument_max_outstanding_quantity_rejects_over_limit(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=15))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 15)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_instrument_max_outstanding_quantity_independent_per_instrument(self) -> None:
|
||
"""Limits on instrument A must not affect orders on instrument B."""
|
||
sym_a = self._create_instrument_via_admin()
|
||
sym_b = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
sym_a, InstrumentRiskLimits(max_outstanding_quantity=10))
|
||
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 10)
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 100)
|
||
|
||
def test_instrument_max_outstanding_quantity_freed_by_cancellation(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=10))
|
||
|
||
resp = client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
order_id = resp.get_response().order_id
|
||
client.test_cancel_order(symbol, order_id=order_id)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
def test_instrument_max_outstanding_quantity_freed_by_trade(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=10))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 10)
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
# =========================================================================
|
||
# Instrument max outstanding amount
|
||
# =========================================================================
|
||
|
||
def test_instrument_max_outstanding_amount_rejects_over_limit(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=1000.0))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_instrument_max_outstanding_amount_considers_price_and_quantity(self) -> None:
|
||
"""Amount = price x quantity. A higher price hits the limit sooner."""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=599.0))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 5)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("10.0"), 10,
|
||
expect_success=False)
|
||
|
||
client.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_instrument_max_outstanding_amount_freed_by_trade(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=1000.0))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 10)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
# =========================================================================
|
||
# Message rate rolling limit
|
||
# =========================================================================
|
||
|
||
def test_message_rate_limit_rejects_excess(self) -> None:
|
||
"""After hitting the message rate limit within the window, further orders are rejected."""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_user_risk_limits(UserRiskLimits(
|
||
message_rate_rolling_limit=RollingWindowLimit(limit=3, window_in_seconds=60)))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("101.0"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("102.0"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("103.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Order quantity rolling limit
|
||
# =========================================================================
|
||
|
||
def test_order_quantity_rolling_limit_rejects_excess(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(symbol, InstrumentRiskLimits(
|
||
order_quantity_rolling_limit=RollingWindowLimit(limit=50, window_in_seconds=60)))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 30)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("101.0"), 20)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("102.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Order amount rolling limit
|
||
# =========================================================================
|
||
|
||
def test_order_amount_rolling_limit_rejects_excess(self) -> None:
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(symbol, InstrumentRiskLimits(
|
||
order_amount_rolling_limit=RollingWindowLimit(limit=5000, window_in_seconds=60)))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 30)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Multi-user isolation
|
||
# =========================================================================
|
||
|
||
def test_limits_are_isolated_between_users(self) -> None:
|
||
"""User A's limits must not affect user B's ability to trade."""
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
|
||
user_a.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=10))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("101.0"), 100)
|
||
|
||
def test_order_from_user_a_does_not_count_against_user_b(self) -> None:
|
||
"""Outstanding orders from user A must not consume user B's limits."""
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
|
||
user_b.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=10))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 100)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("101.0"), 10)
|
||
|
||
# =========================================================================
|
||
# State tracking across trades and partial fills
|
||
# =========================================================================
|
||
|
||
def test_partial_trade_correctly_updates_outstanding_quantity(self) -> None:
|
||
"""After a partial fill, the remaining quantity still counts towards limits."""
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=20))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 20)
|
||
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 5)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 5)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_trade_between_two_limited_users_updates_both(self) -> None:
|
||
"""When two users trade, both users' outstanding quantities must decrease."""
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
|
||
user_a.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=10))
|
||
user_b.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=10))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 10)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("101.0"), 10)
|
||
|
||
def test_multiple_partial_fills_track_remaining_correctly(self) -> None:
|
||
"""Several small trades against a large order must reduce the outstanding by the cumulative traded amount."""
|
||
symbol = self._create_instrument_via_admin()
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=30))
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 30)
|
||
|
||
for _ in range(3):
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("100.0"), 5)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 15)
|
||
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Combined limits
|
||
# =========================================================================
|
||
|
||
def test_both_user_and_instrument_limits_checked(self) -> None:
|
||
"""An order must satisfy both user-level and instrument-level limits."""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=100))
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_quantity=10))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
def test_instrument_limit_hit_while_user_limit_ok(self) -> None:
|
||
"""Even if the user-level limit has room, the instrument-level limit must block."""
|
||
sym_a = self._create_instrument_via_admin()
|
||
sym_b = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=100))
|
||
client.test_set_instrument_risk_limits(
|
||
sym_a, InstrumentRiskLimits(max_outstanding_quantity=5))
|
||
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 5)
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 50)
|
||
|
||
def test_user_limit_hit_while_instrument_limit_ok(self) -> None:
|
||
"""Even if the instrument-level limit has room, the user-level limit must block."""
|
||
sym_a = self._create_instrument_via_admin()
|
||
sym_b = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
|
||
client.test_set_user_risk_limits(
|
||
UserRiskLimits(max_outstanding_quantity=15))
|
||
client.test_set_instrument_risk_limits(
|
||
sym_a, InstrumentRiskLimits(max_outstanding_quantity=100))
|
||
client.test_set_instrument_risk_limits(
|
||
sym_b, InstrumentRiskLimits(max_outstanding_quantity=100))
|
||
|
||
client.test_insert_order(sym_a, Side.BUY, Decimal("100.0"), 10)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 5)
|
||
client.test_insert_order(sym_b, Side.BUY, Decimal("100.0"), 1,
|
||
expect_success=False)
|
||
|
||
# =========================================================================
|
||
# Floating-point precision (4-decimal-digit prices/amounts)
|
||
#
|
||
# The protocol uses double for prices and amounts. Naive use of IEEE 754
|
||
# floats leads to well-known rounding errors (e.g. 0.1 * 3 != 0.3).
|
||
# These tests verify that the solution uses proper precision handling so
|
||
# that orders exactly at the limit are not falsely rejected, and that
|
||
# add/subtract cycles leave no residual error that blocks future orders.
|
||
# =========================================================================
|
||
|
||
def test_amount_boundary_not_falsely_rejected_by_float_multiplication(self) -> None:
|
||
"""price * qty can overshoot in float (e.g. 0.1 * 3 = 0.30000000000000004).
|
||
|
||
An order whose exact amount (4-decimal precision) equals the limit must
|
||
be accepted despite IEEE 754 rounding in the multiplication.
|
||
"""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=300.03))
|
||
|
||
# Exact: 100.01 x 3 = 300.03 — equals the limit, must be accepted.
|
||
# In float: 100.01 * 3 may produce 300.03000000000003 due to the
|
||
# inexact representation of 100.01 in IEEE 754.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.01"), 3)
|
||
|
||
def test_amount_boundary_not_falsely_rejected_by_float_accumulation(self) -> None:
|
||
"""Repeated float additions drift away from the true sum.
|
||
|
||
Three additions of 33.3333 should total 99.9999, leaving room for
|
||
exactly 0.0001 more. A naive float accumulation may overshoot 99.9999,
|
||
falsely blocking the last order.
|
||
"""
|
||
symbol = self._create_instrument_via_admin(tick_size=Decimal("0.0001"))
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=100.0))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("33.3333"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("33.3333"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("33.3333"), 1)
|
||
# Exact total so far: 99.9999. One more at 0.0001 → 100.0 = limit.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.0001"), 1)
|
||
|
||
def test_amount_boundary_with_classic_point_one_plus_point_two(self) -> None:
|
||
"""The textbook float error: 0.1 + 0.2 = 0.30000000000000004 in IEEE 754.
|
||
|
||
With limit = 3000, two orders (0.1 x 10000 = 1000, 0.2 x 10000 = 2000)
|
||
total exactly 3000. A third fills the boundary via small price.
|
||
"""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=3000.0))
|
||
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.1"), 10000)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.2"), 10000)
|
||
# Exact total: 1000 + 2000 = 3000 = limit.
|
||
# Any additional order must be rejected.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.01"), 1,
|
||
expect_success=False)
|
||
|
||
def test_amount_freed_correctly_after_cancelling_fractional_price_orders(self) -> None:
|
||
"""After inserting and cancelling orders at fractional prices, the full
|
||
capacity must be available again — no residual float error may block.
|
||
"""
|
||
symbol = self._create_instrument_via_admin(tick_size=Decimal("0.0001"))
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=1000.0))
|
||
|
||
for price in ["33.3333", "33.3334", "33.3333"]:
|
||
resp = client.test_insert_order(symbol, Side.BUY, Decimal(price), 1)
|
||
order_id = resp.get_response().order_id
|
||
client.test_cancel_order(symbol, order_id=order_id)
|
||
|
||
# Full capacity must be restored despite float add/subtract drift.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
def test_amount_freed_correctly_after_trades_at_fractional_prices(self) -> None:
|
||
"""After trades fully fill orders at fractional prices, the outstanding
|
||
amount must return to zero so the full limit is available again.
|
||
"""
|
||
symbol = self._create_instrument_via_admin(tick_size=Decimal("0.0001"))
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=1000.0))
|
||
|
||
# Insert and fully fill 3 orders at fractional prices.
|
||
for price in ["33.3333", "33.3334", "33.3333"]:
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal(price), 1)
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal(price), 1)
|
||
|
||
# Full capacity must be available again.
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 10)
|
||
|
||
def test_amount_freed_correctly_after_partial_trade_at_fractional_price(self) -> None:
|
||
"""A partial fill at a fractional price must reduce the outstanding
|
||
amount by exactly traded_qty x price, not by a drifted float value.
|
||
"""
|
||
symbol = self._create_instrument_via_admin(tick_size=Decimal("0.0001"))
|
||
user_a = self._connect_and_login(f"{self.test_name}_2")
|
||
user_b = self._connect_and_login(f"{self.test_name}_3")
|
||
user_a.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=100.0))
|
||
|
||
# Insert qty 10 at 10.01 → amount = 100.1 > limit? No: 10.01 x 10 = 100.10
|
||
# Use a value that fits: 10.0 x 10 = 100.0 = limit
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("10.0001"), 5)
|
||
# Outstanding: 50.0005. Partial fill 3 units → frees 30.0003.
|
||
user_b.test_insert_order(symbol, Side.SELL, Decimal("10.0001"), 3)
|
||
# Remaining outstanding: 20.0002. Room left: 79.9998.
|
||
user_a.test_insert_order(symbol, Side.BUY, Decimal("79.9998"), 1)
|
||
|
||
def test_order_amount_rolling_limit_boundary_with_fractional_multiplication(self) -> None:
|
||
"""The rolling-window amount sum must handle precision the same way as
|
||
the outstanding amount — no false rejection at the exact boundary.
|
||
"""
|
||
symbol = self._create_instrument_via_admin()
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(symbol, InstrumentRiskLimits(
|
||
order_amount_rolling_limit=RollingWindowLimit(
|
||
limit=300, window_in_seconds=60)))
|
||
|
||
# Exact: 100.01 x 3 = 300.03, but limit is 300 → rejected.
|
||
# First fill up to just under the limit.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("99.99"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("99.99"), 1)
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("99.99"), 1)
|
||
# Exact rolling total: 299.97. Room for 0.03 more.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.01"), 3)
|
||
# Now exact total: 299.97 + 0.03 = 300.0 = limit. Next must fail.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("0.01"), 1,
|
||
expect_success=False)
|
||
|
||
def test_many_fractional_insert_cancel_cycles_leave_no_residual(self) -> None:
|
||
"""Repeated insert-cancel cycles at the same fractional price must not
|
||
accumulate residual float error that eventually blocks orders.
|
||
"""
|
||
symbol = self._create_instrument_via_admin(tick_size=Decimal("0.0001"))
|
||
client = self._connect_and_login()
|
||
client.test_set_instrument_risk_limits(
|
||
symbol, InstrumentRiskLimits(max_outstanding_amount=100.0))
|
||
|
||
for _ in range(20):
|
||
resp = client.test_insert_order(
|
||
symbol, Side.BUY, Decimal("33.3333"), 1)
|
||
order_id = resp.get_response().order_id
|
||
client.test_cancel_order(symbol, order_id=order_id)
|
||
|
||
# After 20 round-trips, full capacity must still be available.
|
||
client.test_insert_order(symbol, Side.BUY, Decimal("100.0"), 1)
|