Qubx 0.1.90__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.
- qubx/__init__.py +6 -12
- qubx/backtester/__init__.py +2 -0
- qubx/backtester/ome.py +237 -0
- qubx/backtester/optimization.py +141 -0
- qubx/backtester/queue.py +243 -0
- qubx/backtester/simulator.py +896 -0
- qubx/core/account.py +111 -48
- qubx/core/basics.py +287 -95
- qubx/core/context.py +760 -0
- qubx/core/exceptions.py +22 -0
- qubx/core/helpers.py +110 -78
- qubx/core/loggers.py +146 -75
- qubx/core/lookups.py +110 -82
- qubx/core/metrics.py +901 -0
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyi +29 -0
- qubx/core/series.pyx +1 -1
- qubx/core/strategy.py +210 -610
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +4 -0
- qubx/data/readers.py +177 -110
- qubx/gathering/simplest.py +41 -0
- qubx/impl/ccxt_connector.py +170 -74
- qubx/impl/ccxt_customizations.py +97 -23
- qubx/impl/ccxt_trading.py +107 -72
- qubx/impl/ccxt_utils.py +47 -41
- qubx/pandaz/utils.py +145 -111
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pyi +16 -0
- qubx/trackers/__init__.py +3 -1
- qubx/trackers/rebalancers.py +89 -64
- qubx/trackers/riskctrl.py +152 -0
- qubx/trackers/sizers.py +104 -0
- qubx/utils/__init__.py +2 -1
- qubx/utils/charting/lookinglass.py +1088 -0
- qubx/utils/misc.py +28 -9
- qubx/utils/ntp.py +58 -0
- qubx/utils/runner.py +79 -61
- qubx/utils/time.py +49 -31
- {qubx-0.1.90.dist-info → qubx-0.2.2.dist-info}/METADATA +2 -1
- qubx-0.2.2.dist-info/RECORD +55 -0
- qubx-0.1.90.dist-info/RECORD +0 -39
- {qubx-0.1.90.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
|
|
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
|
+
}
|
qubx/backtester/queue.py
ADDED
|
@@ -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]
|