Qubx 0.6.64__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.

Files changed (42) hide show
  1. qubx/backtester/broker.py +9 -2
  2. qubx/backtester/data.py +1 -1
  3. qubx/backtester/runner.py +116 -12
  4. qubx/backtester/sentinels.py +23 -0
  5. qubx/backtester/simulated_data.py +23 -9
  6. qubx/backtester/simulated_exchange.py +6 -3
  7. qubx/backtester/simulator.py +39 -6
  8. qubx/backtester/utils.py +48 -15
  9. qubx/connectors/ccxt/data.py +1 -1
  10. qubx/connectors/ccxt/reader.py +4 -4
  11. qubx/connectors/ccxt/utils.py +3 -3
  12. qubx/core/basics.py +18 -23
  13. qubx/core/context.py +24 -0
  14. qubx/core/helpers.py +21 -4
  15. qubx/core/initializer.py +86 -1
  16. qubx/core/interfaces.py +82 -0
  17. qubx/core/metrics.py +110 -5
  18. qubx/core/mixins/processing.py +96 -7
  19. qubx/core/mixins/trading.py +34 -4
  20. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  21. qubx/core/stale_data_detector.py +418 -0
  22. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  23. qubx/data/__init__.py +2 -1
  24. qubx/data/composite.py +7 -4
  25. qubx/data/helpers.py +1618 -7
  26. qubx/data/readers.py +21 -8
  27. qubx/emitters/base.py +1 -1
  28. qubx/gathering/simplest.py +3 -1
  29. qubx/restarts/state_resolvers.py +5 -1
  30. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  31. qubx/trackers/__init__.py +2 -0
  32. qubx/trackers/riskctrl.py +13 -2
  33. qubx/trackers/sizers.py +56 -0
  34. qubx/utils/runner/_jupyter_runner.pyt +9 -2
  35. qubx/utils/runner/configs.py +11 -0
  36. qubx/utils/runner/runner.py +7 -0
  37. qubx/utils/time.py +56 -0
  38. {qubx-0.6.64.dist-info → qubx-0.6.66.dist-info}/METADATA +1 -1
  39. {qubx-0.6.64.dist-info → qubx-0.6.66.dist-info}/RECORD +42 -40
  40. {qubx-0.6.64.dist-info → qubx-0.6.66.dist-info}/LICENSE +0 -0
  41. {qubx-0.6.64.dist-info → qubx-0.6.66.dist-info}/WHEEL +0 -0
  42. {qubx-0.6.64.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
- self._send_execution_report(order_update := self._exchange.cancel_order(order_id))
67
- return order_update.order if order_update is not None else None
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/data.py CHANGED
@@ -71,7 +71,7 @@ class SimulatedDataProvider(IDataProvider):
71
71
  # Check if the instrument was actually subscribed (not filtered out)
72
72
  if not self.has_subscription(i, subscription_type):
73
73
  continue
74
-
74
+
75
75
  h_data = self._data_source.peek_historical_data(i, subscription_type)
76
76
  if h_data:
77
77
  # _s_type = DataType.from_str(subscription_type)[0]
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 = pd.Timestamp(start), pd.Timestamp(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 = pd.Timestamp(start)
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
- if not _run(instrument, data_type, event, is_hist):
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
- if not _run(instrument, data_type, event, is_hist):
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 = pd.Timestamp(event.time)
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
- _aux_data = self.data_config.get_timeguarded_aux_reader(simulated_clock)
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=_aux_data,
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,
@@ -422,6 +483,10 @@ class SimulationRunner:
422
483
  logger.debug(f"[<y>simulator</y>] :: Setting default schedule: {self.data_config.default_trigger_schedule}")
423
484
  ctx.set_event_schedule(self.data_config.default_trigger_schedule)
424
485
 
486
+ if self.setup.enable_funding:
487
+ logger.debug("[<y>simulator</y>] :: Enabling funding rate simulation")
488
+ ctx.subscribe(DataType.FUNDING_PAYMENT)
489
+
425
490
  self.logs_writer = logs_writer
426
491
  self.channel = channel
427
492
  self.time_provider = simulated_clock
@@ -483,3 +548,42 @@ class SimulationRunner:
483
548
  time_provider=time_provider,
484
549
  account_processors=_account_processors,
485
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
@@ -259,14 +260,14 @@ class IterableSimulationData(Iterator):
259
260
  def _filter_instruments_for_subscription(self, data_type: str, instruments: list[Instrument]) -> list[Instrument]:
260
261
  """
261
262
  Filter instruments based on subscription type requirements.
262
-
263
+
263
264
  For funding payment subscriptions, only SWAP instruments are supported since
264
265
  funding payments are specific to perpetual swap contracts.
265
-
266
+
266
267
  Args:
267
268
  data_type: The data type being subscribed to
268
269
  instruments: List of instruments to filter
269
-
270
+
270
271
  Returns:
271
272
  Filtered list of instruments appropriate for the subscription type
272
273
  """
@@ -275,23 +276,25 @@ class IterableSimulationData(Iterator):
275
276
  original_count = len(instruments)
276
277
  filtered_instruments = [i for i in instruments if i.market_type == MarketType.SWAP]
277
278
  filtered_count = len(filtered_instruments)
278
-
279
+
279
280
  # Log if instruments were filtered out (debug info)
280
281
  if filtered_count < original_count:
281
- logger.debug(f"Filtered {original_count - filtered_count} non-SWAP instruments from funding payment subscription")
282
-
282
+ logger.debug(
283
+ f"Filtered {original_count - filtered_count} non-SWAP instruments from funding payment subscription"
284
+ )
285
+
283
286
  return filtered_instruments
284
-
287
+
285
288
  # For all other subscription types, return instruments unchanged
286
289
  return instruments
287
290
 
288
291
  def add_instruments_for_subscription(self, subscription: str, instruments: list[Instrument] | Instrument):
289
292
  instruments = instruments if isinstance(instruments, list) else [instruments]
290
293
  _subt_key, _data_type, _params = self._parse_subscription_spec(subscription)
291
-
294
+
292
295
  # Filter instruments based on subscription type requirements
293
296
  instruments = self._filter_instruments_for_subscription(_data_type, instruments)
294
-
297
+
295
298
  # If no instruments remain after filtering, skip subscription
296
299
  if not instruments:
297
300
  return
@@ -445,6 +448,17 @@ class IterableSimulationData(Iterator):
445
448
  try:
446
449
  while data := next(self._slicing_iterator): # type: ignore
447
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
+
448
462
  instr, fetcher, subt = self._instruments[k]
449
463
  data_type = fetcher._producing_data_type
450
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
- logger.warning(f"[<y>{self.__class__.__name__}</y>] :: cancel_order :: can't find order '{order_id}'!")
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
- return self._process_ome_response(ome.cancel_order(order_id))
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:
@@ -4,13 +4,15 @@ import pandas as pd
4
4
  from joblib import delayed
5
5
 
6
6
  from qubx import QubxLogConfig, logger
7
+ from qubx.backtester.utils import SetupTypes
7
8
  from qubx.core.basics import Instrument
8
9
  from qubx.core.exceptions import SimulationError
9
10
  from qubx.core.metrics import TradingSessionResult
10
11
  from qubx.data.readers import DataReader
11
12
  from qubx.emitters.inmemory import InMemoryMetricEmitter
12
13
  from qubx.utils.misc import ProgressParallel, Stopwatch, get_current_user
13
- from qubx.utils.time import handle_start_stop
14
+ from qubx.utils.runner.configs import PrefetchConfig
15
+ from qubx.utils.time import handle_start_stop, to_utc_naive
14
16
 
15
17
  from .runner import SimulationRunner
16
18
  from .utils import (
@@ -31,10 +33,10 @@ def simulate(
31
33
  strategies: StrategiesDecls_t,
32
34
  data: DataDecls_t,
33
35
  capital: float | dict[str, float],
34
- instruments: list[str] | list[Instrument] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
35
- commissions: str | dict[str, str | None] | None,
36
36
  start: str | pd.Timestamp,
37
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,
38
40
  exchange: ExchangeName_t | list[ExchangeName_t] | None = None,
39
41
  base_currency: str = "USDT",
40
42
  n_jobs: int = 1,
@@ -43,6 +45,7 @@ def simulate(
43
45
  accurate_stop_orders_execution: bool = False,
44
46
  signal_timeframe: str = "1Min",
45
47
  open_close_time_indent_secs=1,
48
+ enable_funding: bool = False,
46
49
  debug: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "WARNING",
47
50
  show_latency_report: bool = False,
48
51
  portfolio_log_freq: str = "5Min",
@@ -50,6 +53,7 @@ def simulate(
50
53
  enable_inmemory_emitter: bool = False,
51
54
  emitter_stats_interval: str = "1h",
52
55
  run_separate_instruments: bool = False,
56
+ prefetch_config: PrefetchConfig | None = None,
53
57
  ) -> list[TradingSessionResult]:
54
58
  """
55
59
  Backtest utility for trading strategies or signals using historical data.
@@ -70,6 +74,7 @@ def simulate(
70
74
  - accurate_stop_orders_execution (bool): If True, enables more accurate stop order execution simulation.
71
75
  - signal_timeframe (str): Timeframe for signals, default is "1Min".
72
76
  - open_close_time_indent_secs (int): Time indent in seconds for open/close times, default is 1.
77
+ - enable_funding (bool): If True, enables funding rate simulation, default is False.
73
78
  - debug (Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None): Logging level for debugging.
74
79
  - show_latency_report: If True, shows simulator's latency report.
75
80
  - portfolio_log_freq (str): Frequency for portfolio logging, default is "5Min".
@@ -77,6 +82,7 @@ def simulate(
77
82
  - enable_inmemory_emitter (bool): If True, attaches an in-memory metric emitter and returns its dataframe in TradingSessionResult.emitter_data.
78
83
  - emitter_stats_interval (str): Interval for emitting stats in the in-memory emitter (default: "1h").
79
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.
80
86
 
81
87
  Returns:
82
88
  - list[TradingSessionResult]: A list of TradingSessionResult objects containing the results of each simulation setup.
@@ -88,6 +94,9 @@ def simulate(
88
94
  # - we need to reset stopwatch
89
95
  Stopwatch().reset()
90
96
 
97
+ if instruments is None:
98
+ instruments = []
99
+
91
100
  # - process instruments:
92
101
  _instruments, _exchanges = find_instruments_and_exchanges(instruments, exchange)
93
102
 
@@ -100,7 +109,9 @@ def simulate(
100
109
  raise SimulationError(_msg)
101
110
 
102
111
  # - recognize provided data
103
- data_setup = recognize_simulation_data_config(data, _instruments, open_close_time_indent_secs, aux_data)
112
+ data_setup = recognize_simulation_data_config(
113
+ data, _instruments, open_close_time_indent_secs, aux_data, prefetch_config
114
+ )
104
115
 
105
116
  # - recognize setup: it can be either a strategy or set of signals
106
117
  simulation_setups = recognize_simulation_configuration(
@@ -114,6 +125,7 @@ def simulate(
114
125
  signal_timeframe=signal_timeframe,
115
126
  accurate_stop_orders_execution=accurate_stop_orders_execution,
116
127
  run_separate_instruments=run_separate_instruments,
128
+ enable_funding=enable_funding,
117
129
  )
118
130
  if not simulation_setups:
119
131
  logger.error(
@@ -129,7 +141,7 @@ def simulate(
129
141
  # - preprocess start and stop and convert to datetime if necessary
130
142
  if stop is None:
131
143
  # - check stop time : here we try to backtest till now (may be we need to get max available time from data reader ?)
132
- stop = pd.Timestamp.now(tz="UTC").astimezone(None)
144
+ stop = to_utc_naive(pd.Timestamp.now(tz="UTC"))
133
145
 
134
146
  _start, _stop = handle_start_stop(start, stop, convert=pd.Timestamp)
135
147
  assert isinstance(_start, pd.Timestamp) and isinstance(_stop, pd.Timestamp), "Invalid start and stop times"
@@ -204,6 +216,7 @@ def _run_setups(
204
216
  portfolio_log_freq,
205
217
  enable_inmemory_emitter,
206
218
  emitter_stats_interval,
219
+ close_data_readers=True,
207
220
  )
208
221
  for id, setup in enumerate(strategies_setups)
209
222
  )
@@ -219,6 +232,20 @@ def _run_setups(
219
232
  return successful_reports
220
233
 
221
234
 
235
+ def _adjust_start_date_for_min_instrument_onboard(setup: SimulationSetup, start: pd.Timestamp) -> pd.Timestamp:
236
+ """
237
+ Adjust the start date for the simulation to the onboard date of the instrument with the minimum onboard date.
238
+ """
239
+ onboard_dates = [
240
+ to_utc_naive(pd.Timestamp(instrument.onboard_date))
241
+ for instrument in setup.instruments
242
+ if instrument.onboard_date is not None
243
+ ]
244
+ if not onboard_dates:
245
+ return start
246
+ return max(start, min(onboard_dates))
247
+
248
+
222
249
  def _run_setup(
223
250
  setup_id: int,
224
251
  account_id: str,
@@ -231,12 +258,18 @@ def _run_setup(
231
258
  portfolio_log_freq: str,
232
259
  enable_inmemory_emitter: bool = False,
233
260
  emitter_stats_interval: str = "1h",
261
+ close_data_readers: bool = False,
234
262
  ) -> TradingSessionResult | None:
235
263
  try:
236
264
  emitter = None
237
265
  emitter_data = None
238
266
  if enable_inmemory_emitter:
239
267
  emitter = InMemoryMetricEmitter(stats_interval=emitter_stats_interval)
268
+
269
+ # TODO: this can be removed once we add some artificial data stream to move the simulation
270
+ if setup.setup_type in [SetupTypes.SIGNAL, SetupTypes.SIGNAL_AND_TRACKER]:
271
+ start = _adjust_start_date_for_min_instrument_onboard(setup, start)
272
+
240
273
  runner = SimulationRunner(
241
274
  setup=setup,
242
275
  data_config=data_setup,
@@ -252,7 +285,7 @@ def _run_setup(
252
285
  level=QubxLogConfig.get_log_level(), custom_formatter=SimulatedLogFormatter(runner.ctx).formatter
253
286
  )
254
287
 
255
- runner.run(silent=silent)
288
+ runner.run(silent=silent, close_data_readers=close_data_readers)
256
289
 
257
290
  # - service latency report
258
291
  if show_latency_report: