wiz-trader 0.22.0__tar.gz → 0.23.0__tar.gz
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-0.22.0/src/wiz_trader.egg-info → wiz_trader-0.23.0}/PKG-INFO +1 -1
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/pyproject.toml +1 -1
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/setup.py +1 -1
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader/__init__.py +1 -1
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader/quotes/client.py +86 -94
- {wiz_trader-0.22.0 → wiz_trader-0.23.0/src/wiz_trader.egg-info}/PKG-INFO +1 -1
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/MANIFEST.in +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/README.md +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/setup.cfg +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader/apis/__init__.py +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader/apis/client.py +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader/quotes/__init__.py +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader.egg-info/SOURCES.txt +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader.egg-info/dependency_links.txt +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader.egg-info/requires.txt +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/src/wiz_trader.egg-info/top_level.txt +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/tests/test_apis.py +0 -0
- {wiz_trader-0.22.0 → wiz_trader-0.23.0}/tests/test_quotes.py +0 -0
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
|
|
2
2
|
|
3
3
|
setup(
|
4
4
|
name='wiz_trader',
|
5
|
-
version='0.
|
5
|
+
version='0.23.0',
|
6
6
|
description='A Python SDK for connecting to the Wizzer.',
|
7
7
|
long_description=open('README.md').read() if open('README.md') else "",
|
8
8
|
long_description_content_type='text/markdown',
|
@@ -3,11 +3,16 @@ 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, Iterator, Dict, Set, TypeVar, Protocol, runtime_checkable
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
T = TypeVar('T')
|
9
|
+
|
10
|
+
@runtime_checkable
|
11
|
+
class WebSocketClientProtocol(Protocol):
|
12
|
+
async def send(self, message: str) -> None: ...
|
13
|
+
async def close(self) -> None: ...
|
14
|
+
@property
|
15
|
+
def open(self) -> bool: ...
|
11
16
|
|
12
17
|
# Setup module‐level logger with a default handler if none exists.
|
13
18
|
logger = logging.getLogger(__name__)
|
@@ -39,6 +44,9 @@ class QuotesClient:
|
|
39
44
|
max_message_size: int = 10 * 1024 * 1024,
|
40
45
|
batch_size: int = 20
|
41
46
|
):
|
47
|
+
logger.debug("Initializing QuotesClient with params: base_url=%s, log_level=%s, max_message_size=%d, batch_size=%d",
|
48
|
+
base_url, log_level, max_message_size, batch_size)
|
49
|
+
|
42
50
|
valid_levels = {"error": logging.ERROR, "info": logging.INFO, "debug": logging.DEBUG}
|
43
51
|
if log_level not in valid_levels:
|
44
52
|
raise ValueError(f"log_level must be one of {list(valid_levels.keys())}")
|
@@ -56,65 +64,60 @@ class QuotesClient:
|
|
56
64
|
raise ValueError("Base URL must be provided as an argument or in .env (WZ__QUOTES_BASE_URL)")
|
57
65
|
|
58
66
|
self.url = f"{self.base_url}?token={self.token}"
|
59
|
-
self.ws: Optional[
|
60
|
-
self.subscribed_instruments:
|
67
|
+
self.ws: Optional[WebSocketClientProtocol] = None
|
68
|
+
self.subscribed_instruments: Set[str] = set()
|
61
69
|
self._running = False
|
62
|
-
self._background_task = None
|
70
|
+
self._background_task: Optional[asyncio.Task[None]] = None
|
63
71
|
|
64
72
|
self._backoff_base = 1
|
65
73
|
self._backoff_factor = 2
|
66
74
|
self._backoff_max = 60
|
67
75
|
|
68
76
|
# Callbacks are plain synchronous functions
|
69
|
-
self.on_tick: Optional[Callable[[Any,
|
77
|
+
self.on_tick: Optional[Callable[[Any, Dict[str, Any]], None]] = None
|
70
78
|
self.on_connect: Optional[Callable[[Any], None]] = None
|
71
79
|
self.on_close: Optional[Callable[[Any, Optional[int], Optional[str]], None]] = None
|
72
80
|
self.on_error: Optional[Callable[[Any, Exception], None]] = None
|
73
81
|
|
74
|
-
logger.debug("
|
82
|
+
logger.debug("QuotesClient initialized successfully")
|
75
83
|
|
76
84
|
def _chunk_list(self, data: List[Any], chunk_size: int) -> Iterator[List[Any]]:
|
77
|
-
|
78
|
-
|
85
|
+
logger.debug("Chunking list of size %d with chunk_size %d", len(data), chunk_size)
|
86
|
+
return (data[i:i + chunk_size] for i in range(0, len(data), chunk_size))
|
79
87
|
|
80
88
|
async def _connect_with_backoff(self) -> None:
|
81
89
|
backoff = self._backoff_base
|
90
|
+
logger.debug("Starting connection with initial backoff: %d", backoff)
|
82
91
|
|
83
92
|
while self._running:
|
84
93
|
try:
|
85
|
-
logger.
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
94
|
+
logger.debug("Attempting WebSocket connection to %s", self.url)
|
95
|
+
# Using string literal for import to avoid type checking issues
|
96
|
+
ws = await __import__('websockets').connect(self.url, max_size=self.max_message_size)
|
97
|
+
self.ws = ws
|
98
|
+
logger.debug("WebSocket connection established successfully")
|
99
|
+
|
100
|
+
if self.on_connect:
|
101
|
+
logger.debug("Executing on_connect callback")
|
102
|
+
try:
|
103
|
+
self.on_connect(self)
|
104
|
+
except Exception as e:
|
105
|
+
logger.error("Error in on_connect callback: %s", e, exc_info=True)
|
106
|
+
|
107
|
+
if self.subscribed_instruments:
|
108
|
+
logger.debug("Re-subscribing to %d instruments", len(self.subscribed_instruments))
|
109
|
+
for batch in self._chunk_list(list(self.subscribed_instruments), self.batch_size):
|
110
|
+
msg = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
|
111
|
+
if self.ws: # Check if ws is not None
|
101
112
|
await self.ws.send(json.dumps(msg))
|
102
113
|
logger.info("Re-subscribed to %d instruments", len(batch))
|
103
114
|
await asyncio.sleep(0.1)
|
104
115
|
|
105
|
-
|
106
|
-
|
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)
|
116
|
+
backoff = self._backoff_base
|
117
|
+
await self._handle_messages()
|
115
118
|
|
116
119
|
except Exception as e:
|
117
|
-
logger.
|
120
|
+
logger.debug("Connection error occurred: %s", str(e), exc_info=True)
|
118
121
|
if self.on_error:
|
119
122
|
try:
|
120
123
|
self.on_error(self, e)
|
@@ -122,120 +125,115 @@ class QuotesClient:
|
|
122
125
|
logger.error("Error in on_error callback: %s", ex, exc_info=True)
|
123
126
|
|
124
127
|
if not self._running:
|
128
|
+
logger.debug("Client stopped, breaking reconnection loop")
|
125
129
|
break
|
126
130
|
|
127
131
|
sleep_time = min(backoff, self._backoff_max)
|
128
|
-
logger.
|
132
|
+
logger.debug("Calculated reconnection backoff: %s seconds", sleep_time)
|
129
133
|
await asyncio.sleep(sleep_time)
|
130
134
|
backoff = backoff * self._backoff_factor + random.uniform(0, 1)
|
131
135
|
|
132
136
|
async def _handle_messages(self) -> None:
|
133
137
|
try:
|
138
|
+
logger.debug("Starting message handling loop")
|
139
|
+
if self.ws is None:
|
140
|
+
logger.error("WebSocket connection is None")
|
141
|
+
return
|
142
|
+
|
143
|
+
# Using string literal for import to avoid type checking issues
|
134
144
|
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
145
|
if isinstance(message, str):
|
146
|
+
msg_size = len(message.encode("utf-8"))
|
147
|
+
logger.debug("Received message of size: %d bytes", msg_size)
|
148
|
+
|
141
149
|
for chunk in message.strip().split("\n"):
|
142
150
|
if not chunk:
|
143
151
|
continue
|
144
152
|
try:
|
145
153
|
tick = json.loads(chunk)
|
154
|
+
logger.debug("Successfully parsed JSON message for instrument: %s",
|
155
|
+
tick.get('instrument', 'unknown'))
|
146
156
|
if self.on_tick:
|
157
|
+
logger.debug("Executing on_tick callback")
|
147
158
|
self.on_tick(self, tick)
|
148
159
|
except json.JSONDecodeError as e:
|
160
|
+
logger.debug("Failed to parse JSON message: %s. Content: %s", str(e), chunk[:100])
|
149
161
|
logger.error("JSON parse error: %s", e)
|
150
162
|
else:
|
151
|
-
logger.
|
152
|
-
|
153
|
-
logger.info("Connection closed during message handling")
|
163
|
+
logger.debug("Received non-string message of type: %s", type(message).__name__)
|
164
|
+
logger.warning("Non-string message: %s", type(message).__name__)
|
154
165
|
except Exception as e:
|
155
|
-
logger.
|
166
|
+
logger.debug("Error in message handling: %s", str(e), exc_info=True)
|
156
167
|
if self.on_error:
|
157
168
|
try:
|
158
169
|
self.on_error(self, e)
|
159
170
|
except Exception:
|
160
171
|
pass
|
161
172
|
|
162
|
-
# -- Async core methods (for internal use) --
|
163
|
-
|
164
173
|
async def _subscribe_async(self, instruments: List[str]) -> None:
|
165
|
-
""
|
166
|
-
|
167
|
-
"""
|
168
|
-
if self.ws and self.ws.state == State.OPEN:
|
174
|
+
logger.debug("Processing async subscription request for %d instruments", len(instruments))
|
175
|
+
if self.ws and self.ws.open:
|
169
176
|
new = set(instruments) - self.subscribed_instruments
|
170
177
|
if new:
|
178
|
+
logger.debug("Found %d new instruments to subscribe", len(new))
|
171
179
|
self.subscribed_instruments |= new
|
172
180
|
for batch in self._chunk_list(list(new), self.batch_size):
|
173
|
-
logger.
|
181
|
+
logger.debug("Sending subscription request for batch of %d instruments", len(batch))
|
174
182
|
await self.ws.send(json.dumps({
|
175
183
|
"action": self.ACTION_SUBSCRIBE,
|
176
184
|
"instruments": batch
|
177
185
|
}))
|
178
186
|
await asyncio.sleep(0.1)
|
179
187
|
else:
|
180
|
-
|
188
|
+
logger.debug("WebSocket not ready, queueing %d instruments for later subscription", len(instruments))
|
181
189
|
self.subscribed_instruments |= set(instruments)
|
182
190
|
|
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
191
|
async def _unsubscribe_async(self, instruments: List[str]) -> None:
|
197
|
-
""
|
198
|
-
|
199
|
-
"""
|
200
|
-
# Only send unsubs if the socket is open
|
201
|
-
if self.ws and self.ws.state == State.OPEN:
|
192
|
+
logger.debug("Processing async unsubscription request for %d instruments", len(instruments))
|
193
|
+
if self.ws and self.ws.open:
|
202
194
|
to_remove = set(instruments) & self.subscribed_instruments
|
203
195
|
if to_remove:
|
196
|
+
logger.debug("Found %d instruments to unsubscribe", len(to_remove))
|
204
197
|
for batch in self._chunk_list(list(to_remove), self.batch_size):
|
205
|
-
logger.
|
198
|
+
logger.debug("Sending unsubscription request for batch of %d instruments", len(batch))
|
206
199
|
await self.ws.send(json.dumps({
|
207
200
|
"action": self.ACTION_UNSUBSCRIBE,
|
208
201
|
"instruments": batch
|
209
202
|
}))
|
210
203
|
await asyncio.sleep(0.1)
|
211
|
-
|
204
|
+
logger.debug("Removed %d instruments from subscription set", len(to_remove))
|
212
205
|
self.subscribed_instruments -= to_remove
|
213
206
|
else:
|
214
|
-
|
207
|
+
logger.debug("WebSocket not ready, removing %d instruments from queue", len(instruments))
|
215
208
|
self.subscribed_instruments -= set(instruments)
|
216
209
|
|
210
|
+
def subscribe(self, instruments: List[str]) -> None:
|
211
|
+
logger.debug("Scheduling subscription for %d instruments", len(instruments))
|
212
|
+
try:
|
213
|
+
loop = asyncio.get_event_loop()
|
214
|
+
except RuntimeError:
|
215
|
+
loop = asyncio.new_event_loop()
|
216
|
+
asyncio.set_event_loop(loop)
|
217
|
+
loop.create_task(self._subscribe_async(instruments))
|
218
|
+
|
217
219
|
def unsubscribe(self, instruments: List[str]) -> None:
|
218
|
-
""
|
219
|
-
Schedule an async unsubscribe so users can call this without 'await'.
|
220
|
-
"""
|
220
|
+
logger.debug("Scheduling unsubscription for %d instruments", len(instruments))
|
221
221
|
try:
|
222
222
|
loop = asyncio.get_event_loop()
|
223
223
|
except RuntimeError:
|
224
224
|
loop = asyncio.new_event_loop()
|
225
225
|
asyncio.set_event_loop(loop)
|
226
226
|
loop.create_task(self._unsubscribe_async(instruments))
|
227
|
-
|
228
|
-
# You could add a similar unsubscribe wrapper if needed
|
229
227
|
|
230
228
|
async def close(self) -> None:
|
231
|
-
""
|
232
|
-
Close the WebSocket connection.
|
233
|
-
"""
|
229
|
+
logger.debug("Initiating WebSocket connection closure")
|
234
230
|
self._running = False
|
235
231
|
if self.ws:
|
232
|
+
logger.debug("Closing active WebSocket connection")
|
236
233
|
await self.ws.close()
|
237
234
|
logger.info("WebSocket closed.")
|
238
235
|
if self._background_task and not self._background_task.done():
|
236
|
+
logger.debug("Cancelling background connection task")
|
239
237
|
self._background_task.cancel()
|
240
238
|
try:
|
241
239
|
await self._background_task
|
@@ -243,9 +241,7 @@ class QuotesClient:
|
|
243
241
|
pass
|
244
242
|
|
245
243
|
def connect(self) -> None:
|
246
|
-
""
|
247
|
-
Blocking connect (runs the internal asyncio loop until stop()).
|
248
|
-
"""
|
244
|
+
logger.debug("Starting blocking connect operation")
|
249
245
|
self._running = True
|
250
246
|
try:
|
251
247
|
loop = asyncio.get_event_loop()
|
@@ -266,9 +262,7 @@ class QuotesClient:
|
|
266
262
|
pass
|
267
263
|
|
268
264
|
def connect_async(self) -> None:
|
269
|
-
""
|
270
|
-
Non-blocking connect: starts the background task.
|
271
|
-
"""
|
265
|
+
logger.debug("Starting non-blocking async connect operation")
|
272
266
|
if self._running:
|
273
267
|
logger.warning("Client already running.")
|
274
268
|
return
|
@@ -277,8 +271,6 @@ class QuotesClient:
|
|
277
271
|
self._background_task = loop.create_task(self._connect_with_backoff())
|
278
272
|
|
279
273
|
def stop(self) -> None:
|
280
|
-
""
|
281
|
-
Signal the client to stop and close.
|
282
|
-
"""
|
274
|
+
logger.debug("Stop signal received")
|
283
275
|
self._running = False
|
284
276
|
logger.info("Client stopping; will close soon.")
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|