Qubx 0.2.2__cp311-cp311-manylinux_2_35_x86_64.whl → 0.2.5__cp311-cp311-manylinux_2_35_x86_64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of Qubx might be problematic. Click here for more details.
- qubx/backtester/ome.py +7 -3
- qubx/backtester/optimization.py +19 -5
- qubx/backtester/queue.py +152 -5
- qubx/backtester/simulator.py +32 -9
- qubx/core/basics.py +43 -7
- qubx/core/context.py +165 -32
- qubx/core/exceptions.py +4 -0
- qubx/core/helpers.py +11 -5
- qubx/core/loggers.py +73 -3
- qubx/core/lookups.py +6 -0
- qubx/core/metrics.py +151 -45
- qubx/core/series.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyi +56 -1
- qubx/core/strategy.py +36 -9
- qubx/core/utils.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/data/helpers.py +29 -0
- qubx/data/readers.py +121 -16
- qubx/gathering/simplest.py +6 -5
- qubx/pandaz/ta.py +2 -2
- qubx/ta/indicators.cpython-311-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +138 -0
- qubx/ta/indicators.pyi +8 -2
- qubx/ta/indicators.pyx +62 -92
- qubx/trackers/composite.py +144 -0
- qubx/trackers/riskctrl.py +217 -32
- qubx/trackers/sizers.py +56 -7
- {qubx-0.2.2.dist-info → qubx-0.2.5.dist-info}/METADATA +1 -1
- qubx-0.2.5.dist-info/RECORD +58 -0
- qubx-0.2.2.dist-info/RECORD +0 -55
- {qubx-0.2.2.dist-info → qubx-0.2.5.dist-info}/WHEEL +0 -0
qubx/backtester/ome.py
CHANGED
|
@@ -90,6 +90,7 @@ class OrdersManagementEngine:
|
|
|
90
90
|
price: float | None = None,
|
|
91
91
|
client_id: str | None = None,
|
|
92
92
|
time_in_force: str = "gtc",
|
|
93
|
+
fill_at_price: bool = False,
|
|
93
94
|
) -> OmeReport:
|
|
94
95
|
|
|
95
96
|
if self.bbo is None:
|
|
@@ -114,12 +115,12 @@ class OrdersManagementEngine:
|
|
|
114
115
|
client_id,
|
|
115
116
|
)
|
|
116
117
|
|
|
117
|
-
return self._process_order(timestamp, order)
|
|
118
|
+
return self._process_order(timestamp, order, fill_at_price=fill_at_price)
|
|
118
119
|
|
|
119
120
|
def _dbg(self, message, **kwargs) -> None:
|
|
120
121
|
logger.debug(f"[OMS] {self.instrument.symbol} - {message}", **kwargs)
|
|
121
122
|
|
|
122
|
-
def _process_order(self, timestamp: dt_64, order: Order) -> OmeReport:
|
|
123
|
+
def _process_order(self, timestamp: dt_64, order: Order, fill_at_price: bool = False) -> OmeReport:
|
|
123
124
|
if order.status in ["CLOSED", "CANCELED"]:
|
|
124
125
|
raise InvalidOrder(f"Order {order.id} is already closed or canceled.")
|
|
125
126
|
|
|
@@ -129,7 +130,10 @@ class OrdersManagementEngine:
|
|
|
129
130
|
|
|
130
131
|
# - check if order can be "executed" immediately
|
|
131
132
|
exec_price = None
|
|
132
|
-
if order.
|
|
133
|
+
if fill_at_price and order.price:
|
|
134
|
+
exec_price = order.price
|
|
135
|
+
|
|
136
|
+
elif order.type == "MARKET":
|
|
133
137
|
exec_price = c_ask if buy_side else c_bid
|
|
134
138
|
|
|
135
139
|
elif order.type == "LIMIT":
|
qubx/backtester/optimization.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Any, Dict, List, Sequence, Tuple
|
|
1
|
+
from typing import Any, Dict, List, Sequence, Tuple, Type
|
|
2
2
|
import numpy as np
|
|
3
3
|
import re
|
|
4
4
|
|
|
@@ -71,7 +71,7 @@ def permutate_params(
|
|
|
71
71
|
case str():
|
|
72
72
|
vals.append([v])
|
|
73
73
|
case _:
|
|
74
|
-
vals.append(
|
|
74
|
+
vals.append([v])
|
|
75
75
|
# vals.append(v if isinstance(v, (List, Tuple)) else list(v) if isinstance(v, range) else [v])
|
|
76
76
|
d = [dict(zip(args, p)) for p in product(*vals)]
|
|
77
77
|
result = []
|
|
@@ -90,7 +90,7 @@ def permutate_params(
|
|
|
90
90
|
return _wrap_single_list(result) if wrap_as_list else result
|
|
91
91
|
|
|
92
92
|
|
|
93
|
-
def variate(clz, *args, conditions=None, **kwargs) -> Dict[str, Any]:
|
|
93
|
+
def variate(clz: Type[Any] | List[Type[Any]], *args, conditions=None, **kwargs) -> Dict[str, Any]:
|
|
94
94
|
"""
|
|
95
95
|
Make variations of parameters for simulations (micro optimizer)
|
|
96
96
|
|
|
@@ -127,15 +127,29 @@ def variate(clz, *args, conditions=None, **kwargs) -> Dict[str, Any]:
|
|
|
127
127
|
>>> variate(MomentumStrategy_Ex1_test, 10, lookback_period=[1,2,3], filter_type=['ema', 'sma'], skip_entries_flag=[True, False]),
|
|
128
128
|
>>> data, capital, ["BINANCE.UM:BTCUSDT"], dict(type="ohlc", timeframe="5Min", nback=0), "5Min -1Sec", "vip0_usdt", "2024-01-01", "2024-01-02"
|
|
129
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])
|
|
130
133
|
"""
|
|
131
134
|
|
|
132
135
|
def _cmprss(xs: str):
|
|
133
136
|
return "".join([x[0] for x in re.split("((?<!-)(?=[A-Z]))|_|(\d)", xs) if x])
|
|
134
137
|
|
|
135
|
-
|
|
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
|
+
|
|
136
149
|
to_excl = [s for s, v in kwargs.items() if not isinstance(v, (list, set, tuple, range))]
|
|
137
150
|
dic2str = lambda ds: [_cmprss(k) + "=" + str(v) for k, v in ds.items() if k not in to_excl]
|
|
138
151
|
|
|
139
152
|
return {
|
|
140
|
-
f"{sfx}_({ ','.join(dic2str(z)) })": clz
|
|
153
|
+
f"{sfx}_({ ','.join(dic2str(z)) })": _mk(clz, *args, **z)
|
|
154
|
+
for z in permutate_params(kwargs, conditions=conditions)
|
|
141
155
|
}
|
qubx/backtester/queue.py
CHANGED
|
@@ -4,11 +4,13 @@ import heapq
|
|
|
4
4
|
from dataclasses import dataclass
|
|
5
5
|
from collections import defaultdict
|
|
6
6
|
from typing import Any, Iterator, Iterable
|
|
7
|
+
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, Future
|
|
7
8
|
|
|
8
9
|
from qubx import logger
|
|
9
10
|
from qubx.core.basics import Instrument, dt_64, BatchEvent
|
|
10
11
|
from qubx.data.readers import DataReader, DataTransformer
|
|
11
12
|
from qubx.utils.misc import Stopwatch
|
|
13
|
+
from qubx.core.exceptions import SimulatorError
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
_SW = Stopwatch()
|
|
@@ -133,17 +135,141 @@ class SimulatedDataQueue:
|
|
|
133
135
|
return self
|
|
134
136
|
|
|
135
137
|
def __iter__(self) -> Iterator:
|
|
136
|
-
logger.
|
|
138
|
+
logger.debug("Initializing chunks for each loader")
|
|
139
|
+
assert self._start is not None
|
|
140
|
+
self._current_time = int(pd.Timestamp(self._start).timestamp() * 1e9)
|
|
141
|
+
self._index_to_chunk_size = {}
|
|
142
|
+
self._index_to_iterator = {}
|
|
143
|
+
self._event_heap = []
|
|
144
|
+
for loader_index in self._index_to_loader.keys():
|
|
145
|
+
self._add_chunk_to_heap(loader_index)
|
|
146
|
+
return self
|
|
147
|
+
|
|
148
|
+
@_SW.watch("DataQueue")
|
|
149
|
+
def __next__(self) -> tuple[str, str, Any]:
|
|
150
|
+
if not self._event_heap:
|
|
151
|
+
raise StopIteration
|
|
152
|
+
|
|
153
|
+
loader_index = None
|
|
154
|
+
|
|
155
|
+
# get the next event from the heap
|
|
156
|
+
# if the loader_index is in the removed_loader_indices, skip it (optimization to avoid unnecessary heap operations)
|
|
157
|
+
while self._event_heap and (loader_index is None or loader_index in self._removed_loader_indices):
|
|
158
|
+
dt, loader_index, chunk_index, event = heapq.heappop(self._event_heap)
|
|
159
|
+
|
|
160
|
+
if loader_index is None or loader_index in self._removed_loader_indices:
|
|
161
|
+
raise StopIteration
|
|
162
|
+
|
|
163
|
+
loader = self._index_to_loader[loader_index]
|
|
164
|
+
data_type = loader.data_type
|
|
165
|
+
if dt < self._current_time: # type: ignore
|
|
166
|
+
data_type = f"hist_{data_type}"
|
|
167
|
+
else:
|
|
168
|
+
# only update the current time if the event is not historical
|
|
169
|
+
self._current_time = dt
|
|
170
|
+
|
|
171
|
+
chunk_size = self._index_to_chunk_size[loader_index]
|
|
172
|
+
if chunk_index + 1 == chunk_size:
|
|
173
|
+
self._add_chunk_to_heap(loader_index)
|
|
174
|
+
|
|
175
|
+
return loader.symbol, data_type, event
|
|
176
|
+
|
|
177
|
+
@_SW.watch("DataQueue")
|
|
178
|
+
def _add_chunk_to_heap(self, loader_index: int):
|
|
179
|
+
chunk = self._next_chunk(loader_index)
|
|
180
|
+
self._index_to_chunk_size[loader_index] = len(chunk)
|
|
181
|
+
for chunk_index, event in enumerate(chunk):
|
|
182
|
+
dt = event.time # type: ignore
|
|
183
|
+
heapq.heappush(self._event_heap, (dt, loader_index, chunk_index, event))
|
|
184
|
+
|
|
185
|
+
@_SW.watch("DataQueue")
|
|
186
|
+
def _next_chunk(self, index: int) -> list[Any]:
|
|
187
|
+
if index not in self._index_to_iterator:
|
|
188
|
+
self._index_to_iterator[index] = self._index_to_loader[index].load(pd.Timestamp(self._current_time, unit="ns"), self._stop) # type: ignore
|
|
189
|
+
iterator = self._index_to_iterator[index]
|
|
190
|
+
try:
|
|
191
|
+
return next(iterator)
|
|
192
|
+
except StopIteration:
|
|
193
|
+
return []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SimulatedDataQueueWithThreads(SimulatedDataQueue):
|
|
197
|
+
_loaders: dict[str, list[DataLoader]]
|
|
198
|
+
|
|
199
|
+
def __init__(self, workers: int = 4, prefetch_chunk_count: int = 1):
|
|
200
|
+
self._loaders = defaultdict(list)
|
|
201
|
+
self._start = None
|
|
202
|
+
self._stop = None
|
|
203
|
+
self._current_time = None
|
|
204
|
+
self._index_to_loader: dict[int, DataLoader] = {}
|
|
205
|
+
self._index_to_prefetch: dict[int, list[Future]] = defaultdict(list)
|
|
206
|
+
self._index_to_done: dict[int, bool] = defaultdict(bool)
|
|
207
|
+
self._loader_to_index = {}
|
|
208
|
+
self._index_to_chunk_size = {}
|
|
209
|
+
self._index_to_iterator = {}
|
|
210
|
+
self._latest_loader_index = -1
|
|
211
|
+
self._removed_loader_indices = set()
|
|
212
|
+
# TODO: potentially use ProcessPoolExecutor for better performance
|
|
213
|
+
self._pool = ThreadPoolExecutor(max_workers=workers)
|
|
214
|
+
self._prefetch_chunk_count = prefetch_chunk_count
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def is_running(self) -> bool:
|
|
218
|
+
return self._current_time is not None
|
|
219
|
+
|
|
220
|
+
def __add__(self, loader: DataLoader) -> "SimulatedDataQueueWithThreads":
|
|
221
|
+
self._latest_loader_index += 1
|
|
222
|
+
new_loader_index = self._latest_loader_index
|
|
223
|
+
self._loaders[loader.symbol].append(loader)
|
|
224
|
+
self._index_to_loader[new_loader_index] = loader
|
|
225
|
+
self._loader_to_index[loader] = new_loader_index
|
|
226
|
+
if self.is_running:
|
|
227
|
+
self._submit_chunk(new_loader_index)
|
|
228
|
+
self._add_chunk_to_heap(new_loader_index)
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def __sub__(self, loader: DataLoader) -> "SimulatedDataQueueWithThreads":
|
|
232
|
+
loader_index = self._loader_to_index[loader]
|
|
233
|
+
self._loaders[loader.symbol].remove(loader)
|
|
234
|
+
del self._index_to_loader[loader_index]
|
|
235
|
+
del self._loader_to_index[loader]
|
|
236
|
+
del self._index_to_chunk_size[loader_index]
|
|
237
|
+
del self._index_to_iterator[loader_index]
|
|
238
|
+
del self._index_to_done[loader_index]
|
|
239
|
+
for future in self._index_to_prefetch[loader_index]:
|
|
240
|
+
future.cancel()
|
|
241
|
+
del self._index_to_prefetch[loader_index]
|
|
242
|
+
self._removed_loader_indices.add(loader_index)
|
|
243
|
+
return self
|
|
244
|
+
|
|
245
|
+
def get_loader(self, symbol: str, data_type: str) -> DataLoader:
|
|
246
|
+
loaders = self._loaders[symbol]
|
|
247
|
+
for loader in loaders:
|
|
248
|
+
if loader.data_type == data_type:
|
|
249
|
+
return loader
|
|
250
|
+
raise ValueError(f"Loader for {symbol} and {data_type} not found")
|
|
251
|
+
|
|
252
|
+
def create_iterable(self, start: str | pd.Timestamp, stop: str | pd.Timestamp) -> Iterator:
|
|
253
|
+
self._start = start
|
|
254
|
+
self._stop = stop
|
|
255
|
+
self._current_time = None
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def __iter__(self) -> Iterator:
|
|
259
|
+
logger.debug("Initializing chunks for each loader")
|
|
137
260
|
self._current_time = self._start
|
|
138
261
|
self._index_to_chunk_size = {}
|
|
139
262
|
self._index_to_iterator = {}
|
|
140
263
|
self._event_heap = []
|
|
264
|
+
self._submit_chunk_prefetchers()
|
|
141
265
|
for loader_index in self._index_to_loader.keys():
|
|
142
266
|
self._add_chunk_to_heap(loader_index)
|
|
143
267
|
return self
|
|
144
268
|
|
|
145
269
|
@_SW.watch("DataQueue")
|
|
146
270
|
def __next__(self) -> tuple[str, str, Any]:
|
|
271
|
+
self._submit_chunk_prefetchers()
|
|
272
|
+
|
|
147
273
|
if not self._event_heap:
|
|
148
274
|
raise StopIteration
|
|
149
275
|
|
|
@@ -167,13 +293,21 @@ class SimulatedDataQueue:
|
|
|
167
293
|
|
|
168
294
|
@_SW.watch("DataQueue")
|
|
169
295
|
def _add_chunk_to_heap(self, loader_index: int):
|
|
170
|
-
|
|
296
|
+
futures = self._index_to_prefetch[loader_index]
|
|
297
|
+
if not futures and not self._index_to_done[loader_index]:
|
|
298
|
+
loader = self._index_to_loader[loader_index]
|
|
299
|
+
logger.error(f"Error state: No submitted tasks for loader {loader.symbol} {loader.data_type}")
|
|
300
|
+
raise SimulatorError("No submitted tasks for loader")
|
|
301
|
+
elif self._index_to_done[loader_index]:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
# wait for future to finish if needed
|
|
305
|
+
chunk = futures.pop(0).result()
|
|
171
306
|
self._index_to_chunk_size[loader_index] = len(chunk)
|
|
172
307
|
for chunk_index, event in enumerate(chunk):
|
|
173
308
|
dt = event.time # type: ignore
|
|
174
309
|
heapq.heappush(self._event_heap, (dt, loader_index, chunk_index, event))
|
|
175
310
|
|
|
176
|
-
@_SW.watch("DataQueue")
|
|
177
311
|
def _next_chunk(self, index: int) -> list[Any]:
|
|
178
312
|
if index not in self._index_to_iterator:
|
|
179
313
|
self._index_to_iterator[index] = self._index_to_loader[index].load(self._current_time, self._stop) # type: ignore
|
|
@@ -183,6 +317,18 @@ class SimulatedDataQueue:
|
|
|
183
317
|
except StopIteration:
|
|
184
318
|
return []
|
|
185
319
|
|
|
320
|
+
def _submit_chunk_prefetchers(self):
|
|
321
|
+
for index in self._index_to_loader.keys():
|
|
322
|
+
if len(self._index_to_prefetch[index]) < self._prefetch_chunk_count:
|
|
323
|
+
self._submit_chunk(index)
|
|
324
|
+
|
|
325
|
+
def _submit_chunk(self, loader_index: int) -> None:
|
|
326
|
+
future = self._pool.submit(self._next_chunk, loader_index)
|
|
327
|
+
self._index_to_prefetch[loader_index].append(future)
|
|
328
|
+
|
|
329
|
+
def __del__(self):
|
|
330
|
+
self._pool.shutdown()
|
|
331
|
+
|
|
186
332
|
|
|
187
333
|
class EventBatcher:
|
|
188
334
|
_BATCH_SETTINGS = {
|
|
@@ -202,7 +348,8 @@ class EventBatcher:
|
|
|
202
348
|
yield from _iter
|
|
203
349
|
return
|
|
204
350
|
|
|
205
|
-
last_symbol
|
|
351
|
+
last_symbol: str = None # type: ignore
|
|
352
|
+
last_data_type: str = None # type: ignore
|
|
206
353
|
buffer = []
|
|
207
354
|
for symbol, data_type, event in self.source_iterator:
|
|
208
355
|
time: dt_64 = event.time # type: ignore
|
|
@@ -233,7 +380,7 @@ class EventBatcher:
|
|
|
233
380
|
if pd.Timedelta(time - buffer[0].time) >= self._batch_settings[data_type]:
|
|
234
381
|
yield symbol, data_type, self._batch_event(buffer)
|
|
235
382
|
buffer = []
|
|
236
|
-
last_symbol, last_data_type = None, None
|
|
383
|
+
last_symbol, last_data_type = None, None # type: ignore
|
|
237
384
|
|
|
238
385
|
if buffer:
|
|
239
386
|
yield last_symbol, last_data_type, self._batch_event(buffer)
|
qubx/backtester/simulator.py
CHANGED
|
@@ -187,13 +187,22 @@ class SimulatedTrading(ITradingServiceProvider):
|
|
|
187
187
|
price: float | None = None,
|
|
188
188
|
client_id: str | None = None,
|
|
189
189
|
time_in_force: str = "gtc",
|
|
190
|
+
**optional,
|
|
190
191
|
) -> Order:
|
|
191
192
|
ome = self._ome.get(instrument.symbol)
|
|
192
193
|
if ome is None:
|
|
193
194
|
raise ValueError(f"ExchangeService:send_order :: No OME configured for '{instrument.symbol}'!")
|
|
194
195
|
|
|
195
196
|
# - try to place order in OME
|
|
196
|
-
report = ome.place_order(
|
|
197
|
+
report = ome.place_order(
|
|
198
|
+
order_side.upper(),
|
|
199
|
+
order_type.upper(),
|
|
200
|
+
amount,
|
|
201
|
+
price,
|
|
202
|
+
client_id,
|
|
203
|
+
time_in_force,
|
|
204
|
+
fill_at_price=optional.get("fill_at_price", False),
|
|
205
|
+
)
|
|
197
206
|
order = report.order
|
|
198
207
|
self._order_to_symbol[order.id] = instrument.symbol
|
|
199
208
|
|
|
@@ -476,6 +485,9 @@ class SimulatedExchange(IBrokerServiceProvider):
|
|
|
476
485
|
logger.info(f"SimulatedExchangeService :: run :: Simulation finished at {end}")
|
|
477
486
|
|
|
478
487
|
def _run_generated_signals(self, symbol: str, data_type: str, data: Any) -> None:
|
|
488
|
+
is_hist = data_type.startswith("hist")
|
|
489
|
+
if is_hist:
|
|
490
|
+
raise ValueError("Historical data is not supported for pre-generated signals !")
|
|
479
491
|
cc = self.get_communication_channel()
|
|
480
492
|
t = data.time # type: ignore
|
|
481
493
|
self._current_time = max(np.datetime64(t, "ns"), self._current_time)
|
|
@@ -496,18 +508,20 @@ class SimulatedExchange(IBrokerServiceProvider):
|
|
|
496
508
|
t = data.time # type: ignore
|
|
497
509
|
self._current_time = max(np.datetime64(t, "ns"), self._current_time)
|
|
498
510
|
q = self.trading_service.emulate_quote_from_data(symbol, np.datetime64(t, "ns"), data)
|
|
499
|
-
|
|
511
|
+
is_hist = data_type.startswith("hist")
|
|
512
|
+
if not is_hist and q is not None:
|
|
500
513
|
self._last_quotes[symbol] = q
|
|
501
514
|
self.trading_service.update_position_price(symbol, self._current_time, q)
|
|
502
515
|
|
|
503
516
|
cc.send((symbol, data_type, data))
|
|
504
517
|
|
|
505
|
-
if
|
|
506
|
-
|
|
518
|
+
if not is_hist:
|
|
519
|
+
if q is not None and data_type != "quote":
|
|
520
|
+
cc.send((symbol, "quote", q))
|
|
507
521
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
522
|
+
if self._scheduler.check_and_run_tasks():
|
|
523
|
+
# - push nothing - it will force to process last event
|
|
524
|
+
cc.send((None, "time", None))
|
|
511
525
|
|
|
512
526
|
def get_quote(self, symbol: str) -> Optional[Quote]:
|
|
513
527
|
return self._last_quotes[symbol]
|
|
@@ -664,13 +678,14 @@ def simulate(
|
|
|
664
678
|
commissions: str,
|
|
665
679
|
start: str | pd.Timestamp,
|
|
666
680
|
stop: str | pd.Timestamp | None = None,
|
|
681
|
+
fit: str | None = None,
|
|
667
682
|
exchange: str | None = None, # in case if exchange is not specified in symbols list
|
|
668
683
|
base_currency: str = "USDT",
|
|
669
684
|
leverage: float = 1.0, # TODO: we need to add support for leverage
|
|
670
685
|
n_jobs: int = 1,
|
|
671
686
|
silent: bool = False,
|
|
672
687
|
enable_event_batching: bool = True,
|
|
673
|
-
) ->
|
|
688
|
+
) -> list[TradingSessionResult]:
|
|
674
689
|
# - recognize provided data
|
|
675
690
|
if isinstance(data, dict):
|
|
676
691
|
data_reader = InMemoryDataFrameReader(data)
|
|
@@ -723,6 +738,7 @@ def simulate(
|
|
|
723
738
|
data_reader,
|
|
724
739
|
subscription,
|
|
725
740
|
trigger,
|
|
741
|
+
fit=fit,
|
|
726
742
|
n_jobs=n_jobs,
|
|
727
743
|
silent=silent,
|
|
728
744
|
enable_event_batching=enable_event_batching,
|
|
@@ -785,6 +801,7 @@ def _run_setups(
|
|
|
785
801
|
data_reader: DataReader,
|
|
786
802
|
subscription: Dict[str, Any],
|
|
787
803
|
trigger: str | list[str],
|
|
804
|
+
fit: str | None,
|
|
788
805
|
n_jobs: int = -1,
|
|
789
806
|
silent: bool = False,
|
|
790
807
|
enable_event_batching: bool = True,
|
|
@@ -799,27 +816,31 @@ def _run_setups(
|
|
|
799
816
|
|
|
800
817
|
reports = ProgressParallel(n_jobs=n_jobs, total=len(setups), silent=_main_loop_silent, backend="multiprocessing")(
|
|
801
818
|
delayed(_run_setup)(
|
|
819
|
+
id,
|
|
802
820
|
s,
|
|
803
821
|
start,
|
|
804
822
|
stop,
|
|
805
823
|
data_reader,
|
|
806
824
|
subscription,
|
|
807
825
|
trigger,
|
|
826
|
+
fit=fit,
|
|
808
827
|
silent=silent,
|
|
809
828
|
enable_event_batching=enable_event_batching,
|
|
810
829
|
)
|
|
811
|
-
for s in setups
|
|
830
|
+
for id, s in enumerate(setups)
|
|
812
831
|
)
|
|
813
832
|
return reports # type: ignore
|
|
814
833
|
|
|
815
834
|
|
|
816
835
|
def _run_setup(
|
|
836
|
+
setup_id: int,
|
|
817
837
|
setup: SimulationSetup,
|
|
818
838
|
start: str | pd.Timestamp,
|
|
819
839
|
stop: str | pd.Timestamp,
|
|
820
840
|
data_reader: DataReader,
|
|
821
841
|
subscription: Dict[str, Any],
|
|
822
842
|
trigger: str | list[str],
|
|
843
|
+
fit: str | None,
|
|
823
844
|
silent: bool = False,
|
|
824
845
|
enable_event_batching: bool = True,
|
|
825
846
|
) -> TradingSessionResult:
|
|
@@ -870,6 +891,7 @@ def _run_setup(
|
|
|
870
891
|
instruments=setup.instruments,
|
|
871
892
|
md_subscription=subscription,
|
|
872
893
|
trigger_spec=_trigger,
|
|
894
|
+
fit_spec=fit,
|
|
873
895
|
logs_writer=logs_writer,
|
|
874
896
|
)
|
|
875
897
|
ctx.start()
|
|
@@ -880,6 +902,7 @@ def _run_setup(
|
|
|
880
902
|
logger.error("Simulated trading interrupted by user !")
|
|
881
903
|
|
|
882
904
|
return TradingSessionResult(
|
|
905
|
+
setup_id,
|
|
883
906
|
setup.name,
|
|
884
907
|
start,
|
|
885
908
|
stop,
|
qubx/core/basics.py
CHANGED
|
@@ -19,6 +19,13 @@ td_64 = np.timedelta64
|
|
|
19
19
|
class Signal:
|
|
20
20
|
"""
|
|
21
21
|
Class for presenting signals generated by strategy
|
|
22
|
+
|
|
23
|
+
Attributes:
|
|
24
|
+
reference_price: float - exact price when signal was generated
|
|
25
|
+
|
|
26
|
+
Options:
|
|
27
|
+
- fill_at_signal_price: bool - if True, then fill order at signal price (only used in backtesting)
|
|
28
|
+
- allow_override: bool - if True, and there is another signal for the same instrument, then override current.
|
|
22
29
|
"""
|
|
23
30
|
|
|
24
31
|
instrument: "Instrument"
|
|
@@ -26,15 +33,20 @@ class Signal:
|
|
|
26
33
|
price: float | None = None
|
|
27
34
|
stop: float | None = None
|
|
28
35
|
take: float | None = None
|
|
36
|
+
reference_price: float | None = None
|
|
29
37
|
group: str = ""
|
|
30
38
|
comment: str = ""
|
|
39
|
+
options: dict[str, Any] = field(default_factory=dict)
|
|
31
40
|
|
|
32
41
|
def __str__(self) -> str:
|
|
33
42
|
_p = f" @ { self.price }" if self.price is not None else ""
|
|
34
43
|
_s = f" stop: { self.stop }" if self.stop is not None else ""
|
|
35
44
|
_t = f" take: { self.take }" if self.take is not None else ""
|
|
45
|
+
_r = f" {self.reference_price:.2f}" if self.reference_price is not None else ""
|
|
36
46
|
_c = f" [{self.comment}]" if self.take is not None else ""
|
|
37
|
-
return
|
|
47
|
+
return (
|
|
48
|
+
f"{self.group}{_r} {self.signal:+f} {self.instrument.symbol}{_p}{_s}{_t} on {self.instrument.exchange}{_c}"
|
|
49
|
+
)
|
|
38
50
|
|
|
39
51
|
|
|
40
52
|
@dataclass
|
|
@@ -43,9 +55,18 @@ class TargetPosition:
|
|
|
43
55
|
Class for presenting target position calculated from signal
|
|
44
56
|
"""
|
|
45
57
|
|
|
58
|
+
time: dt_64 # time when position was set
|
|
46
59
|
signal: Signal # original signal
|
|
47
60
|
target_position_size: float # actual position size after processing in sizer
|
|
48
61
|
|
|
62
|
+
@staticmethod
|
|
63
|
+
def create(ctx: "ITimeProvider", signal: Signal, target_size: float) -> "TargetPosition":
|
|
64
|
+
return TargetPosition(ctx.time(), signal, target_size)
|
|
65
|
+
|
|
66
|
+
@staticmethod
|
|
67
|
+
def zero(ctx: "ITimeProvider", signal: Signal) -> "TargetPosition":
|
|
68
|
+
return TargetPosition(ctx.time(), signal, 0.0)
|
|
69
|
+
|
|
49
70
|
@property
|
|
50
71
|
def instrument(self) -> "Instrument":
|
|
51
72
|
return self.signal.instrument
|
|
@@ -63,7 +84,7 @@ class TargetPosition:
|
|
|
63
84
|
return self.signal.take
|
|
64
85
|
|
|
65
86
|
def __str__(self) -> str:
|
|
66
|
-
return f"Target for {self.signal} -> {self.target_position_size}"
|
|
87
|
+
return f"Target for {self.signal} -> {self.target_position_size} at {self.time}"
|
|
67
88
|
|
|
68
89
|
|
|
69
90
|
@dataclass
|
|
@@ -125,14 +146,26 @@ class Instrument:
|
|
|
125
146
|
take: float | None = None,
|
|
126
147
|
group: str = "",
|
|
127
148
|
comment: str = "",
|
|
149
|
+
options: dict[str, Any] = None,
|
|
128
150
|
) -> Signal:
|
|
129
|
-
return Signal(
|
|
151
|
+
return Signal(
|
|
152
|
+
self,
|
|
153
|
+
signal=signal,
|
|
154
|
+
price=price,
|
|
155
|
+
stop=stop,
|
|
156
|
+
take=take,
|
|
157
|
+
group=group,
|
|
158
|
+
comment=comment,
|
|
159
|
+
options=options or {},
|
|
160
|
+
)
|
|
130
161
|
|
|
131
162
|
def __hash__(self) -> int:
|
|
132
163
|
return hash((self.symbol, self.exchange, self.market_type))
|
|
133
164
|
|
|
134
165
|
def __eq__(self, other: Any) -> bool:
|
|
135
|
-
if
|
|
166
|
+
if other is None:
|
|
167
|
+
return False
|
|
168
|
+
if type(other) != type(self):
|
|
136
169
|
return False
|
|
137
170
|
return self.symbol == other.symbol and self.exchange == other.exchange and self.market_type == other.market_type
|
|
138
171
|
|
|
@@ -502,7 +535,8 @@ class ITimeProvider:
|
|
|
502
535
|
|
|
503
536
|
|
|
504
537
|
class TradingSessionResult:
|
|
505
|
-
|
|
538
|
+
id: int
|
|
539
|
+
name: str
|
|
506
540
|
start: str | pd.Timestamp
|
|
507
541
|
stop: str | pd.Timestamp
|
|
508
542
|
exchange: str
|
|
@@ -518,7 +552,8 @@ class TradingSessionResult:
|
|
|
518
552
|
|
|
519
553
|
def __init__(
|
|
520
554
|
self,
|
|
521
|
-
|
|
555
|
+
id: int,
|
|
556
|
+
name: str,
|
|
522
557
|
start: str | pd.Timestamp,
|
|
523
558
|
stop: str | pd.Timestamp,
|
|
524
559
|
exchange: str,
|
|
@@ -532,7 +567,8 @@ class TradingSessionResult:
|
|
|
532
567
|
signals_log: pd.DataFrame,
|
|
533
568
|
is_simulation=True,
|
|
534
569
|
):
|
|
535
|
-
self.
|
|
570
|
+
self.id = id
|
|
571
|
+
self.name = name
|
|
536
572
|
self.start = start
|
|
537
573
|
self.stop = stop
|
|
538
574
|
self.exchange = exchange
|