Qubx 0.6.65__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.66__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/backtester/broker.py +9 -2
- qubx/backtester/runner.py +112 -12
- qubx/backtester/sentinels.py +23 -0
- qubx/backtester/simulated_data.py +12 -0
- qubx/backtester/simulated_exchange.py +6 -3
- qubx/backtester/simulator.py +14 -6
- qubx/backtester/utils.py +25 -5
- qubx/connectors/ccxt/data.py +1 -1
- qubx/connectors/ccxt/reader.py +4 -4
- qubx/connectors/ccxt/utils.py +3 -3
- qubx/core/basics.py +18 -23
- qubx/core/context.py +24 -0
- qubx/core/helpers.py +21 -4
- qubx/core/initializer.py +86 -1
- qubx/core/interfaces.py +82 -0
- qubx/core/mixins/processing.py +88 -1
- qubx/core/mixins/trading.py +34 -4
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/stale_data_detector.py +418 -0
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/data/__init__.py +2 -1
- qubx/data/composite.py +7 -4
- qubx/data/helpers.py +1618 -7
- qubx/data/readers.py +3 -5
- qubx/emitters/base.py +1 -1
- qubx/gathering/simplest.py +3 -1
- qubx/restarts/state_resolvers.py +5 -1
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/trackers/riskctrl.py +13 -2
- qubx/utils/runner/_jupyter_runner.pyt +9 -2
- qubx/utils/runner/configs.py +11 -0
- qubx/utils/runner/runner.py +7 -0
- qubx/utils/time.py +56 -0
- {qubx-0.6.65.dist-info → qubx-0.6.66.dist-info}/METADATA +1 -1
- {qubx-0.6.65.dist-info → qubx-0.6.66.dist-info}/RECORD +38 -36
- {qubx-0.6.65.dist-info → qubx-0.6.66.dist-info}/LICENSE +0 -0
- {qubx-0.6.65.dist-info → qubx-0.6.66.dist-info}/WHEEL +0 -0
- {qubx-0.6.65.dist-info → qubx-0.6.66.dist-info}/entry_points.txt +0 -0
qubx/backtester/broker.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from qubx import logger
|
|
1
2
|
from qubx.backtester.ome import SimulatedExecutionReport
|
|
2
3
|
from qubx.backtester.simulated_exchange import ISimulatedExchange
|
|
3
4
|
from qubx.core.basics import (
|
|
@@ -5,6 +6,7 @@ from qubx.core.basics import (
|
|
|
5
6
|
Instrument,
|
|
6
7
|
Order,
|
|
7
8
|
)
|
|
9
|
+
from qubx.core.exceptions import OrderNotFound
|
|
8
10
|
from qubx.core.interfaces import IBroker
|
|
9
11
|
|
|
10
12
|
from .account import SimulatedAccountProcessor
|
|
@@ -63,8 +65,13 @@ class SimulatedBroker(IBroker):
|
|
|
63
65
|
self.send_order(instrument, order_side, order_type, amount, price, client_id, time_in_force, **optional)
|
|
64
66
|
|
|
65
67
|
def cancel_order(self, order_id: str) -> Order | None:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
try:
|
|
69
|
+
self._send_execution_report(order_update := self._exchange.cancel_order(order_id))
|
|
70
|
+
return order_update.order if order_update is not None else None
|
|
71
|
+
except OrderNotFound:
|
|
72
|
+
# Order was already cancelled or doesn't exist
|
|
73
|
+
logger.debug(f"Order {order_id} not found")
|
|
74
|
+
return None
|
|
68
75
|
|
|
69
76
|
def cancel_orders(self, instrument: Instrument) -> None:
|
|
70
77
|
raise NotImplementedError("Not implemented yet")
|
qubx/backtester/runner.py
CHANGED
|
@@ -5,7 +5,9 @@ import pandas as pd
|
|
|
5
5
|
from tqdm.auto import tqdm
|
|
6
6
|
|
|
7
7
|
from qubx import logger
|
|
8
|
+
from qubx.backtester.sentinels import NoDataContinue
|
|
8
9
|
from qubx.backtester.simulated_data import IterableSimulationData
|
|
10
|
+
from qubx.backtester.utils import SimulationDataConfig, TimeGuardedWrapper
|
|
9
11
|
from qubx.core.account import CompositeAccountProcessor
|
|
10
12
|
from qubx.core.basics import SW, DataType, Instrument, TransactionCostsCalculator
|
|
11
13
|
from qubx.core.context import StrategyContext
|
|
@@ -22,8 +24,10 @@ from qubx.core.interfaces import (
|
|
|
22
24
|
)
|
|
23
25
|
from qubx.core.loggers import StrategyLogging
|
|
24
26
|
from qubx.core.lookups import lookup
|
|
27
|
+
from qubx.data.helpers import CachedPrefetchReader
|
|
25
28
|
from qubx.loggers.inmemory import InMemoryLogsWriter
|
|
26
29
|
from qubx.pandaz.utils import _frame_to_str
|
|
30
|
+
from qubx.utils.time import now_ns
|
|
27
31
|
|
|
28
32
|
from .account import SimulatedAccountProcessor
|
|
29
33
|
from .broker import SimulatedBroker
|
|
@@ -80,6 +84,7 @@ class SimulationRunner:
|
|
|
80
84
|
emitter: IMetricEmitter | None = None,
|
|
81
85
|
strategy_state: StrategyState | None = None,
|
|
82
86
|
initializer: BasicStrategyInitializer | None = None,
|
|
87
|
+
warmup_mode: bool = False,
|
|
83
88
|
):
|
|
84
89
|
"""
|
|
85
90
|
Initialize the BacktestContextRunner with a strategy context.
|
|
@@ -102,8 +107,10 @@ class SimulationRunner:
|
|
|
102
107
|
self.emitter = emitter
|
|
103
108
|
self.strategy_state = strategy_state if strategy_state is not None else StrategyState()
|
|
104
109
|
self.initializer = initializer
|
|
110
|
+
self.warmup_mode = warmup_mode
|
|
105
111
|
self._pregenerated_signals = dict()
|
|
106
112
|
self._to_process = {}
|
|
113
|
+
self._aux_data_reader = None
|
|
107
114
|
|
|
108
115
|
# - get strategy parameters BEFORE simulation start
|
|
109
116
|
# potentially strategy may change it's parameters during simulation
|
|
@@ -126,6 +133,8 @@ class SimulationRunner:
|
|
|
126
133
|
"""
|
|
127
134
|
logger.debug(f"[<y>SimulationRunner</y>] :: Running simulation from {self.start} to {self.stop}")
|
|
128
135
|
|
|
136
|
+
self._prefetch_aux_data()
|
|
137
|
+
|
|
129
138
|
# Start the context
|
|
130
139
|
self.ctx.start()
|
|
131
140
|
|
|
@@ -164,6 +173,8 @@ class SimulationRunner:
|
|
|
164
173
|
logger.error("Simulated trading interrupted by user!")
|
|
165
174
|
if not catch_keyboard_interrupt:
|
|
166
175
|
raise
|
|
176
|
+
except Exception as e:
|
|
177
|
+
raise e
|
|
167
178
|
finally:
|
|
168
179
|
# Stop the context
|
|
169
180
|
self.ctx.stop()
|
|
@@ -196,7 +207,7 @@ class SimulationRunner:
|
|
|
196
207
|
for i in self._data_providers[0].get_subscribed_instruments():
|
|
197
208
|
# - we can process series with variable id's if we can find some similar instrument
|
|
198
209
|
if s == i.symbol or s == str(i) or s == f"{i.exchange}:{i.symbol}" or str(s) == str(i):
|
|
199
|
-
_start, _end =
|
|
210
|
+
_start, _end = np.datetime64(start), np.datetime64(end)
|
|
200
211
|
_start_idx, _end_idx = v.index.get_indexer([_start, _end], method="ffill")
|
|
201
212
|
sel = v.iloc[max(_start_idx, 0) : _end_idx + 1]
|
|
202
213
|
|
|
@@ -268,21 +279,34 @@ class SimulationRunner:
|
|
|
268
279
|
start, stop = pd.Timestamp(start), pd.Timestamp(stop)
|
|
269
280
|
total_duration = stop - start
|
|
270
281
|
update_delta = total_duration / 100
|
|
271
|
-
prev_dt =
|
|
282
|
+
prev_dt = np.datetime64(start)
|
|
272
283
|
|
|
273
284
|
# - date iteration
|
|
274
285
|
qiter = self._data_source.create_iterable(start, stop)
|
|
286
|
+
|
|
275
287
|
if silent:
|
|
276
288
|
for instrument, data_type, event, is_hist in qiter:
|
|
277
|
-
|
|
289
|
+
# Handle NoDataContinue sentinel
|
|
290
|
+
if isinstance(event, NoDataContinue):
|
|
291
|
+
if not self._handle_no_data_scenario(stop):
|
|
292
|
+
break
|
|
293
|
+
continue
|
|
294
|
+
|
|
295
|
+
if not self._process_event(instrument, data_type, event, is_hist, _run, stop):
|
|
278
296
|
break
|
|
279
297
|
else:
|
|
280
298
|
_p = 0
|
|
281
299
|
with tqdm(total=100, desc="Simulating", unit="%", leave=False) as pbar:
|
|
282
300
|
for instrument, data_type, event, is_hist in qiter:
|
|
283
|
-
|
|
301
|
+
# Handle NoDataContinue sentinel
|
|
302
|
+
if isinstance(event, NoDataContinue):
|
|
303
|
+
if not self._handle_no_data_scenario(stop):
|
|
304
|
+
break
|
|
305
|
+
continue
|
|
306
|
+
|
|
307
|
+
if not self._process_event(instrument, data_type, event, is_hist, _run, stop):
|
|
284
308
|
break
|
|
285
|
-
dt =
|
|
309
|
+
dt = np.datetime64(event.time, "ns")
|
|
286
310
|
# update only if date has changed
|
|
287
311
|
if dt - prev_dt > update_delta:
|
|
288
312
|
_p += 1
|
|
@@ -294,6 +318,43 @@ class SimulationRunner:
|
|
|
294
318
|
|
|
295
319
|
logger.info(f"{self.__class__.__name__} ::: Simulation finished at {stop} :::")
|
|
296
320
|
|
|
321
|
+
def _process_event(self, instrument, data_type, event, is_hist, _run, stop_time):
|
|
322
|
+
"""Process a single simulation event with proper time advancement and scheduler checks."""
|
|
323
|
+
# During warmup, clamp future timestamps to current time
|
|
324
|
+
if self.warmup_mode and hasattr(event, "time"):
|
|
325
|
+
current_real_time = now_ns()
|
|
326
|
+
if event.time > current_real_time:
|
|
327
|
+
event.time = current_real_time
|
|
328
|
+
|
|
329
|
+
if not _run(instrument, data_type, event, is_hist):
|
|
330
|
+
return False
|
|
331
|
+
return True
|
|
332
|
+
|
|
333
|
+
def _handle_no_data_scenario(self, stop_time):
|
|
334
|
+
"""Handle scenario when no data is available but scheduler might have events."""
|
|
335
|
+
# Check if we have pending scheduled events
|
|
336
|
+
if hasattr(self.scheduler, "_next_nearest_time"):
|
|
337
|
+
next_scheduled_time = self.scheduler._next_nearest_time
|
|
338
|
+
current_time = self.time_provider.time()
|
|
339
|
+
|
|
340
|
+
# Convert to int64 for numerical comparisons (avoid type issues)
|
|
341
|
+
next_time_ns = next_scheduled_time.astype("int64")
|
|
342
|
+
current_time_ns = current_time.astype("int64")
|
|
343
|
+
stop_time_ns = stop_time.value # Already int64
|
|
344
|
+
|
|
345
|
+
# Check if we've reached the stop time
|
|
346
|
+
if current_time_ns >= stop_time_ns:
|
|
347
|
+
return False # Stop simulation
|
|
348
|
+
|
|
349
|
+
# If there's a scheduled event before stop time, advance to it
|
|
350
|
+
if next_time_ns < np.iinfo(np.int64).max and next_time_ns < stop_time_ns:
|
|
351
|
+
# Use the original datetime64 object for set_time (not the int64 conversion)
|
|
352
|
+
self.time_provider.set_time(next_scheduled_time)
|
|
353
|
+
self.scheduler.check_and_run_tasks()
|
|
354
|
+
return True # Continue simulation
|
|
355
|
+
|
|
356
|
+
return False # No scheduled events, stop simulation
|
|
357
|
+
|
|
297
358
|
def print_latency_report(self) -> None:
|
|
298
359
|
_l_r = SW.latency_report()
|
|
299
360
|
if _l_r is not None:
|
|
@@ -311,11 +372,6 @@ class SimulationRunner:
|
|
|
311
372
|
f"for {self.setup.capital} {self.setup.base_currency}..."
|
|
312
373
|
)
|
|
313
374
|
|
|
314
|
-
data_source = IterableSimulationData(
|
|
315
|
-
self.data_config.data_providers,
|
|
316
|
-
open_close_time_indent_secs=self.data_config.adjusted_open_close_time_indent_secs,
|
|
317
|
-
)
|
|
318
|
-
|
|
319
375
|
channel = SimulatedCtrlChannel("databus", sentinel=(None, None, None, None))
|
|
320
376
|
simulated_clock = SimulatedTimeProvider(np.datetime64(self.start, "ns"))
|
|
321
377
|
|
|
@@ -325,6 +381,11 @@ class SimulationRunner:
|
|
|
325
381
|
|
|
326
382
|
scheduler = SimulatedScheduler(channel, lambda: simulated_clock.time().item())
|
|
327
383
|
|
|
384
|
+
data_source = IterableSimulationData(
|
|
385
|
+
self.data_config.data_providers,
|
|
386
|
+
open_close_time_indent_secs=self.data_config.adjusted_open_close_time_indent_secs,
|
|
387
|
+
)
|
|
388
|
+
|
|
328
389
|
brokers = []
|
|
329
390
|
for exchange in self.setup.exchanges:
|
|
330
391
|
_exchange_account = account.get_account_processor(exchange)
|
|
@@ -349,7 +410,7 @@ class SimulationRunner:
|
|
|
349
410
|
)
|
|
350
411
|
|
|
351
412
|
# - get aux data provider
|
|
352
|
-
|
|
413
|
+
self._aux_data_reader = self.data_config.get_timeguarded_aux_reader(simulated_clock)
|
|
353
414
|
|
|
354
415
|
# - it will store simulation results into memory
|
|
355
416
|
logs_writer = InMemoryLogsWriter(self.account_id, self.setup.name, "0")
|
|
@@ -401,7 +462,7 @@ class SimulationRunner:
|
|
|
401
462
|
time_provider=simulated_clock,
|
|
402
463
|
instruments=self.setup.instruments,
|
|
403
464
|
logging=StrategyLogging(logs_writer, portfolio_log_freq=self.portfolio_log_freq),
|
|
404
|
-
aux_data_provider=
|
|
465
|
+
aux_data_provider=self._aux_data_reader,
|
|
405
466
|
emitter=self.emitter,
|
|
406
467
|
strategy_state=self.strategy_state,
|
|
407
468
|
initializer=self.initializer,
|
|
@@ -487,3 +548,42 @@ class SimulationRunner:
|
|
|
487
548
|
time_provider=time_provider,
|
|
488
549
|
account_processors=_account_processors,
|
|
489
550
|
)
|
|
551
|
+
|
|
552
|
+
def _prefetch_aux_data(self):
|
|
553
|
+
# Perform prefetch of aux data if enabled
|
|
554
|
+
if self._aux_data_reader is None:
|
|
555
|
+
return
|
|
556
|
+
|
|
557
|
+
aux_reader = self._aux_data_reader
|
|
558
|
+
if isinstance(aux_reader, TimeGuardedWrapper) and isinstance(aux_reader._reader, CachedPrefetchReader):
|
|
559
|
+
aux_reader = aux_reader._reader
|
|
560
|
+
elif isinstance(aux_reader, CachedPrefetchReader):
|
|
561
|
+
aux_reader = aux_reader
|
|
562
|
+
else:
|
|
563
|
+
return
|
|
564
|
+
|
|
565
|
+
if self.data_config.prefetch_config and self.data_config.prefetch_config.enabled:
|
|
566
|
+
# Prepare prefetch arguments
|
|
567
|
+
prefetch_args = self.data_config.prefetch_config.args.copy()
|
|
568
|
+
|
|
569
|
+
# Add exchange info if available from instruments
|
|
570
|
+
if self.setup.instruments and "exchange" not in prefetch_args:
|
|
571
|
+
# Get exchange from first instrument
|
|
572
|
+
first_exchange = self.setup.instruments[0].exchange
|
|
573
|
+
if first_exchange:
|
|
574
|
+
prefetch_args["exchange"] = first_exchange
|
|
575
|
+
|
|
576
|
+
logger.info(
|
|
577
|
+
f"Prefetching aux data: {self.data_config.prefetch_config.aux_data_names} for period {self.start} to {self.stop}"
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
# Perform the prefetch
|
|
582
|
+
aux_reader.prefetch_aux_data(
|
|
583
|
+
self.data_config.prefetch_config.aux_data_names,
|
|
584
|
+
start=str(self.start),
|
|
585
|
+
stop=str(self.stop),
|
|
586
|
+
**prefetch_args,
|
|
587
|
+
)
|
|
588
|
+
except Exception as e:
|
|
589
|
+
logger.warning(f"Prefetch failed: {e}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class NoDataContinue:
|
|
7
|
+
"""Sentinel indicating no data streams available but simulation should continue.
|
|
8
|
+
|
|
9
|
+
This is used when all instruments are unsubscribed but there may still be
|
|
10
|
+
scheduled events to process before the simulation stop time.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, next_scheduled_time: Optional[int] = None):
|
|
14
|
+
"""Initialize the sentinel.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
next_scheduled_time: The next scheduled event time in nanoseconds,
|
|
18
|
+
or None if no scheduled events exist.
|
|
19
|
+
"""
|
|
20
|
+
self.next_scheduled_time = next_scheduled_time
|
|
21
|
+
|
|
22
|
+
def __repr__(self) -> str:
|
|
23
|
+
return f"NoDataContinue(next_scheduled_time={self.next_scheduled_time})"
|
|
@@ -3,6 +3,7 @@ from typing import Any, Iterator
|
|
|
3
3
|
import pandas as pd
|
|
4
4
|
|
|
5
5
|
from qubx import logger
|
|
6
|
+
from qubx.backtester.sentinels import NoDataContinue
|
|
6
7
|
from qubx.core.basics import DataType, Instrument, MarketType, Timestamped
|
|
7
8
|
from qubx.core.exceptions import SimulationError
|
|
8
9
|
from qubx.data.composite import IteratedDataStreamsSlicer
|
|
@@ -447,6 +448,17 @@ class IterableSimulationData(Iterator):
|
|
|
447
448
|
try:
|
|
448
449
|
while data := next(self._slicing_iterator): # type: ignore
|
|
449
450
|
k, t, v = data
|
|
451
|
+
|
|
452
|
+
# Check if we've reached or exceeded the stop time
|
|
453
|
+
# It's commented out because we expect data readers to stop on their own
|
|
454
|
+
# if self._stop is not None and t > self._stop.value:
|
|
455
|
+
# raise StopIteration
|
|
456
|
+
|
|
457
|
+
# Handle NoDataContinue sentinel
|
|
458
|
+
if isinstance(v, NoDataContinue):
|
|
459
|
+
# Return the sentinel as the event - the runner will detect it with isinstance
|
|
460
|
+
return None, "", v, False
|
|
461
|
+
|
|
450
462
|
instr, fetcher, subt = self._instruments[k]
|
|
451
463
|
data_type = fetcher._producing_data_type
|
|
452
464
|
_is_historical = False
|
|
@@ -11,6 +11,7 @@ from qubx.core.basics import (
|
|
|
11
11
|
TransactionCostsCalculator,
|
|
12
12
|
dt_64,
|
|
13
13
|
)
|
|
14
|
+
from qubx.core.exceptions import OrderNotFound
|
|
14
15
|
from qubx.core.series import Bar, OrderBook, Quote, Trade, TradeArray
|
|
15
16
|
|
|
16
17
|
|
|
@@ -155,8 +156,7 @@ class BasicSimulatedExchange(ISimulatedExchange):
|
|
|
155
156
|
if order.id == order_id:
|
|
156
157
|
return self._process_ome_response(o.cancel_order(order_id))
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
return None
|
|
159
|
+
raise OrderNotFound(f"Order '{order_id}' not found")
|
|
160
160
|
|
|
161
161
|
ome = self._ome.get(instrument)
|
|
162
162
|
if ome is None:
|
|
@@ -165,7 +165,10 @@ class BasicSimulatedExchange(ISimulatedExchange):
|
|
|
165
165
|
)
|
|
166
166
|
|
|
167
167
|
# - cancel order in OME and remove from the map to free memory
|
|
168
|
-
|
|
168
|
+
result = self._process_ome_response(ome.cancel_order(order_id))
|
|
169
|
+
if result is None:
|
|
170
|
+
raise OrderNotFound(f"Order '{order_id}' not found")
|
|
171
|
+
return result
|
|
169
172
|
|
|
170
173
|
def _process_ome_response(self, report: SimulatedExecutionReport | None) -> SimulatedExecutionReport | None:
|
|
171
174
|
if report is not None:
|
qubx/backtester/simulator.py
CHANGED
|
@@ -11,7 +11,8 @@ from qubx.core.metrics import TradingSessionResult
|
|
|
11
11
|
from qubx.data.readers import DataReader
|
|
12
12
|
from qubx.emitters.inmemory import InMemoryMetricEmitter
|
|
13
13
|
from qubx.utils.misc import ProgressParallel, Stopwatch, get_current_user
|
|
14
|
-
from qubx.utils.
|
|
14
|
+
from qubx.utils.runner.configs import PrefetchConfig
|
|
15
|
+
from qubx.utils.time import handle_start_stop, to_utc_naive
|
|
15
16
|
|
|
16
17
|
from .runner import SimulationRunner
|
|
17
18
|
from .utils import (
|
|
@@ -32,10 +33,10 @@ def simulate(
|
|
|
32
33
|
strategies: StrategiesDecls_t,
|
|
33
34
|
data: DataDecls_t,
|
|
34
35
|
capital: float | dict[str, float],
|
|
35
|
-
instruments: list[str] | list[Instrument] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
|
|
36
|
-
commissions: str | dict[str, str | None] | None,
|
|
37
36
|
start: str | pd.Timestamp,
|
|
38
37
|
stop: str | pd.Timestamp | None = None,
|
|
38
|
+
instruments: list[str] | list[Instrument] | dict[ExchangeName_t, list[SymbolOrInstrument_t]] | None = None,
|
|
39
|
+
commissions: str | dict[str, str | None] | None = None,
|
|
39
40
|
exchange: ExchangeName_t | list[ExchangeName_t] | None = None,
|
|
40
41
|
base_currency: str = "USDT",
|
|
41
42
|
n_jobs: int = 1,
|
|
@@ -52,6 +53,7 @@ def simulate(
|
|
|
52
53
|
enable_inmemory_emitter: bool = False,
|
|
53
54
|
emitter_stats_interval: str = "1h",
|
|
54
55
|
run_separate_instruments: bool = False,
|
|
56
|
+
prefetch_config: PrefetchConfig | None = None,
|
|
55
57
|
) -> list[TradingSessionResult]:
|
|
56
58
|
"""
|
|
57
59
|
Backtest utility for trading strategies or signals using historical data.
|
|
@@ -80,6 +82,7 @@ def simulate(
|
|
|
80
82
|
- enable_inmemory_emitter (bool): If True, attaches an in-memory metric emitter and returns its dataframe in TradingSessionResult.emitter_data.
|
|
81
83
|
- emitter_stats_interval (str): Interval for emitting stats in the in-memory emitter (default: "1h").
|
|
82
84
|
- run_separate_instruments (bool): If True, creates separate simulation setups for each instrument, default is False.
|
|
85
|
+
- prefetch_config (dict[str, Any] | None): Configuration for prefetching auxiliary data, default is None.
|
|
83
86
|
|
|
84
87
|
Returns:
|
|
85
88
|
- list[TradingSessionResult]: A list of TradingSessionResult objects containing the results of each simulation setup.
|
|
@@ -91,6 +94,9 @@ def simulate(
|
|
|
91
94
|
# - we need to reset stopwatch
|
|
92
95
|
Stopwatch().reset()
|
|
93
96
|
|
|
97
|
+
if instruments is None:
|
|
98
|
+
instruments = []
|
|
99
|
+
|
|
94
100
|
# - process instruments:
|
|
95
101
|
_instruments, _exchanges = find_instruments_and_exchanges(instruments, exchange)
|
|
96
102
|
|
|
@@ -103,7 +109,9 @@ def simulate(
|
|
|
103
109
|
raise SimulationError(_msg)
|
|
104
110
|
|
|
105
111
|
# - recognize provided data
|
|
106
|
-
data_setup = recognize_simulation_data_config(
|
|
112
|
+
data_setup = recognize_simulation_data_config(
|
|
113
|
+
data, _instruments, open_close_time_indent_secs, aux_data, prefetch_config
|
|
114
|
+
)
|
|
107
115
|
|
|
108
116
|
# - recognize setup: it can be either a strategy or set of signals
|
|
109
117
|
simulation_setups = recognize_simulation_configuration(
|
|
@@ -133,7 +141,7 @@ def simulate(
|
|
|
133
141
|
# - preprocess start and stop and convert to datetime if necessary
|
|
134
142
|
if stop is None:
|
|
135
143
|
# - check stop time : here we try to backtest till now (may be we need to get max available time from data reader ?)
|
|
136
|
-
stop = pd.Timestamp.now(tz="UTC")
|
|
144
|
+
stop = to_utc_naive(pd.Timestamp.now(tz="UTC"))
|
|
137
145
|
|
|
138
146
|
_start, _stop = handle_start_stop(start, stop, convert=pd.Timestamp)
|
|
139
147
|
assert isinstance(_start, pd.Timestamp) and isinstance(_stop, pd.Timestamp), "Invalid start and stop times"
|
|
@@ -229,7 +237,7 @@ def _adjust_start_date_for_min_instrument_onboard(setup: SimulationSetup, start:
|
|
|
229
237
|
Adjust the start date for the simulation to the onboard date of the instrument with the minimum onboard date.
|
|
230
238
|
"""
|
|
231
239
|
onboard_dates = [
|
|
232
|
-
pd.Timestamp(instrument.onboard_date)
|
|
240
|
+
to_utc_naive(pd.Timestamp(instrument.onboard_date))
|
|
233
241
|
for instrument in setup.instruments
|
|
234
242
|
if instrument.onboard_date is not None
|
|
235
243
|
]
|
qubx/backtester/utils.py
CHANGED
|
@@ -24,9 +24,10 @@ from qubx.core.lookups import lookup
|
|
|
24
24
|
from qubx.core.series import OHLCV, Bar, Quote, Trade
|
|
25
25
|
from qubx.core.utils import time_delta_to_str
|
|
26
26
|
from qubx.data import TardisMachineReader
|
|
27
|
-
from qubx.data.helpers import
|
|
27
|
+
from qubx.data.helpers import CachedPrefetchReader, TimeGuardedWrapper
|
|
28
28
|
from qubx.data.hft import HftDataReader
|
|
29
29
|
from qubx.data.readers import AsDict, DataReader, InMemoryDataFrameReader
|
|
30
|
+
from qubx.utils.runner.configs import PrefetchConfig
|
|
30
31
|
from qubx.utils.time import infer_series_frequency, timedelta_to_crontab
|
|
31
32
|
|
|
32
33
|
SymbolOrInstrument_t: TypeAlias = str | Instrument
|
|
@@ -108,14 +109,31 @@ class SimulationDataConfig:
|
|
|
108
109
|
default_warmups: dict[str, str] # default warmups periods
|
|
109
110
|
open_close_time_indent_secs: int # open/close ticks shift in seconds
|
|
110
111
|
adjusted_open_close_time_indent_secs: int # adjusted open/close ticks shift in seconds
|
|
111
|
-
aux_data_provider:
|
|
112
|
+
aux_data_provider: DataReader | None = None # auxiliary data provider
|
|
113
|
+
prefetch_config: PrefetchConfig | None = None # prefetch configuration
|
|
112
114
|
|
|
113
115
|
def get_timeguarded_aux_reader(self, time_provider: ITimeProvider) -> TimeGuardedWrapper | None:
|
|
114
116
|
_aux = None
|
|
115
117
|
if self.aux_data_provider is not None:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
aux_reader = self.aux_data_provider
|
|
119
|
+
|
|
120
|
+
# Wrap with CachedPrefetchReader if not already wrapped
|
|
121
|
+
if not isinstance(aux_reader, CachedPrefetchReader):
|
|
122
|
+
prefetch_period = "1w"
|
|
123
|
+
cache_size_mb = 100
|
|
124
|
+
|
|
125
|
+
# Get prefetch configuration if available
|
|
126
|
+
if self.prefetch_config:
|
|
127
|
+
prefetch_period = self.prefetch_config.prefetch_period
|
|
128
|
+
cache_size_mb = self.prefetch_config.cache_size_mb
|
|
129
|
+
|
|
130
|
+
aux_reader = CachedPrefetchReader(
|
|
131
|
+
aux_reader,
|
|
132
|
+
prefetch_period=prefetch_period,
|
|
133
|
+
cache_size_mb=cache_size_mb
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
_aux = TimeGuardedWrapper(aux_reader, time_guard=time_provider)
|
|
119
137
|
return _aux
|
|
120
138
|
# fmt: on
|
|
121
139
|
|
|
@@ -777,6 +795,7 @@ def recognize_simulation_data_config(
|
|
|
777
795
|
instruments: list[Instrument],
|
|
778
796
|
open_close_time_indent_secs: int = 1,
|
|
779
797
|
aux_data: DataReader | None = None,
|
|
798
|
+
prefetch_config: PrefetchConfig | None = None,
|
|
780
799
|
) -> SimulationDataConfig:
|
|
781
800
|
"""
|
|
782
801
|
Recognizes and configures simulation data based on the provided declarations.
|
|
@@ -920,5 +939,6 @@ def recognize_simulation_data_config(
|
|
|
920
939
|
|
|
921
940
|
# - just pass it to config, TODO: we need to think how to handle auxiliary data provider better
|
|
922
941
|
_setup_defaults.aux_data_provider = aux_data # type: ignore
|
|
942
|
+
_setup_defaults.prefetch_config = prefetch_config
|
|
923
943
|
|
|
924
944
|
return _setup_defaults
|
qubx/connectors/ccxt/data.py
CHANGED
|
@@ -444,7 +444,7 @@ class CcxtDataProvider(IDataProvider):
|
|
|
444
444
|
logger.info(f"<yellow>{self._exchange_id}</yellow> {name} listening has been stopped")
|
|
445
445
|
break
|
|
446
446
|
except (NetworkError, ExchangeError, ExchangeNotAvailable) as e:
|
|
447
|
-
logger.error(f"<yellow>{self._exchange_id}</yellow> Error in {name} : {e}")
|
|
447
|
+
logger.error(f"<yellow>{self._exchange_id}</yellow> {e.__class__.__name__} :: Error in {name} : {e}")
|
|
448
448
|
await asyncio.sleep(1)
|
|
449
449
|
continue
|
|
450
450
|
except Exception as e:
|
qubx/connectors/ccxt/reader.py
CHANGED
|
@@ -10,7 +10,7 @@ from qubx.core.basics import DataType, Instrument
|
|
|
10
10
|
from qubx.data.readers import DataReader, DataTransformer
|
|
11
11
|
from qubx.data.registry import reader
|
|
12
12
|
from qubx.utils.misc import AsyncThreadLoop
|
|
13
|
-
from qubx.utils.time import handle_start_stop
|
|
13
|
+
from qubx.utils.time import handle_start_stop, now_utc
|
|
14
14
|
|
|
15
15
|
from .factory import get_ccxt_exchange
|
|
16
16
|
from .utils import ccxt_find_instrument, instrument_to_ccxt_symbol
|
|
@@ -123,7 +123,7 @@ class CcxtDataReader(DataReader):
|
|
|
123
123
|
if dtype != "ohlc":
|
|
124
124
|
return None, None
|
|
125
125
|
|
|
126
|
-
end_time =
|
|
126
|
+
end_time = now_utc()
|
|
127
127
|
start_time = end_time - self._max_history
|
|
128
128
|
return start_time.to_datetime64(), end_time.to_datetime64()
|
|
129
129
|
|
|
@@ -153,14 +153,14 @@ class CcxtDataReader(DataReader):
|
|
|
153
153
|
self, start: str | None, stop: str | None, timeframe: pd.Timedelta
|
|
154
154
|
) -> tuple[pd.Timestamp, pd.Timestamp]:
|
|
155
155
|
if not stop:
|
|
156
|
-
stop =
|
|
156
|
+
stop = now_utc().isoformat()
|
|
157
157
|
_start, _stop = handle_start_stop(start, stop, convert=lambda x: pd.Timestamp(x))
|
|
158
158
|
assert isinstance(_stop, pd.Timestamp)
|
|
159
159
|
if not _start:
|
|
160
160
|
_start = _stop - timeframe * self._max_bars
|
|
161
161
|
assert isinstance(_start, pd.Timestamp)
|
|
162
162
|
|
|
163
|
-
if _start < (_max_time :=
|
|
163
|
+
if _start < (_max_time := now_utc() - self._max_history):
|
|
164
164
|
_start = _max_time
|
|
165
165
|
|
|
166
166
|
return _start, _stop
|
qubx/connectors/ccxt/utils.py
CHANGED
|
@@ -3,7 +3,6 @@ from typing import Any, Dict, List
|
|
|
3
3
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
import pandas as pd
|
|
6
|
-
from numba import njit
|
|
7
6
|
|
|
8
7
|
import ccxt.pro as cxp
|
|
9
8
|
from ccxt import BadSymbol
|
|
@@ -24,6 +23,7 @@ from qubx.utils.marketdata.ccxt import (
|
|
|
24
23
|
ccxt_symbol_to_instrument,
|
|
25
24
|
)
|
|
26
25
|
from qubx.utils.orderbook import accumulate_orderbook_levels
|
|
26
|
+
from qubx.utils.time import to_utc_naive
|
|
27
27
|
|
|
28
28
|
from .exceptions import (
|
|
29
29
|
CcxtLiquidationParsingError,
|
|
@@ -244,7 +244,7 @@ def ccxt_convert_orderbook(
|
|
|
244
244
|
|
|
245
245
|
def ccxt_convert_liquidation(liq: dict[str, Any]) -> Liquidation:
|
|
246
246
|
try:
|
|
247
|
-
_dt = pd.Timestamp(liq["datetime"])
|
|
247
|
+
_dt = to_utc_naive(pd.Timestamp(liq["datetime"])).asm8
|
|
248
248
|
return Liquidation(
|
|
249
249
|
time=_dt,
|
|
250
250
|
price=liq["price"],
|
|
@@ -265,7 +265,7 @@ def ccxt_convert_ticker(ticker: dict[str, Any]) -> Quote:
|
|
|
265
265
|
Quote: The converted Quote object.
|
|
266
266
|
"""
|
|
267
267
|
return Quote(
|
|
268
|
-
time=pd.Timestamp(ticker["datetime"])
|
|
268
|
+
time=to_utc_naive(pd.Timestamp(ticker["datetime"])).asm8,
|
|
269
269
|
bid=ticker["bid"],
|
|
270
270
|
ask=ticker["ask"],
|
|
271
271
|
bid_size=ticker["bidVolume"],
|
qubx/core/basics.py
CHANGED
|
@@ -48,22 +48,18 @@ class FundingRate:
|
|
|
48
48
|
class FundingPayment:
|
|
49
49
|
"""
|
|
50
50
|
Represents a funding payment for a perpetual swap position.
|
|
51
|
-
|
|
51
|
+
|
|
52
52
|
Based on QuestDB schema: timestamp, symbol, funding_rate, funding_interval_hours
|
|
53
53
|
"""
|
|
54
|
+
|
|
54
55
|
time: dt_64
|
|
55
|
-
symbol: str
|
|
56
56
|
funding_rate: float
|
|
57
57
|
funding_interval_hours: int
|
|
58
|
-
|
|
58
|
+
|
|
59
59
|
def __post_init__(self):
|
|
60
|
-
# Validation logic
|
|
61
|
-
if not self.symbol or self.symbol.strip() == '':
|
|
62
|
-
raise ValueError("Symbol cannot be empty")
|
|
63
|
-
|
|
64
60
|
if abs(self.funding_rate) > 1.0:
|
|
65
61
|
raise ValueError(f"Invalid funding rate: {self.funding_rate} (must be between -1.0 and 1.0)")
|
|
66
|
-
|
|
62
|
+
|
|
67
63
|
if self.funding_interval_hours <= 0:
|
|
68
64
|
raise ValueError(f"Invalid funding interval: {self.funding_interval_hours} (must be positive)")
|
|
69
65
|
|
|
@@ -575,7 +571,7 @@ class Position:
|
|
|
575
571
|
# funding payment tracking
|
|
576
572
|
cumulative_funding: float = 0.0 # cumulative funding paid (negative) or received (positive)
|
|
577
573
|
funding_payments: list[FundingPayment] # history of funding payments
|
|
578
|
-
last_funding_time: dt_64 = np.datetime64(
|
|
574
|
+
last_funding_time: dt_64 = np.datetime64("NaT") # last funding payment time
|
|
579
575
|
|
|
580
576
|
# - helpers for position processing
|
|
581
577
|
_qty_multiplier: float = 1.0
|
|
@@ -616,7 +612,7 @@ class Position:
|
|
|
616
612
|
self.maint_margin = 0.0
|
|
617
613
|
self.cumulative_funding = 0.0
|
|
618
614
|
self.funding_payments = []
|
|
619
|
-
self.last_funding_time = np.datetime64(
|
|
615
|
+
self.last_funding_time = np.datetime64("NaT") # type: ignore
|
|
620
616
|
self.__pos_incr_qty = 0
|
|
621
617
|
self._qty_multiplier = self.instrument.contract_size
|
|
622
618
|
|
|
@@ -634,8 +630,8 @@ class Position:
|
|
|
634
630
|
self.last_update_conversion_rate = pos.last_update_conversion_rate
|
|
635
631
|
self.maint_margin = pos.maint_margin
|
|
636
632
|
self.cumulative_funding = pos.cumulative_funding
|
|
637
|
-
self.funding_payments = pos.funding_payments.copy() if hasattr(pos,
|
|
638
|
-
self.last_funding_time = pos.last_funding_time if hasattr(pos,
|
|
633
|
+
self.funding_payments = pos.funding_payments.copy() if hasattr(pos, "funding_payments") else []
|
|
634
|
+
self.last_funding_time = pos.last_funding_time if hasattr(pos, "last_funding_time") else np.datetime64("NaT")
|
|
639
635
|
self.__pos_incr_qty = pos.__pos_incr_qty
|
|
640
636
|
|
|
641
637
|
@property
|
|
@@ -770,48 +766,47 @@ class Position:
|
|
|
770
766
|
def apply_funding_payment(self, funding_payment: FundingPayment, mark_price: float) -> float:
|
|
771
767
|
"""
|
|
772
768
|
Apply a funding payment to this position.
|
|
773
|
-
|
|
769
|
+
|
|
774
770
|
For perpetual swaps:
|
|
775
771
|
- Positive funding rate: longs pay shorts
|
|
776
772
|
- Negative funding rate: shorts pay longs
|
|
777
|
-
|
|
773
|
+
|
|
778
774
|
Args:
|
|
779
775
|
funding_payment: The funding payment event
|
|
780
776
|
mark_price: The mark price at the time of funding
|
|
781
|
-
|
|
777
|
+
|
|
782
778
|
Returns:
|
|
783
779
|
The funding amount (negative if paying, positive if receiving)
|
|
784
780
|
"""
|
|
785
781
|
if abs(self.quantity) < self.instrument.min_size:
|
|
786
782
|
return 0.0
|
|
787
|
-
|
|
783
|
+
|
|
788
784
|
# Calculate funding amount
|
|
789
785
|
# Funding = Position Size * Mark Price * Funding Rate
|
|
790
786
|
funding_amount = self.quantity * mark_price * funding_payment.funding_rate
|
|
791
|
-
|
|
787
|
+
|
|
792
788
|
# For long positions with positive funding rate, amount is negative (paying)
|
|
793
789
|
# For short positions with positive funding rate, amount is positive (receiving)
|
|
794
790
|
funding_amount = -funding_amount
|
|
795
|
-
|
|
791
|
+
|
|
796
792
|
# Update position state
|
|
797
793
|
self.cumulative_funding += funding_amount
|
|
798
794
|
self.r_pnl += funding_amount # Funding affects realized PnL
|
|
799
|
-
self.pnl += funding_amount
|
|
800
|
-
|
|
795
|
+
self.pnl += funding_amount # And total PnL
|
|
796
|
+
|
|
801
797
|
# Track funding payment history (limit to last 100)
|
|
802
798
|
self.funding_payments.append(funding_payment)
|
|
803
799
|
if len(self.funding_payments) > 100:
|
|
804
800
|
self.funding_payments = self.funding_payments[-100:]
|
|
805
|
-
|
|
801
|
+
|
|
806
802
|
self.last_funding_time = funding_payment.time
|
|
807
|
-
|
|
803
|
+
|
|
808
804
|
return funding_amount
|
|
809
805
|
|
|
810
806
|
def get_funding_pnl(self) -> float:
|
|
811
807
|
"""Get cumulative funding PnL for this position."""
|
|
812
808
|
return self.cumulative_funding
|
|
813
809
|
|
|
814
|
-
|
|
815
810
|
def is_open(self) -> bool:
|
|
816
811
|
return abs(self.quantity) > self.instrument.min_size
|
|
817
812
|
|