Qubx 0.1.89__cp311-cp311-manylinux_2_35_x86_64.whl → 0.2.2__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 (44) hide show
  1. qubx/__init__.py +6 -12
  2. qubx/backtester/__init__.py +2 -0
  3. qubx/backtester/ome.py +237 -0
  4. qubx/backtester/optimization.py +141 -0
  5. qubx/backtester/queue.py +243 -0
  6. qubx/backtester/simulator.py +896 -0
  7. qubx/core/account.py +111 -48
  8. qubx/core/basics.py +287 -95
  9. qubx/core/context.py +760 -0
  10. qubx/core/exceptions.py +22 -0
  11. qubx/core/helpers.py +110 -78
  12. qubx/core/loggers.py +146 -75
  13. qubx/core/lookups.py +110 -82
  14. qubx/core/metrics.py +901 -0
  15. qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
  16. qubx/core/series.pxd +5 -0
  17. qubx/core/series.pyi +29 -0
  18. qubx/core/series.pyx +67 -13
  19. qubx/core/strategy.py +210 -610
  20. qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
  21. qubx/core/utils.pyi +4 -0
  22. qubx/data/readers.py +177 -110
  23. qubx/gathering/simplest.py +41 -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/utils.py +145 -111
  29. qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
  30. qubx/ta/indicators.pyi +16 -0
  31. qubx/trackers/__init__.py +3 -1
  32. qubx/trackers/rebalancers.py +89 -64
  33. qubx/trackers/riskctrl.py +152 -0
  34. qubx/trackers/sizers.py +104 -0
  35. qubx/utils/__init__.py +2 -1
  36. qubx/utils/charting/lookinglass.py +1088 -0
  37. qubx/utils/misc.py +28 -9
  38. qubx/utils/ntp.py +58 -0
  39. qubx/utils/runner.py +79 -61
  40. qubx/utils/time.py +49 -31
  41. {qubx-0.1.89.dist-info → qubx-0.2.2.dist-info}/METADATA +2 -1
  42. qubx-0.2.2.dist-info/RECORD +55 -0
  43. qubx-0.1.89.dist-info/RECORD +0 -39
  44. {qubx-0.1.89.dist-info → qubx-0.2.2.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,237 @@
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
+ ) -> OmeReport:
94
+
95
+ if self.bbo is None:
96
+ raise ExchangeError(
97
+ f"Simulator is not ready for order management - no any quote for {self.instrument.symbol}"
98
+ )
99
+
100
+ # - validate order parameters
101
+ self._validate_order(order_side, order_type, amount, price, time_in_force)
102
+
103
+ timestamp = self.time_service.time()
104
+ order = Order(
105
+ self._generate_order_id(),
106
+ order_type,
107
+ self.instrument.symbol,
108
+ timestamp,
109
+ amount,
110
+ price if price is not None else 0,
111
+ order_side,
112
+ "NEW",
113
+ time_in_force,
114
+ client_id,
115
+ )
116
+
117
+ return self._process_order(timestamp, order)
118
+
119
+ def _dbg(self, message, **kwargs) -> None:
120
+ logger.debug(f"[OMS] {self.instrument.symbol} - {message}", **kwargs)
121
+
122
+ def _process_order(self, timestamp: dt_64, order: Order) -> OmeReport:
123
+ if order.status in ["CLOSED", "CANCELED"]:
124
+ raise InvalidOrder(f"Order {order.id} is already closed or canceled.")
125
+
126
+ buy_side = order.side == "BUY"
127
+ c_ask = self.bbo.ask
128
+ c_bid = self.bbo.bid
129
+
130
+ # - check if order can be "executed" immediately
131
+ exec_price = None
132
+ if order.type == "MARKET":
133
+ exec_price = c_ask if buy_side else c_bid
134
+
135
+ elif order.type == "LIMIT":
136
+ if (buy_side and order.price >= c_ask) or (not buy_side and order.price <= c_bid):
137
+ exec_price = c_ask if buy_side else c_bid
138
+
139
+ # - if order must be "executed" immediately
140
+ if exec_price is not None:
141
+ return self._execute_order(timestamp, exec_price, order, True)
142
+
143
+ # - processing limit orders
144
+ if buy_side:
145
+ self.bids.setdefault(order.price, list()).append(order.id)
146
+ else:
147
+ self.asks.setdefault(order.price, list()).append(order.id)
148
+ order.status = "OPEN"
149
+ self._dbg(f"registered {order.id} {order.type} {order.side} {order.quantity} {order.price}")
150
+ self.active_orders[order.id] = order
151
+ return OmeReport(timestamp, order, None)
152
+
153
+ def _execute_order(self, timestamp: dt_64, exec_price: float, order: Order, taker: bool) -> OmeReport:
154
+ order.status = "CLOSED"
155
+ self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} executed at {exec_price}")
156
+ return OmeReport(
157
+ timestamp,
158
+ order,
159
+ Deal(
160
+ id=self._generate_trade_id(),
161
+ order_id=order.id,
162
+ time=timestamp,
163
+ amount=order.quantity if order.side == "BUY" else -order.quantity,
164
+ price=exec_price,
165
+ aggressive=taker,
166
+ fee_amount=self.tcc.get_execution_fees(
167
+ instrument=self.instrument, exec_price=exec_price, amount=order.quantity, crossed_market=taker
168
+ ),
169
+ fee_currency=self.instrument.quote,
170
+ ),
171
+ )
172
+
173
+ def _validate_order(
174
+ self, order_side: str, order_type: str, amount: float, price: float | None, time_in_force: str
175
+ ) -> None:
176
+ if order_side.upper() not in ["BUY", "SELL"]:
177
+ raise InvalidOrder("Invalid order side. Only BUY or SELL is allowed.")
178
+
179
+ if order_type.upper() not in ["LIMIT", "MARKET"]:
180
+ raise InvalidOrder("Invalid order type. Only LIMIT or MARKET is supported.")
181
+
182
+ if amount <= 0:
183
+ raise InvalidOrder("Invalid order amount. Amount must be positive.")
184
+
185
+ if order_type.upper() == "LIMIT" and (price is None or price <= 0):
186
+ raise InvalidOrder("Invalid order price. Price must be positively defined for LIMIT orders.")
187
+
188
+ if time_in_force.upper() not in ["GTC", "IOC"]:
189
+ raise InvalidOrder("Invalid time in force. Only GTC or IOC is supported for now.")
190
+
191
+ def cancel_order(self, order_id: str) -> OmeReport:
192
+ if order_id not in self.active_orders:
193
+ raise InvalidOrder(f"Order {order_id} not found for {self.instrument.symbol}")
194
+
195
+ timestamp = self.time_service.time()
196
+ order = self.active_orders.pop(order_id)
197
+ if order.side == "BUY":
198
+ oids = self.bids[order.price]
199
+ oids.remove(order_id)
200
+ if not oids:
201
+ self.bids.pop(order.price)
202
+ else:
203
+ oids = self.asks[order.price]
204
+ oids.remove(order_id)
205
+ if not oids:
206
+ self.asks.pop(order.price)
207
+
208
+ order.status = "CANCELED"
209
+ self._dbg(f"{order.id} {order.type} {order.side} {order.quantity} canceled")
210
+ return OmeReport(timestamp, order, None)
211
+
212
+ def __str__(self) -> str:
213
+ _a, _b = True, True
214
+
215
+ timestamp = self.time_service.time()
216
+ _s = f"= = ({np.datetime64(timestamp, 'ns')}) = =\n"
217
+ for k, v in reversed(self.asks.items()):
218
+ _sizes = ",".join([f"{self.active_orders[o].quantity}" for o in v])
219
+ _s += f" {k} : [{ _sizes }]\n"
220
+ if k == self.bbo.ask:
221
+ _a = False
222
+
223
+ if _a:
224
+ _s += f" {self.bbo.ask} : \n"
225
+ _s += "- - - - - - - - - - - - - - - - - - - -\n"
226
+
227
+ _s1 = ""
228
+ for k, v in self.bids.items():
229
+ _sizes = ",".join([f"{self.active_orders[o].quantity}" for o in v])
230
+ _s1 += f" {k} : [{ _sizes }]\n"
231
+ if k == self.bbo.bid:
232
+ _b = False
233
+ _s1 += "= = = = = = = = = = = = = = = = = = = =\n"
234
+
235
+ _s1 = f" {self.bbo.bid} : \n" + _s1 if _b else _s1
236
+
237
+ return _s + _s1
@@ -0,0 +1,141 @@
1
+ from typing import Any, Dict, List, Sequence, Tuple
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(list(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, *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
+
132
+ def _cmprss(xs: str):
133
+ return "".join([x[0] for x in re.split("((?<!-)(?=[A-Z]))|_|(\d)", xs) if x])
134
+
135
+ sfx = _cmprss(clz.__name__)
136
+ to_excl = [s for s, v in kwargs.items() if not isinstance(v, (list, set, tuple, range))]
137
+ dic2str = lambda ds: [_cmprss(k) + "=" + str(v) for k, v in ds.items() if k not in to_excl]
138
+
139
+ return {
140
+ f"{sfx}_({ ','.join(dic2str(z)) })": clz(*args, **z) for z in permutate_params(kwargs, conditions=conditions)
141
+ }
@@ -0,0 +1,243 @@
1
+ import pandas as pd
2
+ import heapq
3
+
4
+ from dataclasses import dataclass
5
+ from collections import defaultdict
6
+ from typing import Any, Iterator, Iterable
7
+
8
+ from qubx import logger
9
+ from qubx.core.basics import Instrument, dt_64, BatchEvent
10
+ from qubx.data.readers import DataReader, DataTransformer
11
+ from qubx.utils.misc import Stopwatch
12
+
13
+
14
+ _SW = Stopwatch()
15
+
16
+
17
+ class DataLoader:
18
+ _TYPE_MAPPERS = {"agg_trade": "trade", "ohlc": "bar", "ohlcv": "bar"}
19
+
20
+ def __init__(
21
+ self,
22
+ transformer: DataTransformer,
23
+ reader: DataReader,
24
+ instrument: Instrument,
25
+ timeframe: str | None,
26
+ preload_bars: int = 0,
27
+ data_type: str = "ohlc",
28
+ output_type: str | None = None, # transfomer can somtimes map to a different output type
29
+ chunksize: int = 5_000,
30
+ ) -> None:
31
+ self._instrument = instrument
32
+ self._spec = f"{instrument.exchange}:{instrument.symbol}"
33
+ self._reader = reader
34
+ self._transformer = transformer
35
+ self._init_bars_required = preload_bars
36
+ self._timeframe = timeframe
37
+ self._data_type = data_type
38
+ self._output_type = output_type
39
+ self._first_load = True
40
+ self._chunksize = chunksize
41
+
42
+ def load(self, start: str | pd.Timestamp, end: str | pd.Timestamp) -> Iterator:
43
+ if self._first_load:
44
+ if self._init_bars_required > 0 and self._timeframe:
45
+ start = pd.Timestamp(start) - self._init_bars_required * pd.Timedelta(self._timeframe)
46
+ self._first_load = False
47
+
48
+ args = dict(
49
+ data_id=self._spec,
50
+ start=start,
51
+ stop=end,
52
+ transform=self._transformer,
53
+ data_type=self._data_type,
54
+ chunksize=self._chunksize,
55
+ )
56
+
57
+ if self._timeframe:
58
+ args["timeframe"] = self._timeframe
59
+
60
+ return self._reader.read(**args) # type: ignore
61
+
62
+ @property
63
+ def instrument(self) -> Instrument:
64
+ return self._instrument
65
+
66
+ @property
67
+ def symbol(self) -> str:
68
+ return self._instrument.symbol
69
+
70
+ @property
71
+ def data_type(self) -> str:
72
+ if self._output_type:
73
+ return self._output_type
74
+ return self._TYPE_MAPPERS.get(self._data_type, self._data_type)
75
+
76
+ def __hash__(self) -> int:
77
+ return hash((self._instrument.symbol, self._data_type))
78
+
79
+ def __eq__(self, other: Any) -> bool:
80
+ if not isinstance(other, DataLoader):
81
+ return False
82
+ return self._instrument.symbol == other._instrument.symbol and self._data_type == other._data_type
83
+
84
+
85
+ class SimulatedDataQueue:
86
+ _loaders: dict[str, list[DataLoader]]
87
+
88
+ def __init__(self):
89
+ self._loaders = defaultdict(list)
90
+ self._start = None
91
+ self._stop = None
92
+ self._current_time = None
93
+ self._index_to_loader: dict[int, DataLoader] = {}
94
+ self._loader_to_index = {}
95
+ self._latest_loader_index = -1
96
+ self._removed_loader_indices = set()
97
+
98
+ @property
99
+ def is_running(self) -> bool:
100
+ return self._current_time is not None
101
+
102
+ def __add__(self, loader: DataLoader) -> "SimulatedDataQueue":
103
+ self._latest_loader_index += 1
104
+ new_loader_index = self._latest_loader_index
105
+ self._loaders[loader.symbol].append(loader)
106
+ self._index_to_loader[new_loader_index] = loader
107
+ self._loader_to_index[loader] = new_loader_index
108
+ if self.is_running:
109
+ self._add_chunk_to_heap(new_loader_index)
110
+ return self
111
+
112
+ def __sub__(self, loader: DataLoader) -> "SimulatedDataQueue":
113
+ loader_index = self._loader_to_index[loader]
114
+ self._loaders[loader.symbol].remove(loader)
115
+ del self._index_to_loader[loader_index]
116
+ del self._loader_to_index[loader]
117
+ del self._index_to_chunk_size[loader_index]
118
+ del self._index_to_iterator[loader_index]
119
+ self._removed_loader_indices.add(loader_index)
120
+ return self
121
+
122
+ def get_loader(self, symbol: str, data_type: str) -> DataLoader:
123
+ loaders = self._loaders[symbol]
124
+ for loader in loaders:
125
+ if loader.data_type == data_type:
126
+ return loader
127
+ raise ValueError(f"Loader for {symbol} and {data_type} not found")
128
+
129
+ def create_iterable(self, start: str | pd.Timestamp, stop: str | pd.Timestamp) -> Iterator:
130
+ self._start = start
131
+ self._stop = stop
132
+ self._current_time = None
133
+ return self
134
+
135
+ def __iter__(self) -> Iterator:
136
+ logger.info("Initializing chunks for each loader")
137
+ self._current_time = self._start
138
+ self._index_to_chunk_size = {}
139
+ self._index_to_iterator = {}
140
+ self._event_heap = []
141
+ for loader_index in self._index_to_loader.keys():
142
+ self._add_chunk_to_heap(loader_index)
143
+ return self
144
+
145
+ @_SW.watch("DataQueue")
146
+ def __next__(self) -> tuple[str, str, Any]:
147
+ if not self._event_heap:
148
+ raise StopIteration
149
+
150
+ loader_index = None
151
+
152
+ # get the next event from the heap
153
+ # if the loader_index is in the removed_loader_indices, skip it (optimization to avoid unnecessary heap operations)
154
+ while self._event_heap and (loader_index is None or loader_index in self._removed_loader_indices):
155
+ dt, loader_index, chunk_index, event = heapq.heappop(self._event_heap)
156
+
157
+ if loader_index is None or loader_index in self._removed_loader_indices:
158
+ raise StopIteration
159
+
160
+ self._current_time = dt
161
+ chunk_size = self._index_to_chunk_size[loader_index]
162
+ if chunk_index + 1 == chunk_size:
163
+ self._add_chunk_to_heap(loader_index)
164
+
165
+ loader = self._index_to_loader[loader_index]
166
+ return loader.symbol, loader.data_type, event
167
+
168
+ @_SW.watch("DataQueue")
169
+ def _add_chunk_to_heap(self, loader_index: int):
170
+ chunk = self._next_chunk(loader_index)
171
+ self._index_to_chunk_size[loader_index] = len(chunk)
172
+ for chunk_index, event in enumerate(chunk):
173
+ dt = event.time # type: ignore
174
+ heapq.heappush(self._event_heap, (dt, loader_index, chunk_index, event))
175
+
176
+ @_SW.watch("DataQueue")
177
+ def _next_chunk(self, index: int) -> list[Any]:
178
+ if index not in self._index_to_iterator:
179
+ self._index_to_iterator[index] = self._index_to_loader[index].load(self._current_time, self._stop) # type: ignore
180
+ iterator = self._index_to_iterator[index]
181
+ try:
182
+ return next(iterator)
183
+ except StopIteration:
184
+ return []
185
+
186
+
187
+ class EventBatcher:
188
+ _BATCH_SETTINGS = {
189
+ "trade": "1Sec",
190
+ "orderbook": "1Sec",
191
+ }
192
+
193
+ def __init__(self, source_iterator: Iterator | Iterable, passthrough: bool = False, **kwargs):
194
+ self.source_iterator = source_iterator
195
+ self._passthrough = passthrough
196
+ self._batch_settings = {**self._BATCH_SETTINGS, **kwargs}
197
+ self._batch_settings = {k: pd.Timedelta(v) for k, v in self._batch_settings.items()}
198
+
199
+ def __iter__(self) -> Iterator[tuple[str, str, Any]]:
200
+ if self._passthrough:
201
+ _iter = iter(self.source_iterator) if isinstance(self.source_iterator, Iterable) else self.source_iterator
202
+ yield from _iter
203
+ return
204
+
205
+ last_symbol, last_data_type = None, None
206
+ buffer = []
207
+ for symbol, data_type, event in self.source_iterator:
208
+ time: dt_64 = event.time # type: ignore
209
+
210
+ if data_type not in self._batch_settings:
211
+ if buffer:
212
+ yield last_symbol, last_data_type, self._batch_event(buffer)
213
+ buffer = []
214
+ yield symbol, data_type, event
215
+ last_symbol, last_data_type = symbol, data_type
216
+ continue
217
+
218
+ if symbol != last_symbol:
219
+ if buffer:
220
+ yield last_symbol, last_data_type, self._batch_event(buffer)
221
+ last_symbol, last_data_type = symbol, data_type
222
+ buffer = [event]
223
+ continue
224
+
225
+ if buffer and data_type != last_data_type:
226
+ yield symbol, last_data_type, buffer
227
+ buffer = [event]
228
+ last_symbol, last_data_type = symbol, data_type
229
+ continue
230
+
231
+ last_symbol, last_data_type = symbol, data_type
232
+ buffer.append(event)
233
+ if pd.Timedelta(time - buffer[0].time) >= self._batch_settings[data_type]:
234
+ yield symbol, data_type, self._batch_event(buffer)
235
+ buffer = []
236
+ last_symbol, last_data_type = None, None
237
+
238
+ if buffer:
239
+ yield last_symbol, last_data_type, self._batch_event(buffer)
240
+
241
+ @staticmethod
242
+ def _batch_event(buffer: list[Any]) -> Any:
243
+ return BatchEvent(buffer[-1].time, buffer) if len(buffer) > 1 else buffer[0]