Qubx 0.6.72__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.73__cp312-cp312-manylinux_2_39_x86_64.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.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (39) hide show
  1. qubx/backtester/data.py +16 -6
  2. qubx/backtester/runner.py +1 -0
  3. qubx/connectors/ccxt/adapters/polling_adapter.py +1 -4
  4. qubx/connectors/ccxt/connection_manager.py +122 -133
  5. qubx/connectors/ccxt/data.py +108 -43
  6. qubx/connectors/ccxt/exchanges/base.py +63 -0
  7. qubx/connectors/ccxt/exchanges/binance/exchange.py +159 -154
  8. qubx/connectors/ccxt/exchanges/bitfinex/bitfinex.py +3 -1
  9. qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +4 -1
  10. qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +3 -4
  11. qubx/connectors/ccxt/exchanges/kraken/kraken.py +3 -1
  12. qubx/connectors/ccxt/handlers/funding_rate.py +88 -88
  13. qubx/connectors/ccxt/handlers/liquidation.py +1 -0
  14. qubx/connectors/ccxt/handlers/ohlc.py +63 -45
  15. qubx/connectors/ccxt/handlers/open_interest.py +12 -13
  16. qubx/connectors/ccxt/handlers/orderbook.py +65 -39
  17. qubx/connectors/ccxt/handlers/quote.py +3 -1
  18. qubx/connectors/ccxt/handlers/trade.py +15 -1
  19. qubx/connectors/ccxt/reader.py +8 -13
  20. qubx/connectors/ccxt/subscription_config.py +39 -34
  21. qubx/connectors/ccxt/subscription_manager.py +103 -118
  22. qubx/connectors/ccxt/subscription_orchestrator.py +265 -228
  23. qubx/core/account.py +5 -5
  24. qubx/core/basics.py +19 -1
  25. qubx/core/initializer.py +7 -0
  26. qubx/core/interfaces.py +21 -5
  27. qubx/core/mixins/subscription.py +6 -1
  28. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  29. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  30. qubx/data/readers.py +11 -0
  31. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  32. qubx/utils/charting/mpl_helpers.py +134 -0
  33. qubx/utils/charting/orderbook.py +314 -0
  34. qubx/utils/runner/runner.py +9 -0
  35. {qubx-0.6.72.dist-info → qubx-0.6.73.dist-info}/METADATA +1 -1
  36. {qubx-0.6.72.dist-info → qubx-0.6.73.dist-info}/RECORD +39 -37
  37. {qubx-0.6.72.dist-info → qubx-0.6.73.dist-info}/LICENSE +0 -0
  38. {qubx-0.6.72.dist-info → qubx-0.6.73.dist-info}/WHEEL +0 -0
  39. {qubx-0.6.72.dist-info → qubx-0.6.73.dist-info}/entry_points.txt +0 -0
qubx/backtester/data.py CHANGED
@@ -27,7 +27,7 @@ def _get_first_existing(data: dict, keys: list, default: T = None) -> T:
27
27
  data_get = data.get # Cache method lookup
28
28
  sentinel = object()
29
29
  for key in keys:
30
- if (value := data_get(key, sentinel)) is not sentinel:
30
+ if (value := data_get(key, sentinel)) is not sentinel and value is not None:
31
31
  return value
32
32
  return default
33
33
 
@@ -169,14 +169,24 @@ class SimulatedDataProvider(IDataProvider):
169
169
  if _b_ts_0 <= cut_time_ns and cut_time_ns < _b_ts_1:
170
170
  break
171
171
 
172
+ # Handle None values in OHLC data
173
+ open_price = r.data["open"]
174
+ high_price = r.data["high"]
175
+ low_price = r.data["low"]
176
+ close_price = r.data["close"]
177
+
178
+ # Skip this record if any OHLC value is None
179
+ if open_price is None or high_price is None or low_price is None or close_price is None:
180
+ continue
181
+
172
182
  bars.append(
173
183
  Bar(
174
184
  _b_ts_0,
175
- r.data["open"],
176
- r.data["high"],
177
- r.data["low"],
178
- r.data["close"],
179
- volume=r.data.get("volume", 0),
185
+ open_price,
186
+ high_price,
187
+ low_price,
188
+ close_price,
189
+ volume=r.data.get("volume", 0) or 0, # Handle None volume
180
190
  bought_volume=_get_first_existing(r.data, ["taker_buy_volume", "bought_volume"], 0),
181
191
  volume_quote=_get_first_existing(r.data, ["quote_volume", "volume_quote"], 0),
182
192
  bought_volume_quote=_get_first_existing(
qubx/backtester/runner.py CHANGED
@@ -328,6 +328,7 @@ class SimulationRunner:
328
328
 
329
329
  if not _run(instrument, data_type, event, is_hist):
330
330
  return False
331
+
331
332
  return True
332
333
 
333
334
  def _handle_no_data_scenario(self, stop_time):
@@ -98,9 +98,6 @@ class PollingToWebSocketAdapter:
98
98
  current_symbols = list(self._symbols)
99
99
  symbols_changed = self._symbols_changed
100
100
 
101
- if not current_symbols:
102
- raise ValueError(f"No symbols configured for adapter {self.adapter_id}")
103
-
104
101
  # If symbols changed, poll immediately
105
102
  if symbols_changed:
106
103
  logger.debug(f"Symbols changed, polling immediately for adapter {self.adapter_id}")
@@ -161,7 +158,7 @@ class PollingToWebSocketAdapter:
161
158
  self._poll_count += 1
162
159
  self._last_poll_time = time.time()
163
160
 
164
- logger.debug(f"Polling {len(symbols)} symbols for adapter {self.adapter_id}")
161
+ logger.debug(f"Polling {len(symbols) if symbols else 'all'} symbols for adapter {self.adapter_id}")
165
162
 
166
163
  try:
167
164
  # Filter out adapter-specific parameters
@@ -7,14 +7,16 @@ separating connection concerns from subscription state and data handling.
7
7
 
8
8
  import asyncio
9
9
  import concurrent.futures
10
+ import time
10
11
  from asyncio.exceptions import CancelledError
11
12
  from collections import defaultdict
12
- from typing import Awaitable, Callable, Dict
13
+ from typing import Awaitable, Callable
13
14
 
14
15
  from ccxt import ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable, NetworkError
15
16
  from ccxt.pro import Exchange
16
17
  from qubx import logger
17
18
  from qubx.core.basics import CtrlChannel
19
+ from qubx.utils.misc import AsyncThreadLoop
18
20
 
19
21
  from .exceptions import CcxtSymbolNotRecognized
20
22
  from .subscription_manager import SubscriptionManager
@@ -23,46 +25,51 @@ from .subscription_manager import SubscriptionManager
23
25
  class ConnectionManager:
24
26
  """
25
27
  Manages WebSocket connections and stream lifecycle for CCXT data provider.
26
-
28
+
27
29
  Responsibilities:
28
30
  - Handle WebSocket connection establishment and management
29
31
  - Implement retry logic and error handling
30
32
  - Manage stream lifecycle (start, stop, cleanup)
31
33
  - Coordinate with SubscriptionManager for state updates
32
34
  """
33
-
35
+
34
36
  def __init__(
35
- self,
36
- exchange_id: str,
37
+ self,
38
+ exchange_id: str,
39
+ loop: AsyncThreadLoop,
37
40
  max_ws_retries: int = 10,
38
- subscription_manager: SubscriptionManager | None = None
41
+ subscription_manager: SubscriptionManager | None = None,
42
+ cleanup_timeout: float = 3.0,
39
43
  ):
40
44
  self._exchange_id = exchange_id
45
+ self._loop = loop
41
46
  self.max_ws_retries = max_ws_retries
42
47
  self._subscription_manager = subscription_manager
43
-
48
+ self._cleanup_timeout = cleanup_timeout
49
+
44
50
  # Stream state management
45
- self._is_stream_enabled: Dict[str, bool] = defaultdict(lambda: False)
46
- self._stream_to_unsubscriber: Dict[str, Callable[[], Awaitable[None]]] = {}
47
-
51
+ self._is_stream_enabled: dict[str, bool] = defaultdict(lambda: False)
52
+ self._stream_to_unsubscriber: dict[str, Callable[[], Awaitable[None]]] = {}
53
+
48
54
  # Connection tracking
49
- self._stream_to_coro: Dict[str, concurrent.futures.Future] = {}
50
-
55
+ self._stream_to_coro: dict[str, concurrent.futures.Future] = {}
56
+
51
57
  def set_subscription_manager(self, subscription_manager: SubscriptionManager) -> None:
52
58
  """Set the subscription manager for state coordination."""
53
59
  self._subscription_manager = subscription_manager
54
-
60
+
55
61
  async def listen_to_stream(
56
62
  self,
57
63
  subscriber: Callable[[], Awaitable[None]],
58
64
  exchange: Exchange,
59
65
  channel: CtrlChannel,
66
+ subscription_type: str,
60
67
  stream_name: str,
61
68
  unsubscriber: Callable[[], Awaitable[None]] | None = None,
62
69
  ) -> None:
63
70
  """
64
71
  Listen to a WebSocket stream with error handling and retry logic.
65
-
72
+
66
73
  Args:
67
74
  subscriber: Async function that handles the stream data
68
75
  exchange: CCXT exchange instance
@@ -71,32 +78,30 @@ class ConnectionManager:
71
78
  unsubscriber: Optional cleanup function for graceful unsubscription
72
79
  """
73
80
  logger.info(f"<yellow>{self._exchange_id}</yellow> Listening to {stream_name}")
74
-
81
+
75
82
  # Register unsubscriber for cleanup
76
83
  if unsubscriber is not None:
77
84
  self._stream_to_unsubscriber[stream_name] = unsubscriber
78
-
85
+
79
86
  # Enable the stream
80
87
  self._is_stream_enabled[stream_name] = True
81
88
  n_retry = 0
82
89
  connection_established = False
83
-
90
+
84
91
  while channel.control.is_set() and self._is_stream_enabled[stream_name]:
85
92
  try:
86
93
  await subscriber()
87
94
  n_retry = 0 # Reset retry counter on success
88
-
95
+
89
96
  # Mark subscription as active on first successful data reception
90
97
  if not connection_established and self._subscription_manager:
91
- subscription_type = self._subscription_manager.find_subscription_type_by_name(stream_name)
92
- if subscription_type:
93
- self._subscription_manager.mark_subscription_active(subscription_type)
94
- connection_established = True
95
-
98
+ self._subscription_manager.mark_subscription_active(subscription_type)
99
+ connection_established = True
100
+
96
101
  # Check if stream was disabled during subscriber execution
97
102
  if not self._is_stream_enabled[stream_name]:
98
103
  break
99
-
104
+
100
105
  except CcxtSymbolNotRecognized:
101
106
  # Skip unrecognized symbols but continue listening
102
107
  continue
@@ -109,7 +114,9 @@ class ConnectionManager:
109
114
  break
110
115
  except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
111
116
  # Network/exchange errors - retry after short delay
112
- logger.error(f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {stream_name} : {e}")
117
+ logger.error(
118
+ f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {stream_name} : {e}"
119
+ )
113
120
  await asyncio.sleep(1)
114
121
  continue
115
122
  except Exception as e:
@@ -117,10 +124,10 @@ class ConnectionManager:
117
124
  if not channel.control.is_set() or not self._is_stream_enabled[stream_name]:
118
125
  # Channel closed or stream disabled, exit gracefully
119
126
  break
120
-
127
+
121
128
  logger.error(f"<yellow>{self._exchange_id}</yellow> Exception in {stream_name}: {e}")
122
129
  logger.exception(e)
123
-
130
+
124
131
  n_retry += 1
125
132
  if n_retry >= self.max_ws_retries:
126
133
  logger.error(
@@ -129,182 +136,164 @@ class ConnectionManager:
129
136
  # Clean up exchange reference to force reconnection
130
137
  del exchange
131
138
  break
132
-
139
+
133
140
  # Exponential backoff with cap at 60 seconds
134
141
  await asyncio.sleep(min(2**n_retry, 60))
135
-
142
+
136
143
  # Stream ended, cleanup
137
144
  logger.debug(f"<yellow>{self._exchange_id}</yellow> Stream {stream_name} ended")
138
-
139
- async def stop_stream(
140
- self,
141
- stream_name: str,
142
- future: concurrent.futures.Future | None = None,
143
- is_resubscription: bool = False
144
- ) -> None:
145
+
146
+ def stop_stream(self, stream_name: str, wait: bool = True) -> None:
145
147
  """
146
- Stop a stream gracefully with proper cleanup.
147
-
148
+ Stop a stream (signal it to stop).
149
+
148
150
  Args:
149
151
  stream_name: Name of the stream to stop
150
- future: Optional future representing the stream task
151
- is_resubscription: True if this is stopping an old stream during resubscription
152
+ wait: If True, wait for stream and unsubscriber to complete (default).
153
+ If False, cancel asynchronously without waiting.
152
154
  """
153
- try:
154
- context = "old stream" if is_resubscription else "stream"
155
- logger.debug(f"<yellow>{self._exchange_id}</yellow> Stopping {context} {stream_name}")
156
-
157
- # Disable the stream to signal it should stop
158
- self._is_stream_enabled[stream_name] = False
159
-
160
- # Wait for the stream to stop naturally
161
- if future:
162
- total_sleep_time = 0.0
163
- while future.running() and total_sleep_time < 20.0:
164
- await asyncio.sleep(1.0)
165
- total_sleep_time += 1.0
166
-
167
- if future.running():
168
- logger.warning(
169
- f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} is still running. Cancelling it."
170
- )
171
- future.cancel()
172
- else:
173
- logger.debug(f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} has been stopped")
174
-
175
- # Run unsubscriber if available
176
- if stream_name in self._stream_to_unsubscriber:
177
- logger.debug(f"<yellow>{self._exchange_id}</yellow> Unsubscribing from {stream_name}")
178
- await self._stream_to_unsubscriber[stream_name]()
179
- del self._stream_to_unsubscriber[stream_name]
180
-
181
- # Clean up stream state
182
- if is_resubscription:
183
- # For resubscription, only clean up if the stream is actually disabled
184
- # (avoid interfering with new streams using the same name)
185
- if stream_name in self._is_stream_enabled and not self._is_stream_enabled[stream_name]:
186
- del self._is_stream_enabled[stream_name]
187
- else:
188
- # For regular stops, always clean up completely
189
- self._is_stream_enabled.pop(stream_name, None)
190
- self._stream_to_coro.pop(stream_name, None)
191
-
192
- logger.debug(f"<yellow>{self._exchange_id}</yellow> {context.title()} {stream_name} stopped")
193
-
194
- except Exception as e:
195
- logger.error(f"<yellow>{self._exchange_id}</yellow> Error stopping {stream_name}")
196
- logger.exception(e)
197
-
198
- def register_stream_future(
199
- self,
200
- stream_name: str,
201
- future: concurrent.futures.Future
202
- ) -> None:
155
+ assert self._subscription_manager is not None
156
+
157
+ logger.debug(f"Stopping stream: {stream_name}, wait={wait}")
158
+
159
+ self._is_stream_enabled[stream_name] = False
160
+
161
+ stream_future = self.get_stream_future(stream_name)
162
+ if stream_future:
163
+ stream_future.cancel()
164
+ if wait:
165
+ self._wait(stream_future, stream_name)
166
+ else:
167
+ logger.warning(f"[CONNECTION] No stream future found for {stream_name}")
168
+
169
+ unsubscriber = self.get_stream_unsubscriber(stream_name)
170
+ if unsubscriber:
171
+ logger.debug(f"Calling unsubscriber for {stream_name}")
172
+ unsub_task = self._loop.submit(unsubscriber())
173
+ if wait:
174
+ self._wait(unsub_task, f"unsubscriber for {stream_name}")
175
+ # Wait for 1 second just in case
176
+ self._loop.submit(asyncio.sleep(1)).result()
177
+ else:
178
+ logger.debug(f"No unsubscriber found for {stream_name}")
179
+
180
+ self._is_stream_enabled.pop(stream_name, None)
181
+ self._stream_to_coro.pop(stream_name, None)
182
+ self._stream_to_unsubscriber.pop(stream_name, None)
183
+
184
+ def register_stream_future(self, stream_name: str, future: concurrent.futures.Future) -> None:
203
185
  """
204
186
  Register a future for a stream for tracking and cleanup.
205
-
187
+
206
188
  Args:
207
189
  stream_name: Name of the stream
208
190
  future: Future representing the stream task
209
191
  """
192
+ # Add done callback to handle any exceptions and prevent "Future exception was never retrieved"
193
+ future.add_done_callback(lambda f: self._handle_stream_completion(f, stream_name))
210
194
  self._stream_to_coro[stream_name] = future
211
-
195
+
212
196
  def is_stream_enabled(self, stream_name: str) -> bool:
213
197
  """
214
198
  Check if a stream is enabled.
215
-
199
+
216
200
  Args:
217
201
  stream_name: Name of the stream to check
218
-
202
+
219
203
  Returns:
220
204
  True if stream is enabled, False otherwise
221
205
  """
222
206
  return self._is_stream_enabled.get(stream_name, False)
223
-
207
+
224
208
  def get_stream_future(self, stream_name: str) -> concurrent.futures.Future | None:
225
209
  """
226
210
  Get the future for a stream.
227
-
211
+
228
212
  Args:
229
213
  stream_name: Name of the stream
230
-
214
+
231
215
  Returns:
232
216
  Future if exists, None otherwise
233
217
  """
234
218
  return self._stream_to_coro.get(stream_name)
235
-
236
- def disable_stream(self, stream_name: str) -> None:
237
- """
238
- Disable a stream (signal it to stop).
239
-
240
- Args:
241
- stream_name: Name of the stream to disable
242
- """
243
- self._is_stream_enabled[stream_name] = False
244
-
219
+
245
220
  def enable_stream(self, stream_name: str) -> None:
246
221
  """
247
222
  Enable a stream.
248
-
223
+
249
224
  Args:
250
225
  stream_name: Name of the stream to enable
251
226
  """
252
227
  self._is_stream_enabled[stream_name] = True
253
-
254
- def set_stream_unsubscriber(
255
- self,
256
- stream_name: str,
257
- unsubscriber: Callable[[], Awaitable[None]]
258
- ) -> None:
228
+
229
+ def set_stream_unsubscriber(self, stream_name: str, unsubscriber: Callable[[], Awaitable[None]]) -> None:
259
230
  """
260
231
  Set unsubscriber function for a stream.
261
-
232
+
262
233
  Args:
263
234
  stream_name: Name of the stream
264
235
  unsubscriber: Async function to call for unsubscription
265
236
  """
266
237
  self._stream_to_unsubscriber[stream_name] = unsubscriber
267
-
238
+
268
239
  def get_stream_unsubscriber(self, stream_name: str) -> Callable[[], Awaitable[None]] | None:
269
240
  """
270
241
  Get unsubscriber function for a stream.
271
-
242
+
272
243
  Args:
273
244
  stream_name: Name of the stream
274
-
245
+
275
246
  Returns:
276
247
  Unsubscriber function if exists, None otherwise
277
248
  """
278
249
  return self._stream_to_unsubscriber.get(stream_name)
279
-
280
- def set_stream_coro(
281
- self,
282
- stream_name: str,
283
- coro: concurrent.futures.Future
284
- ) -> None:
250
+
251
+ def set_stream_coro(self, stream_name: str, coro: concurrent.futures.Future) -> None:
285
252
  """
286
253
  Set coroutine/future for a stream.
287
-
254
+
288
255
  Args:
289
256
  stream_name: Name of the stream
290
257
  coro: Future representing the stream task
291
258
  """
292
259
  self._stream_to_coro[stream_name] = coro
293
-
260
+
294
261
  def get_stream_coro(self, stream_name: str) -> concurrent.futures.Future | None:
295
262
  """
296
263
  Get coroutine/future for a stream.
297
-
264
+
298
265
  Args:
299
266
  stream_name: Name of the stream
300
-
267
+
301
268
  Returns:
302
269
  Future if exists, None otherwise
303
270
  """
304
271
  return self._stream_to_coro.get(stream_name)
305
272
 
306
- def cleanup_all_streams(self) -> None:
307
- """Clean up all stream state (for shutdown)."""
308
- self._is_stream_enabled.clear()
309
- self._stream_to_unsubscriber.clear()
310
- self._stream_to_coro.clear()
273
+ def _handle_stream_completion(self, future: concurrent.futures.Future, stream_name: str) -> None:
274
+ """
275
+ Handle stream future completion and any exceptions to prevent 'Future exception was never retrieved'.
276
+
277
+ Args:
278
+ future: The completed future
279
+ stream_name: Name of the stream for logging
280
+ """
281
+ try:
282
+ future.result() # Retrieve result to handle any exceptions
283
+ except Exception:
284
+ pass # Silent handling to prevent "Future exception was never retrieved"
285
+
286
+ def _wait(self, future: concurrent.futures.Future, context: str) -> None:
287
+ """Wait for future completion with timeout and exception handling."""
288
+ start_wait = time.time()
289
+ while future.running() and (time.time() - start_wait) < self._cleanup_timeout:
290
+ time.sleep(0.1)
291
+
292
+ if future.running():
293
+ logger.warning(f"[{self._exchange_id}] {context} still running after {self._cleanup_timeout}s timeout")
294
+ else:
295
+ # Always retrieve result to handle exceptions properly and prevent "Future exception was never retrieved"
296
+ try:
297
+ future.result() # This will raise any exception that occurred
298
+ except Exception:
299
+ pass # Silent handling during cleanup - UnsubscribeError is expected