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,59 @@
1
+ import time
2
+
3
+ _MAX_SPIN_FRAC = 0.1 # spin at most 10% of total duration
4
+
5
+
6
+ def _calibrate_spin_tail(samples: int = 100) -> float:
7
+ """Measure ``time.sleep`` overshoot on this system and return a spin tail
8
+ that covers p99 jitter with a 2x safety margin, clamped to [100 µs, 2 ms].
9
+ """
10
+ overshoots: list[float] = []
11
+ for _ in range(samples):
12
+ target = 0.0005
13
+ start = time.monotonic()
14
+ time.sleep(target)
15
+ overshoots.append(time.monotonic() - start - target)
16
+ overshoots.sort()
17
+ p99 = overshoots[int(len(overshoots) * 0.99)]
18
+ return max(0.0001, min(p99 * 2, 0.002))
19
+
20
+
21
+ _SPIN_TAIL_S: float = _calibrate_spin_tail()
22
+
23
+
24
+ def precise_sleep(
25
+ seconds: float = 0,
26
+ milliseconds: float = 0,
27
+ microseconds: float = 0,
28
+ ) -> None:
29
+ """High-precision hybrid sleep with auto-calibrated spin tail.
30
+
31
+ At import time the module measures ``time.sleep`` jitter on the current
32
+ system and picks a spin tail that covers p99 overshoot. The tail is
33
+ further capped at 10 % of the requested duration so that CPU usage
34
+ stays bounded (~5 %) regardless of how short the sleep is.
35
+
36
+ Parameters are additive. At least one must be positive.
37
+
38
+ Parameters
39
+ ----------
40
+ seconds : float
41
+ Duration in seconds.
42
+ milliseconds : float
43
+ Duration in milliseconds.
44
+ microseconds : float
45
+ Duration in microseconds.
46
+ """
47
+ total = seconds + milliseconds / 1_000 + microseconds / 1_000_000
48
+ if total <= 0:
49
+ raise ValueError(
50
+ "wait() requires a positive duration – "
51
+ "provide at least one of seconds, milliseconds, or microseconds"
52
+ )
53
+ deadline = time.monotonic() + total
54
+ tail = min(_SPIN_TAIL_S, total * _MAX_SPIN_FRAC)
55
+ bulk = total - tail
56
+ if bulk > 0:
57
+ time.sleep(bulk)
58
+ while time.monotonic() < deadline:
59
+ pass
@@ -0,0 +1,41 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+
5
+ ConnectionRole = Literal["MD", "TD"]
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ConnectionState:
10
+ role: ConnectionRole
11
+ exchange_id: str
12
+ account_type: str
13
+ ws_name: str
14
+ client_id: int
15
+ required: bool
16
+ connected: bool
17
+ changed_at_ms: int
18
+
19
+ def is_md(self) -> bool:
20
+ return self.role == "MD"
21
+
22
+ def is_td(self) -> bool:
23
+ return self.role == "TD"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ConnectionPolicyState:
28
+ md_ok: bool
29
+ td_ok: bool
30
+
31
+ @property
32
+ def allow_open(self) -> bool:
33
+ return self.md_ok and self.td_ok
34
+
35
+ @property
36
+ def allow_trade(self) -> bool:
37
+ return self.td_ok
38
+
39
+ @property
40
+ def allow_close_only(self) -> bool:
41
+ return self.td_ok and not self.md_ok
@@ -0,0 +1,504 @@
1
+ import inspect
2
+ import signal
3
+ import asyncio
4
+ import uuid
5
+ import warnings
6
+ import nexuslog as logging
7
+
8
+ from typing import Callable, Coroutine, Any, TypeVar, Union
9
+ from typing import Dict, List
10
+ from dataclasses import dataclass
11
+ from walrasquant.core.nautilius_core import LiveClock
12
+ from walrasquant.schema import (
13
+ Kline,
14
+ BookL1,
15
+ Trade,
16
+ FundingRate,
17
+ BookL2,
18
+ IndexPrice,
19
+ MarkPrice,
20
+ )
21
+
22
+ T = TypeVar("T")
23
+ InputDataType = Union[
24
+ Kline, BookL1, Trade, FundingRate, BookL2, IndexPrice, IndexPrice, MarkPrice
25
+ ]
26
+
27
+
28
+ class OidGen:
29
+ __slots__ = ("_shard", "_last_ms", "_seq", "_clock")
30
+
31
+ def __init__(self, clock: LiveClock):
32
+ self._shard = self._generate_shard()
33
+ self._last_ms = 0
34
+ self._seq = 0
35
+ self._clock = clock
36
+
37
+ def _generate_shard(self) -> int:
38
+ uuid_int = uuid.uuid4().int
39
+ shard = (uuid_int % 9999) + 1
40
+ return shard
41
+
42
+ @property
43
+ def oid(self) -> str:
44
+ ms = self._clock.timestamp_ms()
45
+ if ms == self._last_ms:
46
+ self._seq = (self._seq + 1) % 1000
47
+ if self._seq == 0:
48
+ while True:
49
+ ms2 = self._clock.timestamp_ms()
50
+ if ms2 > ms:
51
+ ms = ms2
52
+ break
53
+ else:
54
+ self._seq = 0
55
+ self._last_ms = ms
56
+ return f"{ms:013d}{self._seq:03d}{self._shard:04d}"
57
+
58
+ def get_shard(self) -> int:
59
+ return self._shard
60
+
61
+
62
+ def is_redis_available() -> bool:
63
+ """Check if Redis dependencies and server are available"""
64
+ try:
65
+ # Check if Redis Python packages are installed
66
+ import redis
67
+ import socket
68
+ from walrasquant.constants import get_redis_config
69
+
70
+ # Check if Redis server is accessible
71
+ try:
72
+ # Get Redis config and test connection
73
+ redis_config = get_redis_config()
74
+ client = redis.Redis(**redis_config)
75
+ client.ping() # This will raise an exception if Redis is not accessible
76
+ client.close()
77
+ return True
78
+ except (redis.ConnectionError, redis.TimeoutError, socket.error, Exception):
79
+ return False
80
+
81
+ except ImportError:
82
+ return False
83
+
84
+
85
+ def get_redis_client_if_available():
86
+ import redis
87
+
88
+ """Get Redis client if available, otherwise return None"""
89
+ if not is_redis_available():
90
+ return None
91
+
92
+ try:
93
+ from walrasquant.constants import get_redis_config
94
+
95
+ redis_config = get_redis_config()
96
+ return redis.Redis(**redis_config)
97
+ except Exception:
98
+ return None
99
+
100
+
101
+ @dataclass
102
+ class RateLimit:
103
+ """
104
+ max_rate: Allow up to max_rate / time_period acquisitions before blocking.
105
+ time_period: Time period in seconds.
106
+ """
107
+
108
+ max_rate: float
109
+ time_period: float = 60
110
+
111
+
112
+ class TaskManager:
113
+ def __init__(
114
+ self,
115
+ loop: asyncio.AbstractEventLoop,
116
+ enable_signal_handlers: bool = True,
117
+ cancel_timeout: float | None = 5.0,
118
+ ):
119
+ self._log = logging.getLogger(name=type(self).__name__)
120
+ self._tasks: Dict[str, asyncio.Task] = {}
121
+ self._shutdown_event = asyncio.Event()
122
+ self._loop = loop
123
+ self._cancel_timeout = cancel_timeout
124
+ if enable_signal_handlers:
125
+ self._setup_signal_handlers()
126
+
127
+ @property
128
+ def loop(self) -> asyncio.AbstractEventLoop:
129
+ return self._loop
130
+
131
+ def _setup_signal_handlers(self):
132
+ try:
133
+ for sig in (signal.SIGINT, signal.SIGTERM):
134
+ self._loop.add_signal_handler(
135
+ sig, lambda: self.create_task(self._shutdown())
136
+ )
137
+ except NotImplementedError:
138
+ warnings.warn("Signal handlers not supported on this platform")
139
+
140
+ async def _shutdown(self):
141
+ self._shutdown_event.set()
142
+ self._log.debug("Shutdown signal received, cleaning up...")
143
+
144
+ def create_task(
145
+ self, coro: Coroutine[Any, Any, Any], name: str | None = None
146
+ ) -> asyncio.Task:
147
+ task = asyncio.create_task(coro, name=name)
148
+ self._tasks[task.get_name()] = task
149
+ task.add_done_callback(self._handle_task_done)
150
+ return task
151
+
152
+ def run_sync(self, coro: Coroutine[Any, Any, T]) -> T:
153
+ """
154
+ Run an async coroutine in a synchronous context.
155
+
156
+ Args:
157
+ coro: The coroutine to run
158
+
159
+ Returns:
160
+ The result of the coroutine
161
+
162
+ Raises:
163
+ RuntimeError: If the event loop is not running and cannot be started
164
+ Exception: Any exception raised by the coroutine
165
+ """
166
+ try:
167
+ running = asyncio.get_running_loop()
168
+ except RuntimeError:
169
+ running = None
170
+ if running is self._loop:
171
+ # Called from the loop thread (e.g. inside a fast=True scheduled job, a
172
+ # coroutine, or a data callback like on_bookl1/on_trade). Blocking on
173
+ # run_coroutine_threadsafe(...).result() here would block the loop waiting
174
+ # for itself -> deadlock. Fail fast with an actionable message instead.
175
+ coro.close() # prevent "coroutine was never awaited" warning
176
+ raise RuntimeError(
177
+ "run_sync()/request_* cannot be called from the event loop thread "
178
+ "(e.g. inside a fast=True scheduled job, a coroutine, or a data "
179
+ "callback like on_bookl1/on_trade). It would deadlock the loop. Read "
180
+ "cached data (self.cache...) instead, or run the call from a normal "
181
+ "thread-based schedule() job."
182
+ )
183
+ try:
184
+ if self._loop.is_running():
185
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
186
+ return future.result()
187
+ else:
188
+ if self._loop.is_closed():
189
+ raise RuntimeError("Event loop is closed")
190
+ return self._loop.run_until_complete(coro)
191
+ except asyncio.CancelledError:
192
+ raise RuntimeError("Coroutine was cancelled")
193
+ except Exception as e:
194
+ self._log.error(f"Error running coroutine: {e}")
195
+ raise
196
+
197
+ def cancel_task(self, name: str) -> bool:
198
+ if name in self._tasks:
199
+ self._tasks[name].cancel()
200
+ return True
201
+ return False
202
+
203
+ def _handle_task_done(self, task: asyncio.Task):
204
+ try:
205
+ name = task.get_name()
206
+ self._tasks.pop(name, None)
207
+ task.result()
208
+ except asyncio.CancelledError:
209
+ pass
210
+ except Exception as e:
211
+ self._log.error(f"Error during task done: {e}")
212
+ raise
213
+
214
+ async def wait(self):
215
+ try:
216
+ if self._tasks:
217
+ await self._shutdown_event.wait()
218
+ except Exception as e:
219
+ self._log.error(f"Error during wait: {e}")
220
+ raise
221
+
222
+ async def cancel(self, timeout: float | None = None):
223
+ try:
224
+ # Snapshot tasks to avoid dict mutation during iteration
225
+ tasks = [t for t in self._tasks.values() if not t.done()]
226
+
227
+ for task in tasks:
228
+ task.cancel()
229
+
230
+ if tasks:
231
+ gather_coro = asyncio.gather(*tasks, return_exceptions=True)
232
+ try:
233
+ effective_timeout = (
234
+ self._cancel_timeout if timeout is None else timeout
235
+ )
236
+ if effective_timeout is not None:
237
+ results = await asyncio.wait_for(
238
+ gather_coro, timeout=effective_timeout
239
+ )
240
+ else:
241
+ results = await gather_coro
242
+ except asyncio.TimeoutError:
243
+ self._log.warning(
244
+ "Cancellation timed out; some tasks may still be running"
245
+ )
246
+ else:
247
+ for result in results:
248
+ if isinstance(result, Exception) and not isinstance(
249
+ result, asyncio.CancelledError
250
+ ):
251
+ self._log.error(
252
+ f"Task failed during cancellation: {result}"
253
+ )
254
+
255
+ except Exception as e:
256
+ self._log.error(f"Error during cancellation: {e}")
257
+ raise
258
+ finally:
259
+ self._tasks.clear()
260
+
261
+
262
+ # class Clock:
263
+ # def __init__(self, tick_size: float = 1.0):
264
+ # """
265
+ # :param tick_size_s: Time interval of each tick in seconds (supports sub-second precision).
266
+ # """
267
+ # self._tick_size = tick_size # Tick size in seconds
268
+ # self._current_tick = (time.time() // self._tick_size) * self._tick_size
269
+ # self._clock = LiveClock()
270
+ # self._tick_callbacks: List[Callable[[float], None]] = []
271
+ # self._started = False
272
+
273
+ # @property
274
+ # def tick_size(self) -> float:
275
+ # return self._tick_size
276
+
277
+ # @property
278
+ # def current_timestamp(self) -> float:
279
+ # return self._clock.timestamp()
280
+
281
+ # def add_tick_callback(self, callback: Callable[[float], None]):
282
+ # """
283
+ # Register a callback to be called on each tick.
284
+ # :param callback: Function to be called with current_tick as argument.
285
+ # """
286
+ # self._tick_callbacks.append(callback)
287
+
288
+ # async def run(self):
289
+ # if self._started:
290
+ # raise RuntimeError("Clock is already running.")
291
+ # self._started = True
292
+ # while True:
293
+ # now = time.time()
294
+ # next_tick_time = self._current_tick + self._tick_size
295
+ # sleep_duration = next_tick_time - now
296
+ # if sleep_duration > 0:
297
+ # await asyncio.sleep(sleep_duration)
298
+ # else:
299
+ # # If we're behind schedule, skip to the next tick to prevent drift
300
+ # next_tick_time = now
301
+ # self._current_tick = next_tick_time
302
+ # for callback in self._tick_callbacks:
303
+ # if asyncio.iscoroutinefunction(callback):
304
+ # await callback(self.current_timestamp)
305
+ # else:
306
+ # callback(self.current_timestamp)
307
+
308
+
309
+ class ZeroMQSignalRecv:
310
+ def __init__(self, config, callback: Callable, task_manager: TaskManager):
311
+ self._socket = config.socket
312
+ self._callback = callback
313
+ self._task_manager = task_manager
314
+
315
+ async def _recv(self):
316
+ while True:
317
+ date = await self._socket.recv()
318
+ if inspect.iscoroutinefunction(self._callback):
319
+ await self._callback(date)
320
+ else:
321
+ self._callback(date)
322
+
323
+ async def start(self):
324
+ self._task_manager.create_task(self._recv())
325
+
326
+
327
+ # class MovingAverage:
328
+ # """
329
+ # Calculate moving median or mean using a sliding window.
330
+
331
+ # Args:
332
+ # length: Length of the sliding window
333
+ # method: 'median' or 'mean' calculation method
334
+ # """
335
+
336
+ # def __init__(self, length: int, method: Literal["median", "mean"] = "mean"):
337
+ # if method not in ["median", "mean"]:
338
+ # raise ValueError("method must be either 'median' or 'mean'")
339
+
340
+ # self._length = length
341
+ # self._method = method
342
+ # self._window = deque(maxlen=length)
343
+ # self._calc_func = median if method == "median" else mean
344
+
345
+ # def input(self, value: float) -> float | None:
346
+ # """
347
+ # Input a new value and return the current median/mean.
348
+
349
+ # Args:
350
+ # value: New value to add to sliding window
351
+
352
+ # Returns:
353
+ # Current median/mean value, or None if window not filled
354
+ # """
355
+ # self._window.append(value)
356
+
357
+ # if len(self._window) < self._length:
358
+ # return None
359
+
360
+ # return self._calc_func(self._window)
361
+
362
+
363
+ class DataReady:
364
+ def __init__(
365
+ self,
366
+ symbols: List[str],
367
+ name: str,
368
+ clock: LiveClock,
369
+ timeout: int = 60,
370
+ permanently_ready: bool = False,
371
+ ):
372
+ """
373
+ Initialize DataReady class
374
+
375
+ Args:
376
+ symbols: symbols list need to monitor
377
+ timeout: timeout in seconds
378
+ """
379
+ self._log = logging.getLogger(name=type(self).__name__)
380
+
381
+ # Optimization: Store symbols as a set for faster lookups if needed,
382
+ # but dict is fine for tracking status.
383
+ self._symbols_status = {symbol: False for symbol in symbols}
384
+ self._total_symbols = len(symbols)
385
+ self._ready_symbols_count = 0
386
+
387
+ self._timeout_ms = timeout * 1000 # Store timeout in ms
388
+ self._clock = clock
389
+ self._first_data_time: int | None = None
390
+ self._name = name
391
+ # Optimization: A flag to indicate that the "ready" state is final
392
+ # (either all symbols received or timed out).
393
+ self._is_permanently_ready: bool = permanently_ready
394
+
395
+ if not symbols: # If no symbols to monitor, it's ready immediately (or upon first data for timestamp)
396
+ # We'll consider it ready once _first_data_time is set, which requires at least one input call
397
+ # Or, we can decide it's ready right away if that's the desired behavior.
398
+ # For now, let's assume if symbols is empty, it becomes ready when `ready` is first checked
399
+ # after _first_data_time is set, or on timeout.
400
+ # Alternatively, one could set self._is_permanently_ready = True here.
401
+ # The current logic for _ready_symbols_count == _total_symbols (0 == 0) will handle this.
402
+ self._is_permanently_ready = True
403
+
404
+ def input(self, data: InputDataType) -> None:
405
+ """
406
+ Input data, update the status of the corresponding symbol
407
+
408
+ Args:
409
+ data: data object with symbol attribute
410
+ """
411
+ # Optimization: If already permanently ready, do nothing.
412
+ if self._is_permanently_ready:
413
+ return
414
+
415
+ symbol = data.symbol
416
+
417
+ if self._first_data_time is None:
418
+ self._first_data_time = self._clock.timestamp_ms()
419
+
420
+ # Process only if the symbol is one we are monitoring and it's not yet marked ready.
421
+ if symbol in self._symbols_status and not self._symbols_status[symbol]:
422
+ self._symbols_status[symbol] = True
423
+ self._ready_symbols_count += 1
424
+
425
+ # Optimization: Check if all symbols are now ready.
426
+ if self._ready_symbols_count == self._total_symbols:
427
+ self._log.debug(
428
+ f"All {self._total_symbols} symbols received. {self._name} is ready."
429
+ )
430
+ self._is_permanently_ready = True
431
+ # No need to call self.ready here, the flag is enough.
432
+
433
+ def add_symbols(self, symbols: str | list[str]) -> None:
434
+ """
435
+ Add a new symbol to monitor
436
+
437
+ Args:
438
+ symbol: symbol to add
439
+ """
440
+ if isinstance(symbols, str):
441
+ symbols = [symbols]
442
+
443
+ for symbol in symbols:
444
+ if symbol not in self._symbols_status:
445
+ self._symbols_status[symbol] = False
446
+ self._total_symbols += 1
447
+ # If we were already permanently ready, adding a new symbol means we are no longer ready.
448
+ if self._is_permanently_ready:
449
+ self._is_permanently_ready = False
450
+
451
+ @property
452
+ def ready(self) -> bool:
453
+ """
454
+ Check if all data is ready or if it has timed out
455
+
456
+ Returns:
457
+ bool: if all data is ready or timed out, return True
458
+ """
459
+ # Optimization: If already determined to be permanently ready, return True.
460
+ if self._is_permanently_ready:
461
+ return True
462
+
463
+ if self._first_data_time is None:
464
+ # No data received yet, so not ready (unless symbols list was empty and we decided that's ready from init)
465
+ return False # Or handle empty symbols list case from __init__
466
+
467
+ # Check if all symbols have been received (could have been set by input)
468
+ if self._ready_symbols_count == self._total_symbols:
469
+ # This ensures that even if input didn't set _is_permanently_ready
470
+ # (e.g. if ready is called before next input after last symbol arrived),
471
+ # it's correctly identified.
472
+ if (
473
+ not self._is_permanently_ready
474
+ ): # To avoid repeated logging if already logged by input
475
+ self._log.debug(
476
+ f"All {self._total_symbols} symbols confirmed ready by property access."
477
+ )
478
+ self._is_permanently_ready = True
479
+ return True
480
+
481
+ # Check for timeout
482
+ if self._clock.timestamp_ms() - self._first_data_time > self._timeout_ms:
483
+ # Only issue warning if not all symbols were ready before timeout
484
+ if self._ready_symbols_count < self._total_symbols:
485
+ not_ready_symbols = [
486
+ symbol
487
+ for symbol, status in self._symbols_status.items()
488
+ if not status
489
+ ]
490
+ if not_ready_symbols: # Should always be true if count < total
491
+ warnings.warn(
492
+ f"Data receiving timed out. Timeout: {self._timeout_ms / 1000}s. "
493
+ f"Received {self._ready_symbols_count}/{self._total_symbols} symbols. "
494
+ f"The following symbols are not ready: {', '.join(not_ready_symbols)}"
495
+ )
496
+ else: # All symbols were received, but timeout check ran.
497
+ self._log.debug(
498
+ f"Timeout checked, but all symbols were already received. {self._name} is ready."
499
+ )
500
+
501
+ self._is_permanently_ready = True # Timed out, so state is now final.
502
+ return True
503
+
504
+ return False # Not all symbols ready and not timed out yet.
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import import_module
4
+ from typing import Any, Mapping
5
+
6
+ import nexuslog as logging
7
+ from walrasquant.constants import settings
8
+
9
+
10
+ use_nautilius = settings.get("USE_NAUTILIUS", False)
11
+
12
+ if use_nautilius:
13
+ try:
14
+ component = import_module("nautilus_trader.common.component")
15
+ identifiers = import_module("nautilus_trader.model.identifiers")
16
+ uuid_mod = import_module("nautilus_trader.core.uuid")
17
+ nautilus_pyo3 = import_module("nautilus_trader.core.nautilus_pyo3")
18
+
19
+ MessageBus = component.MessageBus
20
+ LiveClock = component.LiveClock
21
+ TimeEvent = component.TimeEvent
22
+ TraderId = identifiers.TraderId
23
+ UUID4 = uuid_mod.UUID4
24
+ HttpClient = nautilus_pyo3.HttpClient
25
+ HttpMethod = nautilus_pyo3.HttpMethod
26
+ HttpResponse = nautilus_pyo3.HttpResponse
27
+ WebSocketClient = nautilus_pyo3.WebSocketClient
28
+ WebSocketClientError = nautilus_pyo3.WebSocketClientError
29
+ WebSocketConfig = nautilus_pyo3.WebSocketConfig
30
+ hmac_signature = nautilus_pyo3.hmac_signature
31
+ rsa_signature = nautilus_pyo3.rsa_signature
32
+ ed25519_signature = nautilus_pyo3.ed25519_signature
33
+ except ImportError as e:
34
+ raise ImportError(
35
+ "Nautilus Trader is not installed. Please install run `pip install nautilus-trader` to use Nautilus features."
36
+ ) from e
37
+ else:
38
+ from nexuscore import (
39
+ MessageBus,
40
+ LiveClock,
41
+ TimeEvent,
42
+ TraderId,
43
+ UUID4,
44
+ hmac_signature,
45
+ rsa_signature,
46
+ ed25519_signature,
47
+ )
48
+
49
+
50
+ def setup_nexus_core(
51
+ trader_id: str,
52
+ filename: str | None = None,
53
+ level: str = "INFO",
54
+ name_levels: Mapping[str | None, str] | None = None,
55
+ unix_ts: bool = False,
56
+ batch_size: int | None = None,
57
+ ) -> tuple[Any, Any]:
58
+ """
59
+ Setup logging for the application using nexuslog and initialize MessageBus and Clock.
60
+
61
+ Args:
62
+ trader_id: Unique identifier for the trader
63
+ filename: Optional file path for log output. If None, logs to stdout.
64
+ level: Minimum log level to record (TRACE, DEBUG, INFO, WARNING, ERROR)
65
+ unix_ts: If True, emit unix timestamps instead of formatted local time.
66
+
67
+ Returns:
68
+ tuple: (msgbus, clock) - MessageBus and LiveClock instances
69
+ """
70
+ clock = LiveClock()
71
+ msgbus = MessageBus(
72
+ trader_id=TraderId(trader_id),
73
+ clock=clock,
74
+ )
75
+
76
+ # Map log levels from string to nexuslog levels
77
+ level_map = {
78
+ "TRACE": logging.TRACE,
79
+ "DEBUG": logging.DEBUG,
80
+ "INFO": logging.INFO,
81
+ "WARNING": logging.WARNING,
82
+ "ERROR": logging.ERROR,
83
+ }
84
+
85
+ log_level = level_map.get(level, logging.INFO)
86
+
87
+ _name_levels = None
88
+ if name_levels:
89
+ _name_levels = {
90
+ name: level_map.get(lvl_str, logging.INFO)
91
+ for name, lvl_str in name_levels.items()
92
+ }
93
+
94
+ # Configure nexuslog using basicConfig
95
+ logging.basicConfig(
96
+ filename=filename,
97
+ level=log_level,
98
+ name_levels=_name_levels,
99
+ unix_ts=unix_ts,
100
+ batch_size=batch_size,
101
+ )
102
+
103
+ return msgbus, clock