Qubx 0.6.72__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.74__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 (62) 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/broker.py +19 -12
  5. qubx/connectors/ccxt/connection_manager.py +128 -133
  6. qubx/connectors/ccxt/data.py +173 -64
  7. qubx/connectors/ccxt/exchange_manager.py +339 -0
  8. qubx/connectors/ccxt/exchanges/base.py +63 -0
  9. qubx/connectors/ccxt/exchanges/binance/exchange.py +159 -154
  10. qubx/connectors/ccxt/exchanges/bitfinex/bitfinex.py +3 -1
  11. qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +4 -1
  12. qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +3 -4
  13. qubx/connectors/ccxt/exchanges/kraken/kraken.py +3 -1
  14. qubx/connectors/ccxt/factory.py +83 -5
  15. qubx/connectors/ccxt/handlers/base.py +6 -5
  16. qubx/connectors/ccxt/handlers/factory.py +7 -7
  17. qubx/connectors/ccxt/handlers/funding_rate.py +89 -91
  18. qubx/connectors/ccxt/handlers/liquidation.py +7 -6
  19. qubx/connectors/ccxt/handlers/ohlc.py +74 -54
  20. qubx/connectors/ccxt/handlers/open_interest.py +17 -16
  21. qubx/connectors/ccxt/handlers/orderbook.py +72 -46
  22. qubx/connectors/ccxt/handlers/quote.py +13 -8
  23. qubx/connectors/ccxt/handlers/trade.py +23 -6
  24. qubx/connectors/ccxt/reader.py +8 -13
  25. qubx/connectors/ccxt/subscription_config.py +39 -34
  26. qubx/connectors/ccxt/subscription_manager.py +103 -118
  27. qubx/connectors/ccxt/subscription_orchestrator.py +269 -227
  28. qubx/connectors/ccxt/warmup_service.py +9 -3
  29. qubx/connectors/tardis/data.py +1 -1
  30. qubx/core/account.py +5 -5
  31. qubx/core/basics.py +19 -1
  32. qubx/core/context.py +7 -3
  33. qubx/core/initializer.py +7 -0
  34. qubx/core/interfaces.py +41 -8
  35. qubx/core/metrics.py +10 -3
  36. qubx/core/mixins/market.py +17 -3
  37. qubx/core/mixins/processing.py +5 -2
  38. qubx/core/mixins/subscription.py +19 -5
  39. qubx/core/mixins/trading.py +26 -13
  40. qubx/core/mixins/utils.py +4 -0
  41. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  42. qubx/core/series.pyx +1 -1
  43. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  44. qubx/data/readers.py +11 -0
  45. qubx/emitters/indicator.py +5 -5
  46. qubx/exporters/formatters/incremental.py +4 -4
  47. qubx/health/base.py +26 -19
  48. qubx/pandaz/ta.py +1 -12
  49. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  50. qubx/ta/indicators.pxd +10 -0
  51. qubx/ta/indicators.pyi +8 -2
  52. qubx/ta/indicators.pyx +83 -6
  53. qubx/trackers/riskctrl.py +400 -1
  54. qubx/utils/charting/lookinglass.py +11 -1
  55. qubx/utils/charting/mpl_helpers.py +134 -0
  56. qubx/utils/charting/orderbook.py +314 -0
  57. qubx/utils/runner/runner.py +16 -8
  58. {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/METADATA +1 -1
  59. {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/RECORD +62 -58
  60. {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/LICENSE +0 -0
  61. {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/WHEEL +0 -0
  62. {qubx-0.6.72.dist-info → qubx-0.6.74.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
@@ -5,7 +5,7 @@ from typing import Any
5
5
  import pandas as pd
6
6
 
7
7
  import ccxt
8
- import ccxt.pro as cxp
8
+
9
9
  from ccxt.base.errors import ExchangeError
10
10
  from qubx import logger
11
11
  from qubx.core.basics import (
@@ -24,16 +24,16 @@ from qubx.core.interfaces import (
24
24
  )
25
25
  from qubx.utils.misc import AsyncThreadLoop
26
26
 
27
+ from .exchange_manager import ExchangeManager
27
28
  from .utils import ccxt_convert_order_info, instrument_to_ccxt_symbol
28
29
 
29
30
 
30
31
  class CcxtBroker(IBroker):
31
- _exchange: cxp.Exchange
32
- _loop: AsyncThreadLoop
32
+ _exchange_manager: ExchangeManager
33
33
 
34
34
  def __init__(
35
35
  self,
36
- exchange: cxp.Exchange,
36
+ exchange_manager: ExchangeManager,
37
37
  channel: CtrlChannel,
38
38
  time_provider: ITimeProvider,
39
39
  account: IAccountProcessor,
@@ -44,19 +44,24 @@ class CcxtBroker(IBroker):
44
44
  enable_create_order_ws: bool = False,
45
45
  enable_cancel_order_ws: bool = False,
46
46
  ):
47
- self._exchange = exchange
48
- self.ccxt_exchange_id = str(exchange.name)
47
+ self._exchange_manager = exchange_manager
48
+ self.ccxt_exchange_id = str(self._exchange_manager.exchange.name)
49
49
  self.channel = channel
50
50
  self.time_provider = time_provider
51
51
  self.account = account
52
52
  self.data_provider = data_provider
53
- self._loop = AsyncThreadLoop(exchange.asyncio_loop)
54
53
  self.cancel_timeout = cancel_timeout
55
54
  self.cancel_retry_interval = cancel_retry_interval
56
55
  self.max_cancel_retries = max_cancel_retries
57
56
  self.enable_create_order_ws = enable_create_order_ws
58
57
  self.enable_cancel_order_ws = enable_cancel_order_ws
59
58
 
59
+ @property
60
+ def _loop(self) -> AsyncThreadLoop:
61
+ """Get current AsyncThreadLoop for the exchange."""
62
+ return AsyncThreadLoop(self._exchange_manager.exchange.asyncio_loop)
63
+
64
+
60
65
  @property
61
66
  def is_simulated_trading(self) -> bool:
62
67
  return False
@@ -255,9 +260,9 @@ class CcxtBroker(IBroker):
255
260
  instrument, order_side, order_type, amount, price, client_id, time_in_force, **options
256
261
  )
257
262
  if self.enable_create_order_ws:
258
- r = await self._exchange.create_order_ws(**payload)
263
+ r = await self._exchange_manager.exchange.create_order_ws(**payload)
259
264
  else:
260
- r = await self._exchange.create_order(**payload)
265
+ r = await self._exchange_manager.exchange.create_order(**payload)
261
266
 
262
267
  if r is None:
263
268
  msg = "(::_create_order) No response from exchange"
@@ -294,12 +299,14 @@ class CcxtBroker(IBroker):
294
299
  raise BadRequest(f"Quote is not available for order creation for {instrument.symbol}")
295
300
 
296
301
  # TODO: think about automatically setting reduce only when needed
297
- if not options.get("reduceOnly", False):
302
+ if not (reduce_only := options.get("reduceOnly", False)):
298
303
  min_notional = instrument.min_notional
299
304
  if min_notional > 0 and abs(amount) * quote.mid_price() < min_notional:
300
305
  raise InvalidOrderParameters(
301
306
  f"[{instrument.symbol}] Order amount {amount} is too small. Minimum notional is {min_notional}"
302
307
  )
308
+ else:
309
+ params["reduceOnly"] = reduce_only
303
310
 
304
311
  # - handle trigger (stop) orders
305
312
  if _is_trigger_order:
@@ -357,9 +364,9 @@ class CcxtBroker(IBroker):
357
364
  while True:
358
365
  try:
359
366
  if self.enable_cancel_order_ws:
360
- await self._exchange.cancel_order_ws(order_id, symbol=instrument_to_ccxt_symbol(instrument))
367
+ await self._exchange_manager.exchange.cancel_order_ws(order_id, symbol=instrument_to_ccxt_symbol(instrument))
361
368
  else:
362
- await self._exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(instrument))
369
+ await self._exchange_manager.exchange.cancel_order(order_id, symbol=instrument_to_ccxt_symbol(instrument))
363
370
  return True
364
371
  except ccxt.OperationRejected as err:
365
372
  err_msg = str(err).lower()
@@ -7,62 +7,75 @@ 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
22
+ from .exchange_manager import ExchangeManager
20
23
  from .subscription_manager import SubscriptionManager
21
24
 
22
25
 
23
26
  class ConnectionManager:
24
27
  """
25
28
  Manages WebSocket connections and stream lifecycle for CCXT data provider.
26
-
29
+
27
30
  Responsibilities:
28
31
  - Handle WebSocket connection establishment and management
29
32
  - Implement retry logic and error handling
30
33
  - Manage stream lifecycle (start, stop, cleanup)
31
34
  - Coordinate with SubscriptionManager for state updates
32
35
  """
33
-
36
+
34
37
  def __init__(
35
- self,
36
- exchange_id: str,
38
+ self,
39
+ exchange_id: str,
40
+ exchange_manager: ExchangeManager,
37
41
  max_ws_retries: int = 10,
38
- subscription_manager: SubscriptionManager | None = None
42
+ subscription_manager: SubscriptionManager | None = None,
43
+ cleanup_timeout: float = 3.0,
39
44
  ):
40
45
  self._exchange_id = exchange_id
46
+ self._exchange_manager = exchange_manager
41
47
  self.max_ws_retries = max_ws_retries
42
48
  self._subscription_manager = subscription_manager
43
-
49
+ self._cleanup_timeout = cleanup_timeout
50
+
44
51
  # 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
-
52
+ self._is_stream_enabled: dict[str, bool] = defaultdict(lambda: False)
53
+ self._stream_to_unsubscriber: dict[str, Callable[[], Awaitable[None]]] = {}
54
+
48
55
  # Connection tracking
49
- self._stream_to_coro: Dict[str, concurrent.futures.Future] = {}
50
-
56
+ self._stream_to_coro: dict[str, concurrent.futures.Future] = {}
57
+
58
+ @property
59
+ def _loop(self) -> AsyncThreadLoop:
60
+ """Get current AsyncThreadLoop from exchange manager."""
61
+ return AsyncThreadLoop(self._exchange_manager.exchange.asyncio_loop)
62
+
51
63
  def set_subscription_manager(self, subscription_manager: SubscriptionManager) -> None:
52
64
  """Set the subscription manager for state coordination."""
53
65
  self._subscription_manager = subscription_manager
54
-
66
+
55
67
  async def listen_to_stream(
56
68
  self,
57
69
  subscriber: Callable[[], Awaitable[None]],
58
70
  exchange: Exchange,
59
71
  channel: CtrlChannel,
72
+ subscription_type: str,
60
73
  stream_name: str,
61
74
  unsubscriber: Callable[[], Awaitable[None]] | None = None,
62
75
  ) -> None:
63
76
  """
64
77
  Listen to a WebSocket stream with error handling and retry logic.
65
-
78
+
66
79
  Args:
67
80
  subscriber: Async function that handles the stream data
68
81
  exchange: CCXT exchange instance
@@ -71,32 +84,30 @@ class ConnectionManager:
71
84
  unsubscriber: Optional cleanup function for graceful unsubscription
72
85
  """
73
86
  logger.info(f"<yellow>{self._exchange_id}</yellow> Listening to {stream_name}")
74
-
87
+
75
88
  # Register unsubscriber for cleanup
76
89
  if unsubscriber is not None:
77
90
  self._stream_to_unsubscriber[stream_name] = unsubscriber
78
-
91
+
79
92
  # Enable the stream
80
93
  self._is_stream_enabled[stream_name] = True
81
94
  n_retry = 0
82
95
  connection_established = False
83
-
96
+
84
97
  while channel.control.is_set() and self._is_stream_enabled[stream_name]:
85
98
  try:
86
99
  await subscriber()
87
100
  n_retry = 0 # Reset retry counter on success
88
-
101
+
89
102
  # Mark subscription as active on first successful data reception
90
103
  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
-
104
+ self._subscription_manager.mark_subscription_active(subscription_type)
105
+ connection_established = True
106
+
96
107
  # Check if stream was disabled during subscriber execution
97
108
  if not self._is_stream_enabled[stream_name]:
98
109
  break
99
-
110
+
100
111
  except CcxtSymbolNotRecognized:
101
112
  # Skip unrecognized symbols but continue listening
102
113
  continue
@@ -109,7 +120,9 @@ class ConnectionManager:
109
120
  break
110
121
  except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
111
122
  # Network/exchange errors - retry after short delay
112
- logger.error(f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {stream_name} : {e}")
123
+ logger.error(
124
+ f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {stream_name} : {e}"
125
+ )
113
126
  await asyncio.sleep(1)
114
127
  continue
115
128
  except Exception as e:
@@ -117,10 +130,10 @@ class ConnectionManager:
117
130
  if not channel.control.is_set() or not self._is_stream_enabled[stream_name]:
118
131
  # Channel closed or stream disabled, exit gracefully
119
132
  break
120
-
133
+
121
134
  logger.error(f"<yellow>{self._exchange_id}</yellow> Exception in {stream_name}: {e}")
122
135
  logger.exception(e)
123
-
136
+
124
137
  n_retry += 1
125
138
  if n_retry >= self.max_ws_retries:
126
139
  logger.error(
@@ -129,182 +142,164 @@ class ConnectionManager:
129
142
  # Clean up exchange reference to force reconnection
130
143
  del exchange
131
144
  break
132
-
145
+
133
146
  # Exponential backoff with cap at 60 seconds
134
147
  await asyncio.sleep(min(2**n_retry, 60))
135
-
148
+
136
149
  # Stream ended, cleanup
137
150
  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:
151
+
152
+ def stop_stream(self, stream_name: str, wait: bool = True) -> None:
145
153
  """
146
- Stop a stream gracefully with proper cleanup.
147
-
154
+ Stop a stream (signal it to stop).
155
+
148
156
  Args:
149
157
  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
158
+ wait: If True, wait for stream and unsubscriber to complete (default).
159
+ If False, cancel asynchronously without waiting.
152
160
  """
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:
161
+ assert self._subscription_manager is not None
162
+
163
+ logger.debug(f"Stopping stream: {stream_name}, wait={wait}")
164
+
165
+ self._is_stream_enabled[stream_name] = False
166
+
167
+ stream_future = self.get_stream_future(stream_name)
168
+ if stream_future:
169
+ stream_future.cancel()
170
+ if wait:
171
+ self._wait(stream_future, stream_name)
172
+ else:
173
+ logger.warning(f"[CONNECTION] No stream future found for {stream_name}")
174
+
175
+ unsubscriber = self.get_stream_unsubscriber(stream_name)
176
+ if unsubscriber:
177
+ logger.debug(f"Calling unsubscriber for {stream_name}")
178
+ unsub_task = self._loop.submit(unsubscriber())
179
+ if wait:
180
+ self._wait(unsub_task, f"unsubscriber for {stream_name}")
181
+ # Wait for 1 second just in case
182
+ self._loop.submit(asyncio.sleep(1)).result()
183
+ else:
184
+ logger.debug(f"No unsubscriber found for {stream_name}")
185
+
186
+ self._is_stream_enabled.pop(stream_name, None)
187
+ self._stream_to_coro.pop(stream_name, None)
188
+ self._stream_to_unsubscriber.pop(stream_name, None)
189
+
190
+ def register_stream_future(self, stream_name: str, future: concurrent.futures.Future) -> None:
203
191
  """
204
192
  Register a future for a stream for tracking and cleanup.
205
-
193
+
206
194
  Args:
207
195
  stream_name: Name of the stream
208
196
  future: Future representing the stream task
209
197
  """
198
+ # Add done callback to handle any exceptions and prevent "Future exception was never retrieved"
199
+ future.add_done_callback(lambda f: self._handle_stream_completion(f, stream_name))
210
200
  self._stream_to_coro[stream_name] = future
211
-
201
+
212
202
  def is_stream_enabled(self, stream_name: str) -> bool:
213
203
  """
214
204
  Check if a stream is enabled.
215
-
205
+
216
206
  Args:
217
207
  stream_name: Name of the stream to check
218
-
208
+
219
209
  Returns:
220
210
  True if stream is enabled, False otherwise
221
211
  """
222
212
  return self._is_stream_enabled.get(stream_name, False)
223
-
213
+
224
214
  def get_stream_future(self, stream_name: str) -> concurrent.futures.Future | None:
225
215
  """
226
216
  Get the future for a stream.
227
-
217
+
228
218
  Args:
229
219
  stream_name: Name of the stream
230
-
220
+
231
221
  Returns:
232
222
  Future if exists, None otherwise
233
223
  """
234
224
  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
-
225
+
245
226
  def enable_stream(self, stream_name: str) -> None:
246
227
  """
247
228
  Enable a stream.
248
-
229
+
249
230
  Args:
250
231
  stream_name: Name of the stream to enable
251
232
  """
252
233
  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:
234
+
235
+ def set_stream_unsubscriber(self, stream_name: str, unsubscriber: Callable[[], Awaitable[None]]) -> None:
259
236
  """
260
237
  Set unsubscriber function for a stream.
261
-
238
+
262
239
  Args:
263
240
  stream_name: Name of the stream
264
241
  unsubscriber: Async function to call for unsubscription
265
242
  """
266
243
  self._stream_to_unsubscriber[stream_name] = unsubscriber
267
-
244
+
268
245
  def get_stream_unsubscriber(self, stream_name: str) -> Callable[[], Awaitable[None]] | None:
269
246
  """
270
247
  Get unsubscriber function for a stream.
271
-
248
+
272
249
  Args:
273
250
  stream_name: Name of the stream
274
-
251
+
275
252
  Returns:
276
253
  Unsubscriber function if exists, None otherwise
277
254
  """
278
255
  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:
256
+
257
+ def set_stream_coro(self, stream_name: str, coro: concurrent.futures.Future) -> None:
285
258
  """
286
259
  Set coroutine/future for a stream.
287
-
260
+
288
261
  Args:
289
262
  stream_name: Name of the stream
290
263
  coro: Future representing the stream task
291
264
  """
292
265
  self._stream_to_coro[stream_name] = coro
293
-
266
+
294
267
  def get_stream_coro(self, stream_name: str) -> concurrent.futures.Future | None:
295
268
  """
296
269
  Get coroutine/future for a stream.
297
-
270
+
298
271
  Args:
299
272
  stream_name: Name of the stream
300
-
273
+
301
274
  Returns:
302
275
  Future if exists, None otherwise
303
276
  """
304
277
  return self._stream_to_coro.get(stream_name)
305
278
 
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()
279
+ def _handle_stream_completion(self, future: concurrent.futures.Future, stream_name: str) -> None:
280
+ """
281
+ Handle stream future completion and any exceptions to prevent 'Future exception was never retrieved'.
282
+
283
+ Args:
284
+ future: The completed future
285
+ stream_name: Name of the stream for logging
286
+ """
287
+ try:
288
+ future.result() # Retrieve result to handle any exceptions
289
+ except Exception:
290
+ pass # Silent handling to prevent "Future exception was never retrieved"
291
+
292
+ def _wait(self, future: concurrent.futures.Future, context: str) -> None:
293
+ """Wait for future completion with timeout and exception handling."""
294
+ start_wait = time.time()
295
+ while future.running() and (time.time() - start_wait) < self._cleanup_timeout:
296
+ time.sleep(0.1)
297
+
298
+ if future.running():
299
+ logger.warning(f"[{self._exchange_id}] {context} still running after {self._cleanup_timeout}s timeout")
300
+ else:
301
+ # Always retrieve result to handle exceptions properly and prevent "Future exception was never retrieved"
302
+ try:
303
+ future.result() # This will raise any exception that occurred
304
+ except Exception:
305
+ pass # Silent handling during cleanup - UnsubscribeError is expected