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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wiz_trader
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: A Python SDK for connecting to the Wizzer.
5
5
  Home-page: https://bitbucket.org/wizzer-tech/quotes_sdk.git
6
6
  Author: Pawan Wagh
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "wiz_trader"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "A Python SDK for connecting to the Wizzer."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='wiz_trader',
5
- version='0.7.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',
@@ -3,6 +3,6 @@
3
3
  from .quotes import QuotesClient
4
4
  from .apis import WizzerClient
5
5
 
6
- __version__ = "0.7.0"
6
+ __version__ = "0.9.0"
7
7
 
8
8
  __all__ = ["QuotesClient", "WizzerClient"]
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: wiz_trader
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: A Python SDK for connecting to the Wizzer.
5
5
  Home-page: https://bitbucket.org/wizzer-tech/quotes_sdk.git
6
6
  Author: Pawan Wagh
@@ -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