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.
- wiz_trader/__init__.py +1 -1
- wiz_trader/apis/client.py +176 -44
- wiz_trader/quotes/client.py +231 -222
- wiz_trader-0.11.0.dist-info/METADATA +1722 -0
- wiz_trader-0.11.0.dist-info/RECORD +9 -0
- {wiz_trader-0.9.0.dist-info → wiz_trader-0.11.0.dist-info}/WHEEL +1 -1
- wiz_trader-0.9.0.dist-info/METADATA +0 -165
- wiz_trader-0.9.0.dist-info/RECORD +0 -9
- {wiz_trader-0.9.0.dist-info → wiz_trader-0.11.0.dist-info}/top_level.txt +0 -0
wiz_trader/quotes/client.py
CHANGED
@@ -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
|
12
|
+
# Setup module‐level logger with a default handler if none exists.
|
13
13
|
logger = logging.getLogger(__name__)
|
14
14
|
if not logger.handlers:
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
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
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
|
160
|
-
|
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
|
-
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
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
|
-
|
170
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
190
|
-
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
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.")
|