wiz-trader 0.23.0__py3-none-any.whl → 0.24.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/quotes/client.py +99 -91
- {wiz_trader-0.23.0.dist-info → wiz_trader-0.24.0.dist-info}/METADATA +1 -1
- wiz_trader-0.24.0.dist-info/RECORD +9 -0
- wiz_trader-0.23.0.dist-info/RECORD +0 -9
- {wiz_trader-0.23.0.dist-info → wiz_trader-0.24.0.dist-info}/WHEEL +0 -0
- {wiz_trader-0.23.0.dist-info → wiz_trader-0.24.0.dist-info}/top_level.txt +0 -0
wiz_trader/__init__.py
CHANGED
wiz_trader/quotes/client.py
CHANGED
@@ -3,16 +3,11 @@ 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
|
7
7
|
|
8
|
-
|
9
|
-
|
10
|
-
|
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: ...
|
8
|
+
import websockets
|
9
|
+
from websockets.exceptions import ConnectionClosed
|
10
|
+
from websockets.protocol import State
|
16
11
|
|
17
12
|
# Setup module‐level logger with a default handler if none exists.
|
18
13
|
logger = logging.getLogger(__name__)
|
@@ -44,9 +39,6 @@ class QuotesClient:
|
|
44
39
|
max_message_size: int = 10 * 1024 * 1024,
|
45
40
|
batch_size: int = 20
|
46
41
|
):
|
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
|
-
|
50
42
|
valid_levels = {"error": logging.ERROR, "info": logging.INFO, "debug": logging.DEBUG}
|
51
43
|
if log_level not in valid_levels:
|
52
44
|
raise ValueError(f"log_level must be one of {list(valid_levels.keys())}")
|
@@ -64,60 +56,66 @@ class QuotesClient:
|
|
64
56
|
raise ValueError("Base URL must be provided as an argument or in .env (WZ__QUOTES_BASE_URL)")
|
65
57
|
|
66
58
|
self.url = f"{self.base_url}?token={self.token}"
|
67
|
-
self.ws: Optional[WebSocketClientProtocol] = None
|
68
|
-
self.subscribed_instruments:
|
59
|
+
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
60
|
+
self.subscribed_instruments: set = set()
|
69
61
|
self._running = False
|
70
|
-
self._background_task
|
62
|
+
self._background_task = None
|
63
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
71
64
|
|
72
65
|
self._backoff_base = 1
|
73
66
|
self._backoff_factor = 2
|
74
67
|
self._backoff_max = 60
|
75
68
|
|
76
69
|
# Callbacks are plain synchronous functions
|
77
|
-
self.on_tick: Optional[Callable[[Any,
|
70
|
+
self.on_tick: Optional[Callable[[Any, dict], None]] = None
|
78
71
|
self.on_connect: Optional[Callable[[Any], None]] = None
|
79
72
|
self.on_close: Optional[Callable[[Any, Optional[int], Optional[str]], None]] = None
|
80
73
|
self.on_error: Optional[Callable[[Any, Exception], None]] = None
|
81
74
|
|
82
|
-
logger.debug("QuotesClient
|
75
|
+
logger.debug("Initialized QuotesClient with URL: %s", self.url)
|
83
76
|
|
84
77
|
def _chunk_list(self, data: List[Any], chunk_size: int) -> Iterator[List[Any]]:
|
85
|
-
|
86
|
-
|
78
|
+
for i in range(0, len(data), chunk_size):
|
79
|
+
yield data[i:i + chunk_size]
|
87
80
|
|
88
81
|
async def _connect_with_backoff(self) -> None:
|
89
82
|
backoff = self._backoff_base
|
90
|
-
logger.debug("Starting connection with initial backoff: %d", backoff)
|
91
83
|
|
92
84
|
while self._running:
|
93
85
|
try:
|
94
|
-
logger.
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
msg = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
|
111
|
-
if self.ws: # Check if ws is not None
|
86
|
+
logger.info("Connecting to %s ...", self.url)
|
87
|
+
async with websockets.connect(self.url, max_size=self.max_message_size) as websocket:
|
88
|
+
self.ws = websocket
|
89
|
+
logger.info("Connected to the quotes server.")
|
90
|
+
|
91
|
+
# plain sync on_connect
|
92
|
+
if self.on_connect:
|
93
|
+
try:
|
94
|
+
self.on_connect(self)
|
95
|
+
except Exception as e:
|
96
|
+
logger.error("Error in on_connect callback: %s", e, exc_info=True)
|
97
|
+
|
98
|
+
# re-subscribe on reconnect
|
99
|
+
if self.subscribed_instruments:
|
100
|
+
for batch in self._chunk_list(list(self.subscribed_instruments), self.batch_size):
|
101
|
+
msg = {"action": self.ACTION_SUBSCRIBE, "instruments": batch}
|
112
102
|
await self.ws.send(json.dumps(msg))
|
113
103
|
logger.info("Re-subscribed to %d instruments", len(batch))
|
114
104
|
await asyncio.sleep(0.1)
|
115
105
|
|
116
|
-
|
117
|
-
|
106
|
+
backoff = self._backoff_base
|
107
|
+
await self._handle_messages()
|
108
|
+
|
109
|
+
except ConnectionClosed as e:
|
110
|
+
logger.info("Disconnected: %s", e)
|
111
|
+
if self.on_close:
|
112
|
+
try:
|
113
|
+
self.on_close(self, getattr(e, 'code', None), str(e))
|
114
|
+
except Exception as ex:
|
115
|
+
logger.error("Error in on_close callback: %s", ex, exc_info=True)
|
118
116
|
|
119
117
|
except Exception as e:
|
120
|
-
logger.
|
118
|
+
logger.error("Connection error: %s", e, exc_info=True)
|
121
119
|
if self.on_error:
|
122
120
|
try:
|
123
121
|
self.on_error(self, e)
|
@@ -125,115 +123,117 @@ class QuotesClient:
|
|
125
123
|
logger.error("Error in on_error callback: %s", ex, exc_info=True)
|
126
124
|
|
127
125
|
if not self._running:
|
128
|
-
logger.debug("Client stopped, breaking reconnection loop")
|
129
126
|
break
|
130
127
|
|
131
128
|
sleep_time = min(backoff, self._backoff_max)
|
132
|
-
logger.
|
129
|
+
logger.info("Reconnecting in %s seconds...", sleep_time)
|
133
130
|
await asyncio.sleep(sleep_time)
|
134
131
|
backoff = backoff * self._backoff_factor + random.uniform(0, 1)
|
135
132
|
|
136
133
|
async def _handle_messages(self) -> None:
|
137
134
|
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
|
144
135
|
async for message in self.ws: # type: ignore
|
136
|
+
if self.log_level == "debug" and isinstance(message, str):
|
137
|
+
size = len(message.encode("utf-8"))
|
138
|
+
if size > 1024 * 1024:
|
139
|
+
logger.debug("Large message: %d bytes", size)
|
140
|
+
|
145
141
|
if isinstance(message, str):
|
146
|
-
msg_size = len(message.encode("utf-8"))
|
147
|
-
logger.debug("Received message of size: %d bytes", msg_size)
|
148
|
-
|
149
142
|
for chunk in message.strip().split("\n"):
|
150
143
|
if not chunk:
|
151
144
|
continue
|
152
145
|
try:
|
153
146
|
tick = json.loads(chunk)
|
154
|
-
logger.debug("Successfully parsed JSON message for instrument: %s",
|
155
|
-
tick.get('instrument', 'unknown'))
|
156
147
|
if self.on_tick:
|
157
|
-
logger.debug("Executing on_tick callback")
|
158
148
|
self.on_tick(self, tick)
|
159
149
|
except json.JSONDecodeError as e:
|
160
|
-
logger.debug("Failed to parse JSON message: %s. Content: %s", str(e), chunk[:100])
|
161
150
|
logger.error("JSON parse error: %s", e)
|
162
151
|
else:
|
163
|
-
logger.
|
164
|
-
|
152
|
+
logger.warning("Non-string message: %s", type(message))
|
153
|
+
except ConnectionClosed:
|
154
|
+
logger.info("Connection closed during message handling")
|
165
155
|
except Exception as e:
|
166
|
-
logger.
|
156
|
+
logger.error("Error processing message: %s", e, exc_info=True)
|
167
157
|
if self.on_error:
|
168
158
|
try:
|
169
159
|
self.on_error(self, e)
|
170
160
|
except Exception:
|
171
161
|
pass
|
172
162
|
|
163
|
+
# -- Async core methods (for internal use) --
|
164
|
+
|
173
165
|
async def _subscribe_async(self, instruments: List[str]) -> None:
|
174
|
-
|
175
|
-
if self.ws and self.ws.open:
|
166
|
+
if self.ws and self.ws.state == State.OPEN:
|
176
167
|
new = set(instruments) - self.subscribed_instruments
|
177
168
|
if new:
|
178
|
-
logger.debug("Found %d new instruments to subscribe", len(new))
|
179
169
|
self.subscribed_instruments |= new
|
180
170
|
for batch in self._chunk_list(list(new), self.batch_size):
|
181
|
-
logger.
|
171
|
+
logger.info("Subscribing to %d instruments", len(batch))
|
182
172
|
await self.ws.send(json.dumps({
|
183
173
|
"action": self.ACTION_SUBSCRIBE,
|
184
174
|
"instruments": batch
|
185
175
|
}))
|
186
176
|
await asyncio.sleep(0.1)
|
187
177
|
else:
|
188
|
-
logger.debug("WebSocket not ready, queueing %d instruments for later subscription", len(instruments))
|
189
178
|
self.subscribed_instruments |= set(instruments)
|
190
179
|
|
191
180
|
async def _unsubscribe_async(self, instruments: List[str]) -> None:
|
192
|
-
|
193
|
-
if self.ws and self.ws.open:
|
181
|
+
if self.ws and self.ws.state == State.OPEN:
|
194
182
|
to_remove = set(instruments) & self.subscribed_instruments
|
195
183
|
if to_remove:
|
196
|
-
|
184
|
+
self.subscribed_instruments -= to_remove
|
197
185
|
for batch in self._chunk_list(list(to_remove), self.batch_size):
|
198
|
-
logger.
|
186
|
+
logger.info("Unsubscribing from %d instruments", len(batch))
|
199
187
|
await self.ws.send(json.dumps({
|
200
188
|
"action": self.ACTION_UNSUBSCRIBE,
|
201
189
|
"instruments": batch
|
202
190
|
}))
|
203
191
|
await asyncio.sleep(0.1)
|
204
|
-
logger.debug("Removed %d instruments from subscription set", len(to_remove))
|
205
|
-
self.subscribed_instruments -= to_remove
|
206
192
|
else:
|
207
|
-
logger.debug("WebSocket not ready, removing %d instruments from queue", len(instruments))
|
208
193
|
self.subscribed_instruments -= set(instruments)
|
209
194
|
|
195
|
+
# -- Public wrappers for plain callback users --
|
196
|
+
|
210
197
|
def subscribe(self, instruments: List[str]) -> None:
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
198
|
+
"""
|
199
|
+
Schedule subscribe onto the client’s event loop.
|
200
|
+
"""
|
201
|
+
if self._loop:
|
202
|
+
asyncio.run_coroutine_threadsafe(
|
203
|
+
self._subscribe_async(instruments),
|
204
|
+
self._loop
|
205
|
+
)
|
206
|
+
else:
|
207
|
+
self.subscribed_instruments |= set(instruments)
|
218
208
|
|
219
209
|
def unsubscribe(self, instruments: List[str]) -> None:
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
210
|
+
"""
|
211
|
+
Schedule unsubscribe onto the client’s event loop.
|
212
|
+
"""
|
213
|
+
if self._loop:
|
214
|
+
asyncio.run_coroutine_threadsafe(
|
215
|
+
self._unsubscribe_async(instruments),
|
216
|
+
self._loop
|
217
|
+
)
|
218
|
+
else:
|
219
|
+
self.subscribed_instruments -= set(instruments)
|
220
|
+
|
221
|
+
def unsubscribe_all(self) -> None:
|
222
|
+
"""
|
223
|
+
Unsubscribe from all currently subscribed instruments.
|
224
|
+
"""
|
225
|
+
if self.subscribed_instruments:
|
226
|
+
self.unsubscribe(list(self.subscribed_instruments))
|
227
227
|
|
228
228
|
async def close(self) -> None:
|
229
|
-
|
229
|
+
"""
|
230
|
+
Close the WebSocket connection.
|
231
|
+
"""
|
230
232
|
self._running = False
|
231
233
|
if self.ws:
|
232
|
-
logger.debug("Closing active WebSocket connection")
|
233
234
|
await self.ws.close()
|
234
235
|
logger.info("WebSocket closed.")
|
235
236
|
if self._background_task and not self._background_task.done():
|
236
|
-
logger.debug("Cancelling background connection task")
|
237
237
|
self._background_task.cancel()
|
238
238
|
try:
|
239
239
|
await self._background_task
|
@@ -241,13 +241,16 @@ class QuotesClient:
|
|
241
241
|
pass
|
242
242
|
|
243
243
|
def connect(self) -> None:
|
244
|
-
|
244
|
+
"""
|
245
|
+
Blocking connect (runs the internal asyncio loop until stop()).
|
246
|
+
"""
|
245
247
|
self._running = True
|
246
248
|
try:
|
247
249
|
loop = asyncio.get_event_loop()
|
248
250
|
except RuntimeError:
|
249
251
|
loop = asyncio.new_event_loop()
|
250
252
|
asyncio.set_event_loop(loop)
|
253
|
+
self._loop = loop
|
251
254
|
|
252
255
|
try:
|
253
256
|
loop.run_until_complete(self._connect_with_backoff())
|
@@ -262,15 +265,20 @@ class QuotesClient:
|
|
262
265
|
pass
|
263
266
|
|
264
267
|
def connect_async(self) -> None:
|
265
|
-
|
268
|
+
"""
|
269
|
+
Non-blocking connect: starts the background task.
|
270
|
+
"""
|
266
271
|
if self._running:
|
267
272
|
logger.warning("Client already running.")
|
268
273
|
return
|
269
274
|
self._running = True
|
270
275
|
loop = asyncio.get_event_loop()
|
276
|
+
self._loop = loop
|
271
277
|
self._background_task = loop.create_task(self._connect_with_backoff())
|
272
278
|
|
273
279
|
def stop(self) -> None:
|
274
|
-
|
280
|
+
"""
|
281
|
+
Signal the client to stop and close.
|
282
|
+
"""
|
275
283
|
self._running = False
|
276
284
|
logger.info("Client stopping; will close soon.")
|
@@ -0,0 +1,9 @@
|
|
1
|
+
wiz_trader/__init__.py,sha256=hL8zImLBBtwxnrKrTpTqcsIzg5cxFMWDOfTkfKNwo9o,182
|
2
|
+
wiz_trader/apis/__init__.py,sha256=ItWKMOl4omiW0g2f-M7WRW3v-dss_ULd9vYnFyIIT9o,132
|
3
|
+
wiz_trader/apis/client.py,sha256=GY1aAaV4ia1tnFnB2qaNqnv-qeUvkVlvw9xOKN54qIs,59786
|
4
|
+
wiz_trader/quotes/__init__.py,sha256=RF9g9CNP6bVWlmCh_ad8krm3-EWOIuVfLp0-H9fAeEM,108
|
5
|
+
wiz_trader/quotes/client.py,sha256=oPDOKqc9EChID_V0_O7ziCdcDnyM_jC7uuKfkc1GwmI,10959
|
6
|
+
wiz_trader-0.24.0.dist-info/METADATA,sha256=38v7XP4SiKWiNDaIAex2cHkv7afK8ExKzs5pv-MKFzM,87046
|
7
|
+
wiz_trader-0.24.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
+
wiz_trader-0.24.0.dist-info/top_level.txt,sha256=lnYS_g8LlA6ryKYnvY8xIQ6K2K-xzOsd-99AWgnW6VY,11
|
9
|
+
wiz_trader-0.24.0.dist-info/RECORD,,
|
@@ -1,9 +0,0 @@
|
|
1
|
-
wiz_trader/__init__.py,sha256=6c10AeBkvODzgW6IRSOimyIKsojSQTsjDk05-tZwcrs,182
|
2
|
-
wiz_trader/apis/__init__.py,sha256=ItWKMOl4omiW0g2f-M7WRW3v-dss_ULd9vYnFyIIT9o,132
|
3
|
-
wiz_trader/apis/client.py,sha256=GY1aAaV4ia1tnFnB2qaNqnv-qeUvkVlvw9xOKN54qIs,59786
|
4
|
-
wiz_trader/quotes/__init__.py,sha256=RF9g9CNP6bVWlmCh_ad8krm3-EWOIuVfLp0-H9fAeEM,108
|
5
|
-
wiz_trader/quotes/client.py,sha256=UTkBGwrrlNO6i5r8oH63B9vXTvkvb-_bFlqHGiHufik,12289
|
6
|
-
wiz_trader-0.23.0.dist-info/METADATA,sha256=O_3IvT5NG0r-YMthKIZZSq_refcgZRdZfT1LykSFwl0,87046
|
7
|
-
wiz_trader-0.23.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
8
|
-
wiz_trader-0.23.0.dist-info/top_level.txt,sha256=lnYS_g8LlA6ryKYnvY8xIQ6K2K-xzOsd-99AWgnW6VY,11
|
9
|
-
wiz_trader-0.23.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|