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,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
|