class TestLiveExecutionEngine: def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.loop.set_debug(True) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = Logger(self.clock) self.trader_id = TestStubs.trader_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S-001"), clock=self.clock, ) self.random_order_factory = OrderFactory( trader_id=TraderId("RANDOM-042"), strategy_id=StrategyId("S-042"), clock=self.clock, ) self.msgbus = MessageBus( trader_id=self.trader_id, clock=self.clock, logger=self.logger, ) self.cache = TestStubs.cache() self.portfolio = Portfolio( msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.data_engine = LiveDataEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.risk_engine = LiveRiskEngine( loop=self.loop, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.instrument_provider = InstrumentProvider() self.instrument_provider.add(AUDUSD_SIM) self.instrument_provider.add(GBPUSD_SIM) self.client = MockLiveExecutionClient( loop=self.loop, client_id=ClientId(SIM.value), venue_type=VenueType.ECN, account_id=TestStubs.account_id(), account_type=AccountType.CASH, base_currency=USD, instrument_provider=self.instrument_provider, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.portfolio.update_account(TestStubs.event_cash_account_state()) self.exec_engine.register_client(self.client) self.cache.add_instrument(AUDUSD_SIM) def teardown(self): self.exec_engine.dispose() @pytest.mark.asyncio async def test_start_when_loop_not_running_logs(self): # Arrange, Act self.exec_engine.start() # Assert assert True # No exceptions raised self.exec_engine.stop() @pytest.mark.asyncio async def test_message_qsize_at_max_blocks_on_put_command(self): # Arrange # Deregister test fixture ExecutionEngine from msgbus) self.msgbus.deregister(endpoint="ExecEngine.execute", handler=self.exec_engine.execute) self.msgbus.deregister(endpoint="ExecEngine.process", handler=self.exec_engine.process) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, config=LiveExecEngineConfig(qsize=1), ) strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.exec_engine.execute(submit_order) self.exec_engine.execute(submit_order) await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 1 assert self.exec_engine.command_count == 0 @pytest.mark.asyncio async def test_message_qsize_at_max_blocks_on_put_event(self): # Arrange # Deregister test fixture ExecutionEngine from msgbus) self.msgbus.deregister(endpoint="ExecEngine.execute", handler=self.exec_engine.execute) self.msgbus.deregister(endpoint="ExecEngine.process", handler=self.exec_engine.process) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, config=LiveExecEngineConfig(qsize=1), ) strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) event = TestStubs.event_order_submitted(order) # Act self.exec_engine.execute(submit_order) self.exec_engine.process(event) # Add over max size await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 1 assert self.exec_engine.command_count == 0 @pytest.mark.asyncio async def test_start(self): # Arrange, Act self.exec_engine.start() await asyncio.sleep(0.1) # Assert assert self.exec_engine.is_running # Tear Down self.exec_engine.stop() @pytest.mark.asyncio async def test_kill_when_running_and_no_messages_on_queues(self): # Arrange, Act self.exec_engine.start() await asyncio.sleep(0) self.exec_engine.kill() # Assert assert self.exec_engine.is_stopped @pytest.mark.asyncio async def test_kill_when_not_running_with_messages_on_queue(self): # Arrange, Act self.exec_engine.kill() # Assert assert self.exec_engine.qsize() == 0 @pytest.mark.asyncio async def test_execute_command_places_command_on_queue(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.exec_engine.execute(submit_order) await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 0 assert self.exec_engine.command_count == 1 # Tear Down self.exec_engine.stop() @pytest.mark.asyncio async def test_reconcile_state_with_no_active_orders(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) # Act await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() await asyncio.sleep(0.1) # Assert assert True # No exceptions raised @pytest.mark.asyncio async def test_reconcile_state_when_report_agrees_reconciles(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.exec_engine.execute(submit_order) self.exec_engine.process(TestStubs.event_order_submitted(order)) self.exec_engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.ACCEPTED, filled_qty=Quantity.zero(), ts_init=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_canceled_reconciles(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.exec_engine.execute(submit_order) self.exec_engine.process(TestStubs.event_order_submitted(order)) self.exec_engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.CANCELED, filled_qty=Quantity.zero(), ts_init=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_expired_reconciles(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.exec_engine.execute(submit_order) self.exec_engine.process(TestStubs.event_order_submitted(order)) self.exec_engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.EXPIRED, filled_qty=Quantity.zero(), ts_init=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() # Assert assert result @pytest.mark.skip(reason="reimplement reconciliation") @pytest.mark.asyncio async def test_reconcile_state_when_partially_filled_reconciles(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.exec_engine.execute(submit_order) self.exec_engine.process(TestStubs.event_order_submitted(order)) self.exec_engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.PARTIALLY_FILLED, filled_qty=Quantity.from_int(70000), ts_init=0, ) trade1 = ExecutionReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), venue_position_id=None, execution_id=ExecutionId("1"), last_qty=Quantity.from_int(50000), last_px=Price.from_str("1.00000"), commission=Money(5.00, USD), liquidity_side=LiquiditySide.MAKER, ts_event=0, ts_init=0, ) trade2 = ExecutionReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), venue_position_id=None, execution_id=ExecutionId("2"), last_qty=Quantity.from_int(20000), last_px=Price.from_str("1.00000"), commission=Money(2.00, USD), liquidity_side=LiquiditySide.MAKER, ts_event=0, ts_init=0, ) self.client.add_order_status_report(report) self.client.add_trades_list(VenueOrderId("1"), [trade1, trade2]) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() # Assert assert result @pytest.mark.skip(reason="reimplement reconciliation") @pytest.mark.asyncio async def test_reconcile_state_when_filled_reconciles(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.exec_engine.execute(submit_order) self.exec_engine.process(TestStubs.event_order_submitted(order)) self.exec_engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.FILLED, filled_qty=Quantity.from_int(100000), ts_init=0, ) trade1 = ExecutionReport( execution_id=ExecutionId("1"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), venue_position_id=None, last_qty=Quantity.from_int(50000), last_px=Price.from_str("1.00000"), commission=Money(5.00, USD), liquidity_side=LiquiditySide.MAKER, ts_event=0, ts_init=0, ) trade2 = ExecutionReport( execution_id=ExecutionId("2"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), venue_position_id=None, last_qty=Quantity.from_int(50000), last_px=Price.from_str("1.00000"), commission=Money(2.00, USD), liquidity_side=LiquiditySide.MAKER, ts_event=0, ts_init=0, ) self.client.add_order_status_report(report) self.client.add_trades_list(VenueOrderId("1"), [trade1, trade2]) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.exec_engine.reconcile_state(timeout_secs=10) self.exec_engine.stop() # Assert assert result
class TradingNode: """ Provides an asynchronous network node for live trading. """ def __init__( self, strategies: List[TradingStrategy], config: Dict[str, object], ): """ Initialize a new instance of the TradingNode class. Parameters ---------- strategies : list[TradingStrategy] The list of strategies to run on the trading node. config : dict[str, object] The configuration for the trading node. Raises ------ ValueError If strategies is None or empty. ValueError If config is None or empty. """ PyCondition.not_none(strategies, "strategies") PyCondition.not_none(config, "config") PyCondition.not_empty(strategies, "strategies") PyCondition.not_empty(config, "config") # Extract configs config_trader = config.get("trader", {}) config_log = config.get("logging", {}) config_exec_db = config.get("exec_database", {}) config_strategy = config.get("strategy", {}) config_adapters = config.get("adapters", {}) self._uuid_factory = UUIDFactory() self._loop = asyncio.get_event_loop() self._executor = concurrent.futures.ThreadPoolExecutor() self._loop.set_default_executor(self._executor) self._clock = LiveClock(loop=self._loop) self.created_time = self._clock.utc_now() self._is_running = False # Uncomment for debugging # self._loop.set_debug(True) # Setup identifiers self.trader_id = TraderId( name=config_trader["name"], tag=config_trader["id_tag"], ) # Setup logging self._logger = LiveLogger( clock=self._clock, name=self.trader_id.value, level_console=LogLevelParser.from_str_py(config_log.get("log_level_console")), level_file=LogLevelParser.from_str_py(config_log.get("log_level_file")), level_store=LogLevelParser.from_str_py(config_log.get("log_level_store")), run_in_process=config_log.get("run_in_process", True), # Run logger in a separate process log_thread=config_log.get("log_thread_id", False), log_to_file=config_log.get("log_to_file", False), log_file_path=config_log.get("log_file_path", ""), ) self._log = LoggerAdapter(component_name=self.__class__.__name__, logger=self._logger) self._log_header() self._log.info("Building...") self._setup_loop() # Requires the logger to be initialized self.portfolio = Portfolio( clock=self._clock, logger=self._logger, ) self._data_engine = LiveDataEngine( loop=self._loop, portfolio=self.portfolio, clock=self._clock, logger=self._logger, config={"qsize": 10000}, ) self.portfolio.register_cache(self._data_engine.cache) self.analyzer = PerformanceAnalyzer() if config_exec_db["type"] == "redis": exec_db = RedisExecutionDatabase( trader_id=self.trader_id, logger=self._logger, command_serializer=MsgPackCommandSerializer(), event_serializer=MsgPackEventSerializer(), config={ "host": config_exec_db["host"], "port": config_exec_db["port"], } ) else: exec_db = BypassExecutionDatabase( trader_id=self.trader_id, logger=self._logger, ) self._exec_engine = LiveExecutionEngine( loop=self._loop, database=exec_db, portfolio=self.portfolio, clock=self._clock, logger=self._logger, config={"qsize": 10000}, ) self._exec_engine.load_cache() self._setup_adapters(config_adapters, self._logger) self.trader = Trader( trader_id=self.trader_id, strategies=strategies, portfolio=self.portfolio, data_engine=self._data_engine, exec_engine=self._exec_engine, clock=self._clock, logger=self._logger, ) self._check_residuals_delay = config_trader.get("check_residuals_delay", 5.0) self._load_strategy_state = config_strategy.get("load_state", True) self._save_strategy_state = config_strategy.get("save_state", True) if self._load_strategy_state: self.trader.load() self._log.info("state=INITIALIZED.") self.time_to_initialize = self._clock.delta(self.created_time) self._log.info(f"Initialized in {self.time_to_initialize.total_seconds():.3f}s.") @property def is_running(self) -> bool: """ If the trading node is running. Returns ------- bool True if running, else False. """ return self._is_running def get_event_loop(self) -> asyncio.AbstractEventLoop: """ Return the event loop of the trading node. Returns ------- asyncio.AbstractEventLoop """ return self._loop def get_logger(self) -> LiveLogger: """ Return the logger for the trading node. Returns ------- LiveLogger """ return self._logger def start(self) -> None: """ Start the trading node. """ try: if self._loop.is_running(): self._loop.create_task(self._run()) else: self._loop.run_until_complete(self._run()) except RuntimeError as ex: self._log.exception(ex) def stop(self) -> None: """ Stop the trading node gracefully. After a specified delay the internal `Trader` residuals will be checked. If save strategy is specified then strategy states will then be saved. """ try: if self._loop.is_running(): self._loop.create_task(self._stop()) else: self._loop.run_until_complete(self._stop()) except RuntimeError as ex: self._log.exception(ex) def dispose(self) -> None: """ Dispose of the trading node. Gracefully shuts down the executor and event loop. """ try: timeout = self._clock.utc_now() + timedelta(seconds=5) while self._is_running: time.sleep(0.1) if self._clock.utc_now() >= timeout: self._log.warning("Timed out (5s) waiting for node to stop.") break self._log.info("state=DISPOSING...") self._log.debug(f"{self._data_engine.get_run_queue_task()}") self._log.debug(f"{self._exec_engine.get_run_queue_task()}") self.trader.dispose() self._data_engine.dispose() self._exec_engine.dispose() self._log.info("Shutting down executor...") if sys.version_info >= (3, 9): # cancel_futures added in Python 3.9 self._executor.shutdown(wait=True, cancel_futures=True) else: self._executor.shutdown(wait=True) self._log.info("Stopping event loop...") self._loop.stop() self._cancel_all_tasks() except RuntimeError as ex: self._log.error("Shutdown coro issues will be fixed soon...") # TODO: Remove when fixed self._log.exception(ex) finally: if self._loop.is_running(): self._log.warning("Cannot close a running event loop.") else: self._log.info("Closing event loop...") self._loop.close() # Check and log if event loop is running if self._loop.is_running(): self._log.warning(f"loop.is_running={self._loop.is_running()}") else: self._log.info(f"loop.is_running={self._loop.is_running()}") # Check and log if event loop is closed if not self._loop.is_closed(): self._log.warning(f"loop.is_closed={self._loop.is_closed()}") else: self._log.info(f"loop.is_closed={self._loop.is_closed()}") self._log.info("state=DISPOSED.") self._logger.stop() # Ensure process is stopped time.sleep(0.1) # Ensure final log messages def _log_header(self) -> None: nautilus_header(self._log) self._log.info(f"redis {redis.__version__}") self._log.info(f"msgpack {msgpack.version[0]}.{msgpack.version[1]}.{msgpack.version[2]}") if uvloop_version: self._log.info(f"uvloop {uvloop_version}") self._log.info("=================================================================") def _setup_loop(self) -> None: if self._loop.is_closed(): self._log.error("Cannot setup signal handling (event loop was closed).") return signal.signal(signal.SIGINT, signal.SIG_DFL) signals = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP, signal.SIGABRT) for sig in signals: self._loop.add_signal_handler(sig, self._loop_sig_handler, sig) self._log.debug(f"Event loop {signals} handling setup.") def _loop_sig_handler(self, sig: signal.signal) -> None: self._loop.remove_signal_handler(signal.SIGTERM) self._loop.add_signal_handler(signal.SIGINT, lambda: None) self._log.warning(f"Received {sig!s}, shutting down...") self.stop() def _setup_adapters(self, config: Dict[str, object], logger: LiveLogger) -> None: # Setup each data client for name, config in config.items(): if name.startswith("ccxt-"): try: import ccxtpro # TODO: Find a better way of doing this except ImportError: raise ImportError("ccxtpro is not installed, " "installation instructions can be found at https://ccxt.pro") client_cls = getattr(ccxtpro, name.partition('-')[2].lower()) data_client, exec_client = CCXTClientsFactory.create( client_cls=client_cls, config=config, data_engine=self._data_engine, exec_engine=self._exec_engine, clock=self._clock, logger=logger, ) elif name == "oanda": data_client = OandaDataClientFactory.create( config=config, data_engine=self._data_engine, clock=self._clock, logger=logger, ) exec_client = None # TODO: Implement else: self._log.error(f"No adapter available for `{name}`.") continue if data_client is not None: self._data_engine.register_client(data_client) if exec_client is not None: self._exec_engine.register_client(exec_client) async def _run(self) -> None: try: self._log.info("state=STARTING...") self._is_running = True self._data_engine.start() self._exec_engine.start() result: bool = await self._await_engines_connected() if not result: return result: bool = await self._exec_engine.resolve_state() if not result: return self.trader.start() if self._loop.is_running(): self._log.info("state=RUNNING.") else: self._log.warning("Event loop is not running.") # Continue to run while engines are running... await self._data_engine.get_run_queue_task() await self._exec_engine.get_run_queue_task() except asyncio.CancelledError as ex: self._log.error(str(ex)) async def _await_engines_connected(self) -> bool: self._log.info("Waiting for engines to initialize...") # The data engine clients will be set as connected when all # instruments are received and updated with the data engine. # The execution engine clients will be set as connected when all # accounts are updated and the current order and position status is # confirmed. Thus any delay here will be due to blocking network IO. seconds = 5 # Hard coded for now timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0.1) if self._clock.utc_now() >= timeout: self._log.error(f"Timed out ({seconds}s) waiting for " f"engines to initialize.") return False if not self._data_engine.check_connected(): continue if not self._exec_engine.check_connected(): continue break return True # Engines initialized async def _stop(self) -> None: self._is_stopping = True self._log.info("state=STOPPING...") if self.trader.state == ComponentState.RUNNING: self.trader.stop() self._log.info(f"Awaiting residual state ({self._check_residuals_delay}s delay)...") await asyncio.sleep(self._check_residuals_delay) self.trader.check_residuals() if self._save_strategy_state: self.trader.save() if self._data_engine.state == ComponentState.RUNNING: self._data_engine.stop() if self._exec_engine.state == ComponentState.RUNNING: self._exec_engine.stop() await self._await_engines_disconnected() # Clean up remaining timers timer_names = self._clock.timer_names() self._clock.cancel_timers() for name in timer_names: self._log.info(f"Cancelled Timer(name={name}).") self._log.info("state=STOPPED.") self._is_running = False async def _await_engines_disconnected(self) -> None: self._log.info("Waiting for engines to disconnect...") seconds = 5 # Hard coded for now timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0.1) if self._clock.utc_now() >= timeout: self._log.warning(f"Timed out ({seconds}s) waiting for engines to disconnect.") break if not self._data_engine.check_disconnected(): continue if not self._exec_engine.check_disconnected(): continue break # Engines initialized def _cancel_all_tasks(self) -> None: to_cancel = asyncio.tasks.all_tasks(self._loop) if not to_cancel: self._log.info("All tasks finished.") return for task in to_cancel: self._log.warning(f"Cancelling pending task {task}") task.cancel() if self._loop.is_running(): self._log.warning("Event loop still running during `cancel_all_tasks`.") return finish_all_tasks: asyncio.Future = asyncio.tasks.gather( *to_cancel, loop=self._loop, return_exceptions=True, ) self._loop.run_until_complete(finish_all_tasks) self._log.debug(f"{finish_all_tasks}") for task in to_cancel: if task.cancelled(): continue if task.exception() is not None: self._loop.call_exception_handler({ 'message': 'unhandled exception during asyncio.run() shutdown', 'exception': task.exception(), 'task': task, })
class LiveExecutionEngineTests(unittest.TestCase): def setUp(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = TestLogger(self.clock) self.trader_id = TraderId("TESTER", "000") self.account_id = TestStubs.account_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S", "001"), clock=self.clock, ) self.random_order_factory = OrderFactory( trader_id=TraderId("RANDOM", "042"), strategy_id=StrategyId("S", "042"), clock=self.clock, ) self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) self.portfolio.register_cache(DataCache(self.logger)) self.analyzer = PerformanceAnalyzer() # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.exec_engine = LiveExecutionEngine( loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) self.venue = Venue("SIM") self.exec_client = MockExecutionClient( self.venue, self.account_id, self.exec_engine, self.clock, self.logger, ) self.exec_engine.register_client(self.exec_client) def tearDown(self): self.exec_engine.dispose() self.loop.stop() self.loop.close() def test_start_when_loop_not_running_logs(self): # Arrange # Act self.exec_engine.start() # Assert self.assertTrue(True) # No exceptions raised self.exec_engine.stop() def test_message_qsize_at_max_blocks_on_put_command(self): # Arrange self.exec_engine = LiveExecutionEngine(loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, config={"qsize": 1}) strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.symbol, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( Venue("SIM"), self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.utc_now(), ) # Act self.exec_engine.execute(submit_order) self.exec_engine.execute(submit_order) # Assert self.assertEqual(1, self.exec_engine.qsize()) self.assertEqual(0, self.exec_engine.command_count) def test_message_qsize_at_max_blocks_on_put_event(self): # Arrange self.exec_engine = LiveExecutionEngine(loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, config={"qsize": 1}) strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.symbol, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( Venue("SIM"), self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.utc_now(), ) event = TestStubs.event_order_submitted(order) # Act self.exec_engine.execute(submit_order) self.exec_engine.process(event) # Add over max size # Assert self.assertEqual(1, self.exec_engine.qsize()) self.assertEqual(0, self.exec_engine.command_count) def test_get_event_loop_returns_expected_loop(self): # Arrange # Act loop = self.exec_engine.get_event_loop() # Assert self.assertEqual(self.loop, loop) def test_start(self): async def run_test(): # Arrange # Act self.exec_engine.start() await asyncio.sleep(0.1) # Assert self.assertEqual(ComponentState.RUNNING, self.exec_engine.state) # Tear Down self.exec_engine.stop() self.loop.run_until_complete(run_test()) def test_kill_when_running_and_no_messages_on_queues(self): async def run_test(): # Arrange # Act self.exec_engine.start() await asyncio.sleep(0) self.exec_engine.kill() # Assert self.assertEqual(ComponentState.STOPPED, self.exec_engine.state) self.loop.run_until_complete(run_test()) def test_kill_when_not_running_with_messages_on_queue(self): async def run_test(): # Arrange # Act self.exec_engine.kill() # Assert self.assertEqual(0, self.exec_engine.qsize()) self.loop.run_until_complete(run_test()) def test_execute_command_places_command_on_queue(self): async def run_test(): # Arrange self.exec_engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.symbol, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( Venue("SIM"), self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.utc_now(), ) # Act self.exec_engine.execute(submit_order) await asyncio.sleep(0.1) # Assert self.assertEqual(0, self.exec_engine.qsize()) self.assertEqual(1, self.exec_engine.command_count) # Tear Down self.exec_engine.stop() self.loop.run_until_complete(run_test()) def test_handle_position_opening_with_position_id_none(self): async def run_test(): # Arrange self.exec_engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.symbol, OrderSide.BUY, Quantity(100000), ) event = TestStubs.event_order_submitted(order) # Act self.exec_engine.process(event) await asyncio.sleep(0.1) # Assert self.assertEqual(0, self.exec_engine.qsize()) self.assertEqual(1, self.exec_engine.event_count) # Tear Down self.exec_engine.stop() self.loop.run_until_complete(run_test())
class BinanceExecutionClientTests(unittest.TestCase): def setUp(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.trader_id = TraderId("TESTER", "001") self.account_id = AccountId("BINANCE", "001") # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) # Setup logging logger = LiveLogger( clock=self.clock, name=self.trader_id.value, level_console=LogLevel.INFO, level_file=LogLevel.DEBUG, level_store=LogLevel.WARNING, run_in_process=False, ) self.logger = LiveLogger(self.clock) self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.exec_engine = LiveExecutionEngine( loop=self.loop, database=database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) with open(TEST_PATH + "markets.json") as response: markets = json.load(response) with open(TEST_PATH + "currencies.json") as response: currencies = json.load(response) with open(TEST_PATH + "fetch_balance.json") as response: fetch_balance = json.load(response) with open(TEST_PATH + "watch_balance.json") as response: watch_balance = json.load(response) self.mock_ccxt = MagicMock() self.mock_ccxt.name = "Binance" self.mock_ccxt.precisionMode = 2 self.mock_ccxt.has = { "fetchBalance": True, "watchBalance": True, "watchMyTrades": True, } self.mock_ccxt.markets = markets self.mock_ccxt.currencies = currencies self.mock_ccxt.fetch_balance = fetch_balance self.mock_ccxt.watch_balance = watch_balance self.client = BinanceExecutionClient( client=self.mock_ccxt, account_id=self.account_id, engine=self.exec_engine, clock=self.clock, logger=logger, ) self.exec_engine.register_client(self.client) def tearDown(self): self.loop.stop() self.loop.close() def test_connect(self): async def run_test(): # Arrange # Act self.exec_engine.start() # Also connects clients await asyncio.sleep(0.3) # Allow engine message queue to start # Assert self.assertTrue(self.client.is_connected) # Tear down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_disconnect(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.disconnect() await asyncio.sleep(0.3) # Assert self.assertFalse(self.client.is_connected) # Tear down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_reset_when_not_connected_successfully_resets(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start self.exec_engine.stop() await asyncio.sleep(0.3) # Allow engine message queue to stop # Act self.client.reset() # Assert self.assertFalse(self.client.is_connected) self.loop.run_until_complete(run_test()) def test_reset_when_connected_does_not_reset(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.reset() # Assert self.assertTrue(self.client.is_connected) # Tear Down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_dispose_when_not_connected_does_not_dispose(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.dispose() # Assert self.assertTrue(self.client.is_connected) # Tear Down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test())
class TestLiveExecutionEngine: def setup(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = Logger(self.clock) self.trader_id = TraderId("TESTER", "000") self.account_id = TestStubs.account_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S", "001"), clock=self.clock, ) self.random_order_factory = OrderFactory( trader_id=TraderId("RANDOM", "042"), strategy_id=StrategyId("S", "042"), clock=self.clock, ) self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) self.portfolio.register_cache(DataCache(self.logger)) self.analyzer = PerformanceAnalyzer() # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.engine = LiveExecutionEngine( loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) self.instrument_provider = InstrumentProvider() self.instrument_provider.add(AUDUSD_SIM) self.instrument_provider.add(GBPUSD_SIM) self.client = MockLiveExecutionClient( name=SIM.value, account_id=self.account_id, engine=self.engine, instrument_provider=self.instrument_provider, clock=self.clock, logger=self.logger, ) self.engine.register_client(self.client) def teardown(self): self.engine.dispose() self.loop.stop() self.loop.close() def test_start_when_loop_not_running_logs(self): # Arrange # Act self.engine.start() # Assert assert True # No exceptions raised self.engine.stop() def test_get_event_loop_returns_expected_loop(self): # Arrange # Act loop = self.engine.get_event_loop() # Assert assert loop == self.loop def test_message_qsize_at_max_blocks_on_put_command(self): # Arrange self.engine = LiveExecutionEngine( loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, config={"qsize": 1}, ) strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( order.instrument_id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.engine.execute(submit_order) self.engine.execute(submit_order) # Assert assert self.engine.qsize() == 1 assert self.engine.command_count == 0 def test_message_qsize_at_max_blocks_on_put_event(self): # Arrange self.engine = LiveExecutionEngine( loop=self.loop, database=self.database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, config={"qsize": 1}, ) strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( order.instrument_id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) event = TestStubs.event_order_submitted(order) # Act self.engine.execute(submit_order) self.engine.process(event) # Add over max size # Assert assert self.engine.qsize() == 1 assert self.engine.command_count == 0 def test_start(self): async def run_test(): # Arrange # Act self.engine.start() await asyncio.sleep(0.1) # Assert assert self.engine.state == ComponentState.RUNNING # Tear Down self.engine.stop() self.loop.run_until_complete(run_test()) def test_kill_when_running_and_no_messages_on_queues(self): async def run_test(): # Arrange # Act self.engine.start() await asyncio.sleep(0) self.engine.kill() # Assert assert self.engine.state == ComponentState.STOPPED self.loop.run_until_complete(run_test()) def test_kill_when_not_running_with_messages_on_queue(self): async def run_test(): # Arrange # Act self.engine.kill() # Assert assert self.engine.qsize() == 0 self.loop.run_until_complete(run_test()) def test_execute_command_places_command_on_queue(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), ) submit_order = SubmitOrder( order.instrument_id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.engine.execute(submit_order) await asyncio.sleep(0.1) # Assert assert self.engine.qsize() == 0 assert self.engine.command_count == 1 # Tear Down self.engine.stop() self.loop.run_until_complete(run_test()) def test_handle_position_opening_with_position_id_none(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), ) event = TestStubs.event_order_submitted(order) # Act self.engine.process(event) await asyncio.sleep(0.1) # Assert assert self.engine.qsize() == 0 assert self.engine.event_count == 1 # Tear Down self.engine.stop() self.loop.run_until_complete(run_test()) def test_reconcile_state_with_no_active_orders(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) # Act await self.engine.reconcile_state() self.engine.stop() # Assert assert True # No exceptions raised self.loop.run_until_complete(run_test()) def test_reconcile_state_when_report_agrees_reconciles(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) self.engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.ACCEPTED, filled_qty=Quantity(0), timestamp_ns=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.engine.reconcile_state() self.engine.stop() # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_cancelled_reconciles(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) self.engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.CANCELLED, filled_qty=Quantity(0), timestamp_ns=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.1) # Allow processing time # Act result = await self.engine.reconcile_state() self.engine.stop() # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_expired_reconciles(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) self.engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.EXPIRED, filled_qty=Quantity(0), timestamp_ns=0, ) self.client.add_order_status_report(report) await asyncio.sleep(0.01) # Act result = await self.engine.reconcile_state() self.engine.stop() # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_partially_filled_reconciles(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) self.engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.PARTIALLY_FILLED, filled_qty=Quantity(70000), timestamp_ns=0, ) trade1 = ExecutionReport( execution_id=ExecutionId("1"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), last_qty=Decimal(50000), last_px=Decimal("1.00000"), commission_amount=Decimal("5.0"), commission_currency="USD", liquidity_side=LiquiditySide.MAKER, execution_ns=0, timestamp_ns=0, ) trade2 = ExecutionReport( execution_id=ExecutionId("2"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), last_qty=Decimal(20000), last_px=Decimal("1.00000"), commission_amount=Decimal("2.0"), commission_currency="USD", liquidity_side=LiquiditySide.MAKER, execution_ns=0, timestamp_ns=0, ) self.client.add_order_status_report(report) self.client.add_trades_list(VenueOrderId("1"), [trade1, trade2]) await asyncio.sleep(0.01) # Act result = await self.engine.reconcile_state() self.engine.stop() # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_filled_reconciles(self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) self.engine.process(TestStubs.event_order_accepted(order)) report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.FILLED, filled_qty=Quantity(100000), timestamp_ns=0, ) trade1 = ExecutionReport( execution_id=ExecutionId("1"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), last_qty=Decimal(50000), last_px=Decimal("1.00000"), commission_amount=Decimal("5.0"), commission_currency="USD", liquidity_side=LiquiditySide.MAKER, execution_ns=0, timestamp_ns=0, ) trade2 = ExecutionReport( execution_id=ExecutionId("2"), client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), last_qty=Decimal(50000), last_px=Decimal("1.00000"), commission_amount=Decimal("2.0"), commission_currency="USD", liquidity_side=LiquiditySide.MAKER, execution_ns=0, timestamp_ns=0, ) self.client.add_order_status_report(report) self.client.add_trades_list(VenueOrderId("1"), [trade1, trade2]) await asyncio.sleep(0.01) # Act result = await self.engine.reconcile_state() self.engine.stop() # Assert assert result self.loop.run_until_complete(run_test())
class LiveExecutionPerformanceTests(unittest.TestCase): def setUp(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = TestLogger(self.clock, bypass_logging=True) self.trader_id = TraderId("TESTER", "000") self.account_id = AccountId("BINANCE", "001") self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) self.portfolio.register_cache(DataCache(self.logger)) self.analyzer = PerformanceAnalyzer() # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.exec_engine = LiveExecutionEngine( loop=self.loop, database=database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) exec_client = MockExecutionClient( venue=Venue("BINANCE"), account_id=self.account_id, exec_engine=self.exec_engine, clock=self.clock, logger=self.logger, ) self.exec_engine.register_client(exec_client) self.exec_engine.process(TestStubs.event_account_state(self.account_id)) self.strategy = TradingStrategy(order_id_tag="001") self.strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(self.strategy) def submit_order(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.symbol, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) def test_execute_command(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.symbol, OrderSide.BUY, Quantity("1.00000000"), ) command = SubmitOrder( order.symbol.venue, self.trader_id, self.account_id, self.strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.utc_now(), ) def execute_command(): self.exec_engine.execute(command) PerformanceHarness.profile_function(execute_command, 10000, 1) # ~0.0ms / ~0.3μs / 253ns minimum of 10,000 runs @ 1 iteration each run. def test_submit_order(self): self.exec_engine.start() time.sleep(0.1) async def run_test(): def submit_order(): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.symbol, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) PerformanceHarness.profile_function(submit_order, 10000, 1) self.loop.run_until_complete(run_test()) # ~0.0ms / ~24.5μs / 24455ns minimum of 10,000 runs @ 1 iteration each run. def test_submit_order_end_to_end(self): self.exec_engine.start() time.sleep(0.1) async def run_test(): for _ in range(10000): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.symbol, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) stats_file = "perf_live_execution.prof" cProfile.runctx("self.loop.run_until_complete(run_test())", globals(), locals(), stats_file) s = pstats.Stats(stats_file) s.strip_dirs().sort_stats("time").print_stats()
class TestLiveExecutionClient: def setup(self): # Fixture Setup # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = Logger(self.clock) self.trader_id = TraderId("TESTER", "000") self.account_id = TestStubs.account_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S", "001"), clock=self.clock, ) self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) self.portfolio.register_cache(DataCache(self.logger)) self.analyzer = PerformanceAnalyzer() # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.engine = LiveExecutionEngine( loop=self.loop, database=database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) self.client = MockLiveExecutionClient( name=SIM.value, account_id=self.account_id, engine=self.engine, instrument_provider=InstrumentProvider(), clock=self.clock, logger=self.logger, ) self.engine.register_client(self.client) def teardown(self): self.client.dispose() def test_reconcile_state_given_no_order_and_not_in_cache_returns_false( self): async def run_test(): # Arrange report = OrderStatusReport( client_order_id=ClientOrderId("O-123456"), venue_order_id=VenueOrderId("1"), order_state=OrderState.FILLED, filled_qty=Quantity(100000), timestamp_ns=0, ) # Act result = await self.client.reconcile_state( report, order=None) # <- order won't be in cache # Assert assert not result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_order_completed_returns_true_with_warning1( self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.stop_market( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue self.engine.process(TestStubs.event_order_cancelled(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.CANCELLED, filled_qty=Quantity(0), timestamp_ns=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_when_order_completed_returns_true_with_warning2( self): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue self.engine.process(TestStubs.event_order_filled( order, AUDUSD_SIM)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.FILLED, filled_qty=Quantity(100000), timestamp_ns=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert result self.loop.run_until_complete(run_test()) def test_reconcile_state_with_filled_order_when_trades_not_given_returns_false( self, ): async def run_test(): # Arrange self.engine.start() strategy = TradingStrategy(order_id_tag="001") strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.engine.register_strategy(strategy) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity(100000), Price("1.00000"), ) submit_order = SubmitOrder( AUDUSD_SIM.id, self.trader_id, self.account_id, strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.engine.execute(submit_order) self.engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_state=OrderState.FILLED, filled_qty=Quantity(100000), timestamp_ns=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert not result self.loop.run_until_complete(run_test())
class TradingNode: """ Provides an asynchronous network node for live trading. """ def __init__( self, strategies: List[TradingStrategy], config: Dict[str, object], ): """ Initialize a new instance of the TradingNode class. Parameters ---------- strategies : list[TradingStrategy] The list of strategies to run on the trading node. config : dict[str, object] The configuration for the trading node. Raises ------ ValueError If strategies is None or empty. ValueError If config is None or empty. """ PyCondition.not_none(strategies, "strategies") PyCondition.not_none(config, "config") PyCondition.not_empty(strategies, "strategies") PyCondition.not_empty(config, "config") self._config = config # Extract configs config_trader = config.get("trader", {}) config_system = config.get("system", {}) config_log = config.get("logging", {}) config_exec_db = config.get("exec_database", {}) config_risk = config.get("risk", {}) config_strategy = config.get("strategy", {}) # System config self._connection_timeout = config_system.get("connection_timeout", 5.0) self._disconnection_timeout = config_system.get( "disconnection_timeout", 5.0) self._check_residuals_delay = config_system.get( "check_residuals_delay", 5.0) self._load_strategy_state = config_strategy.get("load_state", True) self._save_strategy_state = config_strategy.get("save_state", True) # Setup loop self._loop = asyncio.get_event_loop() self._executor = concurrent.futures.ThreadPoolExecutor() self._loop.set_default_executor(self._executor) self._loop.set_debug(config_system.get("loop_debug", False)) # Components self._clock = LiveClock(loop=self._loop) self._uuid_factory = UUIDFactory() self.system_id = self._uuid_factory.generate() self.created_time = self._clock.utc_now() self._is_running = False # Setup identifiers self.trader_id = TraderId( name=config_trader["name"], tag=config_trader["id_tag"], ) # Setup logging level_stdout = LogLevelParser.from_str_py( config_log.get("level_stdout")) self._logger = LiveLogger( loop=self._loop, clock=self._clock, trader_id=self.trader_id, system_id=self.system_id, level_stdout=level_stdout, ) self._log = LoggerAdapter( component=self.__class__.__name__, logger=self._logger, ) self._log_header() self._log.info("Building...") if platform.system() != "Windows": # Requires the logger to be initialized # Windows does not support signal handling # https://stackoverflow.com/questions/45987985/asyncio-loops-add-signal-handler-in-windows self._setup_loop() # Build platform # ---------------------------------------------------------------------- self.portfolio = Portfolio( clock=self._clock, logger=self._logger, ) self._data_engine = LiveDataEngine( loop=self._loop, portfolio=self.portfolio, clock=self._clock, logger=self._logger, config={"qsize": 10000}, ) self.portfolio.register_cache(self._data_engine.cache) self.analyzer = PerformanceAnalyzer() if config_exec_db["type"] == "redis": exec_db = RedisExecutionDatabase( trader_id=self.trader_id, logger=self._logger, command_serializer=MsgPackCommandSerializer(), event_serializer=MsgPackEventSerializer(), config={ "host": config_exec_db["host"], "port": config_exec_db["port"], }, ) else: exec_db = BypassExecutionDatabase( trader_id=self.trader_id, logger=self._logger, ) self._exec_engine = LiveExecutionEngine( loop=self._loop, database=exec_db, portfolio=self.portfolio, clock=self._clock, logger=self._logger, config={"qsize": 10000}, ) self._risk_engine = LiveRiskEngine( loop=self._loop, exec_engine=self._exec_engine, portfolio=self.portfolio, clock=self._clock, logger=self._logger, config=config_risk, ) self._exec_engine.load_cache() self._exec_engine.register_risk_engine(self._risk_engine) self.trader = Trader( trader_id=self.trader_id, strategies=strategies, portfolio=self.portfolio, data_engine=self._data_engine, exec_engine=self._exec_engine, risk_engine=self._risk_engine, clock=self._clock, logger=self._logger, ) if self._load_strategy_state: self.trader.load() self._builder = TradingNodeBuilder( data_engine=self._data_engine, exec_engine=self._exec_engine, risk_engine=self._risk_engine, clock=self._clock, logger=self._logger, log=self._log, ) self._log.info("state=INITIALIZED.") self.time_to_initialize = self._clock.delta(self.created_time) self._log.info( f"Initialized in {self.time_to_initialize.total_seconds():.3f}s.") self._is_built = False @property def is_running(self) -> bool: """ If the trading node is running. Returns ------- bool True if running, else False. """ return self._is_running @property def is_built(self) -> bool: """ If the trading node clients are built. Returns ------- bool True if built, else False. """ return self._is_built def get_event_loop(self) -> asyncio.AbstractEventLoop: """ Return the event loop of the trading node. Returns ------- asyncio.AbstractEventLoop """ return self._loop def get_logger(self) -> LiveLogger: """ Return the logger for the trading node. Returns ------- LiveLogger """ return self._logger def add_data_client_factory(self, name, factory): """ Add the given data client factory to the node. Parameters ---------- name : str The name of the client factory. factory : LiveDataClientFactory or LiveExecutionClientFactory The factory to add. Raises ------ ValueError If name is not a valid string. KeyError If name has already been added. """ self._builder.add_data_client_factory(name, factory) def add_exec_client_factory(self, name, factory): """ Add the given execution client factory to the node. Parameters ---------- name : str The name of the client factory. factory : LiveDataClientFactory or LiveExecutionClientFactory The factory to add. Raises ------ ValueError If name is not a valid string. KeyError If name has already been added. """ self._builder.add_exec_client_factory(name, factory) def build(self) -> None: """ Build the nodes clients. """ if self._is_built: raise RuntimeError("The trading nodes clients are already built.") self._builder.build_data_clients(self._config.get("data_clients")) self._builder.build_exec_clients(self._config.get("exec_clients")) self._is_built = True def start(self) -> None: """ Start the trading node. """ if not self._is_built: raise RuntimeError( "The trading nodes clients have not been built. " "Please run `node.build()` prior to start.") try: if self._loop.is_running(): self._loop.create_task(self._run()) else: self._loop.run_until_complete(self._run()) except RuntimeError as ex: self._log.exception(ex) def stop(self) -> None: """ Stop the trading node gracefully. After a specified delay the internal `Trader` residuals will be checked. If save strategy is specified then strategy states will then be saved. """ try: if self._loop.is_running(): self._loop.create_task(self._stop()) else: self._loop.run_until_complete(self._stop()) except RuntimeError as ex: self._log.exception(ex) def dispose(self) -> None: """ Dispose of the trading node. Gracefully shuts down the executor and event loop. """ try: timeout = self._clock.utc_now() + timedelta(seconds=5) while self._is_running: time.sleep(0.1) if self._clock.utc_now() >= timeout: self._log.warning( "Timed out (5s) waiting for node to stop.") break self._log.info("state=DISPOSING...") self._log.debug(f"{self._data_engine.get_run_queue_task()}") self._log.debug(f"{self._exec_engine.get_run_queue_task()}") self._log.debug(f"{self._risk_engine.get_run_queue_task()}") self.trader.dispose() self._data_engine.dispose() self._exec_engine.dispose() self._risk_engine.dispose() self._log.info("Shutting down executor...") if sys.version_info >= (3, 9): # cancel_futures added in Python 3.9 self._executor.shutdown(wait=True, cancel_futures=True) else: self._executor.shutdown(wait=True) self._log.info("Stopping event loop...") self._cancel_all_tasks() self._logger.stop() self._loop.stop() except RuntimeError as ex: self._log.exception(ex) finally: if self._loop.is_running(): self._log.warning("Cannot close a running event loop.") else: self._log.info("Closing event loop...") self._loop.close() # Check and log if event loop is running if self._loop.is_running(): self._log.warning(f"loop.is_running={self._loop.is_running()}") else: self._log.info(f"loop.is_running={self._loop.is_running()}") # Check and log if event loop is closed if not self._loop.is_closed(): self._log.warning(f"loop.is_closed={self._loop.is_closed()}") else: self._log.info(f"loop.is_closed={self._loop.is_closed()}") self._log.info("state=DISPOSED.") def _log_header(self) -> None: nautilus_header(self._log) self._log.info(f"redis {redis.__version__}") self._log.info( f"msgpack {msgpack.version[0]}.{msgpack.version[1]}.{msgpack.version[2]}" ) if uvloop_version: self._log.info(f"uvloop {uvloop_version}") self._log.info( "=================================================================" ) def _setup_loop(self) -> None: if self._loop.is_closed(): self._log.error( "Cannot setup signal handling (event loop was closed).") return signal.signal(signal.SIGINT, signal.SIG_DFL) signals = (signal.SIGTERM, signal.SIGINT, signal.SIGABRT) for sig in signals: self._loop.add_signal_handler(sig, self._loop_sig_handler, sig) self._log.debug(f"Event loop {signals} handling setup.") def _loop_sig_handler(self, sig: signal.signal) -> None: self._loop.remove_signal_handler(signal.SIGTERM) self._loop.add_signal_handler(signal.SIGINT, lambda: None) self._log.warning(f"Received {sig!s}, shutting down...") self.stop() async def _run(self) -> None: try: self._log.info("state=STARTING...") self._is_running = True self._logger.start() self._data_engine.start() self._exec_engine.start() self._risk_engine.start() result: bool = await self._await_engines_connected() if not result: return result: bool = await self._exec_engine.reconcile_state() if not result: return self.trader.start() if self._loop.is_running(): self._log.info("state=RUNNING.") else: self._log.warning("Event loop is not running.") # Continue to run while engines are running... await self._data_engine.get_run_queue_task() await self._exec_engine.get_run_queue_task() await self._risk_engine.get_run_queue_task() except asyncio.CancelledError as ex: self._log.error(str(ex)) async def _await_engines_connected(self) -> bool: self._log.info(f"Waiting for engines to initialize " f"({self._connection_timeout}s timeout)...") # The data engine clients will be set as connected when all # instruments are received and updated with the data engine. # The execution engine clients will be set as connected when all # accounts are updated and the current order and position status is # reconciled. Thus any delay here will be due to blocking network IO. seconds = self._connection_timeout timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0) if self._clock.utc_now() >= timeout: self._log.error( f"Timed out ({seconds}s) waiting for engines to connect.") return False if not self._data_engine.check_connected(): continue if not self._exec_engine.check_connected(): continue break return True # Engines connected async def _stop(self) -> None: self._is_stopping = True self._log.info("state=STOPPING...") if self.trader.state == ComponentState.RUNNING: self.trader.stop() self._log.info( f"Awaiting residual state ({self._check_residuals_delay}s delay)..." ) await asyncio.sleep(self._check_residuals_delay) self.trader.check_residuals() if self._save_strategy_state: self.trader.save() if self._data_engine.state == ComponentState.RUNNING: self._data_engine.stop() if self._exec_engine.state == ComponentState.RUNNING: self._exec_engine.stop() if self._risk_engine.state == ComponentState.RUNNING: self._risk_engine.stop() await self._await_engines_disconnected() # Clean up remaining timers timer_names = self._clock.timer_names() self._clock.cancel_timers() for name in timer_names: self._log.info(f"Cancelled Timer(name={name}).") self._log.info("state=STOPPED.") self._is_running = False async def _await_engines_disconnected(self) -> None: self._log.info(f"Waiting for engines to disconnect " f"({self._disconnection_timeout}s timeout)...") seconds = self._disconnection_timeout timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0) if self._clock.utc_now() >= timeout: self._log.error( f"Timed out ({seconds}s) waiting for engines to disconnect." ) break if not self._data_engine.check_disconnected(): continue if not self._exec_engine.check_disconnected(): continue break def _cancel_all_tasks(self) -> None: to_cancel = asyncio.tasks.all_tasks(self._loop) if not to_cancel: self._log.info("All tasks finished.") return for task in to_cancel: self._log.warning(f"Cancelling pending task {task}") task.cancel() if self._loop.is_running(): self._log.warning( "Event loop still running during `cancel_all_tasks`.") return finish_all_tasks: asyncio.Future = asyncio.tasks.gather( *to_cancel, loop=self._loop, return_exceptions=True, ) self._loop.run_until_complete(finish_all_tasks) self._log.debug(f"{finish_all_tasks}") for task in to_cancel: if task.cancelled(): continue if task.exception() is not None: self._loop.call_exception_handler({ "message": "unhandled exception during asyncio.run() shutdown", "exception": task.exception(), "task": task, })
class TestBetfairExecutionClient: def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.loop.set_debug(True) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.trader_id = TestStubs.trader_id() self.venue = BETFAIR_VENUE self.account_id = AccountId(self.venue.value, "001") # Setup logging self.logger = LiveLogger(loop=self.loop, clock=self.clock, level_stdout=LogLevel.DEBUG) self._log = LoggerAdapter("TestBetfairExecutionClient", self.logger) self.msgbus = MessageBus( trader_id=self.trader_id, clock=self.clock, logger=self.logger, ) self.cache = TestStubs.cache() self.cache.add_instrument(BetfairTestStubs.betting_instrument()) self.cache.add_account( TestStubs.betting_account(account_id=self.account_id)) self.portfolio = Portfolio( msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.betfair_client: BetfairClient = BetfairTestStubs.betfair_client( loop=self.loop, logger=self.logger) assert self.betfair_client.session_token self.instrument_provider = BetfairTestStubs.instrument_provider( betfair_client=self.betfair_client) self.client = BetfairExecutionClient( loop=asyncio.get_event_loop(), client=self.betfair_client, account_id=self.account_id, base_currency=GBP, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, instrument_provider=self.instrument_provider, market_filter={}, ) self.exec_engine.register_client(self.client) # Re-route exec engine messages through `handler` self.messages = [] def handler(func): def inner(x): self.messages.append(x) return func(x) return inner def listener(x): print(x) self.msgbus.subscribe("*", listener) self.msgbus.deregister(endpoint="ExecEngine.execute", handler=self.exec_engine.execute) self.msgbus.register(endpoint="ExecEngine.execute", handler=handler(self.exec_engine.execute)) self.msgbus.deregister(endpoint="ExecEngine.process", handler=self.exec_engine.process) self.msgbus.register(endpoint="ExecEngine.process", handler=handler(self.exec_engine.process)) self.msgbus.deregister(endpoint="Portfolio.update_account", handler=self.portfolio.update_account) self.msgbus.register(endpoint="Portfolio.update_account", handler=handler(self.portfolio.update_account)) def _prefill_venue_order_id_to_client_order_id(self, update): order_ids = [ update["id"] for market in update.get("oc", []) for order in market.get("orc", []) for update in order.get("uo", []) ] return { VenueOrderId(oid): ClientOrderId(str(i + 1)) for i, oid in enumerate(order_ids) } async def _setup_account(self): await self.client.connection_account_state() def _setup_exec_client_and_cache(self, update): """ Called before processing a test streaming update - ensure all orders are in the cache in `update`. """ venue_order_ids = self._prefill_venue_order_id_to_client_order_id( update) venue_order_id_to_client_order_id = {} for c_id, v_id in enumerate(venue_order_ids): client_order_id = ClientOrderId(str(c_id)) venue_order_id = VenueOrderId(str(v_id)) self._log.debug( f"Adding client_order_id=[{c_id}], venue_order_id=[{v_id}] ") order = BetfairTestStubs.make_accepted_order( venue_order_id=venue_order_id, client_order_id=client_order_id) self._log.debug(f"created order: {order}") venue_order_id_to_client_order_id[v_id] = order.client_order_id cache_order = self.cache.order( client_order_id=order.client_order_id) self._log.debug(f"Cached order: {order}") if cache_order is None: self._log.debug("Adding order to cache") self.cache.add_order(order, position_id=PositionId(v_id.value)) assert self.cache.order( client_order_id).venue_order_id == venue_order_id self.cache.update_order(order) self.client.venue_order_id_to_client_order_id = venue_order_id_to_client_order_id async def _account_state(self): account_details = await self.betfair_client.get_account_details() account_funds = await self.betfair_client.get_account_funds() timestamp = self.clock.timestamp_ns() account_state = betfair_account_to_account_state( account_detail=account_details, account_funds=account_funds, event_id=self.uuid_factory.generate(), ts_event=timestamp, ts_init=timestamp, ) return account_state @pytest.mark.asyncio async def test_submit_order_success(self): # Arrange command = BetfairTestStubs.submit_order_command() mock_betfair_request(self.betfair_client, BetfairResponses.betting_place_order_success()) # Act self.client.submit_order(command) await asyncio.sleep(0) # Assert submitted, accepted = self.messages assert isinstance(submitted, OrderSubmitted) assert isinstance(accepted, OrderAccepted) assert accepted.venue_order_id == VenueOrderId("228302937743") @pytest.mark.asyncio async def test_submit_order_error(self): # Arrange command = BetfairTestStubs.submit_order_command() mock_betfair_request(self.betfair_client, BetfairResponses.betting_place_order_error()) # Act self.client.submit_order(command) await asyncio.sleep(0) # Assert submitted, rejected = self.messages assert isinstance(submitted, OrderSubmitted) assert isinstance(rejected, OrderRejected) assert rejected.reason == "PERMISSION_DENIED: ERROR_IN_ORDER" @pytest.mark.asyncio async def test_modify_order_success(self): # Arrange venue_order_id = VenueOrderId("240808576108") order = BetfairTestStubs.make_accepted_order( venue_order_id=venue_order_id) command = BetfairTestStubs.modify_order_command( instrument_id=order.instrument_id, client_order_id=order.client_order_id, venue_order_id=venue_order_id, ) mock_betfair_request(self.betfair_client, BetfairResponses.betting_replace_orders_success()) # Act self.cache.add_order(order, PositionId("1")) self.client.modify_order(command) await asyncio.sleep(0) # Assert pending_update, updated = self.messages assert isinstance(pending_update, OrderPendingUpdate) assert isinstance(updated, OrderUpdated) assert updated.price == Price.from_str("0.02000") @pytest.mark.asyncio async def test_modify_order_error_order_doesnt_exist(self): # Arrange venue_order_id = VenueOrderId("229435133092") order = BetfairTestStubs.make_accepted_order( venue_order_id=venue_order_id) command = BetfairTestStubs.modify_order_command( instrument_id=order.instrument_id, client_order_id=order.client_order_id, venue_order_id=venue_order_id, ) mock_betfair_request(self.betfair_client, BetfairResponses.betting_replace_orders_success()) # Act self.client.modify_order(command) await asyncio.sleep(0) # Assert pending_update, rejected = self.messages assert isinstance(pending_update, OrderPendingUpdate) assert isinstance(rejected, OrderModifyRejected) assert rejected.reason == "ORDER NOT IN CACHE" @pytest.mark.asyncio async def test_modify_order_error_no_venue_id(self): # Arrange order = BetfairTestStubs.make_submitted_order() self.cache.add_order(order, position_id=BetfairTestStubs.position_id()) command = BetfairTestStubs.modify_order_command( instrument_id=order.instrument_id, client_order_id=order.client_order_id, venue_order_id="", ) mock_betfair_request(self.betfair_client, BetfairResponses.betting_replace_orders_success()) # Act self.client.modify_order(command) await asyncio.sleep(0) # Assert pending_update, rejected = self.messages assert isinstance(pending_update, OrderPendingUpdate) assert isinstance(rejected, OrderModifyRejected) assert rejected.reason == "ORDER MISSING VENUE_ORDER_ID" @pytest.mark.asyncio async def test_cancel_order_success(self): # Arrange order = BetfairTestStubs.make_submitted_order() self.cache.add_order(order, position_id=BetfairTestStubs.position_id()) command = BetfairTestStubs.cancel_order_command( instrument_id=order.instrument_id, client_order_id=order.client_order_id, venue_order_id=VenueOrderId("240564968665"), ) mock_betfair_request(self.betfair_client, BetfairResponses.betting_cancel_orders_success()) # Act self.client.cancel_order(command) await asyncio.sleep(0) # Assert pending_cancel, cancelled = self.messages assert isinstance(pending_cancel, OrderPendingCancel) assert isinstance(cancelled, OrderCanceled) @pytest.mark.asyncio async def test_cancel_order_fail(self): # Arrange order = BetfairTestStubs.make_submitted_order() self.cache.add_order(order, position_id=BetfairTestStubs.position_id()) command = BetfairTestStubs.cancel_order_command( instrument_id=order.instrument_id, client_order_id=order.client_order_id, venue_order_id=VenueOrderId("228302937743"), ) mock_betfair_request(self.betfair_client, BetfairResponses.betting_cancel_orders_error()) # Act self.client.cancel_order(command) await asyncio.sleep(0) # Assert pending_cancel, cancelled = self.messages assert isinstance(pending_cancel, OrderPendingCancel) assert isinstance(cancelled, OrderCancelRejected) @pytest.mark.asyncio async def test_order_multiple_fills(self): # Arrange self.exec_engine.start() client_order_id = ClientOrderId("1") venue_order_id = VenueOrderId("246938411724") submitted = BetfairTestStubs.make_submitted_order( client_order_id=client_order_id, quantity=Quantity.from_int(20)) self.cache.add_order(submitted, position_id=BetfairTestStubs.position_id()) self.client.venue_order_id_to_client_order_id[ venue_order_id] = client_order_id # Act for update in BetfairStreaming.ocm_multiple_fills(): await self.client._handle_order_stream_update(update) await asyncio.sleep(0.1) # Assert result = [fill.last_qty for fill in self.messages] expected = [ Quantity.from_str("16.1900"), Quantity.from_str("0.77"), Quantity.from_str("0.77"), ] assert result == expected @pytest.mark.asyncio async def test_connection_account_state(self): # Arrange, Act, Assert await self.client.connection_account_state() # Assert assert self.cache.account(self.account_id) @pytest.mark.asyncio async def test_check_account_currency(self): # Arrange, Act, Assert await self.client.check_account_currency() @pytest.mark.asyncio async def test_order_stream_full_image(self): # Arrange update = BetfairStreaming.ocm_FULL_IMAGE() await self._setup_account() self._setup_exec_client_and_cache(update=update) # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 7 @pytest.mark.asyncio async def test_order_stream_empty_image(self): # Arrange update = BetfairStreaming.ocm_EMPTY_IMAGE() await self._setup_account() self._setup_exec_client_and_cache(update=update) # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 1 @pytest.mark.asyncio async def test_order_stream_new_full_image(self): update = BetfairStreaming.ocm_NEW_FULL_IMAGE() await self._setup_account() self._setup_exec_client_and_cache(update=update) await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) assert len(self.messages) == 4 @pytest.mark.asyncio async def test_order_stream_sub_image(self): # Arrange update = BetfairStreaming.ocm_SUB_IMAGE() await self._setup_account() self._setup_exec_client_and_cache(update=update) # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 1 @pytest.mark.asyncio async def test_order_stream_update(self): # Arrange update = BetfairStreaming.ocm_UPDATE() await self._setup_account() self._setup_exec_client_and_cache(update=update) # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 2 @pytest.mark.asyncio async def test_order_stream_filled(self): # Arrange update = BetfairStreaming.ocm_FILLED() self._setup_exec_client_and_cache(update) await self._setup_account() # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 2 assert isinstance(self.messages[1], OrderFilled) assert self.messages[1].last_px == Price.from_str("0.9090909") @pytest.mark.asyncio async def test_order_stream_filled_multiple_prices(self): # Arrange await self._setup_account() update1 = BetfairStreaming.generate_order_update( price="1.50", size=20, side="B", status="E", sm=10, avp="1.60", ) self._setup_exec_client_and_cache(update1) await self.client._handle_order_stream_update(update=update1) await asyncio.sleep(0) order = self.cache.order(client_order_id=ClientOrderId("0")) event = self.messages[-1] order.apply(event) # Act update2 = BetfairStreaming.generate_order_update( price="1.50", size=20, side="B", status="EC", sm=20, avp="1.55", ) self._setup_exec_client_and_cache(update2) await self.client._handle_order_stream_update(update=update2) await asyncio.sleep(0) # Assert assert len(self.messages) == 3 assert isinstance(self.messages[1], OrderFilled) assert isinstance(self.messages[2], OrderFilled) assert self.messages[1].last_px == price_to_probability("1.60") assert self.messages[2].last_px == price_to_probability("1.50") @pytest.mark.asyncio async def test_order_stream_mixed(self): # Arrange update = BetfairStreaming.ocm_MIXED() self._setup_exec_client_and_cache(update) await self._setup_account() # Act await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert _, fill1, fill2, cancel = self.messages assert isinstance( fill1, OrderFilled) and fill1.venue_order_id.value == "229430281341" assert isinstance( fill2, OrderFilled) and fill2.venue_order_id.value == "229430281339" assert isinstance( cancel, OrderCanceled) and cancel.venue_order_id.value == "229430281339" @pytest.mark.asyncio @pytest.mark.skip(reason="Not implemented") async def test_generate_order_status_report(self): # Betfair client login orders = await self.betfair_client.list_current_orders() for order in orders: result = await self.client.generate_order_status_report(order=order ) assert result raise NotImplementedError() @pytest.mark.asyncio @pytest.mark.skip async def test_generate_trades_list(self): patch( "betfairlightweight.endpoints.betting.Betting.list_cleared_orders", return_value=BetfairDataProvider.list_cleared_orders( order_id="226125004209"), ) patch.object( self.client, "venue_order_id_to_client_order_id", {"226125004209": ClientOrderId("1")}, ) result = await generate_trades_list(self=self.client, venue_order_id="226125004209", symbol=None, since=None) assert result @pytest.mark.asyncio async def test_duplicate_execution_id(self): # Arrange await self._setup_account() for update in BetfairStreaming.ocm_DUPLICATE_EXECUTION(): self._setup_exec_client_and_cache(update) # # Load submitted orders # for client_order_id in (ClientOrderId('0'), ClientOrderId('1')): # order = BetfairTestStubs.make_order( # price=Price.from_str("0.5"), quantity=Quantity.from_int(10), client_order_id=client_order_id # ) # command = BetfairTestStubs.submit_order_command(order=order) # self.client.submit_order(command) # await asyncio.sleep(0) # Act for update in BetfairStreaming.ocm_DUPLICATE_EXECUTION(): self._setup_exec_client_and_cache(update=update) await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert _, fill1, cancel, fill2, fill3 = self.messages # First order example, partial fill followed by remainder canceled assert isinstance(fill1, OrderFilled) assert isinstance(cancel, OrderCanceled) # Second order example, partial fill followed by remainder filled assert (isinstance(fill2, OrderFilled) and fill2.execution_id.value == "4721ad7594e7a4a4dffb1bacb0cb45ccdec0747a") assert (isinstance(fill3, OrderFilled) and fill3.execution_id.value == "8b3e65be779968a3fdf2d72731c848c5153e88cd") @pytest.mark.asyncio async def test_betfair_order_reduces_balance(self): # Arrange self.client.stream = MagicMock() self.exec_engine.start() await asyncio.sleep(1) balance = self.cache.account_for_venue(self.venue).balances()[GBP] order = BetfairTestStubs.make_order(price=Price.from_str("0.5"), quantity=Quantity.from_int(10)) self.cache.add_order(order=order, position_id=None) mock_betfair_request(self.betfair_client, BetfairResponses.betting_place_order_success()) command = BetfairTestStubs.submit_order_command(order=order) self.client.submit_order(command) await asyncio.sleep(0.01) # Act balance_order = self.cache.account_for_venue( BETFAIR_VENUE).balances()[GBP] # Cancel the order, balance should return command = BetfairTestStubs.cancel_order_command( client_order_id=order.client_order_id, venue_order_id=order.venue_order_id) mock_betfair_request(self.betfair_client, BetfairResponses.betting_cancel_orders_success()) self.client.cancel_order(command) await asyncio.sleep(0.1) balance_cancel = self.cache.account_for_venue( BETFAIR_VENUE).balances()[GBP] # Assert assert balance.free == Money(1000.0, GBP) assert balance_order.free == Money(990.0, GBP) assert balance_cancel.free == Money(1000.0, GBP) self.exec_engine.kill() await asyncio.sleep(1) @pytest.mark.asyncio async def test_betfair_order_cancelled_no_timestamp(self): update = BetfairStreaming.ocm_error_fill() self._setup_exec_client_and_cache(update) for upd in update["oc"][0]["orc"][0]["uo"]: self.client._handle_stream_execution_complete_order_update( update=upd) await asyncio.sleep(1) @pytest.mark.asyncio @pytest.mark.parametrize( "price,size,side,status,updates", [ ("1.50", "50", "B", "EC", [{ "sm": 50 }]), ("1.50", "50", "B", "E", [{ "sm": 10 }, { "sm": 15 }]), ], ) async def test_various_betfair_order_fill_scenarios( self, price, size, side, status, updates): # Arrange update = BetfairStreaming.ocm_filled_different_price() self._setup_exec_client_and_cache(update) await self._setup_account() # Act for raw in updates: update = BetfairStreaming.generate_order_update(price=price, size=size, side=side, status=status, **raw) await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) # Assert assert len(self.messages) == 1 + len(updates) for msg, raw in zip(self.messages[1:], updates): assert isinstance(msg, OrderFilled) assert msg.last_qty == raw["sm"] @pytest.mark.asyncio async def test_order_filled_avp_update(self): # Arrange update = BetfairStreaming.ocm_filled_different_price() self._setup_exec_client_and_cache(update) await self._setup_account() # Act update = BetfairStreaming.generate_order_update(price="1.50", size=20, side="B", status="E", avp="1.50", sm=10) await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0) update = BetfairStreaming.generate_order_update(price="1.30", size=20, side="B", status="E", avp="1.50", sm=10) await self.client._handle_order_stream_update(update=update) await asyncio.sleep(0)
class TestLiveExecutionPerformance(PerformanceHarness): def setup(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.trader_id = TraderId("TESTER", "000") self.logger = Logger(self.clock, bypass_logging=True) self.account_id = AccountId("BINANCE", "001") self.portfolio = Portfolio( clock=self.clock, logger=self.logger, ) self.portfolio.register_cache(DataCache(self.logger)) self.analyzer = PerformanceAnalyzer() # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) database = BypassExecutionDatabase(trader_id=self.trader_id, logger=self.logger) self.exec_engine = LiveExecutionEngine( loop=self.loop, database=database, portfolio=self.portfolio, clock=self.clock, logger=self.logger, ) exec_client = MockExecutionClient( client_id=ClientId("BINANCE"), account_id=self.account_id, engine=self.exec_engine, clock=self.clock, logger=self.logger, ) self.exec_engine.register_client(exec_client) self.exec_engine.process(TestStubs.event_account_state(self.account_id)) self.strategy = TradingStrategy(order_id_tag="001") self.strategy.register_trader( TraderId("TESTER", "000"), self.clock, self.logger, ) self.exec_engine.register_strategy(self.strategy) @pytest.fixture(autouse=True) @pytest.mark.benchmark(disable_gc=True, warmup=True) def setup_benchmark(self, benchmark): self.benchmark = benchmark def submit_order(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) def test_execute_command(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity("1.00000000"), ) command = SubmitOrder( order.instrument_id.venue.client_id, self.trader_id, self.account_id, self.strategy.id, PositionId.null(), order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) def execute_command(): self.exec_engine.execute(command) self.benchmark.pedantic(execute_command, iterations=10_000, rounds=1) # ~0.0ms / ~0.2μs / 218ns minimum of 10,000 runs @ 1 iteration each run. def test_submit_order(self): self.exec_engine.start() time.sleep(0.1) async def run_test(): def submit_order(): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) self.benchmark.pedantic(submit_order, iterations=10_000, rounds=1) self.loop.run_until_complete(run_test()) # ~0.0ms / ~25.3μs / 25326ns minimum of 10,000 runs @ 1 iteration each run. def test_submit_order_end_to_end(self): self.exec_engine.start() time.sleep(0.1) async def run_test(): for _ in range(10000): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity("1.00000000"), ) self.strategy.submit_order(order) stats_file = "perf_live_execution.prof" cProfile.runctx( "self.loop.run_until_complete(run_test())", globals(), locals(), stats_file ) s = pstats.Stats(stats_file) s.strip_dirs().sort_stats("time").print_stats()
class TestLiveExecutionPerformance(PerformanceHarness): def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.loop.set_debug(True) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = Logger(self.clock, bypass=True) self.trader_id = TestIdStubs.trader_id() self.account_id = AccountId(BINANCE.value, "001") self.msgbus = MessageBus( trader_id=self.trader_id, clock=self.clock, logger=self.logger, ) self.cache = TestComponentStubs.cache() self.portfolio = Portfolio( msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.data_engine = LiveDataEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.risk_engine = LiveRiskEngine( loop=self.loop, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_client = MockExecutionClient( client_id=ClientId("BINANCE"), venue=BINANCE, account_type=AccountType.CASH, base_currency=None, # Multi-currency account msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.portfolio.update_account(TestEventStubs.margin_account_state()) self.exec_engine.register_client(self.exec_client) self.strategy = TradingStrategy() self.strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) @pytest.fixture(autouse=True) @pytest.mark.benchmark(disable_gc=True, warmup=True) def setup_benchmark(self, benchmark): self.benchmark = benchmark def submit_order(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity.from_str("1.00000000"), ) self.strategy.submit_order(order) @pytest.mark.skip(reason="For development only, event loop issue") def test_execute_command(self): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity.from_str("1.00000000"), ) command = SubmitOrder( None, self.trader_id, self.strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) def execute_command(): self.exec_engine.execute(command) self.benchmark.pedantic(execute_command, iterations=100, rounds=100, warmup_rounds=5) # ~0.0ms / ~0.2μs / 218ns minimum of 10,000 runs @ 1 iteration each run. @pytest.mark.asyncio async def test_submit_order(self): self.exec_engine.start() await asyncio.sleep(1) def submit_order(): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity.from_str("1.00000000"), ) self.strategy.submit_order(order) self.benchmark.pedantic(submit_order, iterations=100, rounds=100, warmup_rounds=5) # ~0.0ms / ~25.3μs / 25326ns minimum of 10,000 runs @ 1 iteration each run. @pytest.mark.asyncio async def test_submit_order_end_to_end(self): self.exec_engine.start() await asyncio.sleep(1) def run(): for _ in range(1000): order = self.strategy.order_factory.market( BTCUSDT_BINANCE.id, OrderSide.BUY, Quantity.from_str("1.00000000"), ) self.strategy.submit_order(order) self.benchmark.pedantic(run, rounds=10, warmup_rounds=5)
class TradingNode: """ Provides an asynchronous network node for live trading. Parameters ---------- config : TradingNodeConfig, optional The configuration for the instance. Raises ------ TypeError If `config` is not of type `TradingNodeConfig`. """ def __init__(self, config: Optional[TradingNodeConfig] = None): if config is None: config = TradingNodeConfig() PyCondition.not_none(config, "config") PyCondition.type(config, TradingNodeConfig, "config") # Configuration self._config = config # Setup loop self._loop = asyncio.get_event_loop() self._executor = concurrent.futures.ThreadPoolExecutor() self._loop.set_default_executor(self._executor) self._loop.set_debug(config.loop_debug) # Components self._clock = LiveClock(loop=self._loop) self._uuid_factory = UUIDFactory() self.created_time = self._clock.utc_now() self._is_running = False # Identifiers self.trader_id = TraderId(config.trader_id) self.machine_id = socket.gethostname() self.instance_id = self._uuid_factory.generate() # Setup logging self._logger = LiveLogger( loop=self._loop, clock=self._clock, trader_id=self.trader_id, machine_id=self.machine_id, instance_id=self.instance_id, level_stdout=LogLevelParser.from_str_py(config.log_level.upper()), ) self._log = LoggerAdapter( component_name=type(self).__name__, logger=self._logger, ) self._log_header() self._log.info("Building...") if platform.system() != "Windows": # Windows does not support signal handling # https://stackoverflow.com/questions/45987985/asyncio-loops-add-signal-handler-in-windows self._setup_loop() ######################################################################## # Build platform ######################################################################## if config.cache_database is None or config.cache_database.type == "in-memory": cache_db = None elif config.cache_database.type == "redis": cache_db = RedisCacheDatabase( trader_id=self.trader_id, logger=self._logger, serializer=MsgPackSerializer(timestamps_as_str=True), config=config.cache_database, ) else: # pragma: no cover (design-time error) raise ValueError( "The cache_db_type in the configuration is unrecognized, " "can one of {{'in-memory', 'redis'}}.", ) self._msgbus = MessageBus( trader_id=self.trader_id, clock=self._clock, logger=self._logger, ) self._cache = Cache( database=cache_db, logger=self._logger, config=config.cache, ) self.portfolio = Portfolio( msgbus=self._msgbus, cache=self._cache, clock=self._clock, logger=self._logger, ) self._data_engine = LiveDataEngine( loop=self._loop, msgbus=self._msgbus, cache=self._cache, clock=self._clock, logger=self._logger, config=config.data_engine, ) self._exec_engine = LiveExecutionEngine( loop=self._loop, msgbus=self._msgbus, cache=self._cache, clock=self._clock, logger=self._logger, config=config.exec_engine, ) self._exec_engine.load_cache() self._risk_engine = LiveRiskEngine( loop=self._loop, portfolio=self.portfolio, msgbus=self._msgbus, cache=self._cache, clock=self._clock, logger=self._logger, config=config.risk_engine, ) self.trader = Trader( trader_id=self.trader_id, msgbus=self._msgbus, cache=self._cache, portfolio=self.portfolio, data_engine=self._data_engine, risk_engine=self._risk_engine, exec_engine=self._exec_engine, clock=self._clock, logger=self._logger, ) if config.load_strategy_state: self.trader.load() # Setup persistence (requires trader) self.persistence_writers: List[Any] = [] if config.persistence: self._setup_persistence(config=config.persistence) self._builder = TradingNodeBuilder( loop=self._loop, data_engine=self._data_engine, exec_engine=self._exec_engine, msgbus=self._msgbus, cache=self._cache, clock=self._clock, logger=self._logger, log=self._log, ) self._log.info("INITIALIZED.") self.time_to_initialize = self._clock.delta(self.created_time) self._log.info(f"Initialized in {int(self.time_to_initialize.total_seconds() * 1000)}ms.") self._is_built = False @property def is_running(self) -> bool: """ If the trading node is running. Returns ------- bool """ return self._is_running @property def is_built(self) -> bool: """ If the trading node clients are built. Returns ------- bool """ return self._is_built def get_event_loop(self) -> asyncio.AbstractEventLoop: """ Return the event loop of the trading node. Returns ------- asyncio.AbstractEventLoop """ return self._loop def get_logger(self) -> LiveLogger: """ Return the logger for the trading node. Returns ------- LiveLogger """ return self._logger def add_log_sink(self, handler: Callable[[Dict], None]): """ Register the given sink handler with the nodes logger. Parameters ---------- handler : Callable[[Dict], None] The sink handler to register. Raises ------ KeyError If `handler` already registered. """ self._logger.register_sink(handler=handler) def add_data_client_factory(self, name: str, factory): """ Add the given data client factory to the node. Parameters ---------- name : str The name of the client factory. factory : LiveDataClientFactory or LiveExecutionClientFactory The factory to add. Raises ------ ValueError If `name` is not a valid string. KeyError If `name` has already been added. """ self._builder.add_data_client_factory(name, factory) def add_exec_client_factory(self, name: str, factory): """ Add the given execution client factory to the node. Parameters ---------- name : str The name of the client factory. factory : LiveDataClientFactory or LiveExecutionClientFactory The factory to add. Raises ------ ValueError If `name` is not a valid string. KeyError If `name` has already been added. """ self._builder.add_exec_client_factory(name, factory) def build(self) -> None: """ Build the nodes clients. """ if self._is_built: raise RuntimeError("the trading nodes clients are already built.") self._builder.build_data_clients(self._config.data_clients) self._builder.build_exec_clients(self._config.exec_clients) self._is_built = True def start(self) -> Optional[asyncio.Task]: """ Start the trading node. """ if not self._is_built: raise RuntimeError( "The trading nodes clients have not been built. " "Please run `node.build()` prior to start." ) try: if self._loop.is_running(): return self._loop.create_task(self._run()) else: self._loop.run_until_complete(self._run()) return None except RuntimeError as ex: self._log.exception("Error on run", ex) return None def stop(self) -> None: """ Stop the trading node gracefully. After a specified delay the internal `Trader` residuals will be checked. If save strategy is specified then strategy states will then be saved. """ try: if self._loop.is_running(): self._loop.create_task(self._stop()) else: self._loop.run_until_complete(self._stop()) except RuntimeError as ex: self._log.exception("Error on stop", ex) def dispose(self) -> None: """ Dispose of the trading node. Gracefully shuts down the executor and event loop. """ try: timeout = self._clock.utc_now() + timedelta(seconds=self._config.timeout_disconnection) while self._is_running: time.sleep(0.1) if self._clock.utc_now() >= timeout: self._log.warning( f"Timed out ({self._config.timeout_disconnection}s) waiting for node to stop." f"\nStatus" f"\n------" f"\nDataEngine.check_disconnected() == {self._data_engine.check_disconnected()}" f"\nExecEngine.check_disconnected() == {self._exec_engine.check_disconnected()}" ) break self._log.info("DISPOSING...") self._log.debug(f"{self._data_engine.get_run_queue_task()}") self._log.debug(f"{self._exec_engine.get_run_queue_task()}") self._log.debug(f"{self._risk_engine.get_run_queue_task()}") self.trader.dispose() self._data_engine.dispose() self._exec_engine.dispose() self._risk_engine.dispose() self._log.info("Shutting down executor...") if sys.version_info >= (3, 9): # cancel_futures added in Python 3.9 self._executor.shutdown(wait=True, cancel_futures=True) else: self._executor.shutdown(wait=True) self._log.info("Stopping event loop...") self._cancel_all_tasks() self._loop.stop() except RuntimeError as ex: self._log.exception("Error on dispose", ex) finally: if self._loop.is_running(): self._log.warning("Cannot close a running event loop.") else: self._log.info("Closing event loop...") self._loop.close() # Check and log if event loop is running if self._loop.is_running(): self._log.warning(f"loop.is_running={self._loop.is_running()}") else: self._log.info(f"loop.is_running={self._loop.is_running()}") # Check and log if event loop is closed if not self._loop.is_closed(): self._log.warning(f"loop.is_closed={self._loop.is_closed()}") else: self._log.info(f"loop.is_closed={self._loop.is_closed()}") self._log.info("DISPOSED.") def _log_header(self) -> None: nautilus_header(self._log) self._log.info(f"redis {redis.__version__}") # type: ignore self._log.info(f"msgpack {msgpack.version[0]}.{msgpack.version[1]}.{msgpack.version[2]}") if uvloop_version: self._log.info(f"uvloop {uvloop_version}") self._log.info("\033[36m=================================================================") def _setup_loop(self) -> None: if self._loop.is_closed(): self._log.error("Cannot setup signal handling (event loop was closed).") return signal.signal(signal.SIGINT, signal.SIG_DFL) signals = (signal.SIGTERM, signal.SIGINT, signal.SIGABRT) for sig in signals: self._loop.add_signal_handler(sig, self._loop_sig_handler, sig) self._log.debug(f"Event loop signal handling setup for {signals}.") def _setup_persistence(self, config: PersistenceConfig) -> None: # Setup persistence path = f"{config.catalog_path}/live/{self.instance_id}.feather" writer = FeatherWriter( path=path, fs_protocol=config.fs_protocol, flush_interval=config.flush_interval, ) self.persistence_writers.append(writer) self.trader.subscribe("*", writer.write) self._log.info(f"Persisting data & events to {path=}") # Setup logging if config.persist_logs: def sink(record, f): f.write(orjson.dumps(record) + b"\n") path = f"{config.catalog_path}/logs/{self.instance_id}.log" log_sink = open(path, "wb") self.persistence_writers.append(log_sink) self._logger.register_sink(partial(sink, f=log_sink)) self._log.info(f"Persisting logs to {path=}") def _loop_sig_handler(self, sig) -> None: self._loop.remove_signal_handler(signal.SIGTERM) self._loop.add_signal_handler(signal.SIGINT, lambda: None) self._log.warning(f"Received {sig!s}, shutting down...") self.stop() async def _run(self) -> None: try: self._log.info("STARTING...") self._is_running = True # Start system self._logger.start() self._data_engine.start() self._exec_engine.start() self._risk_engine.start() # Connect all clients self._data_engine.connect() self._exec_engine.connect() # Await engine connection and initialization self._log.info( f"Waiting for engines to connect and initialize " f"({self._config.timeout_connection}s timeout)...", color=LogColor.BLUE, ) if not await self._await_engines_connected(): self._log.warning( f"Timed out ({self._config.timeout_connection}s) waiting for engines to connect and initialize." f"\nStatus" f"\n------" f"\nDataEngine.check_connected() == {self._data_engine.check_connected()}" f"\nExecEngine.check_connected() == {self._exec_engine.check_connected()}" ) return self._log.info("Engines connected.", color=LogColor.GREEN) # Await execution state reconciliation self._log.info( f"Waiting for execution state to reconcile " f"({self._config.timeout_reconciliation}s timeout)...", color=LogColor.BLUE, ) if not await self._exec_engine.reconcile_state( timeout_secs=self._config.timeout_reconciliation, ): self._log.error("Execution state could not be reconciled.") return self._log.info("State reconciled.", color=LogColor.GREEN) # Initialize portfolio self.portfolio.initialize_orders() self.portfolio.initialize_positions() # Await portfolio initialization self._log.info( "Waiting for portfolio to initialize " f"({self._config.timeout_portfolio}s timeout)...", color=LogColor.BLUE, ) if not await self._await_portfolio_initialized(): self._log.warning( f"Timed out ({self._config.timeout_portfolio}s) waiting for portfolio to initialize." f"\nStatus" f"\n------" f"\nPortfolio.initialized == {self.portfolio.initialized}" ) return self._log.info("Portfolio initialized.", color=LogColor.GREEN) # Start trader and strategies self.trader.start() if self._loop.is_running(): self._log.info("RUNNING.") else: self._log.warning("Event loop is not running.") # Continue to run while engines are running... await self._data_engine.get_run_queue_task() await self._exec_engine.get_run_queue_task() await self._risk_engine.get_run_queue_task() except asyncio.CancelledError as ex: self._log.error(str(ex)) async def _await_engines_connected(self) -> bool: # - The data engine clients will be set connected when all # instruments are received and updated with the data engine. # - The execution engine clients will be set connected when all # accounts are updated and the current order and position status is # reconciled. # Thus any delay here will be due to blocking network I/O. seconds = self._config.timeout_connection timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0) if self._clock.utc_now() >= timeout: return False if not self._data_engine.check_connected(): continue if not self._exec_engine.check_connected(): continue break return True # Engines connected async def _await_portfolio_initialized(self) -> bool: # - The portfolio will be set initialized when all margin and unrealized # PnL calculations are completed (maybe waiting on first quotes). # Thus any delay here will be due to blocking network I/O. seconds = self._config.timeout_portfolio timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0) if self._clock.utc_now() >= timeout: return False if not self.portfolio.initialized: continue break return True # Portfolio initialized async def _stop(self) -> None: self._is_stopping = True self._log.info("STOPPING...") if self.trader.is_running: self.trader.stop() self._log.info( f"Awaiting residual state ({self._config.check_residuals_delay}s delay)...", color=LogColor.BLUE, ) await asyncio.sleep(self._config.check_residuals_delay) self.trader.check_residuals() if self._config.save_strategy_state: self.trader.save() # Disconnect all clients self._data_engine.disconnect() self._exec_engine.disconnect() if self._data_engine.is_running: self._data_engine.stop() if self._exec_engine.is_running: self._exec_engine.stop() if self._risk_engine.is_running: self._risk_engine.stop() self._log.info( f"Waiting for engines to disconnect " f"({self._config.timeout_disconnection}s timeout)...", color=LogColor.BLUE, ) if not await self._await_engines_disconnected(): self._log.error( f"Timed out ({self._config.timeout_disconnection}s) waiting for engines to disconnect." f"\nStatus" f"\n------" f"\nDataEngine.check_disconnected() == {self._data_engine.check_disconnected()}" f"\nExecEngine.check_disconnected() == {self._exec_engine.check_disconnected()}" ) # Clean up remaining timers timer_names = self._clock.timer_names() self._clock.cancel_timers() for name in timer_names: self._log.info(f"Cancelled Timer(name={name}).") # Clean up persistence for writer in self.persistence_writers: writer.close() self._log.info("STOPPED.") self._logger.stop() self._is_running = False async def _await_engines_disconnected(self) -> bool: seconds = self._config.timeout_disconnection timeout: timedelta = self._clock.utc_now() + timedelta(seconds=seconds) while True: await asyncio.sleep(0) if self._clock.utc_now() >= timeout: return False if not self._data_engine.check_disconnected(): continue if not self._exec_engine.check_disconnected(): continue break return True # Engines disconnected def _cancel_all_tasks(self) -> None: to_cancel = asyncio.tasks.all_tasks(self._loop) if not to_cancel: self._log.info("All tasks canceled.") return for task in to_cancel: self._log.warning(f"Canceling pending task {task}") task.cancel() if self._loop.is_running(): self._log.warning("Event loop still running during `cancel_all_tasks`.") return finish_all_tasks: asyncio.Future = asyncio.tasks.gather( # type: ignore *to_cancel, loop=self._loop, return_exceptions=True, ) self._loop.run_until_complete(finish_all_tasks) self._log.debug(f"{finish_all_tasks}") for task in to_cancel: # pragma: no cover if task.cancelled(): continue if task.exception() is not None: self._loop.call_exception_handler( { "message": "unhandled exception during asyncio.run() shutdown", "exception": task.exception(), "task": task, } )
class CCXTExecutionClientTests(unittest.TestCase): def setUp(self): # Fixture Setup self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.trader_id = TraderId("TESTER-001") self.account_id = AccountId(BINANCE.value, "001") # Fresh isolated loop testing pattern self.loop = asyncio.new_event_loop() asyncio.set_event_loop(self.loop) # Setup logging self.logger = LiveLogger( loop=self.loop, clock=self.clock, ) self.cache = TestStubs.cache() self.portfolio = Portfolio( cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, portfolio=self.portfolio, cache=self.cache, clock=self.clock, logger=self.logger, ) with open(TEST_PATH + "markets.json") as response: markets = json.load(response) with open(TEST_PATH + "currencies.json") as response: currencies = json.load(response) with open(TEST_PATH + "fetch_balance.json") as response: fetch_balance = json.load(response) with open(TEST_PATH + "watch_balance.json") as response: watch_balance = json.load(response) self.mock_ccxt = MagicMock() self.mock_ccxt.name = "Binance" self.mock_ccxt.precisionMode = 2 self.mock_ccxt.has = { "fetchBalance": True, "watchBalance": True, "watchMyTrades": True, } self.mock_ccxt.markets = markets self.mock_ccxt.currencies = currencies self.mock_ccxt.fetch_balance = fetch_balance self.mock_ccxt.watch_balance = watch_balance self.client = CCXTExecutionClient( client=self.mock_ccxt, account_id=self.account_id, account_type=AccountType.CASH, base_currency=None, # Multi-currency account engine=self.exec_engine, clock=self.clock, logger=self.logger, ) self.exec_engine.register_client(self.client) def tearDown(self): self.loop.stop() self.loop.close() def test_connect(self): async def run_test(): # Arrange # Act self.exec_engine.start() # Also connects clients await asyncio.sleep(0.3) # Allow engine message queue to start # Assert self.assertTrue(self.client.is_connected) # Tear down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_disconnect(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.disconnect() await asyncio.sleep(0.3) # Assert self.assertFalse(self.client.is_connected) # Tear down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_reset_when_not_connected_successfully_resets(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start self.exec_engine.stop() await asyncio.sleep(0.3) # Allow engine message queue to stop # Act self.client.reset() # Assert self.assertFalse(self.client.is_connected) self.loop.run_until_complete(run_test()) def test_reset_when_connected_does_not_reset(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.reset() # Assert self.assertTrue(self.client.is_connected) # Tear Down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test()) def test_dispose_when_not_connected_does_not_dispose(self): async def run_test(): # Arrange self.exec_engine.start() await asyncio.sleep(0.3) # Allow engine message queue to start # Act self.client.dispose() # Assert self.assertTrue(self.client.is_connected) # Tear Down self.exec_engine.stop() await self.exec_engine.get_run_queue_task() self.loop.run_until_complete(run_test())
class TestLiveExecutionClient: def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.loop.set_debug(True) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = LiveLogger(self.loop, self.clock) self.trader_id = TestStubs.trader_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S-001"), clock=self.clock, ) self.msgbus = MessageBus( trader_id=self.trader_id, clock=self.clock, logger=self.logger, ) self.cache = TestStubs.cache() self.portfolio = Portfolio( msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.data_engine = LiveDataEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.risk_engine = LiveRiskEngine( loop=self.loop, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.client = MockLiveExecutionClient( loop=self.loop, client_id=ClientId(SIM.value), venue_type=VenueType.ECN, account_id=TestStubs.account_id(), account_type=AccountType.CASH, base_currency=USD, instrument_provider=InstrumentProvider(), msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.portfolio.update_account(TestStubs.event_cash_account_state()) self.exec_engine.register_client(self.client) # Prepare components self.cache.add_instrument(AUDUSD_SIM) def teardown(self): self.client.dispose() @pytest.mark.asyncio async def test_reconcile_state_given_no_order_and_not_in_cache_returns_false( self): # Arrange report = OrderStatusReport( client_order_id=ClientOrderId("O-123456"), venue_order_id=VenueOrderId("1"), order_status=OrderStatus.FILLED, filled_qty=Quantity.from_int(100000), ts_init=0, ) # Act result = await self.client.reconcile_state( report, order=None) # <- order won't be in the cache # Assert assert not result @pytest.mark.asyncio async def test_reconcile_state_when_order_rejected_generates_event(self): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.stop_market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.REJECTED, filled_qty=Quantity.zero(), ts_init=0, ) # Act result = self.client.reconcile_state(report, order) # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_order_expired_generates_event(self): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.stop_market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.EXPIRED, filled_qty=Quantity.zero(), ts_init=0, ) # Act result = self.client.reconcile_state(report, order) # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_order_canceled_generates_event(self): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.stop_market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.CANCELED, filled_qty=Quantity.zero(), ts_init=0, ) # Act result = self.client.reconcile_state(report, order) # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_order_completed_returns_true_with_warning1( self): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.stop_market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_canceled(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.CANCELED, filled_qty=Quantity.zero(), ts_init=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_when_order_completed_returns_true_with_warning2( self): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process( TestStubs.event_order_filled(order, AUDUSD_SIM)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.FILLED, filled_qty=Quantity.from_int(100000), ts_init=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert result @pytest.mark.asyncio async def test_reconcile_state_with_filled_order_when_trades_not_given_returns_false( self, ): # Arrange self.exec_engine.start() self.risk_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.limit( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), Price.from_str("1.00000"), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) self.risk_engine.execute(submit_order) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_submitted(order)) await asyncio.sleep(0) # Process queue self.exec_engine.process(TestStubs.event_order_accepted(order)) await asyncio.sleep(0) # Process queue report = OrderStatusReport( client_order_id=order.client_order_id, venue_order_id=VenueOrderId("1"), # <-- from stub event order_status=OrderStatus.FILLED, filled_qty=Quantity.from_int(100000), ts_init=0, ) # Act result = await self.client.reconcile_state(report, order) # Assert assert not result
class TestLiveExecutionEngine: def setup(self): # Fixture Setup self.loop = asyncio.get_event_loop() self.loop.set_debug(True) self.clock = LiveClock() self.uuid_factory = UUIDFactory() self.logger = Logger(self.clock) self.trader_id = TestIdStubs.trader_id() self.order_factory = OrderFactory( trader_id=self.trader_id, strategy_id=StrategyId("S-001"), clock=self.clock, ) self.random_order_factory = OrderFactory( trader_id=TraderId("RANDOM-042"), strategy_id=StrategyId("S-042"), clock=self.clock, ) self.msgbus = MessageBus( trader_id=self.trader_id, clock=self.clock, logger=self.logger, ) self.cache = TestComponentStubs.cache() self.portfolio = Portfolio( msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.data_engine = LiveDataEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.risk_engine = LiveRiskEngine( loop=self.loop, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.instrument_provider = InstrumentProvider( venue=SIM, logger=self.logger, ) self.instrument_provider.add(AUDUSD_SIM) self.instrument_provider.add(GBPUSD_SIM) self.client = MockLiveExecutionClient( loop=self.loop, client_id=ClientId(SIM.value), venue=SIM, account_type=AccountType.CASH, base_currency=USD, instrument_provider=self.instrument_provider, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) self.portfolio.update_account(TestEventStubs.cash_account_state()) self.exec_engine.register_client(self.client) self.cache.add_instrument(AUDUSD_SIM) def teardown(self): self.exec_engine.dispose() @pytest.mark.asyncio async def test_start_when_loop_not_running_logs(self): # Arrange, Act self.exec_engine.start() # Assert assert True # No exceptions raised self.exec_engine.stop() @pytest.mark.asyncio async def test_message_qsize_at_max_blocks_on_put_command(self): # Arrange # Deregister test fixture ExecutionEngine from msgbus) self.msgbus.deregister( endpoint="ExecEngine.execute", handler=self.exec_engine.execute, ) self.msgbus.deregister( endpoint="ExecEngine.process", handler=self.exec_engine.process, ) self.msgbus.deregister( endpoint="ExecEngine.reconcile_report", handler=self.exec_engine.reconcile_report, ) self.msgbus.deregister( endpoint="ExecEngine.reconcile_mass_status", handler=self.exec_engine.reconcile_mass_status, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, config=LiveExecEngineConfig(qsize=1), ) strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.exec_engine.execute(submit_order) self.exec_engine.execute(submit_order) await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 1 assert self.exec_engine.command_count == 0 @pytest.mark.asyncio async def test_message_qsize_at_max_blocks_on_put_event(self): # Arrange # Deregister test fixture ExecutionEngine from msgbus) self.msgbus.deregister( endpoint="ExecEngine.execute", handler=self.exec_engine.execute, ) self.msgbus.deregister( endpoint="ExecEngine.process", handler=self.exec_engine.process, ) self.msgbus.deregister( endpoint="ExecEngine.reconcile_report", handler=self.exec_engine.reconcile_report, ) self.msgbus.deregister( endpoint="ExecEngine.reconcile_mass_status", handler=self.exec_engine.reconcile_mass_status, ) self.exec_engine = LiveExecutionEngine( loop=self.loop, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, config=LiveExecEngineConfig(qsize=1), ) strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) event = TestEventStubs.order_submitted(order) # Act self.exec_engine.execute(submit_order) self.exec_engine.process(event) # Add over max size await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 1 assert self.exec_engine.command_count == 0 @pytest.mark.asyncio async def test_start(self): # Arrange, Act self.exec_engine.start() await asyncio.sleep(0.1) # Assert assert self.exec_engine.is_running # Tear Down self.exec_engine.stop() @pytest.mark.asyncio async def test_kill_when_running_and_no_messages_on_queues(self): # Arrange, Act self.exec_engine.start() await asyncio.sleep(0) self.exec_engine.kill() # Assert assert self.exec_engine.is_stopped @pytest.mark.asyncio async def test_kill_when_not_running_with_messages_on_queue(self): # Arrange, Act self.exec_engine.kill() # Assert assert self.exec_engine.qsize() == 0 @pytest.mark.asyncio async def test_execute_command_places_command_on_queue(self): # Arrange self.exec_engine.start() strategy = TradingStrategy() strategy.register( trader_id=self.trader_id, portfolio=self.portfolio, msgbus=self.msgbus, cache=self.cache, clock=self.clock, logger=self.logger, ) order = strategy.order_factory.market( AUDUSD_SIM.id, OrderSide.BUY, Quantity.from_int(100000), ) submit_order = SubmitOrder( self.trader_id, strategy.id, None, order, self.uuid_factory.generate(), self.clock.timestamp_ns(), ) # Act self.exec_engine.execute(submit_order) await asyncio.sleep(0.1) # Assert assert self.exec_engine.qsize() == 0 assert self.exec_engine.command_count == 1 # Tear Down self.exec_engine.stop() def test_handle_order_status_report(self): # Arrange order_report = OrderStatusReport( account_id=AccountId("SIM", "001"), instrument_id=AUDUSD_SIM.id, client_order_id=ClientOrderId("O-123456"), order_list_id=OrderListId("1"), venue_order_id=VenueOrderId("2"), order_side=OrderSide.SELL, order_type=OrderType.STOP_LIMIT, contingency_type=ContingencyType.OCO, time_in_force=TimeInForce.DAY, expire_time=None, order_status=OrderStatus.REJECTED, price=Price.from_str("0.90090"), trigger_price=Price.from_str("0.90100"), trigger_type=TriggerType.DEFAULT, limit_offset=None, trailing_offset=Decimal("0.00010"), offset_type=TrailingOffsetType.PRICE, quantity=Quantity.from_int(1_000_000), filled_qty=Quantity.from_int(0), display_qty=None, avg_px=None, post_only=True, reduce_only=False, cancel_reason="SOME_REASON", report_id=UUID4(), ts_accepted=1_000_000, ts_triggered=1_500_000, ts_last=2_000_000, ts_init=3_000_000, ) # Act self.exec_engine.reconcile_report(order_report) # Assert assert self.exec_engine.report_count == 1 def test_handle_trade_report(self): # Arrange trade_report = TradeReport( account_id=AccountId("SIM", "001"), instrument_id=AUDUSD_SIM.id, client_order_id=ClientOrderId("O-123456789"), venue_order_id=VenueOrderId("1"), venue_position_id=PositionId("2"), trade_id=TradeId("3"), order_side=OrderSide.BUY, last_qty=Quantity.from_int(100), last_px=Price.from_str("100.50"), commission=Money("4.50", USD), liquidity_side=LiquiditySide.TAKER, report_id=UUID4(), ts_event=0, ts_init=0, ) # Act self.exec_engine.reconcile_report(trade_report) # Assert assert self.exec_engine.report_count == 1 def test_handle_position_status_report(self): # Arrange position_report = PositionStatusReport( account_id=AccountId("SIM", "001"), instrument_id=AUDUSD_SIM.id, venue_position_id=PositionId("1"), position_side=PositionSide.LONG, quantity=Quantity.from_int(1_000_000), report_id=UUID4(), ts_last=0, ts_init=0, ) # Act self.exec_engine.reconcile_report(position_report) # Assert assert self.exec_engine.report_count == 1 def test_execution_mass_status(self): # Arrange mass_status = ExecutionMassStatus( client_id=ClientId("SIM"), account_id=TestIdStubs.account_id(), venue=Venue("SIM"), report_id=UUID4(), ts_init=0, ) # Act self.exec_engine.reconcile_mass_status(mass_status) # Assert assert self.exec_engine.report_count == 1