Qubx 0.2.82__cp311-cp311-manylinux_2_35_x86_64.whl → 0.3.0__cp311-cp311-manylinux_2_35_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/simulator.py +6 -1
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/ccxt_connector.py +319 -0
- qubx/{impl → connectors/ccxt}/ccxt_customizations.py +9 -11
- qubx/connectors/ccxt/ccxt_exceptions.py +5 -0
- qubx/{impl → connectors/ccxt}/ccxt_trading.py +1 -3
- qubx/{impl → connectors/ccxt}/ccxt_utils.py +61 -3
- qubx/core/basics.py +1 -0
- qubx/core/context.py +16 -6
- qubx/core/helpers.py +1 -1
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +12 -0
- qubx/core/series.pyi +17 -2
- qubx/core/series.pyx +22 -1
- qubx/core/strategy.py +6 -4
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/trackers/__init__.py +1 -1
- qubx/trackers/rebalancers.py +12 -10
- qubx/trackers/sizers.py +52 -18
- qubx/utils/orderbook.py +497 -0
- qubx/utils/runner.py +41 -3
- {qubx-0.2.82.dist-info → qubx-0.3.0.dist-info}/METADATA +2 -1
- {qubx-0.2.82.dist-info → qubx-0.3.0.dist-info}/RECORD +25 -22
- qubx/impl/ccxt_connector.py +0 -311
- {qubx-0.2.82.dist-info → qubx-0.3.0.dist-info}/WHEEL +0 -0
qubx/backtester/simulator.py
CHANGED
|
@@ -124,7 +124,12 @@ class _SimulatedLogFormatter:
|
|
|
124
124
|
if record["level"].name in {"WARNING", "SNAKY"}:
|
|
125
125
|
fmt = "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - %s" % fmt
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
dt = self.time_provider.time()
|
|
128
|
+
if isinstance(dt, int):
|
|
129
|
+
now = pd.Timestamp(dt).strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
130
|
+
else:
|
|
131
|
+
now = self.time_provider.time().astype("datetime64[us]").item().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
132
|
+
|
|
128
133
|
# prefix = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> [ <level>%s</level> ] " % record["level"].icon
|
|
129
134
|
prefix = f"<lc>{now}</lc> [<level>{record['level'].icon}</level>] "
|
|
130
135
|
|
|
File without changes
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
from threading import Thread
|
|
2
|
+
import threading
|
|
3
|
+
from typing import Any, Dict, List, Optional, Callable, Awaitable
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from asyncio.tasks import Task
|
|
7
|
+
from asyncio.events import AbstractEventLoop
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
|
|
10
|
+
import ccxt.pro as cxp
|
|
11
|
+
from ccxt.base.exchange import Exchange
|
|
12
|
+
from ccxt import NetworkError, ExchangeClosedByUser, ExchangeError, ExchangeNotAvailable
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
from qubx import logger
|
|
19
|
+
from qubx.core.basics import Instrument, Position, dt_64, Deal, CtrlChannel
|
|
20
|
+
from qubx.core.helpers import BasicScheduler
|
|
21
|
+
from qubx.core.strategy import IBrokerServiceProvider, ITradingServiceProvider
|
|
22
|
+
from qubx.core.series import TimeSeries, Bar, Trade, Quote
|
|
23
|
+
from qubx.utils.ntp import time_now
|
|
24
|
+
from .ccxt_utils import DATA_PROVIDERS_ALIASES, ccxt_convert_trade, ccxt_convert_orderbook
|
|
25
|
+
|
|
26
|
+
# - register custom wrappers
|
|
27
|
+
from .ccxt_customizations import BinanceQV, BinanceQVUSDM
|
|
28
|
+
|
|
29
|
+
cxp.binanceqv = BinanceQV # type: ignore
|
|
30
|
+
cxp.binanceqv_usdm = BinanceQVUSDM # type: ignore
|
|
31
|
+
cxp.exchanges.append("binanceqv")
|
|
32
|
+
cxp.exchanges.append("binanceqv_usdm")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CCXTExchangesConnector(IBrokerServiceProvider):
|
|
36
|
+
# TODO: implement multi symbol subscription from one channel
|
|
37
|
+
|
|
38
|
+
_exchange: Exchange
|
|
39
|
+
_subscriptions: Dict[str, List[Instrument]]
|
|
40
|
+
_scheduler: BasicScheduler | None = None
|
|
41
|
+
|
|
42
|
+
_last_quotes: Dict[str, Optional[Quote]]
|
|
43
|
+
_loop: AbstractEventLoop
|
|
44
|
+
_thread_event_loop: Thread
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
exchange_id: str,
|
|
49
|
+
trading_service: ITradingServiceProvider,
|
|
50
|
+
read_only: bool = False,
|
|
51
|
+
loop: AbstractEventLoop | None = None,
|
|
52
|
+
max_ws_retries: int = 10,
|
|
53
|
+
**exchange_auth,
|
|
54
|
+
):
|
|
55
|
+
super().__init__(exchange_id, trading_service)
|
|
56
|
+
self.trading_service = trading_service
|
|
57
|
+
self.read_only = read_only
|
|
58
|
+
self.max_ws_retries = max_ws_retries
|
|
59
|
+
exchange_id = exchange_id.lower()
|
|
60
|
+
|
|
61
|
+
# - setup communication bus
|
|
62
|
+
self.set_communication_channel(bus := CtrlChannel("databus", sentinel=(None, None, None)))
|
|
63
|
+
self.trading_service.set_communication_channel(bus)
|
|
64
|
+
|
|
65
|
+
# - init CCXT stuff
|
|
66
|
+
exch = DATA_PROVIDERS_ALIASES.get(exchange_id, exchange_id)
|
|
67
|
+
if exch not in cxp.exchanges:
|
|
68
|
+
raise ValueError(f"Exchange {exchange_id} -> {exch} is not supported by CCXT.pro !")
|
|
69
|
+
|
|
70
|
+
# - create new even loop
|
|
71
|
+
self._loop = asyncio.new_event_loop() if loop is None else loop
|
|
72
|
+
asyncio.set_event_loop(self._loop)
|
|
73
|
+
|
|
74
|
+
# - create exchange's instance
|
|
75
|
+
self._exchange = getattr(cxp, exch)(exchange_auth | {"asyncio_loop": self._loop})
|
|
76
|
+
self._last_quotes = defaultdict(lambda: None)
|
|
77
|
+
self._subscriptions = defaultdict(list)
|
|
78
|
+
|
|
79
|
+
logger.info(f"{exchange_id} initialized - current time {self.trading_service.time()}")
|
|
80
|
+
|
|
81
|
+
def get_scheduler(self) -> BasicScheduler:
|
|
82
|
+
# - standard scheduler
|
|
83
|
+
if self._scheduler is None:
|
|
84
|
+
self._scheduler = BasicScheduler(self.get_communication_channel(), lambda: self.time().item())
|
|
85
|
+
return self._scheduler
|
|
86
|
+
|
|
87
|
+
def subscribe(
|
|
88
|
+
self, subscription_type: str, instruments: List[Instrument], timeframe: Optional[str] = None, nback: int = 0
|
|
89
|
+
) -> bool:
|
|
90
|
+
to_process = self._check_existing_subscription(subscription_type.lower(), instruments)
|
|
91
|
+
if not to_process:
|
|
92
|
+
logger.info(f"Symbols {to_process} already subscribed on {subscription_type} data")
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
# - start event loop if not already running
|
|
96
|
+
if not self._loop.is_running():
|
|
97
|
+
self._thread_event_loop = Thread(target=self._loop.run_forever, args=(), daemon=True)
|
|
98
|
+
self._thread_event_loop.start()
|
|
99
|
+
|
|
100
|
+
to_process_symbols = [s.symbol for s in to_process]
|
|
101
|
+
|
|
102
|
+
# - subscribe to market data updates
|
|
103
|
+
match sbscr := subscription_type.lower():
|
|
104
|
+
case "ohlc":
|
|
105
|
+
if timeframe is None:
|
|
106
|
+
raise ValueError("timeframe must not be None for OHLC data subscription")
|
|
107
|
+
|
|
108
|
+
# convert to exchange format
|
|
109
|
+
tframe = self._get_exch_timeframe(timeframe)
|
|
110
|
+
for s in to_process:
|
|
111
|
+
# self._task_a(self._listen_to_ohlcv(self.get_communication_channel(), s, tframe, nback))
|
|
112
|
+
asyncio.run_coroutine_threadsafe(
|
|
113
|
+
self._listen_to_ohlcv(self.get_communication_channel(), s, tframe, nback), self._loop
|
|
114
|
+
)
|
|
115
|
+
self._subscriptions[sbscr].append(s)
|
|
116
|
+
logger.info(f"Subscribed on {sbscr} updates for {len(to_process)} symbols: \n\t\t{to_process_symbols}")
|
|
117
|
+
|
|
118
|
+
case "trade":
|
|
119
|
+
if timeframe is None:
|
|
120
|
+
timeframe = "1Min"
|
|
121
|
+
tframe = self._get_exch_timeframe(timeframe)
|
|
122
|
+
for s in to_process:
|
|
123
|
+
asyncio.run_coroutine_threadsafe(
|
|
124
|
+
self._listen_to_trades(self.get_communication_channel(), s, tframe, nback), self._loop
|
|
125
|
+
)
|
|
126
|
+
self._subscriptions[sbscr].append(s)
|
|
127
|
+
logger.info(f"Subscribed on {sbscr} updates for {len(to_process)} symbols: \n\t\t{to_process_symbols}")
|
|
128
|
+
|
|
129
|
+
case "orderbook":
|
|
130
|
+
if timeframe is None:
|
|
131
|
+
timeframe = "1Min"
|
|
132
|
+
tframe = self._get_exch_timeframe(timeframe)
|
|
133
|
+
for s in to_process:
|
|
134
|
+
asyncio.run_coroutine_threadsafe(
|
|
135
|
+
self._listen_to_orderbook(self.get_communication_channel(), s, timeframe, nback), self._loop
|
|
136
|
+
)
|
|
137
|
+
self._subscriptions[sbscr].append(s)
|
|
138
|
+
logger.info(f"Subscribed on {sbscr} updates for {len(to_process)} symbols: \n\t\t{to_process_symbols}")
|
|
139
|
+
|
|
140
|
+
case "quote":
|
|
141
|
+
raise ValueError("TODO")
|
|
142
|
+
|
|
143
|
+
case _:
|
|
144
|
+
raise ValueError("TODO")
|
|
145
|
+
|
|
146
|
+
# - subscibe to executions reports
|
|
147
|
+
if not self.read_only:
|
|
148
|
+
for s in to_process:
|
|
149
|
+
asyncio.run_coroutine_threadsafe(
|
|
150
|
+
self._listen_to_execution_reports(self.get_communication_channel(), s), self._loop
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
def has_subscription(self, subscription_type: str, instrument: Instrument) -> bool:
|
|
156
|
+
sub = subscription_type.lower()
|
|
157
|
+
return sub in self._subscriptions and instrument in self._subscriptions[sub]
|
|
158
|
+
|
|
159
|
+
def _check_existing_subscription(self, subscription_type, instruments: List[Instrument]) -> List[Instrument]:
|
|
160
|
+
subscribed = self._subscriptions[subscription_type]
|
|
161
|
+
to_subscribe = []
|
|
162
|
+
for s in instruments:
|
|
163
|
+
if s not in subscribed:
|
|
164
|
+
to_subscribe.append(s)
|
|
165
|
+
return to_subscribe
|
|
166
|
+
|
|
167
|
+
def _time_msec_nbars_back(self, timeframe: str, nbarsback: int) -> int:
|
|
168
|
+
return (self.time() - nbarsback * pd.Timedelta(timeframe)).asm8.item() // 1000000
|
|
169
|
+
|
|
170
|
+
def get_historical_ohlcs(self, symbol: str, timeframe: str, nbarsback: int) -> List[Bar]:
|
|
171
|
+
assert nbarsback >= 1
|
|
172
|
+
since = self._time_msec_nbars_back(timeframe, nbarsback)
|
|
173
|
+
|
|
174
|
+
# - retrieve OHLC data
|
|
175
|
+
# - TODO: check if nbarsback > max_limit (1000) we need to do more requests
|
|
176
|
+
# - TODO: how to get quoted volumes ?
|
|
177
|
+
async def _get():
|
|
178
|
+
return await self._exchange.fetch_ohlcv(symbol, self._get_exch_timeframe(timeframe), since=since, limit=nbarsback + 1) # type: ignore
|
|
179
|
+
|
|
180
|
+
fut = asyncio.run_coroutine_threadsafe(_get(), self._loop)
|
|
181
|
+
res = fut.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
|
+
return _arr
|
|
191
|
+
|
|
192
|
+
async def _listen_to_execution_reports(self, channel: CtrlChannel, symbol: str):
|
|
193
|
+
async def _watch_executions():
|
|
194
|
+
exec = await self._exchange.watch_orders(symbol) # type: ignore
|
|
195
|
+
_msg = f"\nexecs_{symbol} = [\n"
|
|
196
|
+
for report in exec:
|
|
197
|
+
_msg += "\t" + str(report) + ",\n"
|
|
198
|
+
order, deals = self.trading_service.process_execution_report(symbol, report)
|
|
199
|
+
# - send update to client
|
|
200
|
+
channel.send((symbol, "order", order))
|
|
201
|
+
if deals:
|
|
202
|
+
channel.send((symbol, "deals", deals))
|
|
203
|
+
logger.debug(_msg + "]\n")
|
|
204
|
+
|
|
205
|
+
await self._listen_to_stream(_watch_executions, self._exchange, channel, f"{symbol} execution reports")
|
|
206
|
+
|
|
207
|
+
async def _listen_to_ohlcv(self, channel: CtrlChannel, instrument: Instrument, timeframe: str, nbarsback: int):
|
|
208
|
+
symbol = instrument.symbol
|
|
209
|
+
|
|
210
|
+
async def watch_ohlcv():
|
|
211
|
+
ohlcv = await self._exchange.watch_ohlcv(symbol, timeframe) # type: ignore
|
|
212
|
+
# - update positions by actual close price
|
|
213
|
+
last_close = ohlcv[-1][4]
|
|
214
|
+
# - there is no single method to get OHLC update's event time for every broker
|
|
215
|
+
# - for instance it's possible to do for Binance but for example Bitmex doesn't provide such info
|
|
216
|
+
# - so we will use ntp adjusted time here
|
|
217
|
+
self.trading_service.update_position_price(symbol, self.time(), last_close)
|
|
218
|
+
for oh in ohlcv:
|
|
219
|
+
channel.send((symbol, "bar", Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7])))
|
|
220
|
+
|
|
221
|
+
await self._stream_hist_bars(channel, timeframe, instrument.symbol, nbarsback)
|
|
222
|
+
await self._listen_to_stream(watch_ohlcv, self._exchange, channel, f"{symbol} {timeframe} OHLCV")
|
|
223
|
+
|
|
224
|
+
async def _listen_to_trades(self, channel: CtrlChannel, instrument: Instrument, timeframe: str, nbarsback: int):
|
|
225
|
+
symbol = instrument.symbol
|
|
226
|
+
|
|
227
|
+
async def _watch_trades():
|
|
228
|
+
trades = await self._exchange.watch_trades(symbol)
|
|
229
|
+
# - update positions by actual close price
|
|
230
|
+
last_trade = ccxt_convert_trade(trades[-1])
|
|
231
|
+
self.trading_service.update_position_price(symbol, self.time(), last_trade)
|
|
232
|
+
for trade in trades:
|
|
233
|
+
channel.send((symbol, "trade", ccxt_convert_trade(trade)))
|
|
234
|
+
|
|
235
|
+
# TODO: stream historical trades for some period
|
|
236
|
+
await self._stream_hist_bars(channel, timeframe, symbol, nbarsback)
|
|
237
|
+
await self._listen_to_stream(_watch_trades, self._exchange, channel, f"{symbol} trades")
|
|
238
|
+
|
|
239
|
+
async def _listen_to_orderbook(self, channel: CtrlChannel, instrument: Instrument, timeframe: str, nbarsback: int):
|
|
240
|
+
symbol = instrument.symbol
|
|
241
|
+
|
|
242
|
+
async def _watch_orderbook():
|
|
243
|
+
ccxt_ob = await self._exchange.watch_order_book(symbol)
|
|
244
|
+
ob = ccxt_convert_orderbook(ccxt_ob, instrument)
|
|
245
|
+
self.trading_service.update_position_price(symbol, self.time(), ob.to_quote())
|
|
246
|
+
channel.send((symbol, "orderbook", ob))
|
|
247
|
+
|
|
248
|
+
# TODO: stream historical orderbooks for some period
|
|
249
|
+
await self._stream_hist_bars(channel, timeframe, symbol, nbarsback)
|
|
250
|
+
await self._listen_to_stream(_watch_orderbook, self._exchange, channel, f"{symbol} orderbook")
|
|
251
|
+
|
|
252
|
+
async def _listen_to_stream(
|
|
253
|
+
self, subscriber: Callable[[], Awaitable[None]], exchange: Exchange, channel: CtrlChannel, name: str
|
|
254
|
+
):
|
|
255
|
+
logger.debug(f"Listening to {name}")
|
|
256
|
+
n_retry = 0
|
|
257
|
+
while channel.control.is_set():
|
|
258
|
+
try:
|
|
259
|
+
await subscriber()
|
|
260
|
+
n_retry = 0
|
|
261
|
+
except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
|
|
262
|
+
logger.error(f"Error in {name} : {e}")
|
|
263
|
+
await asyncio.sleep(1)
|
|
264
|
+
continue
|
|
265
|
+
except ExchangeClosedByUser:
|
|
266
|
+
# - we closed connection so just stop it
|
|
267
|
+
logger.info(f"{name} listening has been stopped")
|
|
268
|
+
break
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.error(f"exception in {name} : {e}")
|
|
271
|
+
logger.exception(e)
|
|
272
|
+
n_retry += 1
|
|
273
|
+
if n_retry >= self.max_ws_retries:
|
|
274
|
+
logger.error(f"Max retries reached for {name}. Closing connection.")
|
|
275
|
+
await exchange.close() # type: ignore
|
|
276
|
+
break
|
|
277
|
+
await asyncio.sleep(min(2**n_retry, 60)) # Exponential backoff with a cap at 60 seconds
|
|
278
|
+
|
|
279
|
+
async def _stream_hist_bars(self, channel: CtrlChannel, timeframe: str, symbol: str, nbarsback: int) -> None:
|
|
280
|
+
if nbarsback < 1:
|
|
281
|
+
return
|
|
282
|
+
start = self._time_msec_nbars_back(timeframe, nbarsback)
|
|
283
|
+
ohlcv = await self._exchange.fetch_ohlcv(symbol, timeframe, since=start, limit=nbarsback + 1) # type: ignore
|
|
284
|
+
# - just send data as the list
|
|
285
|
+
channel.send(
|
|
286
|
+
(
|
|
287
|
+
symbol,
|
|
288
|
+
"hist_bars",
|
|
289
|
+
[Bar(oh[0] * 1_000_000, oh[1], oh[2], oh[3], oh[4], oh[6], oh[7]) for oh in ohlcv],
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
logger.info(f"{symbol}: loaded {len(ohlcv)} {timeframe} bars")
|
|
293
|
+
|
|
294
|
+
def get_quote(self, symbol: str) -> Optional[Quote]:
|
|
295
|
+
return self._last_quotes[symbol]
|
|
296
|
+
|
|
297
|
+
def _get_exch_timeframe(self, timeframe: str) -> str:
|
|
298
|
+
if timeframe is not None:
|
|
299
|
+
_t = re.match(r"(\d+)(\w+)", timeframe)
|
|
300
|
+
timeframe = f"{_t[1]}{_t[2][0].lower()}" if _t and len(_t.groups()) > 1 else timeframe
|
|
301
|
+
|
|
302
|
+
tframe = self._exchange.find_timeframe(timeframe)
|
|
303
|
+
if tframe is None:
|
|
304
|
+
raise ValueError(f"timeframe {timeframe} is not supported by {self._exchange.name}")
|
|
305
|
+
|
|
306
|
+
return tframe
|
|
307
|
+
|
|
308
|
+
def close(self):
|
|
309
|
+
try:
|
|
310
|
+
# self._loop.run_until_complete(self._exchange.close()) # type: ignore
|
|
311
|
+
asyncio.run_coroutine_threadsafe(self._exchange.close(), self._loop)
|
|
312
|
+
except Exception as e:
|
|
313
|
+
logger.error(e)
|
|
314
|
+
|
|
315
|
+
def time(self) -> dt_64:
|
|
316
|
+
"""
|
|
317
|
+
Returns current time as dt64
|
|
318
|
+
"""
|
|
319
|
+
return time_now()
|
|
@@ -53,7 +53,7 @@ class BinanceQV(cxp.binance):
|
|
|
53
53
|
|
|
54
54
|
def handle_ohlcv(self, client: Client, message):
|
|
55
55
|
event = self.safe_string(message, "e")
|
|
56
|
-
eventMap = {
|
|
56
|
+
eventMap: dict = {
|
|
57
57
|
"indexPrice_kline": "indexPriceKline",
|
|
58
58
|
"markPrice_kline": "markPriceKline",
|
|
59
59
|
}
|
|
@@ -63,11 +63,9 @@ class BinanceQV(cxp.binance):
|
|
|
63
63
|
if event == "indexPriceKline":
|
|
64
64
|
# indexPriceKline doesn't have the _PERP suffix
|
|
65
65
|
marketId = self.safe_string(message, "ps")
|
|
66
|
-
lowercaseMarketId = marketId.lower()
|
|
67
66
|
interval = self.safe_string(kline, "i")
|
|
68
67
|
# use a reverse lookup in a static map instead
|
|
69
|
-
|
|
70
|
-
messageHash = lowercaseMarketId + "@" + event + "_" + interval
|
|
68
|
+
unifiedTimeframe = self.find_timeframe(interval)
|
|
71
69
|
parsed = [
|
|
72
70
|
self.safe_integer(kline, "t"),
|
|
73
71
|
self.safe_float(kline, "o"),
|
|
@@ -83,14 +81,16 @@ class BinanceQV(cxp.binance):
|
|
|
83
81
|
isSpot = (client.url.find("/stream") > -1) or (client.url.find("/testnet.binance") > -1)
|
|
84
82
|
marketType = "spot" if (isSpot) else "contract"
|
|
85
83
|
symbol = self.safe_symbol(marketId, None, None, marketType)
|
|
84
|
+
messageHash = "ohlcv::" + symbol + "::" + unifiedTimeframe
|
|
86
85
|
self.ohlcvs[symbol] = self.safe_value(self.ohlcvs, symbol, {})
|
|
87
|
-
stored = self.safe_value(self.ohlcvs[symbol],
|
|
86
|
+
stored = self.safe_value(self.ohlcvs[symbol], unifiedTimeframe)
|
|
88
87
|
if stored is None:
|
|
89
|
-
limit = self.safe_integer(self.options, "OHLCVLimit",
|
|
88
|
+
limit = self.safe_integer(self.options, "OHLCVLimit", 1000)
|
|
90
89
|
stored = ArrayCacheByTimestamp(limit)
|
|
91
|
-
|
|
90
|
+
self.ohlcvs[symbol][unifiedTimeframe] = stored
|
|
92
91
|
stored.append(parsed)
|
|
93
|
-
|
|
92
|
+
resolveData = [symbol, unifiedTimeframe, stored]
|
|
93
|
+
client.resolve(resolveData, messageHash)
|
|
94
94
|
|
|
95
95
|
def handle_trade(self, client: Client, message):
|
|
96
96
|
"""
|
|
@@ -109,9 +109,7 @@ class BinanceQV(cxp.binance):
|
|
|
109
109
|
marketId = self.safe_string(message, "s")
|
|
110
110
|
market = self.safe_market(marketId, None, None, marketType)
|
|
111
111
|
symbol = market["symbol"]
|
|
112
|
-
|
|
113
|
-
event = self.safe_string(message, "e")
|
|
114
|
-
messageHash = lowerCaseId + "@" + event
|
|
112
|
+
messageHash = "trade::" + symbol
|
|
115
113
|
executionType = self.safe_string(message, "X")
|
|
116
114
|
if executionType == "INSURANCE_FUND":
|
|
117
115
|
return
|
|
@@ -5,8 +5,6 @@ import stackprinter
|
|
|
5
5
|
import traceback
|
|
6
6
|
|
|
7
7
|
import ccxt
|
|
8
|
-
import ccxt.pro as cxp
|
|
9
|
-
from ccxt.base.decimal_to_precision import ROUND_UP
|
|
10
8
|
from ccxt.base.exchange import Exchange, ExchangeError
|
|
11
9
|
|
|
12
10
|
import numpy as np
|
|
@@ -17,7 +15,7 @@ from qubx.core.account import AccountProcessor
|
|
|
17
15
|
from qubx.core.basics import Instrument, Position, Order, TransactionCostsCalculator, dt_64, Deal, CtrlChannel
|
|
18
16
|
from qubx.core.strategy import IBrokerServiceProvider, ITradingServiceProvider
|
|
19
17
|
from qubx.core.series import TimeSeries, Bar, Trade, Quote
|
|
20
|
-
from qubx.
|
|
18
|
+
from qubx.connectors.ccxt.ccxt_utils import (
|
|
21
19
|
EXCHANGE_ALIASES,
|
|
22
20
|
ccxt_convert_order_info,
|
|
23
21
|
ccxt_convert_deal_info,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
2
1
|
import pandas as pd
|
|
2
|
+
import numpy as np
|
|
3
|
+
|
|
4
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
3
5
|
|
|
4
6
|
from qubx import logger
|
|
5
|
-
from qubx.core.basics import Order, Deal, Position
|
|
6
|
-
from qubx.core.series import TimeSeries, Bar, Trade, Quote
|
|
7
|
+
from qubx.core.basics import Order, Deal, Position, Instrument
|
|
8
|
+
from qubx.core.series import TimeSeries, Bar, Trade, Quote, OrderBook, time_as_nsec
|
|
9
|
+
from qubx.utils.orderbook import build_orderbook_snapshots
|
|
10
|
+
from .ccxt_exceptions import CcxtOrderBookParsingError
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
EXCHANGE_ALIASES = {"binance.um": "binanceusdm", "binance.cm": "binancecoinm", "kraken.f": "krakenfutures"}
|
|
@@ -115,3 +119,57 @@ def ccxt_convert_trade(trade: dict[str, Any]) -> Trade:
|
|
|
115
119
|
s, info, price, amnt = trade["symbol"], trade["info"], trade["price"], trade["amount"]
|
|
116
120
|
m = info["m"]
|
|
117
121
|
return Trade(t_ns, price, amnt, int(not m), int(trade["id"]))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def ccxt_convert_orderbook(
|
|
125
|
+
ob: dict, instr: Instrument, levels: int = 50, tick_size_pct: float = 0.01, sizes_in_quoted: bool = False
|
|
126
|
+
) -> OrderBook:
|
|
127
|
+
"""
|
|
128
|
+
Convert a ccxt order book to an OrderBook object with a fixed tick size percentage.
|
|
129
|
+
Parameters:
|
|
130
|
+
ob (dict): The order book dictionary from ccxt.
|
|
131
|
+
instr (Instrument): The instrument object containing market-specific details.
|
|
132
|
+
levels (int, optional): The number of levels to include in the order book. Default is 50.
|
|
133
|
+
tick_size_pct (float, optional): The tick size percentage. Default is 0.01%.
|
|
134
|
+
sizes_in_quoted (bool, optional): Whether the size is in the quoted currency. Default is False.
|
|
135
|
+
Returns:
|
|
136
|
+
OrderBook: The converted OrderBook object.
|
|
137
|
+
"""
|
|
138
|
+
_dt = pd.Timestamp(ob["datetime"]).replace(tzinfo=None).asm8
|
|
139
|
+
_prev_dt = _dt - pd.Timedelta("1ms").asm8
|
|
140
|
+
|
|
141
|
+
updates = [
|
|
142
|
+
*[(_prev_dt, update[0], update[1], True) for update in ob["bids"]],
|
|
143
|
+
*[(_prev_dt, update[0], update[1], False) for update in ob["asks"]],
|
|
144
|
+
]
|
|
145
|
+
# add an artificial update to trigger the snapshot building
|
|
146
|
+
updates.append((_dt, 0, 0, True))
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
snapshots = build_orderbook_snapshots(
|
|
150
|
+
updates,
|
|
151
|
+
levels=levels,
|
|
152
|
+
tick_size_pct=tick_size_pct,
|
|
153
|
+
min_tick_size=instr.min_tick,
|
|
154
|
+
min_size_step=instr.min_size_step,
|
|
155
|
+
sizes_in_quoted=sizes_in_quoted,
|
|
156
|
+
)
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Failed to build order book snapshots: {e}", exc_info=True)
|
|
159
|
+
snapshots = None
|
|
160
|
+
|
|
161
|
+
if not snapshots:
|
|
162
|
+
raise CcxtOrderBookParsingError("Failed to build order book snapshots")
|
|
163
|
+
|
|
164
|
+
(dt, _bids, _asks, top_bid, top_ask, tick_size) = snapshots[-1]
|
|
165
|
+
bids = np.array([s for _, s in _bids[::-1]])
|
|
166
|
+
asks = np.array([s for _, s in _asks])
|
|
167
|
+
|
|
168
|
+
return OrderBook(
|
|
169
|
+
time=time_as_nsec(dt),
|
|
170
|
+
top_bid=top_bid,
|
|
171
|
+
top_ask=top_ask,
|
|
172
|
+
tick_size=tick_size,
|
|
173
|
+
bids=bids,
|
|
174
|
+
asks=asks,
|
|
175
|
+
)
|
qubx/core/basics.py
CHANGED
qubx/core/context.py
CHANGED
|
@@ -35,7 +35,7 @@ from qubx.core.strategy import (
|
|
|
35
35
|
StrategyContext,
|
|
36
36
|
SubscriptionType,
|
|
37
37
|
)
|
|
38
|
-
from qubx.core.series import Trade, Quote, Bar, OHLCV
|
|
38
|
+
from qubx.core.series import Trade, Quote, Bar, OHLCV, OrderBook
|
|
39
39
|
from qubx.data.readers import DataReader
|
|
40
40
|
from qubx.gathering.simplest import SimplePositionGatherer
|
|
41
41
|
from qubx.trackers.sizers import FixedSizer
|
|
@@ -221,6 +221,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
221
221
|
case "trade" | "trades" | "tas":
|
|
222
222
|
timeframe = md_config.get("timeframe", "1Sec")
|
|
223
223
|
self._market_data_subcription_params = {
|
|
224
|
+
"timeframe": timeframe,
|
|
224
225
|
"nback": md_config.get("nback", 1),
|
|
225
226
|
}
|
|
226
227
|
self._cache = CachedMarketDataHolder("1Sec")
|
|
@@ -354,7 +355,7 @@ class StrategyContextImpl(StrategyContext):
|
|
|
354
355
|
|
|
355
356
|
# - if fit was not called - skip on_event call
|
|
356
357
|
if not self.__init_fit_was_called:
|
|
357
|
-
logger.
|
|
358
|
+
logger.debug(
|
|
358
359
|
f"[{self.time()}] {self.strategy.__class__.__name__}::on_event() is SKIPPED for now because on_fit() was not called yet !"
|
|
359
360
|
)
|
|
360
361
|
return False
|
|
@@ -584,10 +585,19 @@ class StrategyContextImpl(StrategyContext):
|
|
|
584
585
|
self.__process_and_log_target_positions(target_positions),
|
|
585
586
|
)
|
|
586
587
|
|
|
587
|
-
if
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
588
|
+
event_type = "trade" if not is_batch_event else "batch:trade"
|
|
589
|
+
return TriggerEvent(self.time(), event_type, self._symb_to_instr.get(symbol), trade)
|
|
590
|
+
|
|
591
|
+
def _processing_orderbook(self, symbol: str, orderbook: OrderBook) -> TriggerEvent | None:
|
|
592
|
+
quote = orderbook.to_quote()
|
|
593
|
+
self._cache.update_by_quote(symbol, quote)
|
|
594
|
+
target_positions = self.positions_tracker.update(self, self._symb_to_instr[symbol], quote)
|
|
595
|
+
self.__process_signals_from_target_positions(target_positions)
|
|
596
|
+
self.positions_gathering.alter_positions(
|
|
597
|
+
self,
|
|
598
|
+
self.__process_and_log_target_positions(target_positions),
|
|
599
|
+
)
|
|
600
|
+
return TriggerEvent(self.time(), "orderbook", self._symb_to_instr.get(symbol), orderbook)
|
|
591
601
|
|
|
592
602
|
def _processing_quote(self, symbol: str, quote: Quote) -> TriggerEvent | None:
|
|
593
603
|
self._cache.update_by_quote(symbol, quote)
|
qubx/core/helpers.py
CHANGED
|
@@ -128,7 +128,7 @@ class CachedMarketDataHolder:
|
|
|
128
128
|
total_vol = trade.size
|
|
129
129
|
bought_vol = total_vol if trade.taker >= 1 else 0.0
|
|
130
130
|
for ser in series.values():
|
|
131
|
-
if len(ser) > 0 and ser[
|
|
131
|
+
if len(ser) > 0 and ser[0].time > trade.time:
|
|
132
132
|
continue
|
|
133
133
|
ser.update(trade.time, trade.price, total_vol, bought_vol)
|
|
134
134
|
|
|
Binary file
|
qubx/core/series.pxd
CHANGED
|
@@ -108,6 +108,18 @@ cdef class Quote:
|
|
|
108
108
|
cpdef double mid_price(Quote self)
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
cdef class OrderBook:
|
|
112
|
+
cdef public long long time
|
|
113
|
+
cdef public double top_bid
|
|
114
|
+
cdef public double top_ask
|
|
115
|
+
cdef public double tick_size
|
|
116
|
+
cdef public np.ndarray bids
|
|
117
|
+
cdef public np.ndarray asks
|
|
118
|
+
|
|
119
|
+
cpdef Quote to_quote(OrderBook self)
|
|
120
|
+
cpdef double mid_price(OrderBook self)
|
|
121
|
+
|
|
122
|
+
|
|
111
123
|
cdef class IndicatorOHLC(Indicator):
|
|
112
124
|
pass
|
|
113
125
|
|
qubx/core/series.pyi
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import numpy as np
|
|
2
1
|
from typing import Any, Tuple
|
|
3
2
|
|
|
3
|
+
import numpy as np
|
|
4
|
+
cimport numpy as np
|
|
5
|
+
|
|
4
6
|
import pandas as pd
|
|
5
7
|
|
|
6
8
|
class Bar:
|
|
@@ -33,6 +35,19 @@ class Trade:
|
|
|
33
35
|
trade_id: int
|
|
34
36
|
def __init__(self, time, price, size, taker=-1, trade_id=0): ...
|
|
35
37
|
|
|
38
|
+
class OrderBook:
|
|
39
|
+
time: int
|
|
40
|
+
top_bid: float
|
|
41
|
+
top_ask: float
|
|
42
|
+
tick_size: float
|
|
43
|
+
bids: np.ndarray
|
|
44
|
+
asks: np.ndarray
|
|
45
|
+
|
|
46
|
+
def __init__(self, time, top_bid, top_ask, tick_size, bids, asks): ...
|
|
47
|
+
def to_quote(self) -> Quote: ...
|
|
48
|
+
def mid_price(self) -> float: ...
|
|
49
|
+
|
|
50
|
+
|
|
36
51
|
class Locator:
|
|
37
52
|
def __getitem__(self, idx): ...
|
|
38
53
|
def find(self, t: str) -> Tuple[np.datetime64, Any]: ...
|
|
@@ -94,7 +109,7 @@ class IndicatorOHLC(Indicator):
|
|
|
94
109
|
series: OHLCV
|
|
95
110
|
def _copy_internal_series(self, start: int, stop: int, *origins): ...
|
|
96
111
|
|
|
97
|
-
def time_as_nsec(time: Any) ->
|
|
112
|
+
def time_as_nsec(time: Any) -> int: ...
|
|
98
113
|
|
|
99
114
|
class RollingSum:
|
|
100
115
|
is_init_stage: bool
|
qubx/core/series.pyx
CHANGED
|
@@ -687,7 +687,7 @@ cdef class Trade:
|
|
|
687
687
|
self.trade_id = trade_id
|
|
688
688
|
|
|
689
689
|
def __repr__(self):
|
|
690
|
-
return "[%s]\t%.5f (%.
|
|
690
|
+
return "[%s]\t%.5f (%.2f) %s %s" % (
|
|
691
691
|
time_to_str(self.time, 'ns'), self.price, self.size,
|
|
692
692
|
'take' if self.taker == 1 else 'make' if self.taker == 0 else '???',
|
|
693
693
|
str(self.trade_id) if self.trade_id > 0 else ''
|
|
@@ -747,6 +747,27 @@ cdef class Bar:
|
|
|
747
747
|
return "{o:%f | h:%f | l:%f | c:%f | v:%f}" % (self.open, self.high, self.low, self.close, self.volume)
|
|
748
748
|
|
|
749
749
|
|
|
750
|
+
cdef class OrderBook:
|
|
751
|
+
|
|
752
|
+
def __init__(self, long long time, top_bid: float, top_ask: float, tick_size: float, bids: np.ndarray, asks: np.ndarray):
|
|
753
|
+
self.time = time
|
|
754
|
+
self.top_bid = top_bid
|
|
755
|
+
self.top_ask = top_ask
|
|
756
|
+
self.tick_size = tick_size
|
|
757
|
+
self.bids = bids
|
|
758
|
+
self.asks = asks
|
|
759
|
+
|
|
760
|
+
def __repr__(self):
|
|
761
|
+
return f"[{time_to_str(self.time, 'ns')}] {self.top_bid} ({self.bids[0]}) | {self.top_ask} ({self.asks[0]})"
|
|
762
|
+
|
|
763
|
+
cpdef Quote to_quote(self):
|
|
764
|
+
return Quote(self.time, self.top_bid, self.top_ask, self.bids[0], self.asks[0])
|
|
765
|
+
|
|
766
|
+
cpdef double mid_price(self):
|
|
767
|
+
return 0.5 * (self.top_ask + self.top_bid)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
|
|
750
771
|
cdef class OHLCV(TimeSeries):
|
|
751
772
|
|
|
752
773
|
def __init__(self, str name, timeframe, max_series_length=INFINITY) -> None:
|