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.
@@ -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)
@@ -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 and update the subscription list.
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": "subscribe", "instruments": batch}
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 and update the subscription list.
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": "unsubscribe", "instruments": batch}
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.")