Qubx 0.5.7__cp312-cp312-manylinux_2_39_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 +207 -0
- qubx/_nb_magic.py +100 -0
- qubx/backtester/__init__.py +5 -0
- qubx/backtester/account.py +145 -0
- qubx/backtester/broker.py +87 -0
- qubx/backtester/data.py +296 -0
- qubx/backtester/management.py +378 -0
- qubx/backtester/ome.py +296 -0
- qubx/backtester/optimization.py +201 -0
- qubx/backtester/simulated_data.py +558 -0
- qubx/backtester/simulator.py +362 -0
- qubx/backtester/utils.py +780 -0
- qubx/cli/__init__.py +0 -0
- qubx/cli/commands.py +67 -0
- qubx/connectors/ccxt/__init__.py +0 -0
- qubx/connectors/ccxt/account.py +495 -0
- qubx/connectors/ccxt/broker.py +132 -0
- qubx/connectors/ccxt/customizations.py +193 -0
- qubx/connectors/ccxt/data.py +612 -0
- qubx/connectors/ccxt/exceptions.py +17 -0
- qubx/connectors/ccxt/factory.py +93 -0
- qubx/connectors/ccxt/utils.py +307 -0
- qubx/core/__init__.py +0 -0
- qubx/core/account.py +251 -0
- qubx/core/basics.py +850 -0
- qubx/core/context.py +420 -0
- qubx/core/exceptions.py +38 -0
- qubx/core/helpers.py +480 -0
- qubx/core/interfaces.py +1150 -0
- qubx/core/loggers.py +514 -0
- qubx/core/lookups.py +475 -0
- qubx/core/metrics.py +1512 -0
- qubx/core/mixins/__init__.py +13 -0
- qubx/core/mixins/market.py +94 -0
- qubx/core/mixins/processing.py +428 -0
- qubx/core/mixins/subscription.py +203 -0
- qubx/core/mixins/trading.py +88 -0
- qubx/core/mixins/universe.py +270 -0
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pxd +125 -0
- qubx/core/series.pyi +118 -0
- qubx/core/series.pyx +988 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyi +6 -0
- qubx/core/utils.pyx +62 -0
- qubx/data/__init__.py +25 -0
- qubx/data/helpers.py +416 -0
- qubx/data/readers.py +1562 -0
- qubx/data/tardis.py +100 -0
- qubx/gathering/simplest.py +88 -0
- qubx/math/__init__.py +3 -0
- qubx/math/stats.py +129 -0
- qubx/pandaz/__init__.py +23 -0
- qubx/pandaz/ta.py +2757 -0
- qubx/pandaz/utils.py +638 -0
- qubx/resources/instruments/symbols-binance.cm.json +1 -0
- qubx/resources/instruments/symbols-binance.json +1 -0
- qubx/resources/instruments/symbols-binance.um.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.f.json +1 -0
- qubx/resources/instruments/symbols-bitfinex.json +1 -0
- qubx/resources/instruments/symbols-kraken.f.json +1 -0
- qubx/resources/instruments/symbols-kraken.json +1 -0
- qubx/ta/__init__.py +0 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/ta/indicators.pxd +149 -0
- qubx/ta/indicators.pyi +41 -0
- qubx/ta/indicators.pyx +787 -0
- qubx/trackers/__init__.py +3 -0
- qubx/trackers/abvanced.py +236 -0
- qubx/trackers/composite.py +146 -0
- qubx/trackers/rebalancers.py +129 -0
- qubx/trackers/riskctrl.py +641 -0
- qubx/trackers/sizers.py +235 -0
- qubx/utils/__init__.py +5 -0
- qubx/utils/_pyxreloader.py +281 -0
- qubx/utils/charting/lookinglass.py +1057 -0
- qubx/utils/charting/mpl_helpers.py +1183 -0
- qubx/utils/marketdata/binance.py +284 -0
- qubx/utils/marketdata/ccxt.py +90 -0
- qubx/utils/marketdata/dukas.py +130 -0
- qubx/utils/misc.py +541 -0
- qubx/utils/ntp.py +63 -0
- qubx/utils/numbers_utils.py +7 -0
- qubx/utils/orderbook.py +491 -0
- qubx/utils/plotting/__init__.py +0 -0
- qubx/utils/plotting/dashboard.py +150 -0
- qubx/utils/plotting/data.py +137 -0
- qubx/utils/plotting/interfaces.py +25 -0
- qubx/utils/plotting/renderers/__init__.py +0 -0
- qubx/utils/plotting/renderers/plotly.py +0 -0
- qubx/utils/runner/__init__.py +1 -0
- qubx/utils/runner/_jupyter_runner.pyt +60 -0
- qubx/utils/runner/accounts.py +88 -0
- qubx/utils/runner/configs.py +65 -0
- qubx/utils/runner/runner.py +470 -0
- qubx/utils/time.py +312 -0
- qubx-0.5.7.dist-info/METADATA +105 -0
- qubx-0.5.7.dist-info/RECORD +100 -0
- qubx-0.5.7.dist-info/WHEEL +4 -0
- qubx-0.5.7.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
from typing import Any, Iterator, TypeAlias
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from qubx import logger
|
|
8
|
+
from qubx.core.basics import DataType, Instrument, Timestamped
|
|
9
|
+
from qubx.core.exceptions import SimulationError
|
|
10
|
+
from qubx.data.readers import (
|
|
11
|
+
AsDict,
|
|
12
|
+
AsQuotes,
|
|
13
|
+
AsTrades,
|
|
14
|
+
DataReader,
|
|
15
|
+
DataTransformer,
|
|
16
|
+
RestoredBarsFromOHLC,
|
|
17
|
+
RestoreQuotesFromOHLC,
|
|
18
|
+
RestoreTradesFromOHLC,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
SlicerOutData: TypeAlias = tuple[str, int, Timestamped] | tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IteratedDataStreamsSlicer(Iterator[SlicerOutData]):
|
|
25
|
+
"""
|
|
26
|
+
This class manages seamless iteration over multiple time-series data streams,
|
|
27
|
+
ensuring that events are processed in the correct chronological order regardless of their source.
|
|
28
|
+
It supports adding / removing new data streams to the slicer on the fly (during the itration).
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_iterators: dict[str, Iterator[list[Timestamped]]]
|
|
32
|
+
_buffers: dict[str, list[Timestamped]]
|
|
33
|
+
_keys: deque[str]
|
|
34
|
+
_iterating: bool
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self._buffers = defaultdict(list)
|
|
38
|
+
self._iterators = {}
|
|
39
|
+
self._keys = deque()
|
|
40
|
+
self._iterating = False
|
|
41
|
+
|
|
42
|
+
def put(self, data: dict[str, Iterator[list[Timestamped]]]):
|
|
43
|
+
_rebuild = False
|
|
44
|
+
for k, vi in data.items():
|
|
45
|
+
if k not in self._keys:
|
|
46
|
+
self._iterators[k] = vi
|
|
47
|
+
self._buffers[k] = self._load_next_chunk_to_buffer(k) # do initial chunk fetching
|
|
48
|
+
self._keys.append(k)
|
|
49
|
+
_rebuild = True
|
|
50
|
+
|
|
51
|
+
# - rebuild strategy
|
|
52
|
+
if _rebuild and self._iterating:
|
|
53
|
+
self._build_initial_iteration_seq()
|
|
54
|
+
|
|
55
|
+
def __add__(self, data: dict[str, Iterator]) -> "IteratedDataStreamsSlicer":
|
|
56
|
+
self.put(data)
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def remove(self, keys: list[str] | str):
|
|
60
|
+
"""
|
|
61
|
+
Remove data iterator and associated keys from the queue.
|
|
62
|
+
If the key is not found, it does nothing.
|
|
63
|
+
"""
|
|
64
|
+
_keys = keys if isinstance(keys, list) else [keys]
|
|
65
|
+
_rebuild = False
|
|
66
|
+
for i in _keys:
|
|
67
|
+
if i in self._buffers:
|
|
68
|
+
self._buffers.pop(i)
|
|
69
|
+
self._iterators.pop(i)
|
|
70
|
+
self._keys.remove(i)
|
|
71
|
+
_rebuild = True
|
|
72
|
+
|
|
73
|
+
# - rebuild strategy
|
|
74
|
+
if _rebuild and self._iterating:
|
|
75
|
+
self._build_initial_iteration_seq()
|
|
76
|
+
|
|
77
|
+
def __iter__(self) -> Iterator:
|
|
78
|
+
self._build_initial_iteration_seq()
|
|
79
|
+
self._iterating = True
|
|
80
|
+
return self
|
|
81
|
+
|
|
82
|
+
def _build_initial_iteration_seq(self):
|
|
83
|
+
_init_seq = {k: self._buffers[k][-1].time for k in self._keys}
|
|
84
|
+
_init_seq = dict(sorted(_init_seq.items(), key=lambda item: item[1]))
|
|
85
|
+
self._keys = deque(_init_seq.keys())
|
|
86
|
+
|
|
87
|
+
def _load_next_chunk_to_buffer(self, index: str) -> list[Timestamped]:
|
|
88
|
+
return list(reversed(next(self._iterators[index])))
|
|
89
|
+
|
|
90
|
+
def _remove_iterator(self, key: str):
|
|
91
|
+
self._buffers.pop(key)
|
|
92
|
+
self._iterators.pop(key)
|
|
93
|
+
self._keys.remove(key)
|
|
94
|
+
|
|
95
|
+
def _pop_top(self, k: str) -> Timestamped:
|
|
96
|
+
"""
|
|
97
|
+
Removes and returns the most recent timestamped data element from the buffer associated with the given key.
|
|
98
|
+
If the buffer is empty after popping, it attempts to load the next chunk of data into the buffer.
|
|
99
|
+
If no more data is available, the iterator associated with the key is removed.
|
|
100
|
+
|
|
101
|
+
Parameters:
|
|
102
|
+
k (str): The key identifying the data stream buffer to pop from.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
Timestamped: The most recent timestamped data element from the buffer.
|
|
106
|
+
"""
|
|
107
|
+
v = (data := self._buffers[k]).pop()
|
|
108
|
+
if not data:
|
|
109
|
+
try:
|
|
110
|
+
data.extend(self._load_next_chunk_to_buffer(k)) # - get next chunk of data
|
|
111
|
+
except StopIteration:
|
|
112
|
+
self._remove_iterator(k) # - remove iterable data
|
|
113
|
+
return v
|
|
114
|
+
|
|
115
|
+
def fetch_before_time(self, key: str, time_ns: int) -> list[Timestamped]:
|
|
116
|
+
"""
|
|
117
|
+
Fetches and returns all timestamped data elements from the buffer associated with the given key
|
|
118
|
+
that have a timestamp earlier than the specified time.
|
|
119
|
+
|
|
120
|
+
Parameters:
|
|
121
|
+
- key (str): The key identifying the data stream buffer to fetch from.
|
|
122
|
+
- time_ns (int): The timestamp in nanoseconds. All returned elements will have a timestamp less than this value.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
- list[Timestamped]: A list of timestamped data elements that occur before the specified time.
|
|
126
|
+
"""
|
|
127
|
+
values = []
|
|
128
|
+
data = self._buffers[key]
|
|
129
|
+
if not data:
|
|
130
|
+
try:
|
|
131
|
+
data.extend(self._load_next_chunk_to_buffer(key)) # - get next chunk of data
|
|
132
|
+
except StopIteration:
|
|
133
|
+
self._remove_iterator(key)
|
|
134
|
+
|
|
135
|
+
# pull most past elements
|
|
136
|
+
v = data[-1]
|
|
137
|
+
while v.time < time_ns:
|
|
138
|
+
values.append(data.pop())
|
|
139
|
+
if not data:
|
|
140
|
+
try:
|
|
141
|
+
data.extend(self._load_next_chunk_to_buffer(key)) # - get next chunk of data
|
|
142
|
+
except StopIteration:
|
|
143
|
+
self._remove_iterator(key)
|
|
144
|
+
break
|
|
145
|
+
v = data[-1]
|
|
146
|
+
|
|
147
|
+
return values
|
|
148
|
+
|
|
149
|
+
def __next__(self) -> SlicerOutData:
|
|
150
|
+
"""
|
|
151
|
+
Advances the iterator to the next available timestamped data element across all data streams.
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
- SlicerOutData: A tuple containing the key of the data stream, the timestamp of the data element, and the data element itself.
|
|
155
|
+
|
|
156
|
+
Raises:
|
|
157
|
+
- StopIteration: If there are no more data elements to iterate over.
|
|
158
|
+
"""
|
|
159
|
+
if not self._keys:
|
|
160
|
+
self._iterating = False
|
|
161
|
+
raise StopIteration
|
|
162
|
+
|
|
163
|
+
_min_t = math.inf
|
|
164
|
+
_min_k = self._keys[0]
|
|
165
|
+
for i in self._keys:
|
|
166
|
+
_x = self._buffers[i][-1]
|
|
167
|
+
if _x.time < _min_t:
|
|
168
|
+
_min_t = _x.time
|
|
169
|
+
_min_k = i
|
|
170
|
+
|
|
171
|
+
_v = self._pop_top(_min_k)
|
|
172
|
+
return (_min_k, _v.time, _v)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DataFetcher:
|
|
176
|
+
_fetcher_id: str
|
|
177
|
+
_reader: DataReader
|
|
178
|
+
_requested_data_type: str
|
|
179
|
+
_producing_data_type: str
|
|
180
|
+
_params: dict[str, object]
|
|
181
|
+
_specs: list[str]
|
|
182
|
+
|
|
183
|
+
_transformer: DataTransformer
|
|
184
|
+
_timeframe: str | None = None
|
|
185
|
+
_warmup_period: pd.Timedelta | None = None
|
|
186
|
+
_chunksize: int = 5000
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
fetcher_id: str,
|
|
191
|
+
reader: DataReader,
|
|
192
|
+
subtype: str,
|
|
193
|
+
params: dict[str, Any],
|
|
194
|
+
warmup_period: pd.Timedelta | None = None,
|
|
195
|
+
chunksize: int = 5000,
|
|
196
|
+
open_close_time_indent_secs=1.0, # open/close shift may depends on simulation
|
|
197
|
+
) -> None:
|
|
198
|
+
self._fetcher_id = fetcher_id
|
|
199
|
+
self._params = params
|
|
200
|
+
self._reader = reader
|
|
201
|
+
|
|
202
|
+
match subtype:
|
|
203
|
+
case DataType.OHLC_QUOTES:
|
|
204
|
+
# - requested restore quotes from OHLC
|
|
205
|
+
self._transformer = RestoreQuotesFromOHLC(open_close_time_shift_secs=open_close_time_indent_secs)
|
|
206
|
+
self._requested_data_type = "ohlc"
|
|
207
|
+
self._producing_data_type = "quote"
|
|
208
|
+
if "timeframe" in params:
|
|
209
|
+
self._timeframe = params.get("timeframe", "1Min")
|
|
210
|
+
|
|
211
|
+
case DataType.OHLC_TRADES:
|
|
212
|
+
# - requested restore trades from OHLC
|
|
213
|
+
self._transformer = RestoreTradesFromOHLC(open_close_time_shift_secs=open_close_time_indent_secs)
|
|
214
|
+
self._requested_data_type = "ohlc"
|
|
215
|
+
self._producing_data_type = "trade"
|
|
216
|
+
if "timeframe" in params:
|
|
217
|
+
self._timeframe = params.get("timeframe", "1Min")
|
|
218
|
+
|
|
219
|
+
case DataType.OHLC:
|
|
220
|
+
# - requested restore bars from OHLC
|
|
221
|
+
self._transformer = RestoredBarsFromOHLC(open_close_time_shift_secs=open_close_time_indent_secs)
|
|
222
|
+
self._requested_data_type = "ohlc"
|
|
223
|
+
self._producing_data_type = "ohlc"
|
|
224
|
+
if "timeframe" in params:
|
|
225
|
+
self._timeframe = params.get("timeframe", "1Min")
|
|
226
|
+
|
|
227
|
+
case DataType.TRADE:
|
|
228
|
+
self._requested_data_type = "trade"
|
|
229
|
+
self._producing_data_type = "trade"
|
|
230
|
+
self._transformer = AsTrades()
|
|
231
|
+
|
|
232
|
+
case DataType.QUOTE:
|
|
233
|
+
self._requested_data_type = "orderbook"
|
|
234
|
+
self._producing_data_type = "quote" # ???
|
|
235
|
+
self._transformer = AsQuotes()
|
|
236
|
+
|
|
237
|
+
case _:
|
|
238
|
+
self._requested_data_type = subtype
|
|
239
|
+
self._producing_data_type = subtype
|
|
240
|
+
self._transformer = AsDict()
|
|
241
|
+
|
|
242
|
+
self._warmup_period = warmup_period
|
|
243
|
+
self._warmed = {}
|
|
244
|
+
self._specs = []
|
|
245
|
+
self._chunksize = chunksize
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _make_request_id(instrument: Instrument) -> str:
|
|
249
|
+
return f"{instrument.exchange}:{instrument.symbol}"
|
|
250
|
+
|
|
251
|
+
def attach_instrument(self, instrument: Instrument) -> str:
|
|
252
|
+
_data_id = self._make_request_id(instrument)
|
|
253
|
+
|
|
254
|
+
if _data_id not in self._specs:
|
|
255
|
+
self._specs.append(_data_id)
|
|
256
|
+
self._warmed[_data_id] = False
|
|
257
|
+
|
|
258
|
+
return self._fetcher_id + "." + _data_id
|
|
259
|
+
|
|
260
|
+
def remove_instrument(self, instrument: Instrument) -> str:
|
|
261
|
+
_data_id = self._make_request_id(instrument)
|
|
262
|
+
|
|
263
|
+
if _data_id in self._specs:
|
|
264
|
+
self._specs.remove(_data_id)
|
|
265
|
+
del self._warmed[_data_id]
|
|
266
|
+
|
|
267
|
+
return self._fetcher_id + "." + _data_id
|
|
268
|
+
|
|
269
|
+
def has_instrument(self, instrument: Instrument) -> bool:
|
|
270
|
+
return self._make_request_id(instrument) in self._specs
|
|
271
|
+
|
|
272
|
+
def get_instruments_indices(self) -> list[str]:
|
|
273
|
+
return [self._fetcher_id + "." + i for i in self._specs]
|
|
274
|
+
|
|
275
|
+
def get_instrument_index(self, instrument: Instrument) -> str:
|
|
276
|
+
return self._fetcher_id + "." + self._make_request_id(instrument)
|
|
277
|
+
|
|
278
|
+
def load(
|
|
279
|
+
self, start: str | pd.Timestamp, end: str | pd.Timestamp, to_load: list[Instrument] | None
|
|
280
|
+
) -> dict[str, Iterator]:
|
|
281
|
+
"""
|
|
282
|
+
Loads data for specified instruments within a given time range.
|
|
283
|
+
|
|
284
|
+
Parameters:
|
|
285
|
+
- start (str | pd.Timestamp): The start time for data loading, can be a string or a pandas Timestamp.
|
|
286
|
+
- end (str | pd.Timestamp): The end time for data loading, can be a string or a pandas Timestamp.
|
|
287
|
+
- to_load (list[Instrument] | None): A list of instruments to load data for. If None, data for all subscribed instruments is loaded.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
- dict[str, Iterator]: A dictionary where keys are instrument identifiers and values are iterators over the loaded data.
|
|
291
|
+
"""
|
|
292
|
+
_requests = self._specs if not to_load else set(self._make_request_id(i) for i in to_load)
|
|
293
|
+
_r_iters = {}
|
|
294
|
+
|
|
295
|
+
for _r in _requests: # - TODO: replace this loop with multi-instrument request after DataReader refactoring
|
|
296
|
+
if _r in self._specs:
|
|
297
|
+
_start = pd.Timestamp(start)
|
|
298
|
+
if self._warmup_period and not self._warmed.get(_r):
|
|
299
|
+
_start -= self._warmup_period
|
|
300
|
+
self._warmed[_r] = True
|
|
301
|
+
|
|
302
|
+
_args = dict(
|
|
303
|
+
data_id=_r,
|
|
304
|
+
start=_start,
|
|
305
|
+
stop=end,
|
|
306
|
+
transform=self._transformer,
|
|
307
|
+
data_type=self._requested_data_type,
|
|
308
|
+
chunksize=self._chunksize,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if self._timeframe:
|
|
312
|
+
_args["timeframe"] = self._timeframe
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
_r_iters[self._fetcher_id + "." + _r] = self._reader.read(**_args) # type: ignore
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f">>> (DataFetcher::load) - failed to load <g>'{self._fetcher_id}'</g> data: {e}")
|
|
318
|
+
else:
|
|
319
|
+
raise IndexError(
|
|
320
|
+
f"Instrument {_r} is not subscribed for this data {self._requested_data_type} in {self._fetcher_id} !"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
return _r_iters
|
|
324
|
+
|
|
325
|
+
def __repr__(self) -> str:
|
|
326
|
+
return f"{self._requested_data_type}({self._params}) (-{self._warmup_period if self._warmup_period else '--'}) [{','.join(self._specs)}] :-> {self._transformer.__class__.__name__}"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class IterableSimulationData(Iterator):
|
|
330
|
+
"""
|
|
331
|
+
This class is a crucial component for backtesting system.
|
|
332
|
+
It provides a flexible and efficient way to simulate market data feeds for strategy testing.
|
|
333
|
+
|
|
334
|
+
Key Features:
|
|
335
|
+
- Supports multiple data types (OHLC, trades, quotes) and instruments.
|
|
336
|
+
- Allows for dynamic addition and removal of instruments during simulation.
|
|
337
|
+
- Handles warmup periods for data preloading.
|
|
338
|
+
- Manages historical and current data distinction during iteration.
|
|
339
|
+
- Utilizes a data slicer (IteratedDataStreamsSlicer) to merge and order data from multiple sources.
|
|
340
|
+
|
|
341
|
+
TODO:
|
|
342
|
+
1. think how to provide initial "market quote" for each instrument
|
|
343
|
+
2. optimization for historical data (return bunch of history instead of each bar in next(...))
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
_readers: dict[str, DataReader]
|
|
347
|
+
_subtyped_fetchers: dict[str, DataFetcher]
|
|
348
|
+
_warmups: dict[str, pd.Timedelta]
|
|
349
|
+
_instruments: dict[str, tuple[Instrument, DataFetcher, DataType]]
|
|
350
|
+
_open_close_time_indent_secs: int | float
|
|
351
|
+
|
|
352
|
+
_slicer_ctrl: IteratedDataStreamsSlicer | None = None
|
|
353
|
+
_slicing_iterator: Iterator | None = None
|
|
354
|
+
_start: pd.Timestamp | None = None
|
|
355
|
+
_stop: pd.Timestamp | None = None
|
|
356
|
+
_current_time: int | None = None
|
|
357
|
+
|
|
358
|
+
def __init__(
|
|
359
|
+
self,
|
|
360
|
+
readers: dict[str, DataReader],
|
|
361
|
+
open_close_time_indent_secs=1, # open/close ticks shift
|
|
362
|
+
):
|
|
363
|
+
self._readers = dict(readers)
|
|
364
|
+
self._instruments = {}
|
|
365
|
+
self._subtyped_fetchers = {}
|
|
366
|
+
self._warmups = {}
|
|
367
|
+
self._open_close_time_indent_secs = open_close_time_indent_secs
|
|
368
|
+
|
|
369
|
+
def set_typed_reader(self, data_type: str, reader: DataReader):
|
|
370
|
+
self._readers[data_type] = reader
|
|
371
|
+
if _fetcher := self._subtyped_fetchers.get(data_type):
|
|
372
|
+
_fetcher._reader = reader
|
|
373
|
+
|
|
374
|
+
def set_warmup_period(self, subscription: str, warmup_period: str | None = None):
|
|
375
|
+
if warmup_period:
|
|
376
|
+
_access_key, _, _ = self._parse_subscription_spec(subscription)
|
|
377
|
+
self._warmups[_access_key] = pd.Timedelta(warmup_period)
|
|
378
|
+
|
|
379
|
+
def _parse_subscription_spec(self, subscription: str) -> tuple[str, str, dict[str, object]]:
|
|
380
|
+
_subtype, _params = DataType.from_str(subscription)
|
|
381
|
+
match _subtype:
|
|
382
|
+
case DataType.OHLC | DataType.OHLC_QUOTES:
|
|
383
|
+
_timeframe = _params.get("timeframe", "1Min")
|
|
384
|
+
_access_key = f"{_subtype}.{_timeframe}"
|
|
385
|
+
case DataType.TRADE | DataType.QUOTE:
|
|
386
|
+
_access_key = f"{_subtype}"
|
|
387
|
+
case _:
|
|
388
|
+
# - any arbitrary data type is passed as is
|
|
389
|
+
_params = {}
|
|
390
|
+
_subtype = subscription
|
|
391
|
+
_access_key = f"{_subtype}"
|
|
392
|
+
return _access_key, _subtype, _params
|
|
393
|
+
|
|
394
|
+
def add_instruments_for_subscription(self, subscription: str, instruments: list[Instrument] | Instrument):
|
|
395
|
+
instruments = instruments if isinstance(instruments, list) else [instruments]
|
|
396
|
+
_subt_key, _data_type, _params = self._parse_subscription_spec(subscription)
|
|
397
|
+
|
|
398
|
+
fetcher = self._subtyped_fetchers.get(_subt_key)
|
|
399
|
+
if not fetcher:
|
|
400
|
+
_reader = self._readers.get(_data_type)
|
|
401
|
+
|
|
402
|
+
if _reader is None:
|
|
403
|
+
raise SimulationError(f"No reader configured for data type: {_data_type}")
|
|
404
|
+
|
|
405
|
+
self._subtyped_fetchers[_subt_key] = (
|
|
406
|
+
fetcher := DataFetcher(
|
|
407
|
+
_subt_key,
|
|
408
|
+
_reader,
|
|
409
|
+
_data_type,
|
|
410
|
+
_params,
|
|
411
|
+
warmup_period=self._warmups.get(_subt_key),
|
|
412
|
+
open_close_time_indent_secs=self._open_close_time_indent_secs,
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
_instrs_to_preload = []
|
|
417
|
+
for i in instruments:
|
|
418
|
+
if not fetcher.has_instrument(i):
|
|
419
|
+
idx = fetcher.attach_instrument(i)
|
|
420
|
+
self._instruments[idx] = (i, fetcher, subscription) # type: ignore
|
|
421
|
+
_instrs_to_preload.append(i)
|
|
422
|
+
|
|
423
|
+
if self.is_running and _instrs_to_preload:
|
|
424
|
+
self._slicer_ctrl += fetcher.load(
|
|
425
|
+
pd.Timestamp(self._current_time, unit="ns"), # type: ignore
|
|
426
|
+
self._stop, # type: ignore
|
|
427
|
+
_instrs_to_preload,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def peek_historical_data(self, instrument: Instrument, subscription: str) -> list[Timestamped]:
|
|
431
|
+
"""
|
|
432
|
+
Retrieves historical data for a specified instrument and subscription type up to the current simulation time.
|
|
433
|
+
|
|
434
|
+
Parameters:
|
|
435
|
+
- instrument (Instrument): instrument for which historical data is requested.
|
|
436
|
+
- subscription (str): type of data subscription (e.g., OHLC, trades, quotes) for the instrument.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
- list[Timestamped]: A list of historical data elements for the specified instrument and subscription type
|
|
440
|
+
that occurred before the current simulation time. If the simulation is not running, returns an empty list.
|
|
441
|
+
|
|
442
|
+
Raises:
|
|
443
|
+
SimulationError: If the instrument does not have the specified subscription in the simulation data provider.
|
|
444
|
+
"""
|
|
445
|
+
if not self.has_subscription(instrument, subscription):
|
|
446
|
+
raise SimulationError(
|
|
447
|
+
f"Instrument: {instrument} has no subscription: {subscription} in this simulation data provider"
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
if not self.is_running:
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
_subt_key, _, _ = self._parse_subscription_spec(subscription)
|
|
454
|
+
_i_key = self._subtyped_fetchers[_subt_key].get_instrument_index(instrument)
|
|
455
|
+
|
|
456
|
+
assert self._slicer_ctrl is not None and self._current_time is not None
|
|
457
|
+
|
|
458
|
+
# fetch historical data for current time
|
|
459
|
+
return self._slicer_ctrl.fetch_before_time(_i_key, self._current_time)
|
|
460
|
+
|
|
461
|
+
def get_instruments_for_subscription(self, subscription: str) -> list[Instrument]:
|
|
462
|
+
if subscription == DataType.ALL:
|
|
463
|
+
return list((i for i, *_ in self._instruments.values()))
|
|
464
|
+
|
|
465
|
+
_subt_key, _, _ = self._parse_subscription_spec(subscription)
|
|
466
|
+
if (fetcher := self._subtyped_fetchers.get(_subt_key)) is not None:
|
|
467
|
+
return [self._instruments[k][0] for k in fetcher.get_instruments_indices()]
|
|
468
|
+
|
|
469
|
+
return []
|
|
470
|
+
|
|
471
|
+
def get_subscriptions_for_instrument(self, instrument: Instrument | None) -> list[str]:
|
|
472
|
+
r = []
|
|
473
|
+
for i, f, s in self._instruments.values():
|
|
474
|
+
if instrument is not None:
|
|
475
|
+
if i == instrument:
|
|
476
|
+
r.append(s)
|
|
477
|
+
else:
|
|
478
|
+
r.append(s)
|
|
479
|
+
return list(set(r))
|
|
480
|
+
|
|
481
|
+
def has_subscription(self, instrument: Instrument, subscription_type: str) -> bool:
|
|
482
|
+
for i, f, s in self._instruments.values():
|
|
483
|
+
if i == instrument and s == subscription_type:
|
|
484
|
+
return True
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
def remove_instruments_from_subscription(self, subscription: str, instruments: list[Instrument] | Instrument):
|
|
488
|
+
def _remove_from_fetcher(_subt_key: str, instruments: list[Instrument]):
|
|
489
|
+
fetcher = self._subtyped_fetchers.get(_subt_key)
|
|
490
|
+
if not fetcher:
|
|
491
|
+
logger.warning(f"No configured data fetcher for '{_subt_key}' subscription !")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
_keys_to_remove = []
|
|
495
|
+
for i in instruments:
|
|
496
|
+
# - try to remove from data fetcher
|
|
497
|
+
if idx := fetcher.remove_instrument(i):
|
|
498
|
+
if idx in self._instruments:
|
|
499
|
+
self._instruments.pop(idx)
|
|
500
|
+
_keys_to_remove.append(idx)
|
|
501
|
+
|
|
502
|
+
# print("REMOVING FROM:", _keys_to_remove)
|
|
503
|
+
if self.is_running and _keys_to_remove:
|
|
504
|
+
self._slicer_ctrl.remove(_keys_to_remove) # type: ignore
|
|
505
|
+
|
|
506
|
+
instruments = instruments if isinstance(instruments, list) else [instruments]
|
|
507
|
+
|
|
508
|
+
# - if we want to remove instruments from all subscriptions
|
|
509
|
+
if subscription == DataType.ALL:
|
|
510
|
+
_f_keys = list(self._subtyped_fetchers.keys())
|
|
511
|
+
for s in _f_keys:
|
|
512
|
+
_remove_from_fetcher(s, instruments)
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
_subt_key, _, _ = self._parse_subscription_spec(subscription)
|
|
516
|
+
_remove_from_fetcher(_subt_key, instruments)
|
|
517
|
+
|
|
518
|
+
@property
|
|
519
|
+
def is_running(self) -> bool:
|
|
520
|
+
return self._current_time is not None
|
|
521
|
+
|
|
522
|
+
def create_iterable(self, start: str | pd.Timestamp, stop: str | pd.Timestamp) -> Iterator:
|
|
523
|
+
self._start = pd.Timestamp(start)
|
|
524
|
+
self._stop = pd.Timestamp(stop)
|
|
525
|
+
self._current_time = None
|
|
526
|
+
self._slicer_ctrl = IteratedDataStreamsSlicer()
|
|
527
|
+
return self
|
|
528
|
+
|
|
529
|
+
def __iter__(self) -> Iterator:
|
|
530
|
+
assert self._start is not None
|
|
531
|
+
self._current_time = int(pd.Timestamp(self._start).timestamp() * 1e9)
|
|
532
|
+
_ct_timestap = pd.Timestamp(self._current_time, unit="ns")
|
|
533
|
+
|
|
534
|
+
for f in self._subtyped_fetchers.values():
|
|
535
|
+
logger.debug(
|
|
536
|
+
f" [<c>IteratedDataStreamsSlicer</c>] :: Preloading initial data for {f._fetcher_id} {self._start} : {self._stop} ..."
|
|
537
|
+
)
|
|
538
|
+
self._slicer_ctrl += f.load(_ct_timestap, self._stop, None) # type: ignore
|
|
539
|
+
|
|
540
|
+
self._slicing_iterator = iter(self._slicer_ctrl)
|
|
541
|
+
return self
|
|
542
|
+
|
|
543
|
+
def __next__(self) -> tuple[Instrument, str, Timestamped, bool]: # type: ignore
|
|
544
|
+
try:
|
|
545
|
+
while data := next(self._slicing_iterator): # type: ignore
|
|
546
|
+
k, t, v = data
|
|
547
|
+
instr, fetcher, subt = self._instruments[k]
|
|
548
|
+
data_type = fetcher._producing_data_type
|
|
549
|
+
_is_historical = False
|
|
550
|
+
if t < self._current_time: # type: ignore
|
|
551
|
+
_is_historical = True
|
|
552
|
+
else:
|
|
553
|
+
# only update the current time if the event is not historical
|
|
554
|
+
self._current_time = t
|
|
555
|
+
|
|
556
|
+
return instr, data_type, v, _is_historical
|
|
557
|
+
except StopIteration as e: # noqa: F841
|
|
558
|
+
raise StopIteration
|