Qubx 0.6.77__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.84__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/management.py +12 -1
- qubx/backtester/runner.py +9 -3
- qubx/cli/release.py +23 -6
- qubx/connectors/ccxt/account.py +22 -4
- qubx/connectors/ccxt/exchange_manager.py +66 -63
- qubx/core/account.py +12 -1
- qubx/core/basics.py +4 -0
- qubx/core/metrics.py +101 -2
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/data/helpers.py +34 -12
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +5 -1
- qubx/ta/indicators.pyi +7 -2
- qubx/ta/indicators.pyx +136 -18
- qubx/trackers/riskctrl.py +83 -3
- qubx/utils/charting/lookinglass.py +42 -0
- qubx/utils/misc.py +23 -6
- qubx/utils/runner/configs.py +1 -0
- qubx/utils/runner/runner.py +27 -2
- {qubx-0.6.77.dist-info → qubx-0.6.84.dist-info}/METADATA +4 -2
- {qubx-0.6.77.dist-info → qubx-0.6.84.dist-info}/RECORD +25 -25
- {qubx-0.6.77.dist-info → qubx-0.6.84.dist-info}/WHEEL +1 -1
- {qubx-0.6.77.dist-info → qubx-0.6.84.dist-info}/entry_points.txt +0 -0
- {qubx-0.6.77.dist-info → qubx-0.6.84.dist-info/licenses}/LICENSE +0 -0
qubx/backtester/management.py
CHANGED
|
@@ -6,6 +6,7 @@ from pathlib import Path
|
|
|
6
6
|
import numpy as np
|
|
7
7
|
import pandas as pd
|
|
8
8
|
import yaml
|
|
9
|
+
from tqdm.auto import tqdm
|
|
9
10
|
|
|
10
11
|
from qubx.core.metrics import TradingSessionResult
|
|
11
12
|
from qubx.utils.misc import blue, cyan, green, magenta, red, yellow
|
|
@@ -420,8 +421,10 @@ class BacktestsResultsManager:
|
|
|
420
421
|
Returns:
|
|
421
422
|
plotly.graph_objects.Figure: The plot of the variation.
|
|
422
423
|
"""
|
|
423
|
-
import plotly.express as px
|
|
424
424
|
from itertools import cycle
|
|
425
|
+
|
|
426
|
+
import plotly.express as px
|
|
427
|
+
|
|
425
428
|
from qubx.utils.misc import string_shortener
|
|
426
429
|
|
|
427
430
|
_vars = self.variations.get(variation_idx)
|
|
@@ -507,3 +510,11 @@ class BacktestsResultsManager:
|
|
|
507
510
|
)
|
|
508
511
|
)
|
|
509
512
|
return figure
|
|
513
|
+
|
|
514
|
+
def export_backtests_to_markdown(self, path: str, tags: tuple[str] | None = None):
|
|
515
|
+
"""
|
|
516
|
+
Export backtests to markdown format
|
|
517
|
+
"""
|
|
518
|
+
for n, v in tqdm(self.results.items()):
|
|
519
|
+
r = TradingSessionResult.from_file(v.get("path"))
|
|
520
|
+
r.to_markdown(path, list(tags) if tags else None)
|
qubx/backtester/runner.py
CHANGED
|
@@ -15,6 +15,7 @@ from qubx.core.helpers import extract_parameters_from_object, full_qualified_cla
|
|
|
15
15
|
from qubx.core.initializer import BasicStrategyInitializer
|
|
16
16
|
from qubx.core.interfaces import (
|
|
17
17
|
CtrlChannel,
|
|
18
|
+
IDataProvider,
|
|
18
19
|
IMetricEmitter,
|
|
19
20
|
IStrategy,
|
|
20
21
|
IStrategyContext,
|
|
@@ -224,7 +225,7 @@ class SimulationRunner:
|
|
|
224
225
|
cc = self.channel
|
|
225
226
|
t = np.datetime64(data.time, "ns")
|
|
226
227
|
_account = self.account.get_account_processor(instrument.exchange)
|
|
227
|
-
_data_provider = self.
|
|
228
|
+
_data_provider = self._get_data_provider(instrument.exchange)
|
|
228
229
|
assert isinstance(_account, SimulatedAccountProcessor)
|
|
229
230
|
assert isinstance(_data_provider, SimulatedDataProvider)
|
|
230
231
|
|
|
@@ -249,7 +250,7 @@ class SimulationRunner:
|
|
|
249
250
|
cc = self.channel
|
|
250
251
|
t = np.datetime64(data.time, "ns")
|
|
251
252
|
_account = self.account.get_account_processor(instrument.exchange)
|
|
252
|
-
_data_provider = self.
|
|
253
|
+
_data_provider = self._get_data_provider(instrument.exchange)
|
|
253
254
|
assert isinstance(_account, SimulatedAccountProcessor)
|
|
254
255
|
assert isinstance(_data_provider, SimulatedDataProvider)
|
|
255
256
|
|
|
@@ -267,6 +268,11 @@ class SimulationRunner:
|
|
|
267
268
|
|
|
268
269
|
return cc.control.is_set()
|
|
269
270
|
|
|
271
|
+
def _get_data_provider(self, exchange: str) -> IDataProvider:
|
|
272
|
+
if exchange in self._exchange_to_data_provider:
|
|
273
|
+
return self._exchange_to_data_provider[exchange]
|
|
274
|
+
raise ValueError(f"Data provider for exchange {exchange} not found")
|
|
275
|
+
|
|
270
276
|
def _run(self, start: pd.Timestamp, stop: pd.Timestamp, silent: bool = False) -> None:
|
|
271
277
|
logger.info(f"{self.__class__.__name__} ::: Simulation started at {start} :::")
|
|
272
278
|
|
|
@@ -328,7 +334,7 @@ class SimulationRunner:
|
|
|
328
334
|
|
|
329
335
|
if not _run(instrument, data_type, event, is_hist):
|
|
330
336
|
return False
|
|
331
|
-
|
|
337
|
+
|
|
332
338
|
return True
|
|
333
339
|
|
|
334
340
|
def _handle_no_data_scenario(self, stop_time):
|
qubx/cli/release.py
CHANGED
|
@@ -84,9 +84,12 @@ def resolve_relative_import(relative_module: str, file_path: str, project_root:
|
|
|
84
84
|
# Get the directory containing the file (remove filename)
|
|
85
85
|
file_dir = os.path.dirname(rel_file_path)
|
|
86
86
|
|
|
87
|
-
# Convert file directory path to module path
|
|
87
|
+
# Convert file directory path to module path
|
|
88
88
|
if file_dir:
|
|
89
89
|
current_module_parts = file_dir.replace(os.sep, ".").split(".")
|
|
90
|
+
# Remove 'src' prefix if present (common Python project structure)
|
|
91
|
+
if current_module_parts[0] == "src" and len(current_module_parts) > 1:
|
|
92
|
+
current_module_parts = current_module_parts[1:]
|
|
90
93
|
else:
|
|
91
94
|
current_module_parts = []
|
|
92
95
|
|
|
@@ -684,8 +687,8 @@ def _copy_dependencies(strategy_path: str, pyproject_root: str, release_dir: str
|
|
|
684
687
|
if _src_root is None:
|
|
685
688
|
raise DependencyResolutionError(f"Could not find the source root for {_src_dir} in {pyproject_root}")
|
|
686
689
|
|
|
687
|
-
# Now call _get_imports with the correct source root directory
|
|
688
|
-
_imports = _get_imports(strategy_path, _src_root, [_src_dir])
|
|
690
|
+
# Now call _get_imports with the correct source root directory and pyproject_root for relative imports
|
|
691
|
+
_imports = _get_imports(strategy_path, _src_root, [_src_dir], pyproject_root)
|
|
689
692
|
|
|
690
693
|
# Validate all dependencies before copying
|
|
691
694
|
valid_imports, missing_dependencies = _validate_dependencies(_imports, _src_root, _src_dir)
|
|
@@ -920,7 +923,7 @@ def _create_zip_archive(output_dir: str, release_dir: str, tag: str) -> None:
|
|
|
920
923
|
shutil.rmtree(release_dir)
|
|
921
924
|
|
|
922
925
|
|
|
923
|
-
def _get_imports(file_name: str, current_directory: str, what_to_look: list[str]) -> list[Import]:
|
|
926
|
+
def _get_imports(file_name: str, current_directory: str, what_to_look: list[str], pyproject_root: str | None = None, visited: set[str] | None = None) -> list[Import]:
|
|
924
927
|
"""
|
|
925
928
|
Recursively get all imports from a file and its dependencies.
|
|
926
929
|
|
|
@@ -928,6 +931,8 @@ def _get_imports(file_name: str, current_directory: str, what_to_look: list[str]
|
|
|
928
931
|
file_name: Path to the Python file to analyze
|
|
929
932
|
current_directory: Root directory for resolving imports
|
|
930
933
|
what_to_look: List of module prefixes to filter for
|
|
934
|
+
pyproject_root: Root directory of the project for resolving relative imports
|
|
935
|
+
visited: Set of already visited files to prevent infinite recursion
|
|
931
936
|
|
|
932
937
|
Returns:
|
|
933
938
|
List of Import objects for all discovered dependencies
|
|
@@ -935,8 +940,20 @@ def _get_imports(file_name: str, current_directory: str, what_to_look: list[str]
|
|
|
935
940
|
Raises:
|
|
936
941
|
DependencyResolutionError: If a required dependency cannot be found or processed
|
|
937
942
|
"""
|
|
943
|
+
# Initialize visited set if not provided
|
|
944
|
+
if visited is None:
|
|
945
|
+
visited = set()
|
|
946
|
+
|
|
947
|
+
# Skip if already visited to prevent infinite recursion
|
|
948
|
+
if file_name in visited:
|
|
949
|
+
return []
|
|
950
|
+
visited.add(file_name)
|
|
951
|
+
|
|
952
|
+
# Use pyproject_root if provided, otherwise use current_directory as fallback
|
|
953
|
+
project_root_for_resolution = pyproject_root or current_directory
|
|
954
|
+
|
|
938
955
|
try:
|
|
939
|
-
imports = list(get_imports(file_name, what_to_look, project_root=
|
|
956
|
+
imports = list(get_imports(file_name, what_to_look, project_root=project_root_for_resolution))
|
|
940
957
|
except (SyntaxError, FileNotFoundError) as e:
|
|
941
958
|
raise DependencyResolutionError(f"Failed to parse imports from {file_name}: {e}")
|
|
942
959
|
|
|
@@ -959,7 +976,7 @@ def _get_imports(file_name: str, current_directory: str, what_to_look: list[str]
|
|
|
959
976
|
if dependency_file:
|
|
960
977
|
# Recursively process the dependency
|
|
961
978
|
try:
|
|
962
|
-
imports.extend(_get_imports(dependency_file, current_directory, what_to_look))
|
|
979
|
+
imports.extend(_get_imports(dependency_file, current_directory, what_to_look, pyproject_root, visited))
|
|
963
980
|
except DependencyResolutionError as e:
|
|
964
981
|
# Log nested dependency errors but continue processing
|
|
965
982
|
logger.warning(f"Failed to resolve nested dependency: {e}")
|
qubx/connectors/ccxt/account.py
CHANGED
|
@@ -82,6 +82,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
82
82
|
open_order_backoff: str = "1Min",
|
|
83
83
|
max_position_restore_days: int = 5,
|
|
84
84
|
max_retries: int = 10,
|
|
85
|
+
connection_timeout: int = 30,
|
|
85
86
|
read_only: bool = False,
|
|
86
87
|
):
|
|
87
88
|
super().__init__(
|
|
@@ -109,6 +110,7 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
109
110
|
self._latest_instruments = set()
|
|
110
111
|
self._subscription_manager = None
|
|
111
112
|
self._read_only = read_only
|
|
113
|
+
self._connection_timeout = connection_timeout
|
|
112
114
|
|
|
113
115
|
def set_subscription_manager(self, manager: ISubscriptionManager) -> None:
|
|
114
116
|
self._subscription_manager = manager
|
|
@@ -172,6 +174,14 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
172
174
|
super().update_position_price(time, instrument, update)
|
|
173
175
|
|
|
174
176
|
def get_total_capital(self, exchange: str | None = None) -> float:
|
|
177
|
+
# If polling is not running yet, we need to fetch balance data directly
|
|
178
|
+
if not self._is_running and self.exchange_manager.exchange:
|
|
179
|
+
try:
|
|
180
|
+
future = self._loop.submit(self._update_balance())
|
|
181
|
+
future.result(timeout=self._connection_timeout)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
logger.warning(f"Failed to fetch balance data before polling started: {e}")
|
|
184
|
+
|
|
175
185
|
# sum of balances + market value of all positions on non spot/margin
|
|
176
186
|
_currency_to_value = {c: self._get_currency_value(b.total, c) for c, b in self._balances.items()}
|
|
177
187
|
_positions_value = sum([p.market_value_funds for p in self._positions.values() if p.instrument.is_futures()])
|
|
@@ -294,7 +304,9 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
294
304
|
async def _update_positions(self) -> None:
|
|
295
305
|
# fetch and update positions from exchange
|
|
296
306
|
ccxt_positions = await self.exchange_manager.exchange.fetch_positions()
|
|
297
|
-
positions = ccxt_convert_positions(
|
|
307
|
+
positions = ccxt_convert_positions(
|
|
308
|
+
ccxt_positions, self.exchange_manager.exchange.name, self.exchange_manager.exchange.markets
|
|
309
|
+
) # type: ignore
|
|
298
310
|
# update required instruments that we need to subscribe to
|
|
299
311
|
self._required_instruments.update([p.instrument for p in positions])
|
|
300
312
|
# update positions
|
|
@@ -458,7 +470,9 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
458
470
|
|
|
459
471
|
async def _cancel_order(order: Order) -> None:
|
|
460
472
|
try:
|
|
461
|
-
await self.exchange_manager.exchange.cancel_order(
|
|
473
|
+
await self.exchange_manager.exchange.cancel_order(
|
|
474
|
+
order.id, symbol=instrument_to_ccxt_symbol(order.instrument)
|
|
475
|
+
)
|
|
462
476
|
logger.debug(
|
|
463
477
|
f" :: [SYNC] Canceled {order.id} {order.instrument.symbol} {order.side} {order.quantity} @ {order.price} ({order.status})"
|
|
464
478
|
)
|
|
@@ -476,7 +490,9 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
476
490
|
) -> dict[str, Order]:
|
|
477
491
|
_start_ms = self._get_start_time_in_ms(days_before) if limit is None else None
|
|
478
492
|
_ccxt_symbol = instrument_to_ccxt_symbol(instrument)
|
|
479
|
-
_fetcher =
|
|
493
|
+
_fetcher = (
|
|
494
|
+
self.exchange_manager.exchange.fetch_open_orders if is_open else self.exchange_manager.exchange.fetch_orders
|
|
495
|
+
)
|
|
480
496
|
_raw_orders = await _fetcher(_ccxt_symbol, since=_start_ms, limit=limit)
|
|
481
497
|
_orders = [ccxt_convert_order_info(instrument, o) for o in _raw_orders]
|
|
482
498
|
_id_to_order = {o.id: o for o in _orders}
|
|
@@ -533,7 +549,9 @@ class CcxtAccountProcessor(BasicAccountProcessor):
|
|
|
533
549
|
async def _watch_executions():
|
|
534
550
|
exec = await self.exchange_manager.exchange.watch_orders()
|
|
535
551
|
for report in exec:
|
|
536
|
-
instrument = ccxt_find_instrument(
|
|
552
|
+
instrument = ccxt_find_instrument(
|
|
553
|
+
report["symbol"], self.exchange_manager.exchange, _symbol_to_instrument
|
|
554
|
+
)
|
|
537
555
|
order = ccxt_convert_order_info(instrument, report)
|
|
538
556
|
deals = ccxt_extract_deals_from_exec(report)
|
|
539
557
|
channel.send((instrument, "order", order, False))
|
|
@@ -24,12 +24,13 @@ SECONDS_PER_HOUR = 3600
|
|
|
24
24
|
|
|
25
25
|
# Custom stall detection thresholds (in seconds)
|
|
26
26
|
STALL_THRESHOLDS = {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
"funding_payment": 12 * SECONDS_PER_HOUR, # 12 hours = 43,200s
|
|
28
|
+
"open_interest": 30 * 60, # 30 minutes = 1,800s
|
|
29
|
+
"orderbook": 5 * 60, # 5 minutes = 300s
|
|
30
|
+
"trade": 60 * 60, # 60 minutes = 3,600s
|
|
31
|
+
"liquidation": 7 * 24 * SECONDS_PER_HOUR, # 7 days = 604,800s
|
|
32
|
+
"ohlc": 5 * 60, # 5 minutes = 300s
|
|
33
|
+
"quote": 5 * 60, # 5 minutes = 300s
|
|
33
34
|
}
|
|
34
35
|
DEFAULT_STALL_THRESHOLD_SECONDS = 2 * SECONDS_PER_HOUR # 2 hours = 7,200s
|
|
35
36
|
|
|
@@ -37,10 +38,10 @@ DEFAULT_STALL_THRESHOLD_SECONDS = 2 * SECONDS_PER_HOUR # 2 hours = 7,200s
|
|
|
37
38
|
class ExchangeManager(IDataArrivalListener):
|
|
38
39
|
"""
|
|
39
40
|
Wrapper for CCXT Exchange that handles recreation internally with self-monitoring.
|
|
40
|
-
|
|
41
|
+
|
|
41
42
|
Exposes the underlying exchange via .exchange property for explicit access.
|
|
42
43
|
Self-monitors for data stalls and triggers recreation automatically.
|
|
43
|
-
|
|
44
|
+
|
|
44
45
|
Key Features:
|
|
45
46
|
- Explicit .exchange property for CCXT access
|
|
46
47
|
- Self-contained stall detection and recreation triggering
|
|
@@ -48,7 +49,7 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
48
49
|
- Atomic exchange transitions during recreation
|
|
49
50
|
- Background monitoring thread for stall detection
|
|
50
51
|
"""
|
|
51
|
-
|
|
52
|
+
|
|
52
53
|
_exchange: cxp.Exchange # Type hint that this is always a valid exchange
|
|
53
54
|
|
|
54
55
|
def __init__(
|
|
@@ -61,10 +62,10 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
61
62
|
check_interval_seconds: float = DEFAULT_CHECK_INTERVAL_SECONDS,
|
|
62
63
|
):
|
|
63
64
|
"""Initialize ExchangeManager with underlying CCXT exchange.
|
|
64
|
-
|
|
65
|
+
|
|
65
66
|
Args:
|
|
66
67
|
exchange_name: Exchange name for factory (e.g., "binance.um")
|
|
67
|
-
factory_params: Parameters for get_ccxt_exchange()
|
|
68
|
+
factory_params: Parameters for get_ccxt_exchange()
|
|
68
69
|
initial_exchange: Pre-created exchange instance (from factory)
|
|
69
70
|
max_recreations: Maximum recreation attempts before giving up
|
|
70
71
|
reset_interval_hours: Hours between recreation count resets
|
|
@@ -74,24 +75,24 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
74
75
|
self._factory_params = factory_params.copy()
|
|
75
76
|
self._max_recreations = max_recreations
|
|
76
77
|
self._reset_interval_hours = reset_interval_hours
|
|
77
|
-
|
|
78
|
+
|
|
78
79
|
# Recreation state
|
|
79
80
|
self._recreation_count = 0
|
|
80
81
|
self._recreation_lock = threading.RLock()
|
|
81
82
|
self._last_successful_reset = time.time()
|
|
82
|
-
|
|
83
|
+
|
|
83
84
|
# Stall detection state
|
|
84
85
|
self._check_interval = check_interval_seconds
|
|
85
86
|
self._last_data_times: dict[str, float] = {}
|
|
86
87
|
self._data_lock = threading.RLock()
|
|
87
|
-
|
|
88
|
+
|
|
88
89
|
# Monitoring control
|
|
89
90
|
self._monitoring_enabled = False
|
|
90
91
|
self._monitor_thread = None
|
|
91
|
-
|
|
92
|
+
|
|
92
93
|
# Recreation callback management
|
|
93
94
|
self._recreation_callbacks: list[Callable[[], None]] = []
|
|
94
|
-
|
|
95
|
+
|
|
95
96
|
# Use provided exchange or create new one
|
|
96
97
|
if initial_exchange:
|
|
97
98
|
self._exchange = initial_exchange
|
|
@@ -105,23 +106,23 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
105
106
|
try:
|
|
106
107
|
# Import here to avoid circular import (factory → broker → exchange_manager)
|
|
107
108
|
from .factory import get_ccxt_exchange
|
|
108
|
-
|
|
109
|
+
|
|
109
110
|
# Create raw exchange using factory logic
|
|
110
111
|
ccxt_exchange = get_ccxt_exchange(**self._factory_params)
|
|
111
|
-
|
|
112
|
+
|
|
112
113
|
# Setup exception handler for the new exchange
|
|
113
114
|
self._setup_ccxt_exception_handler(ccxt_exchange)
|
|
114
|
-
|
|
115
|
+
|
|
115
116
|
logger.debug(f"Created new {self._exchange_name} exchange instance")
|
|
116
117
|
return ccxt_exchange
|
|
117
|
-
|
|
118
|
+
|
|
118
119
|
except Exception as e:
|
|
119
120
|
logger.error(f"Failed to create {self._exchange_name} exchange: {e}")
|
|
120
121
|
raise RuntimeError(f"Failed to create {self._exchange_name} exchange: {e}") from e
|
|
121
|
-
|
|
122
|
+
|
|
122
123
|
def register_recreation_callback(self, callback: Callable[[], None]) -> None:
|
|
123
124
|
"""Register callback to be called after successful exchange recreation.
|
|
124
|
-
|
|
125
|
+
|
|
125
126
|
Args:
|
|
126
127
|
callback: Function to call after successful recreation (no parameters)
|
|
127
128
|
"""
|
|
@@ -131,7 +132,7 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
131
132
|
def _call_recreation_callbacks(self) -> None:
|
|
132
133
|
"""Call all registered recreation callbacks after successful exchange recreation."""
|
|
133
134
|
logger.debug(f"Calling {len(self._recreation_callbacks)} recreation callbacks for {self._exchange_name}")
|
|
134
|
-
|
|
135
|
+
|
|
135
136
|
for callback in self._recreation_callbacks:
|
|
136
137
|
try:
|
|
137
138
|
callback()
|
|
@@ -142,66 +143,68 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
142
143
|
def force_recreation(self) -> bool:
|
|
143
144
|
"""
|
|
144
145
|
Force recreation due to data stalls (called by BaseHealthMonitor).
|
|
145
|
-
|
|
146
|
+
|
|
146
147
|
Returns:
|
|
147
148
|
True if recreation successful, False if failed/limit exceeded
|
|
148
149
|
"""
|
|
149
150
|
with self._recreation_lock:
|
|
150
151
|
# Check recreation limit
|
|
151
152
|
if self._recreation_count >= self._max_recreations:
|
|
152
|
-
logger.error(
|
|
153
|
+
logger.error(
|
|
154
|
+
f"Cannot recreate {self._exchange_name}: recreation limit ({self._max_recreations}) exceeded"
|
|
155
|
+
)
|
|
153
156
|
return False
|
|
154
|
-
|
|
157
|
+
|
|
155
158
|
logger.info(f"Stall-triggered recreation for {self._exchange_name}")
|
|
156
159
|
return self._recreate_exchange()
|
|
157
|
-
|
|
160
|
+
|
|
158
161
|
def _recreate_exchange(self) -> bool:
|
|
159
162
|
"""Recreate the underlying exchange (must be called with _recreation_lock held)."""
|
|
160
163
|
self._recreation_count += 1
|
|
161
|
-
logger.warning(
|
|
162
|
-
|
|
164
|
+
logger.warning(
|
|
165
|
+
f"Recreating {self._exchange_name} exchange (attempt {self._recreation_count}/{self._max_recreations})"
|
|
166
|
+
)
|
|
167
|
+
|
|
163
168
|
# Create new exchange
|
|
164
169
|
try:
|
|
165
170
|
new_exchange = self._create_exchange()
|
|
166
171
|
except Exception as e:
|
|
167
172
|
logger.error(f"Failed to recreate {self._exchange_name} exchange: {e}")
|
|
168
173
|
return False
|
|
169
|
-
|
|
174
|
+
|
|
170
175
|
# Atomically replace the exchange
|
|
171
176
|
old_exchange = self._exchange
|
|
172
177
|
self._exchange = new_exchange
|
|
173
|
-
|
|
178
|
+
|
|
174
179
|
# Clean up old exchange
|
|
175
180
|
try:
|
|
176
|
-
if hasattr(old_exchange,
|
|
177
|
-
old_exchange.asyncio_loop.call_soon_threadsafe(
|
|
178
|
-
lambda: asyncio.create_task(old_exchange.close())
|
|
179
|
-
)
|
|
181
|
+
if hasattr(old_exchange, "close") and hasattr(old_exchange, "asyncio_loop"):
|
|
182
|
+
old_exchange.asyncio_loop.call_soon_threadsafe(lambda: asyncio.create_task(old_exchange.close()))
|
|
180
183
|
except Exception as e:
|
|
181
184
|
logger.warning(f"Error closing old {self._exchange_name} exchange: {e}")
|
|
182
|
-
|
|
185
|
+
|
|
183
186
|
logger.info(f"Successfully recreated {self._exchange_name} exchange")
|
|
184
|
-
|
|
187
|
+
|
|
185
188
|
# Call recreation callbacks after successful recreation
|
|
186
189
|
self._call_recreation_callbacks()
|
|
187
|
-
|
|
190
|
+
|
|
188
191
|
return True
|
|
189
|
-
|
|
192
|
+
|
|
190
193
|
def reset_recreation_count_if_needed(self) -> None:
|
|
191
194
|
"""Reset recreation count periodically (called by monitoring loop)."""
|
|
192
195
|
reset_interval_seconds = self._reset_interval_hours * SECONDS_PER_HOUR
|
|
193
|
-
|
|
196
|
+
|
|
194
197
|
current_time = time.time()
|
|
195
198
|
time_since_reset = current_time - self._last_successful_reset
|
|
196
|
-
|
|
199
|
+
|
|
197
200
|
if time_since_reset >= reset_interval_seconds and self._recreation_count > 0:
|
|
198
201
|
logger.info(f"Resetting recreation count for {self._exchange_name} (was {self._recreation_count})")
|
|
199
202
|
self._recreation_count = 0
|
|
200
203
|
self._last_successful_reset = current_time
|
|
201
|
-
|
|
204
|
+
|
|
202
205
|
def on_data_arrival(self, event_type: str, event_time: dt_64) -> None:
|
|
203
206
|
"""Record data arrival for stall detection.
|
|
204
|
-
|
|
207
|
+
|
|
205
208
|
Args:
|
|
206
209
|
event_type: Type of data event (e.g., "ohlcv", "trade", "orderbook")
|
|
207
210
|
event_time: Timestamp of the data event (unused for stall detection)
|
|
@@ -209,10 +212,10 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
209
212
|
current_timestamp = time.time()
|
|
210
213
|
with self._data_lock:
|
|
211
214
|
self._last_data_times[event_type] = current_timestamp
|
|
212
|
-
|
|
215
|
+
|
|
213
216
|
def _extract_ohlc_timeframe(self, event_type: str) -> Optional[str]:
|
|
214
217
|
"""Extract timeframe from OHLC event type like 'ohlc(1m)' -> '1m'."""
|
|
215
|
-
if event_type.startswith(
|
|
218
|
+
if event_type.startswith("ohlc(") and event_type.endswith(")"):
|
|
216
219
|
return event_type[5:-1] # Simple slice: ohlc(1m) -> 1m
|
|
217
220
|
return None
|
|
218
221
|
|
|
@@ -222,30 +225,30 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
222
225
|
|
|
223
226
|
def _get_stall_threshold(self, event_type: str) -> float:
|
|
224
227
|
"""Get stall threshold for specific event type.
|
|
225
|
-
|
|
228
|
+
|
|
226
229
|
Extracts base data type from parameterized types like 'ohlc(1m)' -> 'ohlc'.
|
|
227
230
|
"""
|
|
228
231
|
# Extract base data type (everything before first '(' if present)
|
|
229
|
-
base_event_type = event_type.split(
|
|
232
|
+
base_event_type = event_type.split("(")[0]
|
|
230
233
|
return float(STALL_THRESHOLDS.get(base_event_type, DEFAULT_STALL_THRESHOLD_SECONDS))
|
|
231
|
-
|
|
234
|
+
|
|
232
235
|
def start_monitoring(self) -> None:
|
|
233
236
|
"""Start background stall detection monitoring."""
|
|
234
237
|
if self._monitoring_enabled:
|
|
235
238
|
return
|
|
236
|
-
|
|
239
|
+
|
|
237
240
|
self._monitoring_enabled = True
|
|
238
241
|
self._monitor_thread = threading.Thread(target=self._stall_monitor_loop, daemon=True)
|
|
239
242
|
self._monitor_thread.start()
|
|
240
243
|
logger.debug(f"ExchangeManager: Started stall monitoring for {self._exchange_name}")
|
|
241
|
-
|
|
244
|
+
|
|
242
245
|
def stop_monitoring(self) -> None:
|
|
243
246
|
"""Stop background stall detection monitoring."""
|
|
244
247
|
self._monitoring_enabled = False
|
|
245
248
|
if self._monitor_thread:
|
|
246
249
|
self._monitor_thread = None
|
|
247
250
|
logger.debug(f"ExchangeManager: Stopped stall monitoring for {self._exchange_name}")
|
|
248
|
-
|
|
251
|
+
|
|
249
252
|
def _stall_monitor_loop(self) -> None:
|
|
250
253
|
"""Background thread that checks for data stalls and triggers self-recreation."""
|
|
251
254
|
while self._monitoring_enabled:
|
|
@@ -256,26 +259,26 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
256
259
|
except Exception as e:
|
|
257
260
|
logger.error(f"Error in ExchangeManager stall detection: {e}")
|
|
258
261
|
time.sleep(self._check_interval)
|
|
259
|
-
|
|
262
|
+
|
|
260
263
|
def _check_and_handle_stalls(self) -> None:
|
|
261
264
|
"""Check for stalls using custom thresholds per data type."""
|
|
262
265
|
current_time = time.time()
|
|
263
266
|
stalled_types = []
|
|
264
|
-
|
|
267
|
+
|
|
265
268
|
with self._data_lock:
|
|
266
269
|
for event_type, last_data_time in self._last_data_times.items():
|
|
267
270
|
time_since_data = current_time - last_data_time
|
|
268
271
|
threshold = self._get_stall_threshold(event_type)
|
|
269
|
-
|
|
272
|
+
|
|
270
273
|
if time_since_data > threshold:
|
|
271
274
|
stalled_types.append((event_type, time_since_data))
|
|
272
|
-
|
|
275
|
+
|
|
273
276
|
if not stalled_types:
|
|
274
277
|
return # No stalls detected
|
|
275
|
-
|
|
278
|
+
|
|
276
279
|
stall_info = ", ".join([f"{event_type}({int(time_since)}s)" for event_type, time_since in stalled_types])
|
|
277
280
|
logger.error(f"Data stalls detected in {self._exchange_name}: {stall_info}")
|
|
278
|
-
|
|
281
|
+
|
|
279
282
|
try:
|
|
280
283
|
logger.info(f"Self-triggering recreation for {self._exchange_name} due to stalls...")
|
|
281
284
|
if self.force_recreation():
|
|
@@ -288,14 +291,14 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
288
291
|
logger.error(f"Stall-triggered recreation failed for {self._exchange_name}")
|
|
289
292
|
except Exception as e:
|
|
290
293
|
logger.error(f"Error during stall-triggered recreation: {e}")
|
|
291
|
-
|
|
294
|
+
|
|
292
295
|
def _setup_ccxt_exception_handler(self, exchange: cxp.Exchange) -> None:
|
|
293
296
|
"""
|
|
294
297
|
Set up global exception handler for the CCXT async loop to handle unretrieved futures.
|
|
295
298
|
|
|
296
299
|
This prevents 'Future exception was never retrieved' warnings from CCXT's internal
|
|
297
300
|
per-symbol futures that complete with UnsubscribeError during resubscription.
|
|
298
|
-
|
|
301
|
+
|
|
299
302
|
Applied to every newly created exchange (initial and recreated).
|
|
300
303
|
"""
|
|
301
304
|
asyncio_loop = exchange.asyncio_loop
|
|
@@ -324,15 +327,15 @@ class ExchangeManager(IDataArrivalListener):
|
|
|
324
327
|
# Set the custom exception handler on the CCXT loop
|
|
325
328
|
asyncio_loop.set_exception_handler(handle_ccxt_exception)
|
|
326
329
|
|
|
327
|
-
# === Exchange Property Access ===
|
|
330
|
+
# === Exchange Property Access ===
|
|
328
331
|
# Explicit property to access underlying CCXT exchange
|
|
329
|
-
|
|
332
|
+
|
|
330
333
|
@property
|
|
331
334
|
def exchange(self) -> cxp.Exchange:
|
|
332
335
|
"""Access to the underlying CCXT exchange instance.
|
|
333
|
-
|
|
336
|
+
|
|
334
337
|
Use this property to call CCXT methods: exchange_manager.exchange.fetch_ticker(symbol)
|
|
335
|
-
|
|
338
|
+
|
|
336
339
|
Returns:
|
|
337
340
|
The current CCXT exchange instance (may change after recreation)
|
|
338
341
|
"""
|
qubx/core/account.py
CHANGED
|
@@ -18,6 +18,7 @@ from qubx.core.basics import (
|
|
|
18
18
|
)
|
|
19
19
|
from qubx.core.helpers import extract_price
|
|
20
20
|
from qubx.core.interfaces import IAccountProcessor, ISubscriptionManager
|
|
21
|
+
from qubx.core.mixins.utils import EXCHANGE_MAPPINGS
|
|
21
22
|
|
|
22
23
|
|
|
23
24
|
class BasicAccountProcessor(IAccountProcessor):
|
|
@@ -343,7 +344,8 @@ class CompositeAccountProcessor(IAccountProcessor):
|
|
|
343
344
|
raise ValueError("At least one account processor must be provided")
|
|
344
345
|
|
|
345
346
|
def get_account_processor(self, exchange: str) -> IAccountProcessor:
|
|
346
|
-
|
|
347
|
+
exch = self._get_exchange(exchange)
|
|
348
|
+
return self._account_processors[exch]
|
|
347
349
|
|
|
348
350
|
def _get_exchange(self, exchange: str | None = None, instrument: Instrument | None = None) -> str:
|
|
349
351
|
"""
|
|
@@ -356,11 +358,20 @@ class CompositeAccountProcessor(IAccountProcessor):
|
|
|
356
358
|
"""
|
|
357
359
|
if exchange:
|
|
358
360
|
if exchange not in self._account_processors:
|
|
361
|
+
# Check if there's a mapping for this exchange
|
|
362
|
+
if exchange in EXCHANGE_MAPPINGS and EXCHANGE_MAPPINGS[exchange] in self._account_processors:
|
|
363
|
+
return EXCHANGE_MAPPINGS[exchange]
|
|
359
364
|
raise ValueError(f"Unknown exchange: {exchange}")
|
|
360
365
|
return exchange
|
|
361
366
|
|
|
362
367
|
if instrument:
|
|
363
368
|
if instrument.exchange not in self._account_processors:
|
|
369
|
+
# Check if there's a mapping for this exchange
|
|
370
|
+
if (
|
|
371
|
+
instrument.exchange in EXCHANGE_MAPPINGS
|
|
372
|
+
and EXCHANGE_MAPPINGS[instrument.exchange] in self._account_processors
|
|
373
|
+
):
|
|
374
|
+
return EXCHANGE_MAPPINGS[instrument.exchange]
|
|
364
375
|
raise ValueError(f"Unknown exchange: {instrument.exchange}")
|
|
365
376
|
return instrument.exchange
|
|
366
377
|
|
qubx/core/basics.py
CHANGED
|
@@ -63,6 +63,10 @@ class FundingPayment:
|
|
|
63
63
|
if self.funding_interval_hours <= 0:
|
|
64
64
|
raise ValueError(f"Invalid funding interval: {self.funding_interval_hours} (must be positive)")
|
|
65
65
|
|
|
66
|
+
@property
|
|
67
|
+
def funding_rate_apr(self) -> float:
|
|
68
|
+
return self.funding_rate * 365 * 24 / self.funding_interval_hours * 100
|
|
69
|
+
|
|
66
70
|
|
|
67
71
|
@dataclass
|
|
68
72
|
class OpenInterest:
|