wiz-trader 0.7.0__tar.gz → 0.9.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.7.0/src/wiz_trader.egg-info → wiz_trader-0.9.0}/PKG-INFO +1 -1
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/pyproject.toml +1 -1
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/setup.py +1 -1
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader/__init__.py +1 -1
- wiz_trader-0.9.0/src/wiz_trader/quotes/client.py +243 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0/src/wiz_trader.egg-info}/PKG-INFO +1 -1
- wiz_trader-0.7.0/src/wiz_trader/quotes/client.py +0 -167
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/MANIFEST.in +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/README.md +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/setup.cfg +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader/apis/__init__.py +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader/apis/client.py +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader/quotes/__init__.py +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader.egg-info/SOURCES.txt +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader.egg-info/dependency_links.txt +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader.egg-info/requires.txt +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/src/wiz_trader.egg-info/top_level.txt +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.0}/tests/test_apis.py +0 -0
- {wiz_trader-0.7.0 → wiz_trader-0.9.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.9.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',
|
@@ -0,0 +1,243 @@
|
|
1
|
+
import asyncio
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import logging
|
5
|
+
import random
|
6
|
+
from typing import Callable, List, Optional, Any, Iterator
|
7
|
+
|
8
|
+
import websockets
|
9
|
+
from websockets.exceptions import ConnectionClosed
|
10
|
+
from websockets.protocol import State
|
11
|
+
|
12
|
+
# Setup module-level logger with a default handler if none exists.
|
13
|
+
logger = logging.getLogger(__name__)
|
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)
|
19
|
+
|
20
|
+
|
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
|
+
"""
|
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]
|
83
|
+
|
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.
|
133
|
+
"""
|
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
|
161
|
+
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])
|
168
|
+
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.")
|
@@ -1,167 +0,0 @@
|
|
1
|
-
import asyncio
|
2
|
-
import json
|
3
|
-
import os
|
4
|
-
import logging
|
5
|
-
import random
|
6
|
-
from typing import Callable, List, Optional
|
7
|
-
|
8
|
-
import websockets
|
9
|
-
from websockets.exceptions import ConnectionClosed
|
10
|
-
from websockets.protocol import State
|
11
|
-
|
12
|
-
# Setup module-level logger with a default handler if none exists.
|
13
|
-
logger = logging.getLogger(__name__)
|
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)
|
19
|
-
|
20
|
-
|
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
|
-
):
|
38
|
-
# Configure logger based on log_level.
|
39
|
-
valid_levels = {"error": logging.ERROR, "info": logging.INFO, "debug": logging.DEBUG}
|
40
|
-
if log_level not in valid_levels:
|
41
|
-
raise ValueError(f"log_level must be one of {list(valid_levels.keys())}")
|
42
|
-
logger.setLevel(valid_levels[log_level])
|
43
|
-
|
44
|
-
self.log_level = log_level
|
45
|
-
# System env vars take precedence over .env
|
46
|
-
self.base_url = base_url or os.environ.get("WZ__QUOTES_BASE_URL")
|
47
|
-
self.token = token or os.environ.get("WZ__TOKEN")
|
48
|
-
if not self.token:
|
49
|
-
raise ValueError("JWT token must be provided as an argument or in .env (WZ__TOKEN)")
|
50
|
-
if not self.base_url:
|
51
|
-
raise ValueError("Base URL must be provided as an argument or in .env (WZ__QUOTES_BASE_URL)")
|
52
|
-
|
53
|
-
# Construct the WebSocket URL.
|
54
|
-
self.url = f"{self.base_url}?token={self.token}"
|
55
|
-
self.ws: Optional[websockets.WebSocketClientProtocol] = None
|
56
|
-
self.on_tick: Optional[Callable[[dict], None]] = None
|
57
|
-
self.subscribed_instruments: set = set()
|
58
|
-
|
59
|
-
# Backoff configuration for reconnection (in seconds)
|
60
|
-
self._backoff_base = 1
|
61
|
-
self._backoff_factor = 2
|
62
|
-
self._backoff_max = 60
|
63
|
-
|
64
|
-
logger.debug("Initialized QuotesClient with URL: %s", self.url)
|
65
|
-
|
66
|
-
async def connect(self) -> None:
|
67
|
-
"""
|
68
|
-
Continuously connect to the quotes server and process incoming messages.
|
69
|
-
Implements an exponential backoff reconnection strategy.
|
70
|
-
"""
|
71
|
-
backoff = self._backoff_base
|
72
|
-
|
73
|
-
while True:
|
74
|
-
try:
|
75
|
-
logger.info("Connecting to %s ...", self.url)
|
76
|
-
async with websockets.connect(self.url) as websocket:
|
77
|
-
self.ws = websocket
|
78
|
-
logger.info("Connected to the quotes server.")
|
79
|
-
|
80
|
-
# On reconnection, re-subscribe if needed.
|
81
|
-
if self.subscribed_instruments:
|
82
|
-
subscribe_msg = {
|
83
|
-
"action": "subscribe",
|
84
|
-
"instruments": list(self.subscribed_instruments)
|
85
|
-
}
|
86
|
-
await self.ws.send(json.dumps(subscribe_msg))
|
87
|
-
logger.info("Re-subscribed to instruments: %s", list(self.subscribed_instruments))
|
88
|
-
|
89
|
-
# Reset backoff after a successful connection.
|
90
|
-
backoff = self._backoff_base
|
91
|
-
|
92
|
-
await self._handle_messages()
|
93
|
-
except ConnectionClosed as e:
|
94
|
-
logger.info("Disconnected from the quotes server: %s", e)
|
95
|
-
except Exception as e:
|
96
|
-
logger.error("Connection error: %s", e, exc_info=True)
|
97
|
-
|
98
|
-
# Exponential backoff before reconnecting.
|
99
|
-
sleep_time = min(backoff, self._backoff_max)
|
100
|
-
logger.info("Reconnecting in %s seconds...", sleep_time)
|
101
|
-
await asyncio.sleep(sleep_time)
|
102
|
-
backoff *= self._backoff_factor
|
103
|
-
# Add a bit of randomness to avoid thundering herd issues.
|
104
|
-
backoff += random.uniform(0, 1)
|
105
|
-
|
106
|
-
async def _handle_messages(self) -> None:
|
107
|
-
"""
|
108
|
-
Handle incoming messages and dispatch them via the on_tick callback.
|
109
|
-
"""
|
110
|
-
try:
|
111
|
-
async for message in self.ws: # type: ignore
|
112
|
-
try:
|
113
|
-
tick = json.loads(message)
|
114
|
-
if self.on_tick:
|
115
|
-
self.on_tick(tick)
|
116
|
-
else:
|
117
|
-
logger.debug("Received tick (no on_tick callback set): %s", tick)
|
118
|
-
except json.JSONDecodeError:
|
119
|
-
logger.debug("Received non-JSON message: %s", message)
|
120
|
-
except ConnectionClosed as e:
|
121
|
-
logger.info("Connection closed during message handling: %s", e)
|
122
|
-
|
123
|
-
async def subscribe(self, instruments: List[str]) -> None:
|
124
|
-
"""
|
125
|
-
Subscribe to a list of instruments and update the subscription list.
|
126
|
-
|
127
|
-
Args:
|
128
|
-
instruments (List[str]): List of instrument identifiers.
|
129
|
-
"""
|
130
|
-
if self.ws and self.ws.state == State.OPEN:
|
131
|
-
new_instruments = set(instruments) - self.subscribed_instruments
|
132
|
-
if new_instruments:
|
133
|
-
self.subscribed_instruments.update(new_instruments)
|
134
|
-
message = {"action": "subscribe", "instruments": list(new_instruments)}
|
135
|
-
await self.ws.send(json.dumps(message))
|
136
|
-
logger.info("Sent subscription message for instruments: %s", list(new_instruments))
|
137
|
-
else:
|
138
|
-
logger.info("Instruments already subscribed: %s", instruments)
|
139
|
-
else:
|
140
|
-
logger.info("Cannot subscribe: WebSocket is not connected.")
|
141
|
-
|
142
|
-
async def unsubscribe(self, instruments: List[str]) -> None:
|
143
|
-
"""
|
144
|
-
Unsubscribe from a list of instruments and update the subscription list.
|
145
|
-
|
146
|
-
Args:
|
147
|
-
instruments (List[str]): List of instrument identifiers.
|
148
|
-
"""
|
149
|
-
if self.ws and self.ws.state == State.OPEN:
|
150
|
-
unsub_set = set(instruments)
|
151
|
-
if unsub_set & self.subscribed_instruments:
|
152
|
-
self.subscribed_instruments.difference_update(unsub_set)
|
153
|
-
message = {"action": "unsubscribe", "instruments": list(unsub_set)}
|
154
|
-
await self.ws.send(json.dumps(message))
|
155
|
-
logger.info("Sent unsubscription message for instruments: %s", list(unsub_set))
|
156
|
-
else:
|
157
|
-
logger.info("No matching instruments found in current subscription.")
|
158
|
-
else:
|
159
|
-
logger.info("Cannot unsubscribe: WebSocket is not connected.")
|
160
|
-
|
161
|
-
async def close(self) -> None:
|
162
|
-
"""
|
163
|
-
Close the WebSocket connection.
|
164
|
-
"""
|
165
|
-
if self.ws:
|
166
|
-
await self.ws.close()
|
167
|
-
logger.info("WebSocket connection closed.")
|
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
|