walrasquant-lib 0.4.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. walrasquant/__init__.py +7 -0
  2. walrasquant/aggregation.py +449 -0
  3. walrasquant/backends/__init__.py +5 -0
  4. walrasquant/backends/db.py +109 -0
  5. walrasquant/backends/db_memory.py +61 -0
  6. walrasquant/backends/db_postgresql.py +321 -0
  7. walrasquant/backends/db_sqlite.py +310 -0
  8. walrasquant/base/__init__.py +24 -0
  9. walrasquant/base/api_client.py +46 -0
  10. walrasquant/base/connector.py +863 -0
  11. walrasquant/base/ems.py +794 -0
  12. walrasquant/base/exchange.py +213 -0
  13. walrasquant/base/oms.py +428 -0
  14. walrasquant/base/retry.py +220 -0
  15. walrasquant/base/sms.py +545 -0
  16. walrasquant/base/ws_client.py +408 -0
  17. walrasquant/config.py +284 -0
  18. walrasquant/constants.py +413 -0
  19. walrasquant/core/__init__.py +0 -0
  20. walrasquant/core/cache.py +688 -0
  21. walrasquant/core/clock.py +59 -0
  22. walrasquant/core/connection.py +41 -0
  23. walrasquant/core/entity.py +504 -0
  24. walrasquant/core/nautilius_core.py +103 -0
  25. walrasquant/core/registry.py +41 -0
  26. walrasquant/engine.py +745 -0
  27. walrasquant/error.py +34 -0
  28. walrasquant/exchange/__init__.py +13 -0
  29. walrasquant/exchange/base_factory.py +172 -0
  30. walrasquant/exchange/binance/__init__.py +30 -0
  31. walrasquant/exchange/binance/connector.py +1093 -0
  32. walrasquant/exchange/binance/constants.py +934 -0
  33. walrasquant/exchange/binance/ems.py +140 -0
  34. walrasquant/exchange/binance/error.py +48 -0
  35. walrasquant/exchange/binance/exchange.py +144 -0
  36. walrasquant/exchange/binance/factory.py +115 -0
  37. walrasquant/exchange/binance/oms.py +1807 -0
  38. walrasquant/exchange/binance/rest_api.py +1653 -0
  39. walrasquant/exchange/binance/schema.py +1063 -0
  40. walrasquant/exchange/binance/websockets.py +389 -0
  41. walrasquant/exchange/bitget/__init__.py +28 -0
  42. walrasquant/exchange/bitget/connector.py +578 -0
  43. walrasquant/exchange/bitget/constants.py +392 -0
  44. walrasquant/exchange/bitget/ems.py +202 -0
  45. walrasquant/exchange/bitget/error.py +36 -0
  46. walrasquant/exchange/bitget/exchange.py +128 -0
  47. walrasquant/exchange/bitget/factory.py +135 -0
  48. walrasquant/exchange/bitget/oms.py +1619 -0
  49. walrasquant/exchange/bitget/rest_api.py +610 -0
  50. walrasquant/exchange/bitget/schema.py +885 -0
  51. walrasquant/exchange/bitget/websockets.py +753 -0
  52. walrasquant/exchange/bybit/__init__.py +32 -0
  53. walrasquant/exchange/bybit/connector.py +819 -0
  54. walrasquant/exchange/bybit/constants.py +479 -0
  55. walrasquant/exchange/bybit/ems.py +93 -0
  56. walrasquant/exchange/bybit/error.py +36 -0
  57. walrasquant/exchange/bybit/exchange.py +108 -0
  58. walrasquant/exchange/bybit/factory.py +128 -0
  59. walrasquant/exchange/bybit/oms.py +1195 -0
  60. walrasquant/exchange/bybit/rest_api.py +570 -0
  61. walrasquant/exchange/bybit/schema.py +867 -0
  62. walrasquant/exchange/bybit/websockets.py +307 -0
  63. walrasquant/exchange/hyperliquid/__init__.py +28 -0
  64. walrasquant/exchange/hyperliquid/connector.py +370 -0
  65. walrasquant/exchange/hyperliquid/constants.py +371 -0
  66. walrasquant/exchange/hyperliquid/ems.py +156 -0
  67. walrasquant/exchange/hyperliquid/error.py +48 -0
  68. walrasquant/exchange/hyperliquid/exchange.py +120 -0
  69. walrasquant/exchange/hyperliquid/factory.py +135 -0
  70. walrasquant/exchange/hyperliquid/oms.py +1081 -0
  71. walrasquant/exchange/hyperliquid/rest_api.py +348 -0
  72. walrasquant/exchange/hyperliquid/schema.py +583 -0
  73. walrasquant/exchange/hyperliquid/websockets.py +592 -0
  74. walrasquant/exchange/okx/__init__.py +25 -0
  75. walrasquant/exchange/okx/connector.py +931 -0
  76. walrasquant/exchange/okx/constants.py +518 -0
  77. walrasquant/exchange/okx/ems.py +144 -0
  78. walrasquant/exchange/okx/error.py +66 -0
  79. walrasquant/exchange/okx/exchange.py +102 -0
  80. walrasquant/exchange/okx/factory.py +138 -0
  81. walrasquant/exchange/okx/oms.py +1199 -0
  82. walrasquant/exchange/okx/rest_api.py +799 -0
  83. walrasquant/exchange/okx/schema.py +1449 -0
  84. walrasquant/exchange/okx/websockets.py +420 -0
  85. walrasquant/exchange/registry.py +201 -0
  86. walrasquant/execution/__init__.py +24 -0
  87. walrasquant/execution/algorithm.py +968 -0
  88. walrasquant/execution/algorithms/__init__.py +3 -0
  89. walrasquant/execution/algorithms/twap.py +392 -0
  90. walrasquant/execution/config.py +34 -0
  91. walrasquant/execution/constants.py +27 -0
  92. walrasquant/execution/schema.py +62 -0
  93. walrasquant/indicator.py +382 -0
  94. walrasquant/push.py +77 -0
  95. walrasquant/schema.py +755 -0
  96. walrasquant/strategy.py +1805 -0
  97. walrasquant/tools/__init__.py +0 -0
  98. walrasquant/tools/pm2_wrapper.py +1016 -0
  99. walrasquant/web/__init__.py +26 -0
  100. walrasquant/web/app.py +157 -0
  101. walrasquant/web/server.py +92 -0
  102. walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
  103. walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
  104. walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
  105. walrasquant_lib-0.4.20.dist-info/entry_points.txt +3 -0
walrasquant/engine.py ADDED
@@ -0,0 +1,745 @@
1
+ import asyncio
2
+ import platform
3
+ import nexuslog as logging
4
+ from typing import Callable, Dict, cast
5
+ from collections import defaultdict
6
+ from walrasquant.constants import AccountType, ExchangeType
7
+ from walrasquant.config import Config, WebConfig
8
+ from walrasquant.strategy import Strategy
9
+ from walrasquant.core.cache import AsyncCache
10
+ from walrasquant.core.registry import OrderRegistry
11
+ from walrasquant.error import EngineBuildError
12
+ from walrasquant.base import (
13
+ ExchangeManager,
14
+ PublicConnector,
15
+ PrivateConnector,
16
+ ExecutionManagementSystem,
17
+ SubscriptionManagementSystem,
18
+ OrderManagementSystem,
19
+ )
20
+ from walrasquant.exchange.registry import get_factory
21
+ from walrasquant.exchange.base_factory import BuildContext
22
+
23
+ from walrasquant.core.entity import TaskManager, ZeroMQSignalRecv
24
+ from walrasquant.core.nautilius_core import setup_nexus_core
25
+ from walrasquant.schema import InstrumentId
26
+ from walrasquant.web.app import StrategyFastAPI
27
+ from walrasquant.web.server import StrategyWebServer
28
+ from walrasquant.base.ws_client import WSClient
29
+ from walrasquant.core.connection import (
30
+ ConnectionState,
31
+ ConnectionPolicyState,
32
+ ConnectionRole,
33
+ )
34
+ from walrasquant.push import FlashDutyPushService
35
+ from walrasquant.execution.algorithm import ExecAlgorithm
36
+ from walrasquant.core.entity import OidGen
37
+
38
+
39
+ class Engine:
40
+ @staticmethod
41
+ def new_event_loop() -> asyncio.AbstractEventLoop:
42
+ # aiofastnet patches the loop so create_connection / start_tls use its
43
+ # optimized transports (faster TLS, KTLS-capable). Built on uvloop on
44
+ # non-Windows platforms. Avoids the legacy event-loop-policy API,
45
+ # deprecated in Python 3.14 and removed in 3.16.
46
+ import aiofastnet
47
+
48
+ if platform.system() != "Windows":
49
+ import uvloop
50
+
51
+ return aiofastnet.loop_factory(uvloop.new_event_loop)()
52
+ return aiofastnet.loop_factory()()
53
+
54
+ def __init__(self, config: Config):
55
+ self._config = config
56
+ self._is_built = False
57
+ self._scheduler_started = False
58
+ self._loop = self.new_event_loop()
59
+ self._task_manager = TaskManager(self._loop)
60
+
61
+ self._exchanges: Dict[ExchangeType, ExchangeManager] = {}
62
+ self._public_connectors: Dict[AccountType, PublicConnector] = {}
63
+ self._private_connectors: Dict[AccountType, PrivateConnector] = {}
64
+
65
+ trader_id = f"{self._config.strategy_id}-{self._config.user_id}"
66
+
67
+ self._custom_signal_recv = None
68
+
69
+ # Initialize nautilus core components and configure nexuslog
70
+ self._msgbus, self._clock = setup_nexus_core(
71
+ trader_id=trader_id,
72
+ filename=self._config.log_config.filename,
73
+ level=self._config.log_config.level,
74
+ name_levels=self._config.log_config.name_levels,
75
+ unix_ts=self._config.log_config.unix_ts,
76
+ batch_size=self._config.log_config.batch_size,
77
+ )
78
+
79
+ # Create logger instance for Engine
80
+ self._log = logging.getLogger(type(self).__name__)
81
+
82
+ self._cache: AsyncCache = AsyncCache(
83
+ strategy_id=config.strategy_id,
84
+ user_id=config.user_id,
85
+ msgbus=self._msgbus,
86
+ clock=self._clock,
87
+ task_manager=self._task_manager,
88
+ storage_backend=config.storage_backend,
89
+ db_path=config.db_path,
90
+ sync_interval=config.cache_sync_interval,
91
+ expired_time=config.cache_expired_time,
92
+ )
93
+ self._oidgen = OidGen(self._clock)
94
+
95
+ self._registry = OrderRegistry()
96
+ self._oms: Dict[ExchangeType, OrderManagementSystem] = {}
97
+ self._ems: Dict[ExchangeType, ExecutionManagementSystem] = {}
98
+ self._custom_ems: Dict[ExchangeType, type[ExecutionManagementSystem]] = {}
99
+ self._exec_algorithms: Dict[str, ExecAlgorithm] = {}
100
+
101
+ self._sms = SubscriptionManagementSystem(
102
+ exchanges=self._exchanges,
103
+ public_connectors=self._public_connectors,
104
+ task_manager=self._task_manager,
105
+ clock=self._clock,
106
+ sms_maxsize=self._config.queue_config.sms_maxsize,
107
+ )
108
+
109
+ self._push_service = FlashDutyPushService(
110
+ integration_key=self._config.flashduty_integration_key,
111
+ strategy_id=self._config.strategy_id,
112
+ user_id=self._config.user_id,
113
+ )
114
+
115
+ self._strategy: Strategy = config.strategy
116
+ self._strategy.register(
117
+ cache=self._cache,
118
+ msgbus=self._msgbus,
119
+ clock=self._clock,
120
+ oidgen=self._oidgen,
121
+ task_manager=self._task_manager,
122
+ ems=self._ems,
123
+ sms=self._sms,
124
+ exchanges=self._exchanges,
125
+ private_connectors=self._private_connectors,
126
+ public_connectors=self._public_connectors,
127
+ push_service=self._push_service,
128
+ exec_algorithms=self._exec_algorithms,
129
+ strategy_id=config.strategy_id,
130
+ user_id=config.user_id,
131
+ )
132
+
133
+ self._web_app: StrategyFastAPI | None = None
134
+ self._web_server: StrategyWebServer | None = None
135
+ self._prepare_web_interface()
136
+
137
+ self._connection_registry: Dict[
138
+ tuple[str, str, str, str, int], ConnectionState
139
+ ] = {}
140
+ self._connection_policy_state: ConnectionPolicyState | None = None
141
+
142
+ def _prepare_web_interface(self) -> None:
143
+ web_app = getattr(self._strategy, "web_app", None)
144
+ if not web_app:
145
+ return
146
+ if not isinstance(web_app, StrategyFastAPI):
147
+ raise EngineBuildError(
148
+ "Strategy.web_app must be created with `create_strategy_app()` or `StrategyFastAPI`."
149
+ )
150
+
151
+ self._web_app = web_app
152
+ self._web_app.bind_strategy(self._strategy)
153
+
154
+ def _start_web_interface(self) -> None:
155
+ if not self._web_app:
156
+ return
157
+
158
+ web_config: WebConfig = getattr(self._config, "web_config", WebConfig())
159
+ if not web_config.enabled:
160
+ self._log.debug("Web interface disabled via configuration")
161
+ return
162
+
163
+ if not self._web_app.has_user_routes():
164
+ self._log.debug(
165
+ "Web interface enabled but no user routes registered; skipping startup"
166
+ )
167
+ return
168
+
169
+ self._web_server = StrategyWebServer(
170
+ self._web_app,
171
+ host=web_config.host,
172
+ port=web_config.port,
173
+ log_level=web_config.log_level,
174
+ )
175
+ try:
176
+ self._web_server.start()
177
+ self._log.info(
178
+ f"Web interface started on http://{web_config.host}:{web_config.port}"
179
+ )
180
+ except Exception as exc: # pragma: no cover - defensive logging
181
+ self._log.error(f"Failed to start web interface: {exc}")
182
+ self._web_server = None
183
+
184
+ def _public_connector_check(self):
185
+ # Group connectors by exchange
186
+ exchange_connectors = defaultdict(dict)
187
+ for account_type, connector in self._public_connectors.items():
188
+ exchange_id = ExchangeType(
189
+ cast(str, getattr(account_type, "exchange_id")).lower()
190
+ )
191
+ exchange_connectors[exchange_id][account_type] = connector
192
+
193
+ # Validate each exchange's connectors
194
+ for exchange_id, connectors in exchange_connectors.items():
195
+ exchange = self._exchanges[exchange_id]
196
+ basic_config = self._config.basic_config.get(exchange_id)
197
+
198
+ if not basic_config:
199
+ raise EngineBuildError(
200
+ f"Basic config for {exchange_id} is not set. Please add `{exchange_id}` in `basic_config`."
201
+ )
202
+
203
+ # Validate each connector configuration
204
+ for account_type in connectors.keys():
205
+ exchange.validate_public_connector_config(account_type, basic_config)
206
+
207
+ # Validate connector limits
208
+ exchange.validate_public_connector_limits(connectors)
209
+
210
+ def _build_public_connectors(self):
211
+ # Create build context
212
+ context = BuildContext(
213
+ msgbus=self._msgbus,
214
+ clock=self._clock,
215
+ task_manager=self._task_manager,
216
+ cache=self._cache,
217
+ registry=self._registry,
218
+ order_query_config=self._config.order_query_config,
219
+ )
220
+
221
+ for exchange_id, public_conn_configs in self._config.public_conn_config.items():
222
+ factory = get_factory(exchange_id)
223
+ exchange = self._exchanges[exchange_id]
224
+
225
+ for config in public_conn_configs:
226
+ public_connector = factory.create_public_connector(
227
+ config, exchange, context
228
+ )
229
+ self._public_connectors[config.account_type] = public_connector
230
+
231
+ self._public_connector_check()
232
+
233
+ def _build_private_connectors(self):
234
+ # Create build context
235
+ context = BuildContext(
236
+ msgbus=self._msgbus,
237
+ clock=self._clock,
238
+ task_manager=self._task_manager,
239
+ cache=self._cache,
240
+ registry=self._registry,
241
+ queue_maxsize=self._config.queue_config.ems_maxsize,
242
+ order_query_config=self._config.order_query_config,
243
+ )
244
+
245
+ for (
246
+ exchange_id,
247
+ private_conn_configs,
248
+ ) in self._config.private_conn_config.items():
249
+ if not private_conn_configs:
250
+ raise EngineBuildError(
251
+ f"Private connector config for {exchange_id} is not set. Please add `{exchange_id}` in `private_conn_config`."
252
+ )
253
+
254
+ factory = get_factory(exchange_id)
255
+ exchange = self._exchanges[exchange_id]
256
+
257
+ for config in private_conn_configs:
258
+ # Let factory determine account type (handles testnet mapping)
259
+ account_type = factory.get_private_account_type(exchange, config)
260
+
261
+ private_connector = factory.create_private_connector(
262
+ config, exchange, context, account_type
263
+ )
264
+ self._private_connectors[account_type] = private_connector
265
+
266
+ def _build_exchanges(self):
267
+ for exchange_id, basic_config in self._config.basic_config.items():
268
+ factory = get_factory(exchange_id)
269
+ self._exchanges[exchange_id] = factory.create_manager(basic_config)
270
+
271
+ def _build_custom_signal_recv(self):
272
+ zmq_config = self._config.zero_mq_signal_config
273
+ if zmq_config:
274
+ if not hasattr(self._strategy, "on_custom_signal"):
275
+ raise EngineBuildError(
276
+ "Please add `on_custom_signal` method to the strategy."
277
+ )
278
+
279
+ self._custom_signal_recv = ZeroMQSignalRecv(
280
+ zmq_config,
281
+ cast(Callable, self._strategy.on_custom_signal),
282
+ self._task_manager,
283
+ )
284
+
285
+ def set_custom_ems(
286
+ self,
287
+ exchange_id: ExchangeType,
288
+ ems_class: type[ExecutionManagementSystem],
289
+ ) -> None:
290
+ """
291
+ Set a custom ExecutionManagementSystem class for a specific exchange.
292
+
293
+ Args:
294
+ exchange_id: The exchange type to set the custom EMS for
295
+ ems_class: A custom EMS class that inherits from ExecutionManagementSystem
296
+ """
297
+ if not issubclass(ems_class, ExecutionManagementSystem):
298
+ raise TypeError(
299
+ "Custom EMS class must inherit from ExecutionManagementSystem"
300
+ )
301
+ self._custom_ems[exchange_id] = ems_class
302
+
303
+ def _build_ems(self):
304
+ # Create build context
305
+ context = BuildContext(
306
+ msgbus=self._msgbus,
307
+ clock=self._clock,
308
+ task_manager=self._task_manager,
309
+ cache=self._cache,
310
+ registry=self._registry,
311
+ order_query_config=self._config.order_query_config,
312
+ queue_maxsize=self._config.queue_config.ems_maxsize,
313
+ )
314
+
315
+ for exchange_id, exchange in self._exchanges.items():
316
+ # Check if there's a custom EMS for this exchange
317
+ if exchange_id in self._custom_ems:
318
+ self._ems[exchange_id] = self._custom_ems[exchange_id](
319
+ market=exchange.market,
320
+ cache=self._cache,
321
+ msgbus=self._msgbus,
322
+ clock=self._clock,
323
+ task_manager=self._task_manager,
324
+ registry=self._registry,
325
+ queue_maxsize=self._config.queue_config.ems_maxsize,
326
+ )
327
+ self._ems[exchange_id]._build(self._private_connectors)
328
+ continue
329
+
330
+ # Use factory to create EMS
331
+ factory = get_factory(exchange_id)
332
+ ems = factory.create_ems(exchange, context)
333
+ ems._build(self._private_connectors)
334
+ self._ems[exchange_id] = ems
335
+
336
+ def _connection_key(
337
+ self,
338
+ role: ConnectionRole,
339
+ exchange_id: str,
340
+ account_type: str,
341
+ ws_name: str,
342
+ client_id: int,
343
+ ) -> tuple[str, str, str, str, int]:
344
+ return (role, exchange_id, account_type, ws_name, client_id)
345
+
346
+ def _recompute_connection_policy(self) -> None:
347
+ md_required = [
348
+ state
349
+ for state in self._connection_registry.values()
350
+ if state.is_md() and state.required
351
+ ]
352
+ td_required = [
353
+ state
354
+ for state in self._connection_registry.values()
355
+ if state.is_td() and state.required
356
+ ]
357
+
358
+ md_ok = bool(md_required) and all(state.connected for state in md_required)
359
+ td_ok = bool(td_required) and all(state.connected for state in td_required)
360
+
361
+ policy = ConnectionPolicyState(md_ok=md_ok, td_ok=td_ok)
362
+
363
+ if policy == self._connection_policy_state:
364
+ return
365
+
366
+ self._connection_policy_state = policy
367
+ self._log.debug(
368
+ "Connection policy update: "
369
+ f"md_ok={policy.md_ok} td_ok={policy.td_ok} "
370
+ f"allow_open={policy.allow_open} allow_trade={policy.allow_trade} "
371
+ f"allow_close_only={policy.allow_close_only}"
372
+ )
373
+
374
+ self._msgbus.send(
375
+ endpoint="connection_status",
376
+ msg=policy,
377
+ )
378
+
379
+ def _register_ws_client(
380
+ self,
381
+ ws_client: WSClient,
382
+ role: ConnectionRole,
383
+ account_type: AccountType,
384
+ ws_name: str,
385
+ required: bool,
386
+ ) -> None:
387
+ exchange_id = getattr(account_type, "exchange_id", str(account_type))
388
+ account_label = str(account_type)
389
+
390
+ def _callback(client_id: int, connected: bool) -> None:
391
+ key = self._connection_key(
392
+ role, exchange_id, account_label, ws_name, client_id
393
+ )
394
+ state = ConnectionState(
395
+ role=role,
396
+ exchange_id=str(exchange_id),
397
+ account_type=account_label,
398
+ ws_name=ws_name,
399
+ client_id=client_id,
400
+ required=required,
401
+ connected=connected,
402
+ changed_at_ms=self._clock.timestamp_ms(),
403
+ )
404
+ self._connection_registry[key] = state
405
+ self._recompute_connection_policy()
406
+
407
+ ws_client.set_connection_change_callback(_callback)
408
+
409
+ def _register_connection_callbacks(self) -> None:
410
+ for account_type, connector in self._public_connectors.items():
411
+ ws_client = getattr(connector, "_ws_client", None)
412
+ if isinstance(ws_client, WSClient):
413
+ self._register_ws_client(
414
+ ws_client=ws_client,
415
+ role="MD",
416
+ account_type=account_type,
417
+ ws_name="public",
418
+ required=True,
419
+ )
420
+ business_ws_client = getattr(connector, "_business_ws_client", None)
421
+ if isinstance(business_ws_client, WSClient):
422
+ self._register_ws_client(
423
+ ws_client=business_ws_client,
424
+ role="MD",
425
+ account_type=account_type,
426
+ ws_name="business",
427
+ required=True,
428
+ )
429
+
430
+ for account_type, connector in self._private_connectors.items():
431
+ oms = getattr(connector, "_oms", None)
432
+ if not oms:
433
+ continue
434
+ ws_client = getattr(oms, "_ws_client", None)
435
+ if isinstance(ws_client, WSClient):
436
+ self._register_ws_client(
437
+ ws_client=ws_client,
438
+ role="TD",
439
+ account_type=account_type,
440
+ ws_name="oms",
441
+ required=True,
442
+ )
443
+ ws_api_client = getattr(oms, "_ws_api_client", None)
444
+ if isinstance(ws_api_client, WSClient):
445
+ self._register_ws_client(
446
+ ws_client=ws_api_client,
447
+ role="TD",
448
+ account_type=account_type,
449
+ ws_name="oms_api",
450
+ required=True,
451
+ )
452
+
453
+ def add_exec_algorithm(self, algorithm: ExecAlgorithm):
454
+ """
455
+ Add an execution algorithm to the engine.
456
+
457
+ Parameters
458
+ ----------
459
+ algorithm : ExecAlgorithm
460
+ The execution algorithm instance.
461
+
462
+ Example
463
+ -------
464
+ >>> from walrasquant.execution import TWAPExecAlgorithm
465
+ >>> engine = Engine(config)
466
+ >>> engine.add_exec_algorithm(TWAPExecAlgorithm())
467
+ >>> engine.start()
468
+ """
469
+ if algorithm.id in self._exec_algorithms:
470
+ raise ValueError(f"Execution algorithm '{algorithm.id}' already registered")
471
+ self._exec_algorithms[algorithm.id] = algorithm
472
+ self._log.info(f"Added execution algorithm: {algorithm.id}")
473
+
474
+ def _build_exec_algorithms(self):
475
+ """Build and register all execution algorithms."""
476
+ if not self._exec_algorithms:
477
+ return
478
+
479
+ # Get all market data from all exchanges
480
+ all_markets = {}
481
+ for exchange_id, exchange_manager in self._exchanges.items():
482
+ all_markets.update(exchange_manager.market)
483
+
484
+ # Get first available EMS for order submission
485
+ for algo_id, algorithm in self._exec_algorithms.items():
486
+ algorithm.register(
487
+ msgbus=self._msgbus,
488
+ clock=self._clock,
489
+ cache=self._cache,
490
+ task_manager=self._task_manager,
491
+ oidgen=self._oidgen,
492
+ registry=self._registry,
493
+ market=all_markets,
494
+ ems=self._ems,
495
+ )
496
+ self._log.debug(f"Registered execution algorithm: {algo_id}")
497
+
498
+ def _start_exec_algorithms(self):
499
+ """Start all execution algorithms."""
500
+ for algorithm in self._exec_algorithms.values():
501
+ algorithm.start()
502
+
503
+ def _stop_exec_algorithms(self):
504
+ """Stop all execution algorithms."""
505
+ for algorithm in self._exec_algorithms.values():
506
+ algorithm.stop()
507
+
508
+ def _build(self):
509
+ self._build_exchanges()
510
+ self._build_public_connectors()
511
+ self._build_private_connectors()
512
+ self._build_ems()
513
+ self._build_exec_algorithms()
514
+ self._build_custom_signal_recv()
515
+ self._register_connection_callbacks()
516
+ self._is_built = True
517
+
518
+ async def _start_connectors(self):
519
+ # Start all public connectors
520
+ for connector in self._public_connectors.values():
521
+ await connector.connect()
522
+
523
+ # Wait for all public connectors to be ready
524
+ for connector in self._public_connectors.values():
525
+ await connector.wait_ready()
526
+
527
+ # Start all private connectors
528
+ for connector in self._private_connectors.values():
529
+ await connector.connect()
530
+
531
+ # Initialize OMS (two-phase init)
532
+ for connector in self._private_connectors.values():
533
+ await connector._oms._async_init()
534
+
535
+ # Wait for all private connectors to be ready
536
+ for connector in self._private_connectors.values():
537
+ await connector.wait_ready()
538
+
539
+ for connector in self._private_connectors.values():
540
+ connector._oms.start_order_query()
541
+
542
+ async def _start_ems(self):
543
+ for ems in self._ems.values():
544
+ await ems.start()
545
+
546
+ def _reload_markets_once(self):
547
+ """Reload market metadata for all configured exchanges."""
548
+ for exchange_id, exchange in self._exchanges.items():
549
+ before_count = len(exchange.market)
550
+ try:
551
+ exchange.load_markets(reload=True)
552
+ except Exception as exc:
553
+ self._log.error(f"Failed to reload markets for {exchange_id}: {exc}")
554
+ continue
555
+
556
+ after_count = len(exchange.market)
557
+ self._log.info(
558
+ f"Reloaded markets for {exchange_id}: {before_count} -> {after_count}"
559
+ )
560
+
561
+ def _schedule_market_reload(self):
562
+ market_reload_config = self._config.market_reload_config
563
+ if not market_reload_config.enabled:
564
+ return
565
+
566
+ kwargs = market_reload_config.scheduler_kwargs()
567
+ self._strategy._scheduler.add_job(
568
+ self._reload_markets_once,
569
+ trigger=market_reload_config.trigger,
570
+ **kwargs,
571
+ )
572
+ self._log.info(
573
+ f"Scheduled market reload: trigger={market_reload_config.trigger}, kwargs={kwargs}"
574
+ )
575
+
576
+ def _start_scheduler(self):
577
+ self._schedule_market_reload()
578
+ self._strategy._scheduler.start()
579
+ self._scheduler_started = True
580
+
581
+ async def _start(self):
582
+ await self._sms.start()
583
+ await self._start_ems()
584
+ await self._start_connectors()
585
+ self._start_exec_algorithms()
586
+ if self._custom_signal_recv:
587
+ await self._custom_signal_recv.start()
588
+ await asyncio.get_event_loop().run_in_executor(None, self._strategy._on_start)
589
+ self._start_scheduler()
590
+ await self._task_manager.wait()
591
+
592
+ async def _cancel_all_open_orders(self):
593
+ """Cancel all open orders across all exchanges during shutdown."""
594
+ # Get all open orders grouped by symbol
595
+ symbol_to_orders = defaultdict(set)
596
+ for exchange, oids in self._cache._mem_open_orders.items():
597
+ if not oids:
598
+ continue
599
+ for oid in oids:
600
+ order = self._cache._mem_orders.get(oid)
601
+ if order:
602
+ symbol_to_orders[order.symbol].add(oid)
603
+
604
+ if not symbol_to_orders:
605
+ self._log.debug("No open orders to cancel")
606
+ return
607
+
608
+ total_symbols = len(symbol_to_orders)
609
+ total_orders = sum(len(oids) for oids in symbol_to_orders.values())
610
+ self._log.info(
611
+ f"Cancelling {total_orders} open orders across {total_symbols} symbols..."
612
+ )
613
+
614
+ # Cancel orders for each symbol
615
+ for symbol, oids in symbol_to_orders.items():
616
+ # Determine account type for this symbol
617
+ instrument_id = InstrumentId.from_str(symbol)
618
+ ems = self._ems.get(instrument_id.exchange)
619
+ if not ems:
620
+ self._log.warning(
621
+ f"EMS {instrument_id.exchange} not found for {symbol}"
622
+ )
623
+ continue
624
+
625
+ account_type = ems._instrument_id_to_account_type(instrument_id)
626
+ connector = self._private_connectors.get(account_type)
627
+ if not connector:
628
+ self._log.warning(f"Private connector not found for {account_type}")
629
+ continue
630
+
631
+ # Cancel all orders for this symbol
632
+ self._log.debug(f"Cancelling {len(oids)} orders for {symbol}")
633
+ max_attempts = 3
634
+ backoff_seconds = 1
635
+ for attempt in range(1, max_attempts + 1):
636
+ result = await connector._oms.cancel_all_orders(instrument_id.symbol)
637
+ if result is not False:
638
+ break
639
+
640
+ if attempt == max_attempts:
641
+ self._log.error(
642
+ f"Failed to cancel all orders for {symbol} "
643
+ f"after {max_attempts} attempts"
644
+ )
645
+ break
646
+
647
+ self._log.warning(
648
+ f"Cancel all orders for {symbol} failed; retrying in "
649
+ f"{backoff_seconds}s ({attempt}/{max_attempts})"
650
+ )
651
+ await asyncio.sleep(backoff_seconds)
652
+ backoff_seconds *= 2
653
+
654
+ # Wait briefly for cancellations to be acknowledged
655
+ await asyncio.sleep(0.1)
656
+ self._log.info("Order cancellation completed")
657
+
658
+ async def _dispose(self):
659
+ # Stop on-loop fast timers (schedule(..., fast=True)) so no more ticks fire
660
+ # during teardown. Runs on the loop thread (this coroutine is run via
661
+ # run_until_complete), so cancelling the TimerHandles here is safe.
662
+ self._strategy._cancel_fast_timers()
663
+
664
+ if self._scheduler_started:
665
+ try:
666
+ # Remove all jobs first to prevent new executions
667
+ self._strategy._scheduler.remove_all_jobs()
668
+ # Short delay to allow running jobs to complete
669
+ await asyncio.sleep(0.05)
670
+ # Shutdown without waiting for jobs to complete
671
+ self._strategy._scheduler.shutdown(wait=False)
672
+ except (asyncio.CancelledError, RuntimeError):
673
+ # Suppress expected shutdown exceptions
674
+ pass
675
+
676
+ # Cancel all open orders if configured
677
+ if self._config.exit_after_cancel:
678
+ await self._cancel_all_open_orders()
679
+
680
+ for connector in self._public_connectors.values():
681
+ await connector.disconnect()
682
+ for connector in self._private_connectors.values():
683
+ await connector.disconnect()
684
+
685
+ await asyncio.sleep(0.2) # NOTE: wait for the websocket to disconnect
686
+
687
+ await self._task_manager.cancel()
688
+ await self._cache.close()
689
+ self._push_service.shutdown()
690
+
691
+ def start(self):
692
+ self._build()
693
+ self._loop.run_until_complete(self._cache.start()) # Initialize cache
694
+ self._start_web_interface()
695
+ try:
696
+ self._loop.run_until_complete(self._start())
697
+ except asyncio.CancelledError:
698
+ pass
699
+
700
+ def _close_event_loop(self):
701
+ """Close event loop with proper error handling."""
702
+ if self._loop.is_closed():
703
+ return
704
+
705
+ try:
706
+ # Cancel any remaining tasks
707
+ pending_tasks = asyncio.all_tasks(self._loop)
708
+ if pending_tasks:
709
+ self._log.debug(f"Cancelling {len(pending_tasks)} pending tasks")
710
+ for task in pending_tasks:
711
+ task.cancel()
712
+
713
+ self._loop.close()
714
+ self._log.debug("Event loop closed successfully")
715
+
716
+ except Exception as e:
717
+ self._log.warning(f"Event loop close error: {e}")
718
+
719
+ def dispose(self):
720
+ try:
721
+ self._stop_exec_algorithms()
722
+
723
+ try:
724
+ self._strategy._on_stop()
725
+ except Exception as e:
726
+ # Ensure strategy stop errors don't block shutdown
727
+ self._log.warning(f"Strategy on_stop error: {e}")
728
+
729
+ self._loop.run_until_complete(self._dispose())
730
+ except Exception as e:
731
+ # Never let dispose crash; make shutdown best-effort
732
+ self._log.error(f"Dispose error: {e}")
733
+ finally:
734
+ if self._web_server:
735
+ try:
736
+ self._web_server.stop()
737
+ except Exception as exc: # pragma: no cover - defensive cleanup
738
+ self._log.debug(f"Error stopping web server: {exc}")
739
+ finally:
740
+ self._web_server = None
741
+ if self._web_app:
742
+ self._web_app.unbind_strategy()
743
+
744
+ self._close_event_loop()
745
+ logging.shutdown()