Qubx 0.5.7__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/__init__.py +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- qubx-0.5.7.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import concurrent.futures
|
|
3
|
+
import re
|
|
4
|
+
from asyncio.exceptions import CancelledError
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from threading import Thread
|
|
7
|
+
from types import FunctionType
|
|
8
|
+
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple
|
|
9
|
+
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
import ccxt.pro as cxp
|
|
13
|
+
from ccxt import (
|
|
14
|
+
ExchangeClosedByUser,
|
|
15
|
+
ExchangeError,
|
|
16
|
+
ExchangeNotAvailable,
|
|
17
|
+
NetworkError,
|
|
18
|
+
)
|
|
19
|
+
from ccxt.pro import Exchange
|
|
20
|
+
from qubx import logger
|
|
21
|
+
from qubx.core.basics import CtrlChannel, DataType, Instrument, ITimeProvider, dt_64
|
|
22
|
+
from qubx.core.helpers import BasicScheduler
|
|
23
|
+
from qubx.core.interfaces import IDataProvider
|
|
24
|
+
from qubx.core.series import Bar, Quote
|
|
25
|
+
from qubx.utils.misc import AsyncThreadLoop
|
|
26
|
+
|
|
27
|
+
from .exceptions import CcxtLiquidationParsingError, CcxtSymbolNotRecognized
|
|
28
|
+
from .utils import (
|
|
29
|
+
ccxt_convert_funding_rate,
|
|
30
|
+
ccxt_convert_liquidation,
|
|
31
|
+
ccxt_convert_orderbook,
|
|
32
|
+
ccxt_convert_ticker,
|
|
33
|
+
ccxt_convert_trade,
|
|
34
|
+
ccxt_find_instrument,
|
|
35
|
+
instrument_to_ccxt_symbol,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CcxtDataProvider(IDataProvider):
|
|
40
|
+
time_provider: ITimeProvider
|
|
41
|
+
_exchange: Exchange
|
|
42
|
+
_scheduler: BasicScheduler | None = None
|
|
43
|
+
|
|
44
|
+
# - subscriptions
|
|
45
|
+
_subscriptions: Dict[str, Set[Instrument]]
|
|
46
|
+
_sub_to_coro: Dict[str, concurrent.futures.Future]
|
|
47
|
+
_sub_to_name: Dict[str, str]
|
|
48
|
+
_sub_to_unsubscribe: Dict[str, Callable[[], Awaitable[None]]]
|
|
49
|
+
_is_sub_name_enabled: Dict[str, bool]
|
|
50
|
+
|
|
51
|
+
_sub_instr_to_time: Dict[Tuple[str, Instrument], dt_64]
|
|
52
|
+
_last_quotes: Dict[Instrument, Optional[Quote]]
|
|
53
|
+
_loop: AsyncThreadLoop
|
|
54
|
+
_thread_event_loop: Thread
|
|
55
|
+
_warmup_timeout: int
|
|
56
|
+
|
|
57
|
+
_subscribers: Dict[str, Callable]
|
|
58
|
+
_warmupers: Dict[str, Callable]
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
exchange: cxp.Exchange,
|
|
63
|
+
time_provider: ITimeProvider,
|
|
64
|
+
channel: CtrlChannel,
|
|
65
|
+
max_ws_retries: int = 10,
|
|
66
|
+
warmup_timeout: int = 120,
|
|
67
|
+
):
|
|
68
|
+
self._exchange_id = str(exchange.name)
|
|
69
|
+
self.time_provider = time_provider
|
|
70
|
+
self.channel = channel
|
|
71
|
+
self.max_ws_retries = max_ws_retries
|
|
72
|
+
self._warmup_timeout = warmup_timeout
|
|
73
|
+
|
|
74
|
+
# - create new even loop
|
|
75
|
+
self._exchange = exchange
|
|
76
|
+
self._loop = AsyncThreadLoop(self._exchange.asyncio_loop)
|
|
77
|
+
|
|
78
|
+
self._last_quotes = defaultdict(lambda: None)
|
|
79
|
+
self._subscriptions = defaultdict(set)
|
|
80
|
+
self._sub_to_coro = {}
|
|
81
|
+
self._sub_to_name = {}
|
|
82
|
+
self._sub_to_unsubscribe = {}
|
|
83
|
+
self._is_sub_name_enabled = defaultdict(lambda: False)
|
|
84
|
+
self._symbol_to_instrument = {}
|
|
85
|
+
self._subscribers = {
|
|
86
|
+
n.split("_subscribe_")[1]: f
|
|
87
|
+
for n, f in self.__class__.__dict__.items()
|
|
88
|
+
if type(f) is FunctionType and n.startswith("_subscribe_")
|
|
89
|
+
}
|
|
90
|
+
self._warmupers = {
|
|
91
|
+
n.split("_warmup_")[1]: f
|
|
92
|
+
for n, f in self.__class__.__dict__.items()
|
|
93
|
+
if type(f) is FunctionType and n.startswith("_warmup_")
|
|
94
|
+
}
|
|
95
|
+
logger.info(f"Initialized {self._exchange_id}")
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def is_simulation(self) -> bool:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
def subscribe(
|
|
102
|
+
self,
|
|
103
|
+
subscription_type: str,
|
|
104
|
+
instruments: List[Instrument],
|
|
105
|
+
reset: bool = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
_updated_instruments = set(instruments)
|
|
108
|
+
# - update symbol to instrument mapping
|
|
109
|
+
self._symbol_to_instrument.update({i.symbol: i for i in instruments})
|
|
110
|
+
|
|
111
|
+
# - add existing subscription instruments if reset is False
|
|
112
|
+
if not reset:
|
|
113
|
+
_current_instruments = self.get_subscribed_instruments(subscription_type)
|
|
114
|
+
_updated_instruments = _updated_instruments.union(_current_instruments)
|
|
115
|
+
|
|
116
|
+
# - update subscriptions
|
|
117
|
+
self._subscribe(_updated_instruments, subscription_type)
|
|
118
|
+
|
|
119
|
+
def unsubscribe(self, subscription_type: str, instruments: List[Instrument]) -> None:
|
|
120
|
+
# _current_instruments = self.get_subscribed_instruments(subscription_type)
|
|
121
|
+
# _updated_instruments = set(_current_instruments).difference(instruments)
|
|
122
|
+
# self._subscribe(_updated_instruments, subscription_type)
|
|
123
|
+
# unsubscribe functionality is handled for ccxt via subscribe with reset=True
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def get_subscriptions(self, instrument: Instrument | None = None) -> List[str]:
|
|
127
|
+
if instrument is not None:
|
|
128
|
+
return [sub for sub, instrs in self._subscriptions.items() if instrument in instrs]
|
|
129
|
+
return [sub for sub, instruments in self._subscriptions.items() if instruments]
|
|
130
|
+
|
|
131
|
+
def get_subscribed_instruments(self, subscription_type: str | None = None) -> list[Instrument]:
|
|
132
|
+
if not subscription_type:
|
|
133
|
+
return list(self.subscribed_instruments)
|
|
134
|
+
return list(self._subscriptions[subscription_type]) if subscription_type in self._subscriptions else []
|
|
135
|
+
|
|
136
|
+
def has_subscription(self, instrument: Instrument, subscription_type: str) -> bool:
|
|
137
|
+
sub = subscription_type.lower()
|
|
138
|
+
return sub in self._subscriptions and instrument in self._subscriptions[sub]
|
|
139
|
+
|
|
140
|
+
def warmup(self, warmups: Dict[Tuple[str, Instrument], str]) -> None:
|
|
141
|
+
_coros = []
|
|
142
|
+
|
|
143
|
+
for (sub_type, instrument), period in warmups.items():
|
|
144
|
+
_sub_type, _params = DataType.from_str(sub_type)
|
|
145
|
+
_warmuper = self._warmupers.get(_sub_type)
|
|
146
|
+
if _warmuper is None:
|
|
147
|
+
logger.warning(f"Warmup for {sub_type} is not supported")
|
|
148
|
+
continue
|
|
149
|
+
_coros.append(
|
|
150
|
+
_warmuper(
|
|
151
|
+
self,
|
|
152
|
+
channel=self.channel,
|
|
153
|
+
instrument=instrument,
|
|
154
|
+
warmup_period=period,
|
|
155
|
+
**_params,
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def gather_coros():
|
|
160
|
+
return await asyncio.gather(*_coros)
|
|
161
|
+
|
|
162
|
+
if _coros:
|
|
163
|
+
self._loop.submit(gather_coros()).result(self._warmup_timeout)
|
|
164
|
+
|
|
165
|
+
def get_quote(self, instrument: Instrument) -> Quote | None:
|
|
166
|
+
return self._last_quotes[instrument]
|
|
167
|
+
|
|
168
|
+
def get_ohlc(self, instrument: Instrument, timeframe: str, nbarsback: int) -> List[Bar]:
|
|
169
|
+
assert nbarsback >= 1
|
|
170
|
+
symbol = instrument.symbol
|
|
171
|
+
since = self._time_msec_nbars_back(timeframe, nbarsback)
|
|
172
|
+
|
|
173
|
+
# - retrieve OHLC data
|
|
174
|
+
# - TODO: check if nbarsback > max_limit (1000) we need to do more requests
|
|
175
|
+
# - TODO: how to get quoted volumes ?
|
|
176
|
+
async def _get():
|
|
177
|
+
return await self._exchange.fetch_ohlcv(
|
|
178
|
+
symbol, self._get_exch_timeframe(timeframe), since=since, limit=nbarsback + 1
|
|
179
|
+
) # type: ignore
|
|
180
|
+
|
|
181
|
+
res = self._loop.submit(_get()).result(60)
|
|
182
|
+
|
|
183
|
+
_arr = []
|
|
184
|
+
for oh in res: # type: ignore
|
|
185
|
+
_arr.append(
|
|
186
|
+
Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7])
|
|
187
|
+
if len(oh) > 6
|
|
188
|
+
else Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[5])
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return _arr
|
|
192
|
+
|
|
193
|
+
def close(self):
|
|
194
|
+
try:
|
|
195
|
+
if hasattr(self._exchange, "close"):
|
|
196
|
+
future = self._loop.submit(self._exchange.close()) # type: ignore
|
|
197
|
+
# - wait for 5 seconds for connection to close
|
|
198
|
+
future.result(5)
|
|
199
|
+
else:
|
|
200
|
+
del self._exchange
|
|
201
|
+
except Exception as e:
|
|
202
|
+
logger.error(e)
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def subscribed_instruments(self) -> Set[Instrument]:
|
|
206
|
+
if not self._subscriptions:
|
|
207
|
+
return set()
|
|
208
|
+
return set.union(*self._subscriptions.values())
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def is_read_only(self) -> bool:
|
|
212
|
+
_key = self._exchange.apiKey
|
|
213
|
+
return _key is None or _key == ""
|
|
214
|
+
|
|
215
|
+
def _subscribe(
|
|
216
|
+
self,
|
|
217
|
+
instruments: Set[Instrument],
|
|
218
|
+
sub_type: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
_sub_type, _params = DataType.from_str(sub_type)
|
|
221
|
+
_subscriber = self._subscribers.get(_sub_type)
|
|
222
|
+
if _subscriber is None:
|
|
223
|
+
raise ValueError(f"Subscription type {sub_type} is not supported")
|
|
224
|
+
|
|
225
|
+
if sub_type in self._sub_to_coro:
|
|
226
|
+
logger.debug(f"Canceling existing {sub_type} subscription for {self._subscriptions[sub_type]}")
|
|
227
|
+
self._loop.submit(self._stop_subscriber(sub_type, self._sub_to_name[sub_type]))
|
|
228
|
+
del self._sub_to_coro[sub_type]
|
|
229
|
+
del self._sub_to_name[sub_type]
|
|
230
|
+
|
|
231
|
+
if instruments is not None and len(instruments) == 0:
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
kwargs = {"instruments": instruments, **_params}
|
|
235
|
+
_subscriber = self._subscribers[_sub_type]
|
|
236
|
+
_subscriber_params = set(_subscriber.__code__.co_varnames[: _subscriber.__code__.co_argcount])
|
|
237
|
+
# - get only parameters that are needed for subscriber
|
|
238
|
+
kwargs = {k: v for k, v in kwargs.items() if k in _subscriber_params}
|
|
239
|
+
self._sub_to_name[sub_type] = (name := self._get_subscription_name(_sub_type, **kwargs))
|
|
240
|
+
self._sub_to_coro[sub_type] = self._loop.submit(_subscriber(self, name, _sub_type, self.channel, **kwargs))
|
|
241
|
+
|
|
242
|
+
self._subscriptions[sub_type] = instruments
|
|
243
|
+
|
|
244
|
+
def _time_msec_nbars_back(self, timeframe: str, nbarsback: int = 1) -> int:
|
|
245
|
+
return (self.time_provider.time() - nbarsback * pd.Timedelta(timeframe)).asm8.item() // 1000000
|
|
246
|
+
|
|
247
|
+
def _get_exch_timeframe(self, timeframe: str) -> str:
|
|
248
|
+
if timeframe is not None:
|
|
249
|
+
_t = re.match(r"(\d+)(\w+)", timeframe)
|
|
250
|
+
timeframe = f"{_t[1]}{_t[2][0].lower()}" if _t and len(_t.groups()) > 1 else timeframe
|
|
251
|
+
|
|
252
|
+
tframe = self._exchange.find_timeframe(timeframe)
|
|
253
|
+
if tframe is None:
|
|
254
|
+
raise ValueError(f"timeframe {timeframe} is not supported by {self._exchange.name}")
|
|
255
|
+
|
|
256
|
+
return tframe
|
|
257
|
+
|
|
258
|
+
def _get_exch_symbol(self, instrument: Instrument) -> str:
|
|
259
|
+
return f"{instrument.base}/{instrument.quote}:{instrument.settle}"
|
|
260
|
+
|
|
261
|
+
def _get_subscription_name(
|
|
262
|
+
self, subscription: str, instruments: List[Instrument] | Set[Instrument] | Instrument | None = None, **kwargs
|
|
263
|
+
) -> str:
|
|
264
|
+
if isinstance(instruments, Instrument):
|
|
265
|
+
instruments = [instruments]
|
|
266
|
+
_symbols = [instrument_to_ccxt_symbol(i) for i in instruments] if instruments is not None else []
|
|
267
|
+
_name = f"{','.join(_symbols)} {subscription}" if _symbols else subscription
|
|
268
|
+
if kwargs:
|
|
269
|
+
kwargs_str = ",".join(f"{k}={v}" for k, v in kwargs.items())
|
|
270
|
+
_name += f" ({kwargs_str})"
|
|
271
|
+
return _name
|
|
272
|
+
|
|
273
|
+
async def _stop_subscriber(self, sub_type: str, sub_name: str) -> None:
|
|
274
|
+
try:
|
|
275
|
+
self._is_sub_name_enabled[sub_name] = False # stop the subscriber
|
|
276
|
+
future = self._sub_to_coro[sub_type]
|
|
277
|
+
total_sleep_time = 0.0
|
|
278
|
+
while future.running():
|
|
279
|
+
await asyncio.sleep(1.0)
|
|
280
|
+
total_sleep_time += 1.0
|
|
281
|
+
if total_sleep_time >= 20.0:
|
|
282
|
+
break
|
|
283
|
+
|
|
284
|
+
if future.running():
|
|
285
|
+
logger.warning(f"Subscriber {sub_name} is still running. Cancelling it.")
|
|
286
|
+
future.cancel()
|
|
287
|
+
else:
|
|
288
|
+
logger.debug(f"Subscriber {sub_name} has been stopped")
|
|
289
|
+
|
|
290
|
+
if sub_name in self._sub_to_unsubscribe:
|
|
291
|
+
logger.debug(f"Unsubscribing from {sub_name}")
|
|
292
|
+
await self._sub_to_unsubscribe[sub_name]()
|
|
293
|
+
del self._sub_to_unsubscribe[sub_name]
|
|
294
|
+
|
|
295
|
+
del self._is_sub_name_enabled[sub_name]
|
|
296
|
+
logger.debug(f"Unsubscribed from {sub_name}")
|
|
297
|
+
except Exception as e:
|
|
298
|
+
logger.error(f"Error stopping {sub_name}")
|
|
299
|
+
logger.exception(e)
|
|
300
|
+
|
|
301
|
+
async def _listen_to_stream(
|
|
302
|
+
self,
|
|
303
|
+
subscriber: Callable[[], Awaitable[None]],
|
|
304
|
+
exchange: Exchange,
|
|
305
|
+
channel: CtrlChannel,
|
|
306
|
+
name: str,
|
|
307
|
+
unsubscriber: Callable[[], Awaitable[None]] | None = None,
|
|
308
|
+
):
|
|
309
|
+
logger.info(f"Listening to {name}")
|
|
310
|
+
if unsubscriber is not None:
|
|
311
|
+
self._sub_to_unsubscribe[name] = unsubscriber
|
|
312
|
+
|
|
313
|
+
self._is_sub_name_enabled[name] = True
|
|
314
|
+
n_retry = 0
|
|
315
|
+
while channel.control.is_set() and self._is_sub_name_enabled[name]:
|
|
316
|
+
try:
|
|
317
|
+
await subscriber()
|
|
318
|
+
n_retry = 0
|
|
319
|
+
if not self._is_sub_name_enabled[name]:
|
|
320
|
+
break
|
|
321
|
+
except CcxtSymbolNotRecognized:
|
|
322
|
+
continue
|
|
323
|
+
except CancelledError:
|
|
324
|
+
break
|
|
325
|
+
except ExchangeClosedByUser:
|
|
326
|
+
# - we closed connection so just stop it
|
|
327
|
+
logger.info(f"{name} listening has been stopped")
|
|
328
|
+
break
|
|
329
|
+
except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
|
|
330
|
+
logger.error(f"Error in {name} : {e}")
|
|
331
|
+
await asyncio.sleep(1)
|
|
332
|
+
continue
|
|
333
|
+
except Exception as e:
|
|
334
|
+
if not channel.control.is_set() or not self._is_sub_name_enabled[name]:
|
|
335
|
+
# If the channel is closed, then ignore all exceptions and exit
|
|
336
|
+
break
|
|
337
|
+
logger.error(f"Exception in {name}")
|
|
338
|
+
logger.exception(e)
|
|
339
|
+
n_retry += 1
|
|
340
|
+
if n_retry >= self.max_ws_retries:
|
|
341
|
+
logger.error(f"Max retries reached for {name}. Closing connection.")
|
|
342
|
+
del exchange
|
|
343
|
+
break
|
|
344
|
+
await asyncio.sleep(min(2**n_retry, 60)) # Exponential backoff with a cap at 60 seconds
|
|
345
|
+
|
|
346
|
+
#############################
|
|
347
|
+
# - Warmup methods
|
|
348
|
+
#############################
|
|
349
|
+
async def _warmup_ohlc(
|
|
350
|
+
self, channel: CtrlChannel, instrument: Instrument, warmup_period: str, timeframe: str
|
|
351
|
+
) -> None:
|
|
352
|
+
nbarsback = pd.Timedelta(warmup_period) // pd.Timedelta(timeframe)
|
|
353
|
+
exch_timeframe = self._get_exch_timeframe(timeframe)
|
|
354
|
+
start = self._time_msec_nbars_back(timeframe, nbarsback)
|
|
355
|
+
ohlcv = await self._exchange.fetch_ohlcv(instrument.symbol, exch_timeframe, since=start, limit=nbarsback + 1)
|
|
356
|
+
logger.debug(f"{instrument}: loaded {len(ohlcv)} {timeframe} bars")
|
|
357
|
+
channel.send(
|
|
358
|
+
(
|
|
359
|
+
instrument,
|
|
360
|
+
DataType.OHLC[timeframe],
|
|
361
|
+
[Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7]) for oh in ohlcv],
|
|
362
|
+
True,
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
async def _warmup_trade(self, channel: CtrlChannel, instrument: Instrument, warmup_period: str):
|
|
367
|
+
trades = await self._exchange.fetch_trades(instrument.symbol, since=self._time_msec_nbars_back(warmup_period))
|
|
368
|
+
logger.debug(f"Loaded {len(trades)} trades for {instrument}")
|
|
369
|
+
channel.send(
|
|
370
|
+
(
|
|
371
|
+
instrument,
|
|
372
|
+
DataType.TRADE,
|
|
373
|
+
[ccxt_convert_trade(trade) for trade in trades],
|
|
374
|
+
True,
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
def _call_by_market_type(
|
|
379
|
+
self, subscriber: Callable[[list[Instrument]], Awaitable[None]], instruments: set[Instrument]
|
|
380
|
+
) -> Any:
|
|
381
|
+
"""Call subscriber for each market type"""
|
|
382
|
+
_instr_by_type: dict[str, list[Instrument]] = defaultdict(list)
|
|
383
|
+
for instr in instruments:
|
|
384
|
+
_instr_by_type[instr.market_type].append(instr)
|
|
385
|
+
|
|
386
|
+
# sort instruments by symbol
|
|
387
|
+
for instrs in _instr_by_type.values():
|
|
388
|
+
instrs.sort(key=lambda i: i.symbol)
|
|
389
|
+
|
|
390
|
+
async def _call_subscriber():
|
|
391
|
+
await asyncio.gather(*[subscriber(instrs) for instrs in _instr_by_type.values()])
|
|
392
|
+
|
|
393
|
+
return _call_subscriber
|
|
394
|
+
|
|
395
|
+
#############################
|
|
396
|
+
# - Subscription methods
|
|
397
|
+
#############################
|
|
398
|
+
async def _subscribe_ohlc(
|
|
399
|
+
self,
|
|
400
|
+
name: str,
|
|
401
|
+
sub_type: str,
|
|
402
|
+
channel: CtrlChannel,
|
|
403
|
+
instruments: Set[Instrument],
|
|
404
|
+
timeframe: str = "1m",
|
|
405
|
+
):
|
|
406
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
407
|
+
_exchange_timeframe = self._get_exch_timeframe(timeframe)
|
|
408
|
+
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
409
|
+
|
|
410
|
+
async def watch_ohlcv(instruments: list[Instrument]):
|
|
411
|
+
_symbol_timeframe_pairs = [[_instr_to_ccxt_symbol[i], _exchange_timeframe] for i in instruments]
|
|
412
|
+
ohlcv = await self._exchange.watch_ohlcv_for_symbols(_symbol_timeframe_pairs)
|
|
413
|
+
# - ohlcv is symbol -> timeframe -> list[timestamp, open, high, low, close, volume]
|
|
414
|
+
for exch_symbol, _data in ohlcv.items():
|
|
415
|
+
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
416
|
+
for _, ohlcvs in _data.items():
|
|
417
|
+
for oh in ohlcvs:
|
|
418
|
+
channel.send(
|
|
419
|
+
(
|
|
420
|
+
instrument,
|
|
421
|
+
sub_type,
|
|
422
|
+
Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7]),
|
|
423
|
+
False, # not historical bar
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
if not (
|
|
427
|
+
self.has_subscription(instrument, DataType.ORDERBOOK)
|
|
428
|
+
or self.has_subscription(instrument, DataType.QUOTE)
|
|
429
|
+
):
|
|
430
|
+
_price = ohlcvs[-1][4]
|
|
431
|
+
_s2 = instrument.tick_size / 2.0
|
|
432
|
+
_bid, _ask = _price - _s2, _price + _s2
|
|
433
|
+
self._last_quotes[instrument] = Quote(oh[0] * 1_000_000, _bid, _ask, 0.0, 0.0)
|
|
434
|
+
|
|
435
|
+
# ohlc subscription reuses the same connection always, unsubscriptions don't work properly
|
|
436
|
+
# but it's likely not very needed
|
|
437
|
+
# async def un_watch_ohlcv(instruments: list[Instrument]):
|
|
438
|
+
# _symbol_timeframe_pairs = [[_instr_to_ccxt_symbol[i], _exchange_timeframe] for i in instruments]
|
|
439
|
+
# await self._exchange.un_watch_ohlcv_for_symbols(_symbol_timeframe_pairs)
|
|
440
|
+
|
|
441
|
+
await self._listen_to_stream(
|
|
442
|
+
subscriber=self._call_by_market_type(watch_ohlcv, instruments),
|
|
443
|
+
exchange=self._exchange,
|
|
444
|
+
channel=channel,
|
|
445
|
+
name=name,
|
|
446
|
+
# unsubscriber=self._call_by_market_type(un_watch_ohlcv, instruments),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
async def _subscribe_trade(
|
|
450
|
+
self,
|
|
451
|
+
name: str,
|
|
452
|
+
sub_type: str,
|
|
453
|
+
channel: CtrlChannel,
|
|
454
|
+
instruments: Set[Instrument],
|
|
455
|
+
):
|
|
456
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
457
|
+
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
458
|
+
|
|
459
|
+
async def watch_trades(instruments: list[Instrument]):
|
|
460
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
461
|
+
trades = await self._exchange.watch_trades_for_symbols(symbols)
|
|
462
|
+
exch_symbol = trades[0]["symbol"]
|
|
463
|
+
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
464
|
+
for trade in trades:
|
|
465
|
+
channel.send((instrument, sub_type, ccxt_convert_trade(trade), False))
|
|
466
|
+
|
|
467
|
+
async def un_watch_trades(instruments: list[Instrument]):
|
|
468
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
469
|
+
await self._exchange.un_watch_trades_for_symbols(symbols)
|
|
470
|
+
|
|
471
|
+
await self._listen_to_stream(
|
|
472
|
+
subscriber=self._call_by_market_type(watch_trades, instruments),
|
|
473
|
+
exchange=self._exchange,
|
|
474
|
+
channel=channel,
|
|
475
|
+
name=name,
|
|
476
|
+
unsubscriber=self._call_by_market_type(un_watch_trades, instruments),
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
async def _subscribe_orderbook(
|
|
480
|
+
self,
|
|
481
|
+
name: str,
|
|
482
|
+
sub_type: str,
|
|
483
|
+
channel: CtrlChannel,
|
|
484
|
+
instruments: Set[Instrument],
|
|
485
|
+
):
|
|
486
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
487
|
+
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
488
|
+
|
|
489
|
+
async def watch_orderbook(instruments: list[Instrument]):
|
|
490
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
491
|
+
ccxt_ob = await self._exchange.watch_order_book_for_symbols(symbols)
|
|
492
|
+
exch_symbol = ccxt_ob["symbol"]
|
|
493
|
+
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
494
|
+
ob = ccxt_convert_orderbook(ccxt_ob, instrument)
|
|
495
|
+
if ob is None:
|
|
496
|
+
return
|
|
497
|
+
quote = ob.to_quote()
|
|
498
|
+
self._last_quotes[instrument] = quote
|
|
499
|
+
channel.send((instrument, sub_type, ob, False))
|
|
500
|
+
|
|
501
|
+
async def un_watch_orderbook(instruments: list[Instrument]):
|
|
502
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
503
|
+
await self._exchange.un_watch_order_book_for_symbols(symbols)
|
|
504
|
+
|
|
505
|
+
await self._listen_to_stream(
|
|
506
|
+
subscriber=self._call_by_market_type(watch_orderbook, instruments),
|
|
507
|
+
exchange=self._exchange,
|
|
508
|
+
channel=channel,
|
|
509
|
+
name=name,
|
|
510
|
+
unsubscriber=self._call_by_market_type(un_watch_orderbook, instruments),
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
async def _subscribe_quote(
|
|
514
|
+
self,
|
|
515
|
+
name: str,
|
|
516
|
+
sub_type: str,
|
|
517
|
+
channel: CtrlChannel,
|
|
518
|
+
instruments: Set[Instrument],
|
|
519
|
+
):
|
|
520
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
521
|
+
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
522
|
+
|
|
523
|
+
async def watch_quote(instruments: list[Instrument]):
|
|
524
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
525
|
+
ccxt_tickers: dict[str, dict] = await self._exchange.watch_tickers(symbols)
|
|
526
|
+
for exch_symbol, ccxt_ticker in ccxt_tickers.items():
|
|
527
|
+
instrument = ccxt_find_instrument(exch_symbol, self._exchange, _symbol_to_instrument)
|
|
528
|
+
quote = ccxt_convert_ticker(ccxt_ticker, instrument)
|
|
529
|
+
self._last_quotes[instrument] = quote
|
|
530
|
+
channel.send((instrument, sub_type, quote, False))
|
|
531
|
+
|
|
532
|
+
async def un_watch_quote(instruments: list[Instrument]):
|
|
533
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
534
|
+
await self._exchange.un_watch_tickers(symbols)
|
|
535
|
+
|
|
536
|
+
await self._listen_to_stream(
|
|
537
|
+
subscriber=self._call_by_market_type(watch_quote, instruments),
|
|
538
|
+
exchange=self._exchange,
|
|
539
|
+
channel=channel,
|
|
540
|
+
name=name,
|
|
541
|
+
unsubscriber=self._call_by_market_type(un_watch_quote, instruments),
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
async def _subscribe_liquidation(
|
|
545
|
+
self,
|
|
546
|
+
name: str,
|
|
547
|
+
sub_type: str,
|
|
548
|
+
channel: CtrlChannel,
|
|
549
|
+
instruments: Set[Instrument],
|
|
550
|
+
):
|
|
551
|
+
_instr_to_ccxt_symbol = {i: instrument_to_ccxt_symbol(i) for i in instruments}
|
|
552
|
+
_symbol_to_instrument = {_instr_to_ccxt_symbol[i]: i for i in instruments}
|
|
553
|
+
|
|
554
|
+
async def watch_liquidation(instruments: list[Instrument]):
|
|
555
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
556
|
+
liquidations = await self._exchange.watch_liquidations_for_symbols(symbols)
|
|
557
|
+
for liquidation in liquidations:
|
|
558
|
+
try:
|
|
559
|
+
instrument = ccxt_find_instrument(liquidation["symbol"], self._exchange, _symbol_to_instrument)
|
|
560
|
+
channel.send((instrument, sub_type, ccxt_convert_liquidation(liquidation), False))
|
|
561
|
+
except CcxtLiquidationParsingError:
|
|
562
|
+
logger.debug(f"Could not parse liquidation {liquidation}")
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
async def un_watch_liquidation(instruments: list[Instrument]):
|
|
566
|
+
symbols = [_instr_to_ccxt_symbol[i] for i in instruments]
|
|
567
|
+
unwatch = getattr(self._exchange, "un_watch_liquidations_for_symbols", lambda _: None)(symbols)
|
|
568
|
+
if unwatch is not None:
|
|
569
|
+
await unwatch
|
|
570
|
+
|
|
571
|
+
# - fetching of liquidations for warmup is not supported by ccxt
|
|
572
|
+
await self._listen_to_stream(
|
|
573
|
+
subscriber=self._call_by_market_type(watch_liquidation, instruments),
|
|
574
|
+
exchange=self._exchange,
|
|
575
|
+
channel=channel,
|
|
576
|
+
name=name,
|
|
577
|
+
unsubscriber=self._call_by_market_type(un_watch_liquidation, instruments),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
async def _subscribe_funding_rate(
|
|
581
|
+
self,
|
|
582
|
+
name: str,
|
|
583
|
+
sub_type: str,
|
|
584
|
+
channel: CtrlChannel,
|
|
585
|
+
):
|
|
586
|
+
# it is expected that we can retrieve funding rates for all instruments
|
|
587
|
+
async def watch_funding_rates():
|
|
588
|
+
funding_rates = await self._exchange.watch_funding_rates() # type: ignore
|
|
589
|
+
instrument_to_funding_rate = {}
|
|
590
|
+
for symbol, info in funding_rates.items():
|
|
591
|
+
try:
|
|
592
|
+
instrument = ccxt_find_instrument(symbol, self._exchange)
|
|
593
|
+
instrument_to_funding_rate[instrument] = ccxt_convert_funding_rate(info)
|
|
594
|
+
except CcxtSymbolNotRecognized:
|
|
595
|
+
continue
|
|
596
|
+
channel.send((None, sub_type, instrument_to_funding_rate, False))
|
|
597
|
+
|
|
598
|
+
async def un_watch_funding_rates():
|
|
599
|
+
unwatch = getattr(self._exchange, "un_watch_funding_rates", lambda: None)()
|
|
600
|
+
if unwatch is not None:
|
|
601
|
+
await unwatch
|
|
602
|
+
|
|
603
|
+
await self._listen_to_stream(
|
|
604
|
+
subscriber=watch_funding_rates,
|
|
605
|
+
exchange=self._exchange,
|
|
606
|
+
channel=channel,
|
|
607
|
+
name=name,
|
|
608
|
+
unsubscriber=un_watch_funding_rates,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
def exchange(self) -> str:
|
|
612
|
+
return self._exchange_id.upper()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from qubx.core.exceptions import BaseError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CcxtOrderBookParsingError(BaseError):
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CcxtSymbolNotRecognized(BaseError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CcxtLiquidationParsingError(BaseError):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CcxtPositionRestoreError(BaseError):
|
|
17
|
+
pass
|