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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import msgspec
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
from typing import Callable, Literal
|
|
6
|
+
import nexuslog as logging
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from walrasquant.core.entity import TaskManager
|
|
10
|
+
from picows import (
|
|
11
|
+
ws_connect,
|
|
12
|
+
WSFrame,
|
|
13
|
+
WSTransport,
|
|
14
|
+
WSListener,
|
|
15
|
+
WSMsgType,
|
|
16
|
+
WSAutoPingStrategy,
|
|
17
|
+
# PICOWS_DEBUG_LL,
|
|
18
|
+
)
|
|
19
|
+
from walrasquant.core.nautilius_core import LiveClock
|
|
20
|
+
|
|
21
|
+
# import logging
|
|
22
|
+
|
|
23
|
+
# file_handler = logging.FileHandler('.log/picows.log')
|
|
24
|
+
# file_handler.setLevel(PICOWS_DEBUG_LL)
|
|
25
|
+
|
|
26
|
+
# formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
27
|
+
# file_handler.setFormatter(formatter)
|
|
28
|
+
|
|
29
|
+
# picows_logger = logging.getLogger("picows")
|
|
30
|
+
# picows_logger.setLevel(PICOWS_DEBUG_LL)
|
|
31
|
+
# picows_logger.addHandler(file_handler)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Listener(WSListener):
|
|
35
|
+
"""WebSocket listener implementation that handles connection events and message frames.
|
|
36
|
+
|
|
37
|
+
Inherits from picows.WSListener to provide WebSocket event handling functionality.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
callback,
|
|
43
|
+
logger,
|
|
44
|
+
specific_ping_msg: bytes | None = None,
|
|
45
|
+
user_pong_callback: Callable[["Listener", WSFrame], bool] | None = None,
|
|
46
|
+
*args,
|
|
47
|
+
**kwargs,
|
|
48
|
+
):
|
|
49
|
+
"""Initialize the WebSocket listener.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
logger: Logger instance for logging events
|
|
53
|
+
specific_ping_msg: Optional custom ping message
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(*args, **kwargs)
|
|
56
|
+
self._log = logger
|
|
57
|
+
self._specific_ping_msg: bytes | None = specific_ping_msg
|
|
58
|
+
self._callback = callback
|
|
59
|
+
self._user_pong_callback = user_pong_callback
|
|
60
|
+
|
|
61
|
+
def send_user_specific_ping(self, transport: WSTransport) -> None:
|
|
62
|
+
"""Send a custom ping message or default ping frame.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
transport (picows.WSTransport): WebSocket transport instance
|
|
66
|
+
"""
|
|
67
|
+
if self._specific_ping_msg:
|
|
68
|
+
transport.send(WSMsgType.TEXT, self._specific_ping_msg)
|
|
69
|
+
self._log.debug(
|
|
70
|
+
f"Sent user specific ping: `{self._specific_ping_msg.decode()}`."
|
|
71
|
+
)
|
|
72
|
+
else:
|
|
73
|
+
transport.send_ping()
|
|
74
|
+
self._log.debug("Sent default ping.")
|
|
75
|
+
|
|
76
|
+
def on_ws_connected(self, transport: WSTransport) -> None:
|
|
77
|
+
"""Called when WebSocket connection is established.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
transport (picows.WSTransport): WebSocket transport instance
|
|
81
|
+
"""
|
|
82
|
+
self._log.debug("Connected to Websocket...")
|
|
83
|
+
|
|
84
|
+
def on_ws_disconnected(self, transport: WSTransport) -> None:
|
|
85
|
+
"""Called when WebSocket connection is closed.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
transport (picows.WSTransport): WebSocket transport instance
|
|
89
|
+
"""
|
|
90
|
+
self._log.debug("Disconnected from Websocket.")
|
|
91
|
+
|
|
92
|
+
def is_user_specific_pong(self, frame: WSFrame) -> bool:
|
|
93
|
+
if self._user_pong_callback is None:
|
|
94
|
+
return False
|
|
95
|
+
return self._user_pong_callback(self, frame)
|
|
96
|
+
|
|
97
|
+
def _decode_frame(self, frame: WSFrame) -> str:
|
|
98
|
+
"""Decode the payload of a WebSocket frame safely.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
frame (picows.WSFrame): Received WebSocket frame
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
str: Decoded payload as UTF-8 text or a placeholder for binary data
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
return frame.get_payload_as_utf8_text()
|
|
108
|
+
except Exception:
|
|
109
|
+
return f"<binary data: {len(frame.get_payload_as_bytes())} bytes>"
|
|
110
|
+
|
|
111
|
+
def on_ws_frame(self, transport: WSTransport, frame: WSFrame) -> None:
|
|
112
|
+
"""Handle incoming WebSocket frames.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
transport (picows.WSTransport): WebSocket transport instance
|
|
116
|
+
frame (picows.WSFrame): Received WebSocket frame
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
match frame.msg_type:
|
|
120
|
+
case WSMsgType.TEXT:
|
|
121
|
+
# Queue raw bytes for handler to decode
|
|
122
|
+
self._callback(frame.get_payload_as_bytes())
|
|
123
|
+
return
|
|
124
|
+
case WSMsgType.CLOSE:
|
|
125
|
+
close_code = frame.get_close_code()
|
|
126
|
+
self._log.warning(
|
|
127
|
+
f"Received close frame. Close code: {str(close_code)}"
|
|
128
|
+
)
|
|
129
|
+
return
|
|
130
|
+
except Exception as e:
|
|
131
|
+
import traceback
|
|
132
|
+
|
|
133
|
+
self._log.error(
|
|
134
|
+
f"Error processing message: {str(e)}\nTraceback: {traceback.format_exc()}\nws_frame: {self._decode_frame(frame)}"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class WSClient(ABC):
|
|
139
|
+
def __init__(
|
|
140
|
+
self,
|
|
141
|
+
url: str,
|
|
142
|
+
handler: Callable[..., Any],
|
|
143
|
+
task_manager: TaskManager,
|
|
144
|
+
clock: LiveClock,
|
|
145
|
+
specific_ping_msg: bytes | None = None,
|
|
146
|
+
reconnect_interval: int = 1,
|
|
147
|
+
ping_idle_timeout: int = 2,
|
|
148
|
+
ping_reply_timeout: int = 1,
|
|
149
|
+
auto_ping_strategy: Literal[
|
|
150
|
+
"ping_when_idle", "ping_periodically"
|
|
151
|
+
] = "ping_when_idle",
|
|
152
|
+
enable_auto_ping: bool = True,
|
|
153
|
+
enable_auto_pong: bool = True,
|
|
154
|
+
user_pong_callback: Callable[["Listener", WSFrame], bool] | None = None,
|
|
155
|
+
max_subscriptions_per_client: int | None = None,
|
|
156
|
+
max_clients: int | None = None,
|
|
157
|
+
):
|
|
158
|
+
self._clock = clock
|
|
159
|
+
self._url = url
|
|
160
|
+
self._specific_ping_msg = specific_ping_msg
|
|
161
|
+
self._reconnect_interval = reconnect_interval
|
|
162
|
+
self._ping_idle_timeout = ping_idle_timeout
|
|
163
|
+
self._ping_reply_timeout = ping_reply_timeout
|
|
164
|
+
self._enable_auto_pong = enable_auto_pong
|
|
165
|
+
self._enable_auto_ping = enable_auto_ping
|
|
166
|
+
self._user_pong_callback = user_pong_callback
|
|
167
|
+
self._listeners: dict[int, WSListener | None] = {}
|
|
168
|
+
self._transports: dict[int, WSTransport | None] = {}
|
|
169
|
+
self._subscriptions: list[Any] = []
|
|
170
|
+
self._client_subscriptions: dict[int, list[Any]] = {}
|
|
171
|
+
self._wait_tasks: dict[int, asyncio.Task] = {}
|
|
172
|
+
self._next_client_id = 0
|
|
173
|
+
self._started = False
|
|
174
|
+
self._max_subscriptions_per_client = max_subscriptions_per_client
|
|
175
|
+
self._max_clients = max_clients
|
|
176
|
+
self._callback = handler
|
|
177
|
+
if auto_ping_strategy == "ping_when_idle":
|
|
178
|
+
self._auto_ping_strategy = WSAutoPingStrategy.PING_WHEN_IDLE
|
|
179
|
+
elif auto_ping_strategy == "ping_periodically":
|
|
180
|
+
self._auto_ping_strategy = WSAutoPingStrategy.PING_PERIODICALLY
|
|
181
|
+
self._task_manager = task_manager
|
|
182
|
+
self._log = logging.getLogger(name=type(self).__name__)
|
|
183
|
+
self._ready = asyncio.Event() # Only set once on initial connection
|
|
184
|
+
self._connection_change_callback: Callable[[int, bool], None] | None = None
|
|
185
|
+
if self._max_subscriptions_per_client is not None:
|
|
186
|
+
if self._max_subscriptions_per_client <= 0:
|
|
187
|
+
raise ValueError("max_subscriptions_per_client must be positive")
|
|
188
|
+
if self._max_clients is not None:
|
|
189
|
+
if self._max_clients <= 0:
|
|
190
|
+
raise ValueError("max_clients must be positive")
|
|
191
|
+
|
|
192
|
+
@property
|
|
193
|
+
def connected(self) -> bool:
|
|
194
|
+
return any(transport is not None for transport in self._transports.values())
|
|
195
|
+
|
|
196
|
+
def set_connection_change_callback(
|
|
197
|
+
self, callback: Callable[[int, bool], None] | None
|
|
198
|
+
) -> None:
|
|
199
|
+
self._connection_change_callback = callback
|
|
200
|
+
|
|
201
|
+
def _notify_connection_change(self, client_id: int, connected: bool) -> None:
|
|
202
|
+
if self._connection_change_callback is None:
|
|
203
|
+
return
|
|
204
|
+
try:
|
|
205
|
+
self._connection_change_callback(client_id, connected)
|
|
206
|
+
except Exception as exc: # pragma: no cover - defensive logging
|
|
207
|
+
self._log.warning(f"Connection change callback error: {exc}")
|
|
208
|
+
|
|
209
|
+
def _is_client_connected(self, client_id: int) -> bool:
|
|
210
|
+
return self._transports.get(client_id) is not None
|
|
211
|
+
|
|
212
|
+
def _primary_client_id(self) -> int | None:
|
|
213
|
+
if 0 in self._client_subscriptions or 0 in self._transports:
|
|
214
|
+
return 0
|
|
215
|
+
if self._client_subscriptions:
|
|
216
|
+
return next(iter(self._client_subscriptions))
|
|
217
|
+
if self._transports:
|
|
218
|
+
return next(iter(self._transports))
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _ensure_client(self, client_id: int) -> None:
|
|
222
|
+
is_new_client = client_id not in self._client_subscriptions
|
|
223
|
+
if client_id not in self._client_subscriptions:
|
|
224
|
+
self._client_subscriptions[client_id] = []
|
|
225
|
+
if client_id not in self._transports:
|
|
226
|
+
self._transports[client_id] = None
|
|
227
|
+
if client_id not in self._listeners:
|
|
228
|
+
self._listeners[client_id] = None
|
|
229
|
+
if client_id >= self._next_client_id:
|
|
230
|
+
self._next_client_id = client_id + 1
|
|
231
|
+
if is_new_client:
|
|
232
|
+
self._notify_connection_change(client_id, False)
|
|
233
|
+
if self._started:
|
|
234
|
+
self._start_client_tasks(client_id)
|
|
235
|
+
|
|
236
|
+
def _multi_client_enabled(self) -> bool:
|
|
237
|
+
return self._max_subscriptions_per_client is not None
|
|
238
|
+
|
|
239
|
+
def _get_client_id_for_new_subscription(self) -> int:
|
|
240
|
+
if not self._multi_client_enabled():
|
|
241
|
+
client_id = 0
|
|
242
|
+
self._ensure_client(client_id)
|
|
243
|
+
return client_id
|
|
244
|
+
|
|
245
|
+
for client_id, subs in self._client_subscriptions.items():
|
|
246
|
+
if len(subs) < self._max_subscriptions_per_client: # type: ignore[operator]
|
|
247
|
+
return client_id
|
|
248
|
+
|
|
249
|
+
if (
|
|
250
|
+
self._max_clients is not None
|
|
251
|
+
and len(self._client_subscriptions) >= self._max_clients
|
|
252
|
+
):
|
|
253
|
+
raise RuntimeError("Maximum number of websocket clients reached")
|
|
254
|
+
|
|
255
|
+
client_id = self._next_client_id
|
|
256
|
+
self._ensure_client(client_id)
|
|
257
|
+
return client_id
|
|
258
|
+
|
|
259
|
+
def _find_client_for_subscription(self, subscription: Any) -> int | None:
|
|
260
|
+
for client_id, subs in self._client_subscriptions.items():
|
|
261
|
+
if subscription in subs:
|
|
262
|
+
return client_id
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
def _register_subscriptions(self, subscriptions: list[Any]) -> dict[int, list[Any]]:
|
|
266
|
+
assigned: dict[int, list[Any]] = {}
|
|
267
|
+
for subscription in subscriptions:
|
|
268
|
+
if subscription in self._subscriptions:
|
|
269
|
+
continue
|
|
270
|
+
client_id = self._get_client_id_for_new_subscription()
|
|
271
|
+
self._subscriptions.append(subscription)
|
|
272
|
+
self._client_subscriptions[client_id].append(subscription)
|
|
273
|
+
assigned.setdefault(client_id, []).append(subscription)
|
|
274
|
+
return assigned
|
|
275
|
+
|
|
276
|
+
def _unregister_subscriptions(
|
|
277
|
+
self, subscriptions: list[Any]
|
|
278
|
+
) -> dict[int, list[Any]]:
|
|
279
|
+
removed: dict[int, list[Any]] = {}
|
|
280
|
+
for subscription in subscriptions:
|
|
281
|
+
if subscription not in self._subscriptions:
|
|
282
|
+
continue
|
|
283
|
+
client_id = self._find_client_for_subscription(subscription)
|
|
284
|
+
if client_id is not None:
|
|
285
|
+
self._client_subscriptions[client_id].remove(subscription)
|
|
286
|
+
removed.setdefault(client_id, []).append(subscription)
|
|
287
|
+
self._subscriptions.remove(subscription)
|
|
288
|
+
return removed
|
|
289
|
+
|
|
290
|
+
async def _connect(self, client_id: int):
|
|
291
|
+
self._log.debug(
|
|
292
|
+
f"Connecting to Websocket at {self._url} (client {client_id})..."
|
|
293
|
+
)
|
|
294
|
+
WSListenerFactory = lambda: Listener( # noqa: E731
|
|
295
|
+
self._callback,
|
|
296
|
+
self._log,
|
|
297
|
+
self._specific_ping_msg,
|
|
298
|
+
self._user_pong_callback,
|
|
299
|
+
)
|
|
300
|
+
transport, listener = await ws_connect(
|
|
301
|
+
WSListenerFactory,
|
|
302
|
+
self._url,
|
|
303
|
+
enable_auto_ping=self._enable_auto_ping,
|
|
304
|
+
auto_ping_idle_timeout=self._ping_idle_timeout,
|
|
305
|
+
auto_ping_reply_timeout=self._ping_reply_timeout,
|
|
306
|
+
auto_ping_strategy=self._auto_ping_strategy,
|
|
307
|
+
enable_auto_pong=self._enable_auto_pong,
|
|
308
|
+
)
|
|
309
|
+
self._transports[client_id] = transport
|
|
310
|
+
self._listeners[client_id] = listener
|
|
311
|
+
self._log.debug(
|
|
312
|
+
f"Websocket connected successfully to {self._url} (client {client_id})."
|
|
313
|
+
)
|
|
314
|
+
self._notify_connection_change(client_id, True)
|
|
315
|
+
|
|
316
|
+
async def connect(self):
|
|
317
|
+
if not self._client_subscriptions:
|
|
318
|
+
self._ensure_client(0)
|
|
319
|
+
if self._started:
|
|
320
|
+
primary_id = self._primary_client_id()
|
|
321
|
+
if primary_id is not None:
|
|
322
|
+
task = self._wait_tasks.get(primary_id)
|
|
323
|
+
if task and not task.done():
|
|
324
|
+
self._log.debug("Websocket wait loop already running.")
|
|
325
|
+
return
|
|
326
|
+
self._started = True
|
|
327
|
+
for client_id in list(self._client_subscriptions.keys()):
|
|
328
|
+
self._start_client_tasks(client_id)
|
|
329
|
+
|
|
330
|
+
async def wait_ready(self):
|
|
331
|
+
"""Wait for the initial connection to be established.
|
|
332
|
+
|
|
333
|
+
This method only waits for the first successful connection.
|
|
334
|
+
Subsequent reconnections will not affect this event.
|
|
335
|
+
"""
|
|
336
|
+
await self._ready.wait()
|
|
337
|
+
|
|
338
|
+
async def _connection_handler(self, client_id: int):
|
|
339
|
+
while True:
|
|
340
|
+
try:
|
|
341
|
+
await self._connect(client_id)
|
|
342
|
+
await self._resubscribe_for_client(
|
|
343
|
+
client_id, self._client_subscriptions.get(client_id, [])
|
|
344
|
+
)
|
|
345
|
+
# Set ready event only on first successful connection
|
|
346
|
+
if not self._ready.is_set():
|
|
347
|
+
self._ready.set()
|
|
348
|
+
self._log.debug("Initial connection ready.")
|
|
349
|
+
transport = self._transports.get(client_id)
|
|
350
|
+
if transport is None:
|
|
351
|
+
break
|
|
352
|
+
await transport.wait_disconnected()
|
|
353
|
+
self._log.debug(f"Websocket disconnected (client {client_id}).")
|
|
354
|
+
self._notify_connection_change(client_id, False)
|
|
355
|
+
except asyncio.CancelledError:
|
|
356
|
+
self._log.debug(
|
|
357
|
+
f"Websocket connection loop cancelled (client {client_id})."
|
|
358
|
+
)
|
|
359
|
+
break
|
|
360
|
+
except Exception as e:
|
|
361
|
+
self._log.error(f"Connection error: {e}")
|
|
362
|
+
# finally:
|
|
363
|
+
# self._clean_up_client(client_id)
|
|
364
|
+
|
|
365
|
+
self._log.warning(
|
|
366
|
+
f"Websocket reconnecting in {self._reconnect_interval} seconds (client {client_id})..."
|
|
367
|
+
)
|
|
368
|
+
await asyncio.sleep(self._reconnect_interval)
|
|
369
|
+
|
|
370
|
+
def _start_client_tasks(self, client_id: int) -> None:
|
|
371
|
+
wait_task = self._wait_tasks.get(client_id)
|
|
372
|
+
if wait_task and not wait_task.done():
|
|
373
|
+
return
|
|
374
|
+
self._wait_tasks[client_id] = self._task_manager.create_task(
|
|
375
|
+
self._connection_handler(client_id)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _clean_up_client(self, client_id: int) -> None:
|
|
379
|
+
self._transports[client_id] = None
|
|
380
|
+
self._listeners[client_id] = None
|
|
381
|
+
|
|
382
|
+
def send(self, payload: dict, client_id: int | None = None):
|
|
383
|
+
target_id = client_id
|
|
384
|
+
if target_id is None:
|
|
385
|
+
target_id = self._primary_client_id()
|
|
386
|
+
if target_id is None:
|
|
387
|
+
self._log.warning(f"Websocket not connected. drop msg: {str(payload)}")
|
|
388
|
+
return
|
|
389
|
+
transport = self._transports.get(target_id)
|
|
390
|
+
if transport is None:
|
|
391
|
+
self._log.warning(f"Websocket not connected. drop msg: {str(payload)}")
|
|
392
|
+
return
|
|
393
|
+
transport.send(WSMsgType.TEXT, msgspec.json.encode(payload))
|
|
394
|
+
|
|
395
|
+
def disconnect(self, client_id: int | None = None):
|
|
396
|
+
if client_id is None:
|
|
397
|
+
client_ids = list(self._transports.keys())
|
|
398
|
+
else:
|
|
399
|
+
client_ids = [client_id]
|
|
400
|
+
|
|
401
|
+
for target_id in client_ids:
|
|
402
|
+
transport = self._transports.get(target_id)
|
|
403
|
+
if transport:
|
|
404
|
+
transport.disconnect()
|
|
405
|
+
|
|
406
|
+
@abstractmethod
|
|
407
|
+
async def _resubscribe_for_client(self, client_id: int, subscriptions: list[Any]):
|
|
408
|
+
pass
|
walrasquant/config.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any, Dict, List, Literal
|
|
3
|
+
from walrasquant.constants import AccountType, ExchangeType, OrderStatus, StorageType
|
|
4
|
+
from walrasquant.strategy import Strategy
|
|
5
|
+
from zmq.asyncio import Socket
|
|
6
|
+
|
|
7
|
+
LOG_LEVELS = Literal["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class LogConfig:
|
|
12
|
+
"""LogConfig for NexusTrader logging system using nexuslog.
|
|
13
|
+
|
|
14
|
+
Maps directly to nexuslog.basicConfig() parameters.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
filename: Optional file path for log output. If None, logs to stdout.
|
|
18
|
+
level: Minimum log level to record. Options: TRACE, DEBUG, INFO, WARNING, ERROR
|
|
19
|
+
unix_ts: If True, emit unix timestamps instead of formatted local time.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
filename: str | None = None
|
|
23
|
+
level: LOG_LEVELS = "INFO"
|
|
24
|
+
name_levels: dict[str | None, LOG_LEVELS] | None = None
|
|
25
|
+
unix_ts: bool = False
|
|
26
|
+
batch_size: int = 1
|
|
27
|
+
|
|
28
|
+
def __post_init__(self):
|
|
29
|
+
if self.level not in ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]:
|
|
30
|
+
raise ValueError(
|
|
31
|
+
f"Invalid level: {self.level}. Must be one of TRACE, DEBUG, INFO, WARNING, ERROR."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if self.name_levels:
|
|
35
|
+
for name, level in self.name_levels.items():
|
|
36
|
+
if level not in ["TRACE", "DEBUG", "INFO", "WARNING", "ERROR"]:
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"Invalid level: {level} for logger name: {name}. Must be one of TRACE, DEBUG, INFO, WARNING, ERROR."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class BasicConfig:
|
|
44
|
+
"""
|
|
45
|
+
Basic configuration for the trading system.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
api_key (str): API key for the exchange. For HyperLiquid, this is the real wallet address, not the address generated by the Agent.
|
|
49
|
+
secret (str): Secret key for the exchange. For HyperLiquid, this is the Agent private key, you can get it at https://app.hyperliquid-testnet.xyz/API
|
|
50
|
+
testnet (bool): Whether to use the testnet environment. Defaults to False.
|
|
51
|
+
passphrase (str | None): Passphrase for the exchange, if required. Defaults to None.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
api_key: str | None = None
|
|
55
|
+
secret: str | None = None
|
|
56
|
+
testnet: bool = False
|
|
57
|
+
passphrase: str | None = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class PublicConnectorConfig:
|
|
62
|
+
account_type: AccountType
|
|
63
|
+
enable_rate_limit: bool = True
|
|
64
|
+
custom_url: str | None = None
|
|
65
|
+
max_subscriptions_per_client: int | None = None
|
|
66
|
+
max_clients: int | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class PrivateConnectorConfig:
|
|
71
|
+
"""
|
|
72
|
+
Configuration for private connector settings.
|
|
73
|
+
|
|
74
|
+
Attributes
|
|
75
|
+
----------
|
|
76
|
+
account_type : AccountType
|
|
77
|
+
The type of account for the private connector.
|
|
78
|
+
enable_rate_limit : bool, default=True
|
|
79
|
+
Whether to enable rate limiting for API requests.
|
|
80
|
+
max_retries for failed order request : int, default=0
|
|
81
|
+
delay_initial_ms : int, default=100
|
|
82
|
+
delay_max_ms : int, default=800
|
|
83
|
+
backoff_factor : int, default=2
|
|
84
|
+
max_slippage : float, default=0.02
|
|
85
|
+
Maximum slippage allowed for market orders, expressed as a percentage (e.g., 0.02 for 2%). Only applicable for Bitget and HyperLiquid exchanges.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
account_type: AccountType
|
|
89
|
+
enable_rate_limit: bool = True
|
|
90
|
+
max_retries: int = 0
|
|
91
|
+
delay_initial_ms: int = 100
|
|
92
|
+
delay_max_ms: int = 800
|
|
93
|
+
backoff_factor: int = 2
|
|
94
|
+
max_slippage: float = 0.02 # 2% slippage
|
|
95
|
+
max_subscriptions_per_client: int | None = None
|
|
96
|
+
max_clients: int | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class ZeroMQSignalConfig:
|
|
101
|
+
"""ZeroMQ Signal Configuration Class.
|
|
102
|
+
|
|
103
|
+
Used to configure the ZeroMQ subscriber socket to receive custom trade signals.
|
|
104
|
+
|
|
105
|
+
Attributes:
|
|
106
|
+
socket (`zmq.asyncio.Socket`): ZeroMQ asynchronous socket object
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
>>> from zmq.asyncio import Context
|
|
110
|
+
>>> context = Context()
|
|
111
|
+
>>> socket = context.socket(zmq.SUB)
|
|
112
|
+
>>> socket.connect("ipc:///tmp/zmq_custom_signal")
|
|
113
|
+
>>> socket.setsockopt(zmq.SUBSCRIBE, b"")
|
|
114
|
+
>>> config = ZeroMQSignalConfig(socket=socket)
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
socket: Socket
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class WebConfig:
|
|
122
|
+
"""Configuration for the optional FastAPI web server."""
|
|
123
|
+
|
|
124
|
+
enabled: bool = False
|
|
125
|
+
host: str = "0.0.0.0"
|
|
126
|
+
port: int = 8000
|
|
127
|
+
log_level: Literal["critical", "error", "warning", "info", "debug"] = "info"
|
|
128
|
+
|
|
129
|
+
def __post_init__(self):
|
|
130
|
+
if not (0 < self.port <= 65535):
|
|
131
|
+
raise ValueError("port must be between 1 and 65535")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class QueueConfig:
|
|
136
|
+
ems_maxsize: int = 100_000
|
|
137
|
+
sms_maxsize: int = 100_000
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class MarketReloadConfig:
|
|
142
|
+
"""
|
|
143
|
+
Configuration for automatically reloading exchange market metadata.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
enabled: Whether to schedule automatic market reloads.
|
|
147
|
+
trigger: APScheduler trigger type. Supports ``interval`` and ``cron``.
|
|
148
|
+
kwargs: Trigger keyword arguments. Defaults to ``{"hours": 1}`` for
|
|
149
|
+
interval reloads, and ``{"hour": "*"}`` when cron is selected
|
|
150
|
+
without explicit kwargs.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
enabled: bool = False
|
|
154
|
+
trigger: Literal["interval", "cron"] = "interval"
|
|
155
|
+
kwargs: dict[str, Any] = field(default_factory=dict)
|
|
156
|
+
|
|
157
|
+
def __post_init__(self):
|
|
158
|
+
if self.trigger not in ("interval", "cron"):
|
|
159
|
+
raise ValueError("market reload trigger must be 'interval' or 'cron'")
|
|
160
|
+
if not isinstance(self.kwargs, dict):
|
|
161
|
+
raise TypeError("market reload kwargs must be a dict")
|
|
162
|
+
|
|
163
|
+
if self.trigger == "cron":
|
|
164
|
+
plural_fields = {"weeks", "days", "hours", "minutes", "seconds"}
|
|
165
|
+
invalid = plural_fields.intersection(self.kwargs)
|
|
166
|
+
if invalid:
|
|
167
|
+
fields = ", ".join(sorted(invalid))
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"cron market reload kwargs use singular field names; invalid: {fields}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def scheduler_kwargs(self) -> dict[str, Any]:
|
|
173
|
+
kwargs = dict(self.kwargs)
|
|
174
|
+
if self.trigger == "interval":
|
|
175
|
+
if not kwargs:
|
|
176
|
+
kwargs["hours"] = 1
|
|
177
|
+
return kwargs
|
|
178
|
+
|
|
179
|
+
if not kwargs:
|
|
180
|
+
kwargs["hour"] = "*"
|
|
181
|
+
return kwargs
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
DEFAULT_ORDER_QUERY_TIMEOUTS: dict[OrderStatus, float] = {
|
|
185
|
+
OrderStatus.PENDING: 2.0,
|
|
186
|
+
OrderStatus.CANCELING: 2.0,
|
|
187
|
+
OrderStatus.ACCEPTED: 600.0,
|
|
188
|
+
OrderStatus.PARTIALLY_FILLED: 600.0,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass
|
|
193
|
+
class OrderQueryConfig:
|
|
194
|
+
enabled: bool = True
|
|
195
|
+
check_interval_seconds: float = -1
|
|
196
|
+
timeout_seconds: dict[OrderStatus | str, float] = field(
|
|
197
|
+
default_factory=lambda: dict(DEFAULT_ORDER_QUERY_TIMEOUTS)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def __post_init__(self):
|
|
201
|
+
if self.check_interval_seconds != -1 and self.check_interval_seconds <= 0:
|
|
202
|
+
raise ValueError(
|
|
203
|
+
"order query check_interval_seconds must be positive or -1"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
normalized = dict(DEFAULT_ORDER_QUERY_TIMEOUTS)
|
|
207
|
+
for status, timeout in self.timeout_seconds.items():
|
|
208
|
+
order_status = self._parse_status(status)
|
|
209
|
+
if timeout <= 0:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"order query timeout_seconds[{order_status.value}] must be positive"
|
|
212
|
+
)
|
|
213
|
+
normalized[order_status] = float(timeout)
|
|
214
|
+
self.timeout_seconds = normalized
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _parse_status(status: OrderStatus | str) -> OrderStatus:
|
|
218
|
+
if isinstance(status, OrderStatus):
|
|
219
|
+
return status
|
|
220
|
+
try:
|
|
221
|
+
return OrderStatus(status)
|
|
222
|
+
except ValueError:
|
|
223
|
+
return OrderStatus[status]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass
|
|
227
|
+
class Config:
|
|
228
|
+
"""
|
|
229
|
+
Represents the configuration for a trading system.
|
|
230
|
+
|
|
231
|
+
This class holds all necessary configuration information including strategy details,
|
|
232
|
+
exchange configurations, connector setups, database settings, and logging preferences.
|
|
233
|
+
|
|
234
|
+
Attributes:
|
|
235
|
+
strategy_id (str): Unique identifier for the strategy.
|
|
236
|
+
user_id (str): Identifier for the user running the strategy.
|
|
237
|
+
strategy (Strategy): The trading strategy to be executed.
|
|
238
|
+
basic_config (Dict[ExchangeType, BasicConfig]): Basic configuration for each exchange.
|
|
239
|
+
public_conn_config (Dict[ExchangeType, List[PublicConnectorConfig]]): Public connector configurations by exchange.
|
|
240
|
+
private_conn_config (Dict[ExchangeType, List[PrivateConnectorConfig]]):
|
|
241
|
+
Private connector configurations by exchange.
|
|
242
|
+
zero_mq_signal_config (ZeroMQSignalConfig | None): Configuration for ZeroMQ signal, if used.
|
|
243
|
+
db_path (str): Path to the database file. Defaults to ".keys/cache.db".
|
|
244
|
+
storage_backend (StorageType): Type of storage backend to use. Defaults to SQLITE.
|
|
245
|
+
cache_sync_interval (int): Interval in seconds for cache synchronization. Defaults to 60.
|
|
246
|
+
cache_expired_time (int): Time in seconds after which cache entries expire. Defaults to 3600.
|
|
247
|
+
log_config (LogConfig): Configuration for logging. Defaults to a new LogConfig instance.
|
|
248
|
+
web_config (WebConfig): Settings for the optional FastAPI web interface.
|
|
249
|
+
flashduty_integration_key (str | None): FlashDuty integration key for push alerts. Defaults to None.
|
|
250
|
+
exit_after_cancel (bool): Whether to cancel all open orders when the engine is disposed (e.g., on Ctrl+C). Defaults to True.
|
|
251
|
+
market_reload_config (MarketReloadConfig): Optional automatic market metadata reload configuration.
|
|
252
|
+
order_query_config (OrderQueryConfig): Optional recovery query configuration for stale open/canceling orders.
|
|
253
|
+
"""
|
|
254
|
+
|
|
255
|
+
strategy_id: str
|
|
256
|
+
user_id: str
|
|
257
|
+
strategy: Strategy
|
|
258
|
+
basic_config: Dict[ExchangeType, BasicConfig]
|
|
259
|
+
public_conn_config: Dict[ExchangeType, List[PublicConnectorConfig]]
|
|
260
|
+
private_conn_config: Dict[ExchangeType, List[PrivateConnectorConfig]] = field(
|
|
261
|
+
default_factory=dict
|
|
262
|
+
)
|
|
263
|
+
zero_mq_signal_config: ZeroMQSignalConfig | None = None
|
|
264
|
+
db_path: str = ".keys/cache.db"
|
|
265
|
+
storage_backend: StorageType = StorageType.SQLITE
|
|
266
|
+
cache_sync_interval: int = 60
|
|
267
|
+
cache_expired_time: int = 3600
|
|
268
|
+
log_config: LogConfig = field(default_factory=LogConfig)
|
|
269
|
+
web_config: WebConfig = field(default_factory=WebConfig)
|
|
270
|
+
exit_after_cancel: bool = True
|
|
271
|
+
flashduty_integration_key: str | None = None
|
|
272
|
+
queue_config: QueueConfig = field(default_factory=QueueConfig)
|
|
273
|
+
market_reload_config: MarketReloadConfig = field(default_factory=MarketReloadConfig)
|
|
274
|
+
order_query_config: OrderQueryConfig = field(default_factory=OrderQueryConfig)
|
|
275
|
+
|
|
276
|
+
def __post_init__(self):
|
|
277
|
+
if self.log_config.filename is None:
|
|
278
|
+
import os
|
|
279
|
+
|
|
280
|
+
log_folder = "logs"
|
|
281
|
+
os.makedirs(log_folder, exist_ok=True)
|
|
282
|
+
self.log_config.filename = (
|
|
283
|
+
f"{log_folder}/{self.strategy_id}_{self.user_id}.log"
|
|
284
|
+
)
|