From c59cefadc49f25e6a6d0f7edf8766c1243be415f Mon Sep 17 00:00:00 2001 From: Chris Sellers Date: Sat, 2 Dec 2023 09:38:03 +1100 Subject: [PATCH] Refine RiskEngine notional checks and add test --- nautilus_trader/risk/engine.pyx | 14 ++--- nautilus_trader/test_kit/stubs/events.py | 33 ++++++++--- tests/unit_tests/risk/test_engine.py | 70 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 16 deletions(-) diff --git a/nautilus_trader/risk/engine.pyx b/nautilus_trader/risk/engine.pyx index 49660cf1b92..9dedfe7b784 100644 --- a/nautilus_trader/risk/engine.pyx +++ b/nautilus_trader/risk/engine.pyx @@ -679,7 +679,7 @@ cdef class RiskEngine(Component): if max_notional and notional._mem.raw > max_notional._mem.raw: self._deny_order( order=order, - reason=f"NOTIONAL_EXCEEDS_MAX_PER_ORDER {max_notional.to_str()} @ {notional.to_str()}", + reason=f"NOTIONAL_EXCEEDS_MAX_PER_ORDER: max_notional={max_notional.to_str()}, notional={notional.to_str()}", ) return False # Denied @@ -691,7 +691,7 @@ cdef class RiskEngine(Component): ): self._deny_order( order=order, - reason=f"NOTIONAL_LESS_THAN_MIN_FOR_INSTRUMENT {instrument.min_notional.to_str()} @ {notional.to_str()}", + reason=f"NOTIONAL_LESS_THAN_MIN_FOR_INSTRUMENT: min_notional={instrument.min_notional.to_str()} , notional={notional.to_str()}", ) return False # Denied @@ -703,7 +703,7 @@ cdef class RiskEngine(Component): ): self._deny_order( order=order, - reason=f"NOTIONAL_GREATER_THAN_MAX_FOR_INSTRUMENT {instrument.max_notional.to_str()} @ {notional.to_str()}", + reason=f"NOTIONAL_GREATER_THAN_MAX_FOR_INSTRUMENT: max_notional={instrument.max_notional.to_str()}, notional={notional.to_str()}", ) return False # Denied @@ -712,7 +712,7 @@ cdef class RiskEngine(Component): if free is not None and (free._mem.raw + order_balance_impact._mem.raw) < 0: self._deny_order( order=order, - reason=f"NOTIONAL_EXCEEDS_FREE_BALANCE {free.to_str()} @ {order_balance_impact.to_str()}", + reason=f"NOTIONAL_EXCEEDS_FREE_BALANCE: free={free.to_str()}, notional={order_balance_impact.to_str()}", ) return False # Denied @@ -727,7 +727,7 @@ cdef class RiskEngine(Component): if free is not None and cum_notional_buy._mem.raw >= free._mem.raw: self._deny_order( order=order, - reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE {free.to_str()} @ {cum_notional_buy.to_str()}", + reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free.to_str()}, cum_notional={cum_notional_buy.to_str()}", ) return False # Denied elif order.is_sell_c(): @@ -739,7 +739,7 @@ cdef class RiskEngine(Component): if free is not None and cum_notional_sell._mem.raw >= free._mem.raw: self._deny_order( order=order, - reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE {free.to_str()} @ {cum_notional_sell.to_str()}", + reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free.to_str()}, cum_notional={cum_notional_sell.to_str()}", ) return False # Denied elif base_currency is not None: @@ -751,7 +751,7 @@ cdef class RiskEngine(Component): if free is not None and cum_notional_sell._mem.raw >= free._mem.raw: self._deny_order( order=order, - reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE {free.to_str()} @ {cum_notional_sell.to_str()}", + reason=f"CUM_NOTIONAL_EXCEEDS_FREE_BALANCE: free={free.to_str()}, cum_notional={cum_notional_sell.to_str()}", ) return False # Denied diff --git a/nautilus_trader/test_kit/stubs/events.py b/nautilus_trader/test_kit/stubs/events.py index a9e7cae6aa1..76fc66b6d8f 100644 --- a/nautilus_trader/test_kit/stubs/events.py +++ b/nautilus_trader/test_kit/stubs/events.py @@ -20,6 +20,7 @@ from nautilus_trader.common.messages import ComponentStateChanged from nautilus_trader.common.messages import TradingStateChanged from nautilus_trader.core.uuid import UUID4 +from nautilus_trader.model.currencies import AUD from nautilus_trader.model.currencies import GBP from nautilus_trader.model.currencies import USD from nautilus_trader.model.enums import AccountType @@ -84,19 +85,33 @@ def trading_state_changed() -> TradingStateChanged: ) @staticmethod - def cash_account_state(account_id: AccountId | None = None) -> AccountState: + def cash_account_state( + account_id: AccountId | None = None, + base_currency: Currency | None = USD, + ) -> AccountState: + balances = [ + AccountBalance( + Money(1_000_000, USD), + Money(0, USD), + Money(1_000_000, USD), + ), + ] + + if base_currency is None: + balances.append( + AccountBalance( + Money(10_000, AUD), + Money(0, AUD), + Money(10_000, AUD), + ), + ) + return AccountState( account_id=account_id or TestIdStubs.account_id(), account_type=AccountType.CASH, - base_currency=USD, + base_currency=base_currency, reported=True, # reported - balances=[ - AccountBalance( - Money(1_000_000, USD), - Money(0, USD), - Money(1_000_000, USD), - ), - ], + balances=balances, margins=[], info={}, event_id=UUID4(), diff --git a/tests/unit_tests/risk/test_engine.py b/tests/unit_tests/risk/test_engine.py index 7598fbcbdd0..e32d572b2dd 100644 --- a/tests/unit_tests/risk/test_engine.py +++ b/tests/unit_tests/risk/test_engine.py @@ -1178,6 +1178,76 @@ def test_submit_order_list_sells_when_over_free_balance_then_denies(self): assert order2.status == OrderStatus.DENIED assert self.exec_engine.command_count == 0 # <-- Command never reaches engine + def test_submit_order_list_sells_when_multi_currency_cash_account_over_cumulative_notional( + self, + ): + # Arrange - change account + exec_client = MockExecutionClient( + client_id=ClientId(self.venue.value), + venue=self.venue, + account_type=AccountType.CASH, + base_currency=None, # <-- Multi-currency + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + logger=self.logger, + ) + + self.exec_engine.deregister_client(self.exec_client) + self.exec_engine.register_client(exec_client) + self.cache.reset() # Clear accounts + self.cache.add_instrument(AUDUSD_SIM) # Re-add instrument + self.portfolio.update_account(TestEventStubs.cash_account_state(base_currency=None)) + + # Prepare market + quote = TestDataStubs.quote_tick(AUDUSD_SIM) + self.cache.add_quote_tick(quote) + + self.exec_engine.start() + + strategy = Strategy() + strategy.register( + trader_id=self.trader_id, + portfolio=self.portfolio, + msgbus=self.msgbus, + cache=self.cache, + clock=self.clock, + logger=self.logger, + ) + + order1 = strategy.order_factory.market( + AUDUSD_SIM.id, + OrderSide.SELL, + Quantity.from_int(5_000), + ) + + order2 = strategy.order_factory.market( + AUDUSD_SIM.id, + OrderSide.SELL, + Quantity.from_int(5_000), + ) + + order_list = OrderList( + order_list_id=OrderListId("1"), + orders=[order1, order2], + ) + + submit_order = SubmitOrderList( + self.trader_id, + strategy.id, + order_list, + UUID4(), + self.clock.timestamp_ns(), + ) + + # Act + self.risk_engine.execute(submit_order) + + # Assert + assert order1.status == OrderStatus.DENIED + assert order2.status == OrderStatus.DENIED + assert self.exec_engine.command_count == 0 # <-- Command never reaches engine + def test_submit_order_when_reducing_and_buy_order_adds_then_denies(self): # Arrange self.risk_engine.set_max_notional_per_order(AUDUSD_SIM.id, 1_000_000)