Qubx 0.2.82__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Qubx might be problematic. Click here for more details.

Files changed (61) hide show
  1. {qubx-0.2.82 → qubx-0.3.0}/PKG-INFO +2 -1
  2. {qubx-0.2.82 → qubx-0.3.0}/pyproject.toml +2 -1
  3. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/backtester/simulator.py +6 -1
  4. qubx-0.3.0/src/qubx/connectors/ccxt/ccxt_connector.py +319 -0
  5. {qubx-0.2.82/src/qubx/impl → qubx-0.3.0/src/qubx/connectors/ccxt}/ccxt_customizations.py +9 -11
  6. qubx-0.3.0/src/qubx/connectors/ccxt/ccxt_exceptions.py +5 -0
  7. {qubx-0.2.82/src/qubx/impl → qubx-0.3.0/src/qubx/connectors/ccxt}/ccxt_trading.py +1 -3
  8. {qubx-0.2.82/src/qubx/impl → qubx-0.3.0/src/qubx/connectors/ccxt}/ccxt_utils.py +61 -3
  9. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/basics.py +1 -0
  10. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/context.py +16 -6
  11. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/helpers.py +1 -1
  12. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/series.pxd +12 -0
  13. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/series.pyi +17 -2
  14. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/series.pyx +22 -1
  15. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/strategy.py +6 -4
  16. qubx-0.3.0/src/qubx/ta/__init__.py +0 -0
  17. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/trackers/__init__.py +1 -1
  18. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/trackers/rebalancers.py +12 -10
  19. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/trackers/sizers.py +52 -18
  20. qubx-0.3.0/src/qubx/utils/orderbook.py +497 -0
  21. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/runner.py +41 -3
  22. qubx-0.2.82/src/qubx/impl/ccxt_connector.py +0 -311
  23. {qubx-0.2.82 → qubx-0.3.0}/README.md +0 -0
  24. {qubx-0.2.82 → qubx-0.3.0}/build.py +0 -0
  25. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/__init__.py +0 -0
  26. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/_nb_magic.py +0 -0
  27. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/backtester/__init__.py +0 -0
  28. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/backtester/ome.py +0 -0
  29. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/backtester/optimization.py +0 -0
  30. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/backtester/queue.py +0 -0
  31. {qubx-0.2.82/src/qubx/core → qubx-0.3.0/src/qubx/connectors/ccxt}/__init__.py +0 -0
  32. {qubx-0.2.82/src/qubx/ta → qubx-0.3.0/src/qubx/core}/__init__.py +0 -0
  33. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/account.py +0 -0
  34. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/exceptions.py +0 -0
  35. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/loggers.py +0 -0
  36. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/lookups.py +0 -0
  37. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/metrics.py +0 -0
  38. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/utils.pyi +0 -0
  39. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/core/utils.pyx +0 -0
  40. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/data/__init__.py +0 -0
  41. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/data/helpers.py +0 -0
  42. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/data/readers.py +0 -0
  43. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/gathering/simplest.py +0 -0
  44. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/math/__init__.py +0 -0
  45. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/math/stats.py +0 -0
  46. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/pandaz/__init__.py +0 -0
  47. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/pandaz/ta.py +0 -0
  48. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/pandaz/utils.py +0 -0
  49. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/ta/indicators.pxd +0 -0
  50. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/ta/indicators.pyi +0 -0
  51. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/ta/indicators.pyx +0 -0
  52. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/trackers/composite.py +0 -0
  53. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/trackers/riskctrl.py +0 -0
  54. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/__init__.py +0 -0
  55. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/_pyxreloader.py +0 -0
  56. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/charting/lookinglass.py +0 -0
  57. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/charting/mpl_helpers.py +0 -0
  58. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/marketdata/binance.py +0 -0
  59. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/misc.py +0 -0
  60. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/ntp.py +0 -0
  61. {qubx-0.2.82 → qubx-0.3.0}/src/qubx/utils/time.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: Qubx
3
- Version: 0.2.82
3
+ Version: 0.3.0
4
4
  Summary: Qubx - quantitative trading framework
5
5
  Home-page: https://github.com/dmarienko/Qubx
6
6
  Author: Dmitry Marienko
@@ -16,6 +16,7 @@ Requires-Dist: cython (==3.0.8)
16
16
  Requires-Dist: importlib-metadata
17
17
  Requires-Dist: loguru (>=0.7.2,<0.8.0)
18
18
  Requires-Dist: matplotlib (>=3.8.4,<4.0.0)
19
+ Requires-Dist: msgspec (>=0.18.6,<0.19.0)
19
20
  Requires-Dist: ntplib (>=0.4.0,<0.5.0)
20
21
  Requires-Dist: numba (>=0.59.1,<0.60.0)
21
22
  Requires-Dist: numpy (>=1.26.3,<2.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "Qubx"
3
- version = "0.2.82"
3
+ version = "0.3.0"
4
4
  description = "Qubx - quantitative trading framework"
5
5
  authors = ["Dmitry Marienko <dmitry@gmail.com>", "Yuriy Arabskyy <yuriy.arabskyy@gmail.com>"]
6
6
  readme = "README.md"
@@ -42,6 +42,7 @@ plotly = "^5.22.0"
42
42
  psycopg-binary = "^3.1.19"
43
43
  psycopg-pool = "^3.2.2"
44
44
  sortedcontainers = "^2.4.0"
45
+ msgspec = "^0.18.6"
45
46
 
46
47
  [tool.poetry.group.dev.dependencies]
47
48
  pre-commit = "^2.20.0"
@@ -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
- now = self.time_provider.time().astype("datetime64[us]").item().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
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
 
@@ -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
- timeframe = self.find_timeframe(interval)
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], timeframe)
86
+ stored = self.safe_value(self.ohlcvs[symbol], unifiedTimeframe)
88
87
  if stored is None:
89
- limit = self.safe_integer(self.options, "OHLCVLimit", 2)
88
+ limit = self.safe_integer(self.options, "OHLCVLimit", 1000)
90
89
  stored = ArrayCacheByTimestamp(limit)
91
- # self.ohlcvs[symbol][timeframe] = stored
90
+ self.ohlcvs[symbol][unifiedTimeframe] = stored
92
91
  stored.append(parsed)
93
- client.resolve(stored, messageHash)
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
- lowerCaseId = self.safe_string_lower(message, "s")
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
@@ -0,0 +1,5 @@
1
+ from qubx.core.exceptions import BaseError
2
+
3
+
4
+ class CcxtOrderBookParsingError(BaseError):
5
+ pass
@@ -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.impl.ccxt_utils import (
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
+ )
@@ -13,6 +13,7 @@ from qubx.core.utils import prec_ceil, prec_floor
13
13
 
14
14
  dt_64 = np.datetime64
15
15
  td_64 = np.timedelta64
16
+ ns_to_dt_64 = lambda ns: np.datetime64(ns, "ns")
16
17
 
17
18
  OPTION_FILL_AT_SIGNAL_PRICE = "fill_at_signal_price"
18
19
 
@@ -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.warning(
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 self._trig_on_trade:
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
- return None
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)
@@ -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[-1].time > trade.time:
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
 
@@ -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
 
@@ -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) -> np.datetime64: ...
112
+ def time_as_nsec(time: Any) -> int: ...
98
113
 
99
114
  class RollingSum:
100
115
  is_init_stage: bool