wiz-trader 0.8.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 +154 -29
- wiz_trader-0.10.0.dist-info/METADATA +1781 -0
- wiz_trader-0.10.0.dist-info/RECORD +9 -0
- {wiz_trader-0.8.0.dist-info → wiz_trader-0.10.0.dist-info}/WHEEL +1 -1
- wiz_trader-0.8.0.dist-info/METADATA +0 -165
- wiz_trader-0.8.0.dist-info/RECORD +0 -9
- {wiz_trader-0.8.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)
|
@@ -129,33 +161,59 @@ class QuotesClient:
|
|
129
161
|
async def _handle_messages(self) -> None:
|
130
162
|
"""
|
131
163
|
Handle incoming messages and dispatch them via the on_tick callback.
|
164
|
+
Handles newline-delimited JSON objects in a single message.
|
132
165
|
"""
|
133
166
|
try:
|
134
167
|
async for message in self.ws: # type: ignore
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
168
|
+
# Log message size for debugging large message issues
|
169
|
+
if self.log_level == "debug" and isinstance(message, str):
|
170
|
+
message_size = len(message.encode("utf-8"))
|
171
|
+
if message_size > 1024 * 1024: # Over 1MB
|
172
|
+
logger.debug("Received large message: %d bytes", message_size)
|
173
|
+
# Log the beginning of the message for debugging
|
174
|
+
logger.debug("Message starts with: %s", message[:100])
|
175
|
+
|
176
|
+
if isinstance(message, str):
|
177
|
+
# Special handling for newline-delimited JSON
|
178
|
+
if '\n' in message:
|
179
|
+
# Split by newlines and process each JSON object separately
|
180
|
+
for json_str in message.strip().split('\n'):
|
181
|
+
if not json_str:
|
182
|
+
continue
|
183
|
+
|
184
|
+
try:
|
185
|
+
tick = json.loads(json_str)
|
186
|
+
if self.on_tick:
|
187
|
+
self.on_tick(self, tick)
|
188
|
+
except json.JSONDecodeError as e:
|
189
|
+
logger.error("Failed to parse JSON object: %s", str(e))
|
190
|
+
logger.error("Invalid JSON: %s...", json_str[:100])
|
145
191
|
else:
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
192
|
+
# Single JSON object
|
193
|
+
try:
|
194
|
+
tick = json.loads(message)
|
195
|
+
if self.on_tick:
|
196
|
+
self.on_tick(self, tick)
|
197
|
+
except json.JSONDecodeError as e:
|
198
|
+
logger.error("Failed to parse JSON: %s", str(e))
|
199
|
+
logger.error("Invalid JSON message: %s...", message[:100])
|
200
|
+
else:
|
201
|
+
logger.warning("Received non-string message: %s", type(message))
|
150
202
|
except ConnectionClosed as e:
|
151
203
|
logger.info("Connection closed during message handling: %s", e)
|
204
|
+
# Let the _connect_with_backoff method handle reconnection
|
152
205
|
except Exception as e:
|
153
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)
|
154
213
|
|
155
214
|
async def subscribe(self, instruments: List[str]) -> None:
|
156
215
|
"""
|
157
|
-
Subscribe to a list of instruments
|
158
|
-
Splits large subscription requests into batches to avoid message size issues.
|
216
|
+
Subscribe to a list of instruments.
|
159
217
|
|
160
218
|
Args:
|
161
219
|
instruments (List[str]): List of instrument identifiers.
|
@@ -169,7 +227,7 @@ class QuotesClient:
|
|
169
227
|
new_instruments_list = list(new_instruments)
|
170
228
|
for batch in self._chunk_list(new_instruments_list, self.batch_size):
|
171
229
|
logger.info("Subscribing to batch of %d instruments", len(batch))
|
172
|
-
message = {"action":
|
230
|
+
message = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
|
173
231
|
await self.ws.send(json.dumps(message))
|
174
232
|
# Small delay between batches to avoid overwhelming the server
|
175
233
|
await asyncio.sleep(0.1)
|
@@ -184,8 +242,7 @@ class QuotesClient:
|
|
184
242
|
|
185
243
|
async def unsubscribe(self, instruments: List[str]) -> None:
|
186
244
|
"""
|
187
|
-
Unsubscribe from a list of instruments
|
188
|
-
Splits large unsubscription requests into batches to avoid message size issues.
|
245
|
+
Unsubscribe from a list of instruments.
|
189
246
|
|
190
247
|
Args:
|
191
248
|
instruments (List[str]): List of instrument identifiers.
|
@@ -201,7 +258,7 @@ class QuotesClient:
|
|
201
258
|
unsub_list = list(to_unsubscribe)
|
202
259
|
for batch in self._chunk_list(unsub_list, self.batch_size):
|
203
260
|
logger.info("Unsubscribing from batch of %d instruments", len(batch))
|
204
|
-
message = {"action":
|
261
|
+
message = {"action": self.ACTION_UNSUBSCRIBE, "instruments": batch}
|
205
262
|
await self.ws.send(json.dumps(message))
|
206
263
|
# Small delay between batches to avoid overwhelming the server
|
207
264
|
await asyncio.sleep(0.1)
|
@@ -218,6 +275,74 @@ class QuotesClient:
|
|
218
275
|
"""
|
219
276
|
Close the WebSocket connection.
|
220
277
|
"""
|
278
|
+
self._running = False
|
221
279
|
if self.ws:
|
222
280
|
await self.ws.close()
|
223
|
-
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.")
|