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.
- qubx/backtester/data.py +16 -6
- qubx/backtester/runner.py +1 -0
- qubx/connectors/ccxt/adapters/polling_adapter.py +1 -4
- qubx/connectors/ccxt/broker.py +19 -12
- qubx/connectors/ccxt/connection_manager.py +128 -133
- qubx/connectors/ccxt/data.py +173 -64
- qubx/connectors/ccxt/exchange_manager.py +339 -0
- qubx/connectors/ccxt/exchanges/base.py +63 -0
- qubx/connectors/ccxt/exchanges/binance/exchange.py +159 -154
- qubx/connectors/ccxt/exchanges/bitfinex/bitfinex.py +3 -1
- qubx/connectors/ccxt/exchanges/hyperliquid/__init__.py +4 -1
- qubx/connectors/ccxt/exchanges/hyperliquid/hyperliquid.py +3 -4
- qubx/connectors/ccxt/exchanges/kraken/kraken.py +3 -1
- qubx/connectors/ccxt/factory.py +83 -5
- qubx/connectors/ccxt/handlers/base.py +6 -5
- qubx/connectors/ccxt/handlers/factory.py +7 -7
- qubx/connectors/ccxt/handlers/funding_rate.py +89 -91
- qubx/connectors/ccxt/handlers/liquidation.py +7 -6
- qubx/connectors/ccxt/handlers/ohlc.py +74 -54
- qubx/connectors/ccxt/handlers/open_interest.py +17 -16
- qubx/connectors/ccxt/handlers/orderbook.py +72 -46
- qubx/connectors/ccxt/handlers/quote.py +13 -8
- qubx/connectors/ccxt/handlers/trade.py +23 -6
- qubx/connectors/ccxt/reader.py +8 -13
- qubx/connectors/ccxt/subscription_config.py +39 -34
- qubx/connectors/ccxt/subscription_manager.py +103 -118
- qubx/connectors/ccxt/subscription_orchestrator.py +269 -227
- qubx/connectors/ccxt/warmup_service.py +9 -3
- qubx/connectors/tardis/data.py +1 -1
- qubx/core/account.py +5 -5
- qubx/core/basics.py +19 -1
- qubx/core/context.py +7 -3
- qubx/core/initializer.py +7 -0
- qubx/core/interfaces.py +41 -8
- qubx/core/metrics.py +10 -3
- qubx/core/mixins/market.py +17 -3
- qubx/core/mixins/processing.py +5 -2
- qubx/core/mixins/subscription.py +19 -5
- qubx/core/mixins/trading.py +26 -13
- qubx/core/mixins/utils.py +4 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyx +1 -1
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/data/readers.py +11 -0
- qubx/emitters/indicator.py +5 -5
- qubx/exporters/formatters/incremental.py +4 -4
- qubx/health/base.py +26 -19
- qubx/pandaz/ta.py +1 -12
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +10 -0
- qubx/ta/indicators.pyi +8 -2
- qubx/ta/indicators.pyx +83 -6
- qubx/trackers/riskctrl.py +400 -1
- qubx/utils/charting/lookinglass.py +11 -1
- qubx/utils/charting/mpl_helpers.py +134 -0
- qubx/utils/charting/orderbook.py +314 -0
- qubx/utils/runner/runner.py +16 -8
- {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/METADATA +1 -1
- {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/RECORD +62 -58
- {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/LICENSE +0 -0
- {qubx-0.6.72.dist-info → qubx-0.6.74.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
@@ -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
|
qubx/connectors/ccxt/broker.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Any
|
|
|
5
5
|
import pandas as pd
|
|
6
6
|
|
|
7
7
|
import ccxt
|
|
8
|
-
|
|
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
|
-
|
|
32
|
-
_loop: AsyncThreadLoop
|
|
32
|
+
_exchange_manager: ExchangeManager
|
|
33
33
|
|
|
34
34
|
def __init__(
|
|
35
35
|
self,
|
|
36
|
-
|
|
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.
|
|
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.
|
|
263
|
+
r = await self._exchange_manager.exchange.create_order_ws(**payload)
|
|
259
264
|
else:
|
|
260
|
-
r = await self.
|
|
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.
|
|
367
|
+
await self._exchange_manager.exchange.cancel_order_ws(order_id, symbol=instrument_to_ccxt_symbol(instrument))
|
|
361
368
|
else:
|
|
362
|
-
await self.
|
|
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
|
|
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:
|
|
46
|
-
self._stream_to_unsubscriber:
|
|
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:
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
147
|
-
|
|
154
|
+
Stop a stream (signal it to stop).
|
|
155
|
+
|
|
148
156
|
Args:
|
|
149
157
|
stream_name: Name of the stream to stop
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
wait: If True, wait for stream and unsubscriber to complete (default).
|
|
159
|
+
If False, cancel asynchronously without waiting.
|
|
152
160
|
"""
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
|
307
|
-
"""
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|