wiz-trader 0.9.0__py3-none-any.whl → 0.11.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.
@@ -9,235 +9,244 @@ import websockets
9
9
  from websockets.exceptions import ConnectionClosed
10
10
  from websockets.protocol import State
11
11
 
12
- # Setup module-level logger with a default handler if none exists.
12
+ # Setup modulelevel logger with a default handler if none exists.
13
13
  logger = logging.getLogger(__name__)
14
14
  if not logger.handlers:
15
- handler = logging.StreamHandler()
16
- formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
17
- handler.setFormatter(formatter)
18
- logger.addHandler(handler)
15
+ handler = logging.StreamHandler()
16
+ formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
17
+ handler.setFormatter(formatter)
18
+ logger.addHandler(handler)
19
19
 
20
20
 
21
21
  class QuotesClient:
22
- """
23
- A Python SDK for connecting to the Quotes Server via WebSocket.
24
-
25
- Attributes:
26
- base_url (str): WebSocket URL of the quotes server.
27
- token (str): JWT token for authentication.
28
- on_tick (Callable[[dict], None]): Callback to process received tick data.
29
- log_level (str): Logging level. Options: "error", "info", "debug".
30
- """
31
-
32
- def __init__(
33
- self,
34
- base_url: Optional[str] = None,
35
- token: Optional[str] = None,
36
- log_level: str = "error", # default only errors
37
- max_message_size: int = 10 * 1024 * 1024, # 10MB default max size
38
- batch_size: int = 20 # Max number of instruments to subscribe to at once
39
- ):
40
- # Configure logger based on log_level.
41
- valid_levels = {"error": logging.ERROR, "info": logging.INFO, "debug": logging.DEBUG}
42
- if log_level not in valid_levels:
43
- raise ValueError(f"log_level must be one of {list(valid_levels.keys())}")
44
- logger.setLevel(valid_levels[log_level])
45
-
46
- self.log_level = log_level
47
- self.max_message_size = max_message_size
48
- self.batch_size = batch_size
49
- # System env vars take precedence over .env
50
- self.base_url = base_url or os.environ.get("WZ__QUOTES_BASE_URL")
51
- self.token = token or os.environ.get("WZ__TOKEN")
52
- if not self.token:
53
- raise ValueError("JWT token must be provided as an argument or in .env (WZ__TOKEN)")
54
- if not self.base_url:
55
- raise ValueError("Base URL must be provided as an argument or in .env (WZ__QUOTES_BASE_URL)")
56
-
57
- # Construct the WebSocket URL.
58
- self.url = f"{self.base_url}?token={self.token}"
59
- self.ws: Optional[websockets.WebSocketClientProtocol] = None
60
- self.on_tick: Optional[Callable[[dict], None]] = None
61
- self.subscribed_instruments: set = set()
62
-
63
- # Backoff configuration for reconnection (in seconds)
64
- self._backoff_base = 1
65
- self._backoff_factor = 2
66
- self._backoff_max = 60
67
-
68
- logger.debug("Initialized QuotesClient with URL: %s", self.url)
69
-
70
- def _chunk_list(self, data: List[Any], chunk_size: int) -> Iterator[List[Any]]:
71
22
  """
72
- Split a list into smaller chunks.
73
-
74
- Args:
75
- data (List[Any]): The list to split.
76
- chunk_size (int): Maximum size of each chunk.
77
-
78
- Returns:
79
- Iterator[List[Any]]: Iterator of list chunks.
80
- """
81
- for i in range(0, len(data), chunk_size):
82
- yield data[i:i + chunk_size]
23
+ A Python SDK for connecting to the Quotes Server via WebSocket.
83
24
 
84
- async def connect(self) -> None:
85
- """
86
- Continuously connect to the quotes server and process incoming messages.
87
- Implements an exponential backoff reconnection strategy.
88
- """
89
- backoff = self._backoff_base
90
-
91
- while True:
92
- try:
93
- logger.info("Connecting to %s ...", self.url)
94
- async with websockets.connect(self.url, max_size=self.max_message_size) as websocket:
95
- self.ws = websocket
96
- logger.info("Connected to the quotes server.")
97
-
98
- # On reconnection, re-subscribe if needed.
99
- if self.subscribed_instruments:
100
- # Split into batches to avoid message size issues
101
- instruments_list = list(self.subscribed_instruments)
102
- for batch in self._chunk_list(instruments_list, self.batch_size):
103
- subscribe_msg = {
104
- "action": "subscribe",
105
- "instruments": batch
106
- }
107
- await self.ws.send(json.dumps(subscribe_msg))
108
- logger.info("Re-subscribed to batch of %d instruments", len(batch))
109
- # Small delay between batches to avoid overwhelming the server
110
- await asyncio.sleep(0.1)
111
-
112
- # Reset backoff after a successful connection.
113
- backoff = self._backoff_base
114
-
115
- await self._handle_messages()
116
- except ConnectionClosed as e:
117
- logger.info("Disconnected from the quotes server: %s", e)
118
- except Exception as e:
119
- logger.error("Connection error: %s", e, exc_info=True)
120
-
121
- # Exponential backoff before reconnecting.
122
- sleep_time = min(backoff, self._backoff_max)
123
- logger.info("Reconnecting in %s seconds...", sleep_time)
124
- await asyncio.sleep(sleep_time)
125
- backoff *= self._backoff_factor
126
- # Add a bit of randomness to avoid thundering herd issues.
127
- backoff += random.uniform(0, 1)
128
-
129
- async def _handle_messages(self) -> None:
130
- """
131
- Handle incoming messages and dispatch them via the on_tick callback.
132
- Handles newline-delimited JSON objects in a single message.
25
+ Attributes:
26
+ base_url (str): WebSocket URL of the quotes server.
27
+ token (str): JWT token for authentication.
28
+ log_level (str): Logging level. Options: "error", "info", "debug".
133
29
  """
134
- try:
135
- async for message in self.ws: # type: ignore
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
- # Log the beginning of the message for debugging
142
- logger.debug("Message starts with: %s", message[:100])
143
-
144
- if isinstance(message, str):
145
- # Special handling for newline-delimited JSON
146
- if '\n' in message:
147
- # Split by newlines and process each JSON object separately
148
- for json_str in message.strip().split('\n'):
149
- if not json_str:
150
- continue
151
-
152
- try:
153
- tick = json.loads(json_str)
154
- if self.on_tick:
155
- self.on_tick(tick)
156
- except json.JSONDecodeError as e:
157
- logger.error("Failed to parse JSON object: %s", str(e))
158
- logger.error("Invalid JSON: %s...", json_str[:100])
159
- else:
160
- # Single JSON object
30
+
31
+ ACTION_SUBSCRIBE = "subscribe"
32
+ ACTION_UNSUBSCRIBE = "unsubscribe"
33
+
34
+ def __init__(
35
+ self,
36
+ base_url: Optional[str] = None,
37
+ token: Optional[str] = None,
38
+ log_level: str = "error",
39
+ max_message_size: int = 10 * 1024 * 1024,
40
+ batch_size: int = 20
41
+ ):
42
+ valid_levels = {"error": logging.ERROR, "info": logging.INFO, "debug": logging.DEBUG}
43
+ if log_level not in valid_levels:
44
+ raise ValueError(f"log_level must be one of {list(valid_levels.keys())}")
45
+ logger.setLevel(valid_levels[log_level])
46
+
47
+ self.log_level = log_level
48
+ self.max_message_size = max_message_size
49
+ self.batch_size = batch_size
50
+
51
+ self.base_url = base_url or os.environ.get("WZ__QUOTES_BASE_URL")
52
+ self.token = token or os.environ.get("WZ__TOKEN")
53
+ if not self.token:
54
+ raise ValueError("JWT token must be provided as an argument or in .env (WZ__TOKEN)")
55
+ if not self.base_url:
56
+ raise ValueError("Base URL must be provided as an argument or in .env (WZ__QUOTES_BASE_URL)")
57
+
58
+ self.url = f"{self.base_url}?token={self.token}"
59
+ self.ws: Optional[websockets.WebSocketClientProtocol] = None
60
+ self.subscribed_instruments: set = set()
61
+ self._running = False
62
+ self._background_task = None
63
+
64
+ self._backoff_base = 1
65
+ self._backoff_factor = 2
66
+ self._backoff_max = 60
67
+
68
+ # Callbacks are plain synchronous functions
69
+ self.on_tick: Optional[Callable[[Any, dict], None]] = None
70
+ self.on_connect: Optional[Callable[[Any], None]] = None
71
+ self.on_close: Optional[Callable[[Any, Optional[int], Optional[str]], None]] = None
72
+ self.on_error: Optional[Callable[[Any, Exception], None]] = None
73
+
74
+ logger.debug("Initialized QuotesClient with URL: %s", self.url)
75
+
76
+ def _chunk_list(self, data: List[Any], chunk_size: int) -> Iterator[List[Any]]:
77
+ for i in range(0, len(data), chunk_size):
78
+ yield data[i:i + chunk_size]
79
+
80
+ async def _connect_with_backoff(self) -> None:
81
+ backoff = self._backoff_base
82
+
83
+ while self._running:
161
84
  try:
162
- tick = json.loads(message)
163
- if self.on_tick:
164
- self.on_tick(tick)
165
- except json.JSONDecodeError as e:
166
- logger.error("Failed to parse JSON: %s", str(e))
167
- logger.error("Invalid JSON message: %s...", message[:100])
85
+ logger.info("Connecting to %s ...", self.url)
86
+ async with websockets.connect(self.url, max_size=self.max_message_size) as websocket:
87
+ self.ws = websocket
88
+ logger.info("Connected to the quotes server.")
89
+
90
+ # plain sync on_connect
91
+ if self.on_connect:
92
+ try:
93
+ self.on_connect(self)
94
+ except Exception as e:
95
+ logger.error("Error in on_connect callback: %s", e, exc_info=True)
96
+
97
+ # re-subscribe on reconnect
98
+ if self.subscribed_instruments:
99
+ for batch in self._chunk_list(list(self.subscribed_instruments), self.batch_size):
100
+ msg = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
101
+ await self.ws.send(json.dumps(msg))
102
+ logger.info("Re-subscribed to %d instruments", len(batch))
103
+ await asyncio.sleep(0.1)
104
+
105
+ backoff = self._backoff_base
106
+ await self._handle_messages()
107
+
108
+ except ConnectionClosed as e:
109
+ logger.info("Disconnected: %s", e)
110
+ if self.on_close:
111
+ try:
112
+ self.on_close(self, getattr(e, 'code', None), str(e))
113
+ except Exception as ex:
114
+ logger.error("Error in on_close callback: %s", ex, exc_info=True)
115
+
116
+ except Exception as e:
117
+ logger.error("Connection error: %s", e, exc_info=True)
118
+ if self.on_error:
119
+ try:
120
+ self.on_error(self, e)
121
+ except Exception as ex:
122
+ logger.error("Error in on_error callback: %s", ex, exc_info=True)
123
+
124
+ if not self._running:
125
+ break
126
+
127
+ sleep_time = min(backoff, self._backoff_max)
128
+ logger.info("Reconnecting in %s seconds...", sleep_time)
129
+ await asyncio.sleep(sleep_time)
130
+ backoff = backoff * self._backoff_factor + random.uniform(0, 1)
131
+
132
+ async def _handle_messages(self) -> None:
133
+ try:
134
+ async for message in self.ws: # type: ignore
135
+ if self.log_level == "debug" and isinstance(message, str):
136
+ size = len(message.encode("utf-8"))
137
+ if size > 1024 * 1024:
138
+ logger.debug("Large message: %d bytes", size)
139
+
140
+ if isinstance(message, str):
141
+ for chunk in message.strip().split("\n"):
142
+ if not chunk:
143
+ continue
144
+ try:
145
+ tick = json.loads(chunk)
146
+ if self.on_tick:
147
+ self.on_tick(self, tick)
148
+ except json.JSONDecodeError as e:
149
+ logger.error("JSON parse error: %s", e)
150
+ else:
151
+ logger.warning("Non-string message: %s", type(message))
152
+ except ConnectionClosed:
153
+ logger.info("Connection closed during message handling")
154
+ except Exception as e:
155
+ logger.error("Error processing message: %s", e, exc_info=True)
156
+ if self.on_error:
157
+ try:
158
+ self.on_error(self, e)
159
+ except Exception:
160
+ pass
161
+
162
+ # -- Async core methods (for internal use) --
163
+
164
+ async def _subscribe_async(self, instruments: List[str]) -> None:
165
+ """
166
+ Internal async subscription. Use `subscribe()` wrapper to schedule this.
167
+ """
168
+ if self.ws and self.ws.state == State.OPEN:
169
+ new = set(instruments) - self.subscribed_instruments
170
+ if new:
171
+ self.subscribed_instruments |= new
172
+ for batch in self._chunk_list(list(new), self.batch_size):
173
+ logger.info("Subscribing to %d instruments", len(batch))
174
+ await self.ws.send(json.dumps({
175
+ "action": self.ACTION_SUBSCRIBE,
176
+ "instruments": batch
177
+ }))
178
+ await asyncio.sleep(0.1)
168
179
  else:
169
- logger.warning("Received non-string message: %s", type(message))
170
- except ConnectionClosed as e:
171
- logger.info("Connection closed during message handling: %s", e)
172
- except Exception as e:
173
- logger.error("Error processing message: %s", str(e), exc_info=True)
174
-
175
- async def subscribe(self, instruments: List[str]) -> None:
176
- """
177
- Subscribe to a list of instruments and update the subscription list.
178
- Splits large subscription requests into batches to avoid message size issues.
179
-
180
- Args:
181
- instruments (List[str]): List of instrument identifiers.
182
- """
183
- if self.ws and self.ws.state == State.OPEN:
184
- new_instruments = set(instruments) - self.subscribed_instruments
185
- if new_instruments:
186
- self.subscribed_instruments.update(new_instruments)
187
-
188
- # Split into batches to avoid message size issues
189
- new_instruments_list = list(new_instruments)
190
- for batch in self._chunk_list(new_instruments_list, self.batch_size):
191
- logger.info("Subscribing to batch of %d instruments", len(batch))
192
- message = {"action": "subscribe", "instruments": batch}
193
- await self.ws.send(json.dumps(message))
194
- # Small delay between batches to avoid overwhelming the server
195
- await asyncio.sleep(0.1)
196
-
197
- logger.info("Completed subscription for %d new instruments", len(new_instruments))
198
- else:
199
- logger.info("Instruments already subscribed: %s", instruments)
200
- else:
201
- logger.info("Cannot subscribe: WebSocket is not connected.")
202
- # Still update the subscription list so we can subscribe when connected
203
- self.subscribed_instruments.update(set(instruments))
204
-
205
- async def unsubscribe(self, instruments: List[str]) -> None:
206
- """
207
- Unsubscribe from a list of instruments and update the subscription list.
208
- Splits large unsubscription requests into batches to avoid message size issues.
209
-
210
- Args:
211
- instruments (List[str]): List of instrument identifiers.
212
- """
213
- if self.ws and self.ws.state == State.OPEN:
214
- unsub_set = set(instruments)
215
- to_unsubscribe = unsub_set & self.subscribed_instruments
216
-
217
- if to_unsubscribe:
218
- self.subscribed_instruments.difference_update(to_unsubscribe)
219
-
220
- # Split into batches to avoid message size issues
221
- unsub_list = list(to_unsubscribe)
222
- for batch in self._chunk_list(unsub_list, self.batch_size):
223
- logger.info("Unsubscribing from batch of %d instruments", len(batch))
224
- message = {"action": "unsubscribe", "instruments": batch}
225
- await self.ws.send(json.dumps(message))
226
- # Small delay between batches to avoid overwhelming the server
227
- await asyncio.sleep(0.1)
228
-
229
- logger.info("Completed unsubscription for %d instruments", len(to_unsubscribe))
230
- else:
231
- logger.info("No matching instruments found in current subscription.")
232
- else:
233
- logger.info("Cannot unsubscribe: WebSocket is not connected.")
234
- # Still update the subscription list
235
- self.subscribed_instruments.difference_update(set(instruments))
236
-
237
- async def close(self) -> None:
238
- """
239
- Close the WebSocket connection.
240
- """
241
- if self.ws:
242
- await self.ws.close()
243
- logger.info("WebSocket connection closed.")
180
+ # queue it for next connect
181
+ self.subscribed_instruments |= set(instruments)
182
+
183
+ # -- Public wrappers for plain callback users --
184
+
185
+ def subscribe(self, instruments: List[str]) -> None:
186
+ """
187
+ Schedule an async subscribe so users can call this without 'await'.
188
+ """
189
+ try:
190
+ loop = asyncio.get_event_loop()
191
+ except RuntimeError:
192
+ loop = asyncio.new_event_loop()
193
+ asyncio.set_event_loop(loop)
194
+ loop.create_task(self._subscribe_async(instruments))
195
+
196
+ # You could add a similar unsubscribe wrapper if needed
197
+
198
+ async def close(self) -> None:
199
+ """
200
+ Close the WebSocket connection.
201
+ """
202
+ self._running = False
203
+ if self.ws:
204
+ await self.ws.close()
205
+ logger.info("WebSocket closed.")
206
+ if self._background_task and not self._background_task.done():
207
+ self._background_task.cancel()
208
+ try:
209
+ await self._background_task
210
+ except asyncio.CancelledError:
211
+ pass
212
+
213
+ def connect(self) -> None:
214
+ """
215
+ Blocking connect (runs the internal asyncio loop until stop()).
216
+ """
217
+ self._running = True
218
+ try:
219
+ loop = asyncio.get_event_loop()
220
+ except RuntimeError:
221
+ loop = asyncio.new_event_loop()
222
+ asyncio.set_event_loop(loop)
223
+
224
+ try:
225
+ loop.run_until_complete(self._connect_with_backoff())
226
+ finally:
227
+ if not loop.is_closed():
228
+ tasks = asyncio.all_tasks(loop)
229
+ for t in tasks:
230
+ t.cancel()
231
+ try:
232
+ loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
233
+ except Exception:
234
+ pass
235
+
236
+ def connect_async(self) -> None:
237
+ """
238
+ Non-blocking connect: starts the background task.
239
+ """
240
+ if self._running:
241
+ logger.warning("Client already running.")
242
+ return
243
+ self._running = True
244
+ loop = asyncio.get_event_loop()
245
+ self._background_task = loop.create_task(self._connect_with_backoff())
246
+
247
+ def stop(self) -> None:
248
+ """
249
+ Signal the client to stop and close.
250
+ """
251
+ self._running = False
252
+ logger.info("Client stopping; will close soon.")