Qubx 0.1.90__cp311-cp311-manylinux_2_35_x86_64.whl → 0.2.4__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.

Files changed (47) hide show
  1. qubx/__init__.py +6 -12
  2. qubx/backtester/__init__.py +2 -0
  3. qubx/backtester/ome.py +241 -0
  4. qubx/backtester/optimization.py +155 -0
  5. qubx/backtester/queue.py +390 -0
  6. qubx/backtester/simulator.py +919 -0
  7. qubx/core/account.py +111 -48
  8. qubx/core/basics.py +323 -95
  9. qubx/core/context.py +893 -0
  10. qubx/core/exceptions.py +26 -0
  11. qubx/core/helpers.py +119 -82
  12. qubx/core/loggers.py +217 -76
  13. qubx/core/lookups.py +116 -82
  14. qubx/core/metrics.py +1000 -0
  15. qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
  16. qubx/core/series.pyi +72 -0
  17. qubx/core/series.pyx +1 -1
  18. qubx/core/strategy.py +238 -611
  19. qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
  20. qubx/core/utils.pyi +4 -0
  21. qubx/data/helpers.py +29 -0
  22. qubx/data/readers.py +294 -122
  23. qubx/gathering/simplest.py +42 -0
  24. qubx/impl/ccxt_connector.py +170 -74
  25. qubx/impl/ccxt_customizations.py +97 -23
  26. qubx/impl/ccxt_trading.py +107 -72
  27. qubx/impl/ccxt_utils.py +47 -41
  28. qubx/pandaz/ta.py +1 -1
  29. qubx/pandaz/utils.py +145 -111
  30. qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
  31. qubx/ta/indicators.pyi +16 -0
  32. qubx/ta/indicators.pyx +57 -13
  33. qubx/trackers/__init__.py +3 -1
  34. qubx/trackers/composite.py +144 -0
  35. qubx/trackers/rebalancers.py +89 -64
  36. qubx/trackers/riskctrl.py +337 -0
  37. qubx/trackers/sizers.py +145 -0
  38. qubx/utils/__init__.py +2 -1
  39. qubx/utils/charting/lookinglass.py +1088 -0
  40. qubx/utils/misc.py +28 -9
  41. qubx/utils/ntp.py +58 -0
  42. qubx/utils/runner.py +79 -61
  43. qubx/utils/time.py +49 -31
  44. {qubx-0.1.90.dist-info → qubx-0.2.4.dist-info}/METADATA +2 -1
  45. qubx-0.2.4.dist-info/RECORD +57 -0
  46. qubx-0.1.90.dist-info/RECORD +0 -39
  47. {qubx-0.1.90.dist-info → qubx-0.2.4.dist-info}/WHEEL +0 -0
qubx/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from typing import Callable
1
2
  from qubx.utils import set_mpl_theme, runtime_env
2
3
  from qubx.utils.misc import install_pyx_recompiler_for_dev
3
4
 
@@ -10,20 +11,13 @@ def formatter(record):
10
11
  end = record["extra"].get("end", "\n")
11
12
  fmt = "<lvl>{message}</lvl>%s" % end
12
13
  if record["level"].name in {"WARNING", "SNAKY"}:
13
- fmt = (
14
- "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - %s" % fmt
15
- )
14
+ fmt = "<cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - %s" % fmt
16
15
 
17
- prefix = (
18
- "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> [ <level>%s</level> ] "
19
- % record["level"].icon
20
- )
16
+ prefix = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> [ <level>%s</level> ] " % record["level"].icon
21
17
 
22
18
  if record["exception"] is not None:
23
19
  # stackprinter.set_excepthook(style='darkbg2')
24
- record["extra"]["stack"] = stackprinter.format(
25
- record["exception"], style="darkbg"
26
- )
20
+ record["extra"]["stack"] = stackprinter.format(record["exception"], style="darkbg3")
27
21
  fmt += "\n{extra[stack]}\n"
28
22
 
29
23
  if record["level"].name in {"TEXT"}:
@@ -44,7 +38,7 @@ class QubxLogConfig:
44
38
  QubxLogConfig.setup_logger(level)
45
39
 
46
40
  @staticmethod
47
- def setup_logger(level: str | None = None):
41
+ def setup_logger(level: str | None = None, custom_formatter: Callable | None = None):
48
42
  global logger
49
43
  config = {
50
44
  "handlers": [
@@ -55,7 +49,7 @@ class QubxLogConfig:
55
49
  logger.configure(**config)
56
50
  logger.remove(None)
57
51
  level = level or QubxLogConfig.get_log_level()
58
- logger.add(sys.stdout, format=formatter, colorize=True, level=level)
52
+ logger.add(sys.stdout, format=custom_formatter or formatter, colorize=True, level=level, enqueue=True)
59
53
  logger = logger.opt(colors=True)
60
54
 
61
55
 
@@ -0,0 +1,2 @@
1
+ from .simulator import simulate
2
+ from .optimization import variate
qubx/backtester/ome.py ADDED
@@ -0,0 +1,241 @@
1
+ from typing import List, Dict
2
+ from dataclasses import dataclass
3
+ from operator import neg
4
+
5
+ import numpy as np
6
+ from sortedcontainers import SortedDict
7
+
8
+ from qubx import logger
9
+ from qubx.core.basics import Deal, Instrument, Order, Position, Signal, TransactionCostsCalculator, dt_64, ITimeProvider
10
+ from qubx.core.series import Quote, Trade
11
+ from qubx.core.exceptions import (
12
+ ExchangeError,
13
+ InvalidOrder,
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class OmeReport:
19
+ timestamp: dt_64
20
+ order: Order
21
+ exec: Deal | None
22
+
23
+
24
+ class OrdersManagementEngine:
25
+ instrument: Instrument
26
+ time_service: ITimeProvider
27
+ active_orders: Dict[str, Order]
28
+ asks: SortedDict[float, List[str]]
29
+ bids: SortedDict[float, List[str]]
30
+ bbo: Quote | None # current best bid/ask order book (simplest impl)
31
+ __order_id: int
32
+ __trade_id: int
33
+
34
+ def __init__(
35
+ self, instrument: Instrument, time_provider: ITimeProvider, tcc: TransactionCostsCalculator, debug: bool = True
36
+ ) -> None:
37
+ self.instrument = instrument
38
+ self.time_service = time_provider
39
+ self.tcc = tcc
40
+ self.asks = SortedDict()
41
+ self.bids = SortedDict(neg)
42
+ self.active_orders = dict()
43
+ self.bbo = None
44
+ self.__order_id = 100000
45
+ self.__trade_id = 100000
46
+ if not debug:
47
+ self._dbg = lambda message, **kwargs: None
48
+
49
+ def _generate_order_id(self) -> str:
50
+ self.__order_id += 1
51
+ return "SIM-ORDER-" + self.instrument.symbol + "-" + str(self.__order_id)
52
+
53
+ def _generate_trade_id(self) -> str:
54
+ self.__trade_id += 1
55
+ return "SIM-EXEC-" + self.instrument.symbol + "-" + str(self.__trade_id)
56
+
57
+ def get_quote(self) -> Quote:
58
+ return self.bbo
59
+
60
+ def get_open_orders(self) -> List[Order]:
61
+ return list(self.active_orders.values())
62
+
63
+ def update_bbo(self, quote: Quote) -> List[OmeReport]:
64
+ timestamp = self.time_service.time()
65
+ rep = []
66
+
67
+ if self.bbo is not None:
68
+ if quote.bid >= self.bbo.ask:
69
+ for level in self.asks.irange(0, quote.bid):
70
+ for order_id in self.asks[level]:
71
+ order = self.active_orders.pop(order_id)
72
+ rep.append(self._execute_order(timestamp, order.price, order, False))
73
+ self.asks.pop(level)
74
+
75
+ if quote.ask <= self.bbo.bid:
76
+ for level in self.bids.irange(np.inf, quote.ask):
77
+ for order_id in self.bids[level]:
78
+ order = self.active_orders.pop(order_id)
79
+ rep.append(self._execute_order(timestamp, order.price, order, False))
80
+ self.bids.pop(level)
81
+
82
+ self.bbo = quote
83
+ return rep
84
+
85
+ def place_order(
86
+ self,
87
+ order_side: str,
88
+ order_type: str,
89
+ amount: float,
90
+ price: float | None = None,
91
+ client_id: str | None = None,
92
+ time_in_force: str = "gtc",
93
+ fill_at_price: bool = False,
94
+ ) -> OmeReport:
95
+
96
+ if self.bbo is None:
97
+ raise ExchangeError(
98
+ f"Simulator is not ready for order management - no any quote for {self.instrument.symbol}"
99
+ )
100
+
101
+ # - validate order parameters
102
+ self._validate_order(order_side, order_type, amount, price, time_in_force)
103
+
104
+ timestamp = self.time_service.time()
105
+ order = Order(
106
+ self._generate_order_id(),
107
+ order_type,
108
+ self.instrument.symbol,
109
+ timestamp,
110
+ amount,
111
+ price if price is not None else 0,
112
+ order_side,
113
+ "NEW",
114
+ time_in_force,
115
+ client_id,
116
+ )
117
+
118
+ return self._process_order(timestamp, order, fill_at_price=fill_at_price)
119
+
120
+ def _dbg(self, message, **kwargs) -> None:
121
+ logger.debug(f"[OMS] {self.instrument.symbol} - {message}", **kwargs)
122
+
123
+ def _process_order(self, timestamp: dt_64, order: Order, fill_at_price: bool = False) -> OmeReport:
124
+ if order.status in ["CLOSED", "CANCELED"]:
125
+ raise InvalidOrder(f"Order {order.id} is already closed or canceled.")
126
+
127
+ buy_side = order.side == "BUY"
128
+ c_ask = self.bbo.ask
129
+ c_bid = self.bbo.bid
130
+
131
+ # - check if order can be "executed" immediately
132
+ exec_price = None
133
+ if fill_at_price and order.price:
134
+ exec_price = order.price
135
+
136
+ elif order.type == "MARKET":
137
+ exec_price = c_ask if buy_side else c_bid
138
+
139
+ elif order.type == "LIMIT":
140
+ if (buy_side and order.price >= c_ask) or (not buy_side and order.price <= c_bid):
141
+ exec_price = c_ask if buy_side else c_bid
142
+
143
+ # - if order must be "executed" immediately
144
+ if exec_price is not None:
145
+ return self._execute_order(timestamp, exec_price, order, True)
146
+
147
+ # - processing limit orders
148
+ if buy_side:
149
+ self.bids.setdefault(order.price, list()).append(order.id)
150
+ else:
151
+ self.asks.setdefault(order.price, list()).append(order.id)
152
+ order.status = "OPEN"
153
+ self._dbg(f"registered {order.id} {order.type} {order.side} {order.quantity} {order.price}")
154
+ self.active_orders[order.id] = order
155
+ return OmeReport(timestamp, order, None)
156
+
157
+ def _execute_order(self, timestamp: dt_64, exec_price: float, order: Order, taker: bool) -> OmeReport:
158
+ order.status = "CLOSED"
159
+ self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} executed at {exec_price}")
160
+ return OmeReport(
161
+ timestamp,
162
+ order,
163
+ Deal(
164
+ id=self._generate_trade_id(),
165
+ order_id=order.id,
166
+ time=timestamp,
167
+ amount=order.quantity if order.side == "BUY" else -order.quantity,
168
+ price=exec_price,
169
+ aggressive=taker,
170
+ fee_amount=self.tcc.get_execution_fees(
171
+ instrument=self.instrument, exec_price=exec_price, amount=order.quantity, crossed_market=taker
172
+ ),
173
+ fee_currency=self.instrument.quote,
174
+ ),
175
+ )
176
+
177
+ def _validate_order(
178
+ self, order_side: str, order_type: str, amount: float, price: float | None, time_in_force: str
179
+ ) -> None:
180
+ if order_side.upper() not in ["BUY", "SELL"]:
181
+ raise InvalidOrder("Invalid order side. Only BUY or SELL is allowed.")
182
+
183
+ if order_type.upper() not in ["LIMIT", "MARKET"]:
184
+ raise InvalidOrder("Invalid order type. Only LIMIT or MARKET is supported.")
185
+
186
+ if amount <= 0:
187
+ raise InvalidOrder("Invalid order amount. Amount must be positive.")
188
+
189
+ if order_type.upper() == "LIMIT" and (price is None or price <= 0):
190
+ raise InvalidOrder("Invalid order price. Price must be positively defined for LIMIT orders.")
191
+
192
+ if time_in_force.upper() not in ["GTC", "IOC"]:
193
+ raise InvalidOrder("Invalid time in force. Only GTC or IOC is supported for now.")
194
+
195
+ def cancel_order(self, order_id: str) -> OmeReport:
196
+ if order_id not in self.active_orders:
197
+ raise InvalidOrder(f"Order {order_id} not found for {self.instrument.symbol}")
198
+
199
+ timestamp = self.time_service.time()
200
+ order = self.active_orders.pop(order_id)
201
+ if order.side == "BUY":
202
+ oids = self.bids[order.price]
203
+ oids.remove(order_id)
204
+ if not oids:
205
+ self.bids.pop(order.price)
206
+ else:
207
+ oids = self.asks[order.price]
208
+ oids.remove(order_id)
209
+ if not oids:
210
+ self.asks.pop(order.price)
211
+
212
+ order.status = "CANCELED"
213
+ self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} canceled")
214
+ return OmeReport(timestamp, order, None)
215
+
216
+ def __str__(self) -> str:
217
+ _a, _b = True, True
218
+
219
+ timestamp = self.time_service.time()
220
+ _s = f"= = ({np.datetime64(timestamp, 'ns')}) = =\n"
221
+ for k, v in reversed(self.asks.items()):
222
+ _sizes = ",".join([f"{self.active_orders[o].quantity}" for o in v])
223
+ _s += f" {k} : [{ _sizes }]\n"
224
+ if k == self.bbo.ask:
225
+ _a = False
226
+
227
+ if _a:
228
+ _s += f" {self.bbo.ask} : \n"
229
+ _s += "- - - - - - - - - - - - - - - - - - - -\n"
230
+
231
+ _s1 = ""
232
+ for k, v in self.bids.items():
233
+ _sizes = ",".join([f"{self.active_orders[o].quantity}" for o in v])
234
+ _s1 += f" {k} : [{ _sizes }]\n"
235
+ if k == self.bbo.bid:
236
+ _b = False
237
+ _s1 += "= = = = = = = = = = = = = = = = = = = =\n"
238
+
239
+ _s1 = f" {self.bbo.bid} : \n" + _s1 if _b else _s1
240
+
241
+ return _s + _s1
@@ -0,0 +1,155 @@
1
+ from typing import Any, Dict, List, Sequence, Tuple, Type
2
+ import numpy as np
3
+ import re
4
+
5
+ from types import FunctionType
6
+ from itertools import product
7
+
8
+
9
+ def _wrap_single_list(param_grid: List | Dict) -> Dict[str, Any] | List:
10
+ """
11
+ Wraps all non list values as single
12
+ :param param_grid:
13
+ :return:
14
+ """
15
+ as_list = lambda x: x if isinstance(x, (tuple, list, dict, np.ndarray)) else [x]
16
+ if isinstance(param_grid, list):
17
+ return [_wrap_single_list(ps) for ps in param_grid]
18
+ return {k: as_list(v) for k, v in param_grid.items()}
19
+
20
+
21
+ def permutate_params(
22
+ parameters: Dict[str, List | Tuple | Any],
23
+ conditions: FunctionType | List | Tuple | None = None,
24
+ wrap_as_list=False,
25
+ ) -> List[Dict]:
26
+ """
27
+ Generate list of all permutations for given parameters and theirs possible values
28
+
29
+ Example:
30
+
31
+ >>> def foo(par1, par2):
32
+ >>> print(par1)
33
+ >>> print(par2)
34
+ >>>
35
+ >>> # permutate all values and call function for every permutation
36
+ >>> [foo(**z) for z in permutate_params({
37
+ >>> 'par1' : [1,2,3],
38
+ >>> 'par2' : [True, False]
39
+ >>> }, conditions=lambda par1, par2: par1<=2 and par2==True)]
40
+
41
+ 1
42
+ True
43
+ 2
44
+ True
45
+
46
+ :param conditions: list of filtering functions
47
+ :param parameters: dictionary
48
+ :param wrap_as_list: if True (default) it wraps all non list values as single lists (required for sklearn)
49
+ :return: list of permutations
50
+ """
51
+ if conditions is None:
52
+ conditions = []
53
+ elif isinstance(conditions, FunctionType):
54
+ conditions = [conditions]
55
+ elif isinstance(conditions, (tuple, list)):
56
+ if not all([isinstance(e, FunctionType) for e in conditions]):
57
+ raise ValueError("every condition must be a function")
58
+ else:
59
+ raise ValueError("conditions must be of type of function, list or tuple")
60
+
61
+ args = []
62
+ vals = []
63
+ for k, v in parameters.items():
64
+ args.append(k)
65
+ # vals.append([v] if not isinstance(v, (list, tuple)) else list(v) if isinstance(v, range) else v)
66
+ match v:
67
+ case list() | tuple():
68
+ vals.append(v)
69
+ case range():
70
+ vals.append(list(v))
71
+ case str():
72
+ vals.append([v])
73
+ case _:
74
+ vals.append([v])
75
+ # vals.append(v if isinstance(v, (List, Tuple)) else list(v) if isinstance(v, range) else [v])
76
+ d = [dict(zip(args, p)) for p in product(*vals)]
77
+ result = []
78
+ for params_set in d:
79
+ conditions_met = True
80
+ for cond_func in conditions:
81
+ func_param_args = cond_func.__code__.co_varnames
82
+ func_param_values = [params_set[arg] for arg in func_param_args]
83
+ if not cond_func(*func_param_values):
84
+ conditions_met = False
85
+ break
86
+ if conditions_met:
87
+ result.append(params_set)
88
+
89
+ # if we need to follow sklearn rules we should wrap every noniterable as list
90
+ return _wrap_single_list(result) if wrap_as_list else result
91
+
92
+
93
+ def variate(clz: Type[Any] | List[Type[Any]], *args, conditions=None, **kwargs) -> Dict[str, Any]:
94
+ """
95
+ Make variations of parameters for simulations (micro optimizer)
96
+
97
+ Example:
98
+
99
+ >>> class MomentumStrategy_Ex1_test:
100
+ >>> def __init__(self, p1, lookback_period=10, filter_type='sma', skip_entries_flag=False):
101
+ >>> self.p1, self.lookback_period, self.filter_type, self.skip_entries_flag = p1, lookback_period, filter_type, skip_entries_flag
102
+ >>>
103
+ >>> def __repr__(self):
104
+ >>> return self.__class__.__name__ + f"({self.p1},{self.lookback_period},{self.filter_type},{self.skip_entries_flag})"
105
+ >>>
106
+ >>> variate(MomentumStrategy_Ex1_test, 10, lookback_period=[1,2,3], filter_type=['ema', 'sma'], skip_entries_flag=[True, False])
107
+
108
+ Output:
109
+ >>> {
110
+ >>> 'MSE1t_(lp=1,ft=ema,sef=True)': MomentumStrategy_Ex1_test(10,1,ema,True),
111
+ >>> 'MSE1t_(lp=1,ft=ema,sef=False)': MomentumStrategy_Ex1_test(10,1,ema,False),
112
+ >>> 'MSE1t_(lp=1,ft=sma,sef=True)': MomentumStrategy_Ex1_test(10,1,sma,True),
113
+ >>> 'MSE1t_(lp=1,ft=sma,sef=False)': MomentumStrategy_Ex1_test(10,1,sma,False),
114
+ >>> 'MSE1t_(lp=2,ft=ema,sef=True)': MomentumStrategy_Ex1_test(10,2,ema,True),
115
+ >>> 'MSE1t_(lp=2,ft=ema,sef=False)': MomentumStrategy_Ex1_test(10,2,ema,False),
116
+ >>> 'MSE1t_(lp=2,ft=sma,sef=True)': MomentumStrategy_Ex1_test(10,2,sma,True),
117
+ >>> 'MSE1t_(lp=2,ft=sma,sef=False)': MomentumStrategy_Ex1_test(10,2,sma,False),
118
+ >>> 'MSE1t_(lp=3,ft=ema,sef=True)': MomentumStrategy_Ex1_test(10,3,ema,True),
119
+ >>> 'MSE1t_(lp=3,ft=ema,sef=False)': MomentumStrategy_Ex1_test(10,3,ema,False),
120
+ >>> 'MSE1t_(lp=3,ft=sma,sef=True)': MomentumStrategy_Ex1_test(10,3,sma,True),
121
+ >>> 'MSE1t_(lp=3,ft=sma,sef=False)': MomentumStrategy_Ex1_test(10,3,sma,False)
122
+ >>> }
123
+
124
+ and using in simuation:
125
+
126
+ >>> r = simulate(
127
+ >>> variate(MomentumStrategy_Ex1_test, 10, lookback_period=[1,2,3], filter_type=['ema', 'sma'], skip_entries_flag=[True, False]),
128
+ >>> data, capital, ["BINANCE.UM:BTCUSDT"], dict(type="ohlc", timeframe="5Min", nback=0), "5Min -1Sec", "vip0_usdt", "2024-01-01", "2024-01-02"
129
+ >>> )
130
+
131
+ Also it's possible to pass a class with tracker:
132
+ >>> variate([MomentumStrategy_Ex1_test, AtrTracker(2, 1)], 10, lookback_period=[1,2,3], filter_type=['ema', 'sma'], skip_entries_flag=[True, False])
133
+ """
134
+
135
+ def _cmprss(xs: str):
136
+ return "".join([x[0] for x in re.split("((?<!-)(?=[A-Z]))|_|(\d)", xs) if x])
137
+
138
+ if isinstance(clz, type):
139
+ sfx = _cmprss(clz.__name__)
140
+ _mk = lambda k, *args, **kwargs: k(*args, **kwargs)
141
+ elif isinstance(clz, (list, tuple)) and clz and isinstance(clz[0], type):
142
+ sfx = _cmprss(clz[0].__name__)
143
+ _mk = lambda k, *args, **kwargs: [k[0](*args, **kwargs), *k[1:]]
144
+ else:
145
+ raise ValueError(
146
+ "Can't recognize data for variating: must be either a class type or a list where first element is class type"
147
+ )
148
+
149
+ to_excl = [s for s, v in kwargs.items() if not isinstance(v, (list, set, tuple, range))]
150
+ dic2str = lambda ds: [_cmprss(k) + "=" + str(v) for k, v in ds.items() if k not in to_excl]
151
+
152
+ return {
153
+ f"{sfx}_({ ','.join(dic2str(z)) })": _mk(clz, *args, **z)
154
+ for z in permutate_params(kwargs, conditions=conditions)
155
+ }