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.
- walrasquant/__init__.py +7 -0
- walrasquant/aggregation.py +449 -0
- walrasquant/backends/__init__.py +5 -0
- walrasquant/backends/db.py +109 -0
- walrasquant/backends/db_memory.py +61 -0
- walrasquant/backends/db_postgresql.py +321 -0
- walrasquant/backends/db_sqlite.py +310 -0
- walrasquant/base/__init__.py +24 -0
- walrasquant/base/api_client.py +46 -0
- walrasquant/base/connector.py +863 -0
- walrasquant/base/ems.py +794 -0
- walrasquant/base/exchange.py +213 -0
- walrasquant/base/oms.py +428 -0
- walrasquant/base/retry.py +220 -0
- walrasquant/base/sms.py +545 -0
- walrasquant/base/ws_client.py +408 -0
- walrasquant/config.py +284 -0
- walrasquant/constants.py +413 -0
- walrasquant/core/__init__.py +0 -0
- walrasquant/core/cache.py +688 -0
- walrasquant/core/clock.py +59 -0
- walrasquant/core/connection.py +41 -0
- walrasquant/core/entity.py +504 -0
- walrasquant/core/nautilius_core.py +103 -0
- walrasquant/core/registry.py +41 -0
- walrasquant/engine.py +745 -0
- walrasquant/error.py +34 -0
- walrasquant/exchange/__init__.py +13 -0
- walrasquant/exchange/base_factory.py +172 -0
- walrasquant/exchange/binance/__init__.py +30 -0
- walrasquant/exchange/binance/connector.py +1093 -0
- walrasquant/exchange/binance/constants.py +934 -0
- walrasquant/exchange/binance/ems.py +140 -0
- walrasquant/exchange/binance/error.py +48 -0
- walrasquant/exchange/binance/exchange.py +144 -0
- walrasquant/exchange/binance/factory.py +115 -0
- walrasquant/exchange/binance/oms.py +1807 -0
- walrasquant/exchange/binance/rest_api.py +1653 -0
- walrasquant/exchange/binance/schema.py +1063 -0
- walrasquant/exchange/binance/websockets.py +389 -0
- walrasquant/exchange/bitget/__init__.py +28 -0
- walrasquant/exchange/bitget/connector.py +578 -0
- walrasquant/exchange/bitget/constants.py +392 -0
- walrasquant/exchange/bitget/ems.py +202 -0
- walrasquant/exchange/bitget/error.py +36 -0
- walrasquant/exchange/bitget/exchange.py +128 -0
- walrasquant/exchange/bitget/factory.py +135 -0
- walrasquant/exchange/bitget/oms.py +1619 -0
- walrasquant/exchange/bitget/rest_api.py +610 -0
- walrasquant/exchange/bitget/schema.py +885 -0
- walrasquant/exchange/bitget/websockets.py +753 -0
- walrasquant/exchange/bybit/__init__.py +32 -0
- walrasquant/exchange/bybit/connector.py +819 -0
- walrasquant/exchange/bybit/constants.py +479 -0
- walrasquant/exchange/bybit/ems.py +93 -0
- walrasquant/exchange/bybit/error.py +36 -0
- walrasquant/exchange/bybit/exchange.py +108 -0
- walrasquant/exchange/bybit/factory.py +128 -0
- walrasquant/exchange/bybit/oms.py +1195 -0
- walrasquant/exchange/bybit/rest_api.py +570 -0
- walrasquant/exchange/bybit/schema.py +867 -0
- walrasquant/exchange/bybit/websockets.py +307 -0
- walrasquant/exchange/hyperliquid/__init__.py +28 -0
- walrasquant/exchange/hyperliquid/connector.py +370 -0
- walrasquant/exchange/hyperliquid/constants.py +371 -0
- walrasquant/exchange/hyperliquid/ems.py +156 -0
- walrasquant/exchange/hyperliquid/error.py +48 -0
- walrasquant/exchange/hyperliquid/exchange.py +120 -0
- walrasquant/exchange/hyperliquid/factory.py +135 -0
- walrasquant/exchange/hyperliquid/oms.py +1081 -0
- walrasquant/exchange/hyperliquid/rest_api.py +348 -0
- walrasquant/exchange/hyperliquid/schema.py +583 -0
- walrasquant/exchange/hyperliquid/websockets.py +592 -0
- walrasquant/exchange/okx/__init__.py +25 -0
- walrasquant/exchange/okx/connector.py +931 -0
- walrasquant/exchange/okx/constants.py +518 -0
- walrasquant/exchange/okx/ems.py +144 -0
- walrasquant/exchange/okx/error.py +66 -0
- walrasquant/exchange/okx/exchange.py +102 -0
- walrasquant/exchange/okx/factory.py +138 -0
- walrasquant/exchange/okx/oms.py +1199 -0
- walrasquant/exchange/okx/rest_api.py +799 -0
- walrasquant/exchange/okx/schema.py +1449 -0
- walrasquant/exchange/okx/websockets.py +420 -0
- walrasquant/exchange/registry.py +201 -0
- walrasquant/execution/__init__.py +24 -0
- walrasquant/execution/algorithm.py +968 -0
- walrasquant/execution/algorithms/__init__.py +3 -0
- walrasquant/execution/algorithms/twap.py +392 -0
- walrasquant/execution/config.py +34 -0
- walrasquant/execution/constants.py +27 -0
- walrasquant/execution/schema.py +62 -0
- walrasquant/indicator.py +382 -0
- walrasquant/push.py +77 -0
- walrasquant/schema.py +755 -0
- walrasquant/strategy.py +1805 -0
- walrasquant/tools/__init__.py +0 -0
- walrasquant/tools/pm2_wrapper.py +1016 -0
- walrasquant/web/__init__.py +26 -0
- walrasquant/web/app.py +157 -0
- walrasquant/web/server.py +92 -0
- walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
- walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
- walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
- 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()
|