wiz-trader 0.9.0__py3-none-any.whl → 0.10.0__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.
- wiz_trader/__init__.py +1 -1
- wiz_trader/apis/client.py +176 -44
- wiz_trader/quotes/client.py +122 -17
- wiz_trader-0.10.0.dist-info/METADATA +1781 -0
- wiz_trader-0.10.0.dist-info/RECORD +9 -0
- {wiz_trader-0.9.0.dist-info → wiz_trader-0.10.0.dist-info}/WHEEL +1 -1
- wiz_trader-0.9.0.dist-info/METADATA +0 -165
- wiz_trader-0.9.0.dist-info/RECORD +0 -9
- {wiz_trader-0.9.0.dist-info → wiz_trader-0.10.0.dist-info}/top_level.txt +0 -0
wiz_trader/quotes/client.py
CHANGED
@@ -3,7 +3,7 @@ import json
|
|
3
3
|
import os
|
4
4
|
import logging
|
5
5
|
import random
|
6
|
-
from typing import Callable, List, Optional, Any, Iterator
|
6
|
+
from typing import Callable, List, Optional, Any, Dict, Iterator
|
7
7
|
|
8
8
|
import websockets
|
9
9
|
from websockets.exceptions import ConnectionClosed
|
@@ -25,10 +25,13 @@ class QuotesClient:
|
|
25
25
|
Attributes:
|
26
26
|
base_url (str): WebSocket URL of the quotes server.
|
27
27
|
token (str): JWT token for authentication.
|
28
|
-
on_tick (Callable[[dict], None]): Callback to process received tick data.
|
29
28
|
log_level (str): Logging level. Options: "error", "info", "debug".
|
30
29
|
"""
|
31
30
|
|
31
|
+
# Constants for actions
|
32
|
+
ACTION_SUBSCRIBE = "subscribe"
|
33
|
+
ACTION_UNSUBSCRIBE = "unsubscribe"
|
34
|
+
|
32
35
|
def __init__(
|
33
36
|
self,
|
34
37
|
base_url: Optional[str] = None,
|
@@ -57,14 +60,21 @@ class QuotesClient:
|
|
57
60
|
# Construct the WebSocket URL.
|
58
61
|
self.url = f"{self.base_url}?token={self.token}"
|
59
62
|
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
60
|
-
self.on_tick: Optional[Callable[[dict], None]] = None
|
61
63
|
self.subscribed_instruments: set = set()
|
64
|
+
self._running = False
|
65
|
+
self._background_task = None
|
62
66
|
|
63
67
|
# Backoff configuration for reconnection (in seconds)
|
64
68
|
self._backoff_base = 1
|
65
69
|
self._backoff_factor = 2
|
66
70
|
self._backoff_max = 60
|
67
71
|
|
72
|
+
# Callbacks
|
73
|
+
self.on_tick: Optional[Callable[[Any, dict], None]] = None
|
74
|
+
self.on_connect: Optional[Callable[[Any], None]] = None
|
75
|
+
self.on_close: Optional[Callable[[Any, Optional[int], Optional[str]], None]] = None
|
76
|
+
self.on_error: Optional[Callable[[Any, Exception], None]] = None
|
77
|
+
|
68
78
|
logger.debug("Initialized QuotesClient with URL: %s", self.url)
|
69
79
|
|
70
80
|
def _chunk_list(self, data: List[Any], chunk_size: int) -> Iterator[List[Any]]:
|
@@ -81,27 +91,33 @@ class QuotesClient:
|
|
81
91
|
for i in range(0, len(data), chunk_size):
|
82
92
|
yield data[i:i + chunk_size]
|
83
93
|
|
84
|
-
async def
|
94
|
+
async def _connect_with_backoff(self) -> None:
|
85
95
|
"""
|
86
|
-
Continuously connect to the quotes server
|
87
|
-
Implements an exponential backoff reconnection strategy.
|
96
|
+
Continuously connect to the quotes server with exponential backoff.
|
88
97
|
"""
|
89
98
|
backoff = self._backoff_base
|
90
99
|
|
91
|
-
while
|
100
|
+
while self._running:
|
92
101
|
try:
|
93
102
|
logger.info("Connecting to %s ...", self.url)
|
94
103
|
async with websockets.connect(self.url, max_size=self.max_message_size) as websocket:
|
95
104
|
self.ws = websocket
|
96
105
|
logger.info("Connected to the quotes server.")
|
97
106
|
|
107
|
+
# Call the on_connect callback if provided
|
108
|
+
if self.on_connect:
|
109
|
+
try:
|
110
|
+
self.on_connect(self)
|
111
|
+
except Exception as e:
|
112
|
+
logger.error("Error in on_connect callback: %s", e, exc_info=True)
|
113
|
+
|
98
114
|
# On reconnection, re-subscribe if needed.
|
99
115
|
if self.subscribed_instruments:
|
100
116
|
# Split into batches to avoid message size issues
|
101
117
|
instruments_list = list(self.subscribed_instruments)
|
102
118
|
for batch in self._chunk_list(instruments_list, self.batch_size):
|
103
119
|
subscribe_msg = {
|
104
|
-
"action":
|
120
|
+
"action": self.ACTION_SUBSCRIBE,
|
105
121
|
"instruments": batch
|
106
122
|
}
|
107
123
|
await self.ws.send(json.dumps(subscribe_msg))
|
@@ -115,9 +131,25 @@ class QuotesClient:
|
|
115
131
|
await self._handle_messages()
|
116
132
|
except ConnectionClosed as e:
|
117
133
|
logger.info("Disconnected from the quotes server: %s", e)
|
134
|
+
# Call the on_close callback if provided
|
135
|
+
if self.on_close:
|
136
|
+
try:
|
137
|
+
self.on_close(self, getattr(e, 'code', None), str(e))
|
138
|
+
except Exception as e:
|
139
|
+
logger.error("Error in on_close callback: %s", e, exc_info=True)
|
118
140
|
except Exception as e:
|
119
141
|
logger.error("Connection error: %s", e, exc_info=True)
|
142
|
+
# Call the on_error callback if provided
|
143
|
+
if self.on_error:
|
144
|
+
try:
|
145
|
+
self.on_error(self, e)
|
146
|
+
except Exception as e:
|
147
|
+
logger.error("Error in on_error callback: %s", e, exc_info=True)
|
120
148
|
|
149
|
+
# Don't reconnect if we're no longer running
|
150
|
+
if not self._running:
|
151
|
+
break
|
152
|
+
|
121
153
|
# Exponential backoff before reconnecting.
|
122
154
|
sleep_time = min(backoff, self._backoff_max)
|
123
155
|
logger.info("Reconnecting in %s seconds...", sleep_time)
|
@@ -152,7 +184,7 @@ class QuotesClient:
|
|
152
184
|
try:
|
153
185
|
tick = json.loads(json_str)
|
154
186
|
if self.on_tick:
|
155
|
-
self.on_tick(tick)
|
187
|
+
self.on_tick(self, tick)
|
156
188
|
except json.JSONDecodeError as e:
|
157
189
|
logger.error("Failed to parse JSON object: %s", str(e))
|
158
190
|
logger.error("Invalid JSON: %s...", json_str[:100])
|
@@ -161,7 +193,7 @@ class QuotesClient:
|
|
161
193
|
try:
|
162
194
|
tick = json.loads(message)
|
163
195
|
if self.on_tick:
|
164
|
-
self.on_tick(tick)
|
196
|
+
self.on_tick(self, tick)
|
165
197
|
except json.JSONDecodeError as e:
|
166
198
|
logger.error("Failed to parse JSON: %s", str(e))
|
167
199
|
logger.error("Invalid JSON message: %s...", message[:100])
|
@@ -169,13 +201,19 @@ class QuotesClient:
|
|
169
201
|
logger.warning("Received non-string message: %s", type(message))
|
170
202
|
except ConnectionClosed as e:
|
171
203
|
logger.info("Connection closed during message handling: %s", e)
|
204
|
+
# Let the _connect_with_backoff method handle reconnection
|
172
205
|
except Exception as e:
|
173
206
|
logger.error("Error processing message: %s", str(e), exc_info=True)
|
207
|
+
# Call the on_error callback if provided
|
208
|
+
if self.on_error:
|
209
|
+
try:
|
210
|
+
self.on_error(self, e)
|
211
|
+
except Exception as e:
|
212
|
+
logger.error("Error in on_error callback: %s", e, exc_info=True)
|
174
213
|
|
175
214
|
async def subscribe(self, instruments: List[str]) -> None:
|
176
215
|
"""
|
177
|
-
Subscribe to a list of instruments
|
178
|
-
Splits large subscription requests into batches to avoid message size issues.
|
216
|
+
Subscribe to a list of instruments.
|
179
217
|
|
180
218
|
Args:
|
181
219
|
instruments (List[str]): List of instrument identifiers.
|
@@ -189,7 +227,7 @@ class QuotesClient:
|
|
189
227
|
new_instruments_list = list(new_instruments)
|
190
228
|
for batch in self._chunk_list(new_instruments_list, self.batch_size):
|
191
229
|
logger.info("Subscribing to batch of %d instruments", len(batch))
|
192
|
-
message = {"action":
|
230
|
+
message = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
|
193
231
|
await self.ws.send(json.dumps(message))
|
194
232
|
# Small delay between batches to avoid overwhelming the server
|
195
233
|
await asyncio.sleep(0.1)
|
@@ -204,8 +242,7 @@ class QuotesClient:
|
|
204
242
|
|
205
243
|
async def unsubscribe(self, instruments: List[str]) -> None:
|
206
244
|
"""
|
207
|
-
Unsubscribe from a list of instruments
|
208
|
-
Splits large unsubscription requests into batches to avoid message size issues.
|
245
|
+
Unsubscribe from a list of instruments.
|
209
246
|
|
210
247
|
Args:
|
211
248
|
instruments (List[str]): List of instrument identifiers.
|
@@ -221,7 +258,7 @@ class QuotesClient:
|
|
221
258
|
unsub_list = list(to_unsubscribe)
|
222
259
|
for batch in self._chunk_list(unsub_list, self.batch_size):
|
223
260
|
logger.info("Unsubscribing from batch of %d instruments", len(batch))
|
224
|
-
message = {"action":
|
261
|
+
message = {"action": self.ACTION_UNSUBSCRIBE, "instruments": batch}
|
225
262
|
await self.ws.send(json.dumps(message))
|
226
263
|
# Small delay between batches to avoid overwhelming the server
|
227
264
|
await asyncio.sleep(0.1)
|
@@ -238,6 +275,74 @@ class QuotesClient:
|
|
238
275
|
"""
|
239
276
|
Close the WebSocket connection.
|
240
277
|
"""
|
278
|
+
self._running = False
|
241
279
|
if self.ws:
|
242
280
|
await self.ws.close()
|
243
|
-
logger.info("WebSocket connection closed.")
|
281
|
+
logger.info("WebSocket connection closed.")
|
282
|
+
|
283
|
+
# Cancel the background task if it exists
|
284
|
+
if self._background_task and not self._background_task.done():
|
285
|
+
self._background_task.cancel()
|
286
|
+
try:
|
287
|
+
await self._background_task
|
288
|
+
except asyncio.CancelledError:
|
289
|
+
pass
|
290
|
+
|
291
|
+
def connect(self) -> None:
|
292
|
+
"""
|
293
|
+
Connect to the websocket server in a blocking manner.
|
294
|
+
This method handles the event loop and will not return until stop() is called.
|
295
|
+
|
296
|
+
Similar to KiteTicker's connect() method, this creates and runs the event loop.
|
297
|
+
"""
|
298
|
+
self._running = True
|
299
|
+
|
300
|
+
# If there's already an event loop, use it
|
301
|
+
try:
|
302
|
+
loop = asyncio.get_event_loop()
|
303
|
+
except RuntimeError:
|
304
|
+
# No event loop exists, create a new one
|
305
|
+
loop = asyncio.new_event_loop()
|
306
|
+
asyncio.set_event_loop(loop)
|
307
|
+
|
308
|
+
try:
|
309
|
+
# Run until completion (i.e., until stop() is called)
|
310
|
+
loop.run_until_complete(self._connect_with_backoff())
|
311
|
+
finally:
|
312
|
+
if not loop.is_closed():
|
313
|
+
# Clean up pending tasks
|
314
|
+
pending = asyncio.all_tasks(loop)
|
315
|
+
for task in pending:
|
316
|
+
task.cancel()
|
317
|
+
|
318
|
+
# Run until all tasks are properly canceled
|
319
|
+
try:
|
320
|
+
loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
321
|
+
except Exception:
|
322
|
+
pass
|
323
|
+
|
324
|
+
def connect_async(self) -> None:
|
325
|
+
"""
|
326
|
+
Connect to the websocket server in a non-blocking manner.
|
327
|
+
This method starts the connection in a background task.
|
328
|
+
"""
|
329
|
+
if self._running:
|
330
|
+
logger.warning("Client is already running.")
|
331
|
+
return
|
332
|
+
|
333
|
+
self._running = True
|
334
|
+
try:
|
335
|
+
loop = asyncio.get_event_loop()
|
336
|
+
except RuntimeError:
|
337
|
+
loop = asyncio.new_event_loop()
|
338
|
+
asyncio.set_event_loop(loop)
|
339
|
+
|
340
|
+
self._background_task = asyncio.create_task(self._connect_with_backoff())
|
341
|
+
|
342
|
+
def stop(self) -> None:
|
343
|
+
"""
|
344
|
+
Stop the websocket connection.
|
345
|
+
This is a non-blocking method that just flags the client to stop.
|
346
|
+
"""
|
347
|
+
self._running = False
|
348
|
+
logger.info("Client stopping. Connection will close soon.")
|