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
@@ -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
+ )