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.
@@ -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 connect(self) -> None:
94
+ async def _connect_with_backoff(self) -> None:
85
95
  """
86
- Continuously connect to the quotes server and process incoming messages.
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 True:
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": "subscribe",
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
- try:
136
- # Log message size for debugging large message issues
137
- if self.log_level == "debug" and isinstance(message, str):
138
- message_size = len(message.encode("utf-8"))
139
- if message_size > 1024 * 1024: # Over 1MB
140
- logger.debug("Received large message: %d bytes", message_size)
141
-
142
- tick = json.loads(message)
143
- if self.on_tick:
144
- self.on_tick(tick)
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
- logger.debug("Received tick (no on_tick callback set): %s", tick)
147
- except json.JSONDecodeError as e:
148
- logger.error("Received invalid JSON message: %s...", message[:100] if isinstance(message, str) else str(message)[:100])
149
- logger.error("JSON decode error: %s", str(e))
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 and update the subscription list.
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": "subscribe", "instruments": batch}
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 and update the subscription list.
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": "unsubscribe", "instruments": batch}
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.")