Qubx 0.6.63__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.65__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/data.py +5 -1
  2. qubx/backtester/ome.py +5 -3
  3. qubx/backtester/runner.py +4 -0
  4. qubx/backtester/simulated_data.py +45 -1
  5. qubx/backtester/simulator.py +50 -15
  6. qubx/backtester/utils.py +91 -35
  7. qubx/core/account.py +77 -7
  8. qubx/core/basics.py +97 -3
  9. qubx/core/context.py +8 -3
  10. qubx/core/helpers.py +11 -4
  11. qubx/core/interfaces.py +16 -2
  12. qubx/core/loggers.py +19 -16
  13. qubx/core/lookups.py +7 -7
  14. qubx/core/metrics.py +148 -5
  15. qubx/core/mixins/market.py +22 -12
  16. qubx/core/mixins/processing.py +58 -5
  17. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  18. qubx/core/series.pyi +4 -3
  19. qubx/core/series.pyx +34 -12
  20. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  21. qubx/core/utils.pyx +3 -0
  22. qubx/data/helpers.py +75 -39
  23. qubx/data/readers.py +242 -18
  24. qubx/data/registry.py +1 -1
  25. qubx/emitters/__init__.py +2 -0
  26. qubx/emitters/inmemory.py +244 -0
  27. qubx/features/core.py +11 -8
  28. qubx/features/orderbook.py +2 -1
  29. qubx/features/trades.py +1 -1
  30. qubx/gathering/simplest.py +7 -1
  31. qubx/loggers/inmemory.py +28 -13
  32. qubx/pandaz/ta.py +4 -3
  33. qubx/pandaz/utils.py +11 -0
  34. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  35. qubx/trackers/__init__.py +2 -0
  36. qubx/trackers/sizers.py +56 -0
  37. qubx/utils/time.py +3 -1
  38. {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/METADATA +1 -1
  39. {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/RECORD +42 -41
  40. {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/LICENSE +0 -0
  41. {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/WHEEL +0 -0
  42. {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/entry_points.txt +0 -0
qubx/backtester/data.py CHANGED
@@ -68,6 +68,10 @@ class SimulatedDataProvider(IDataProvider):
68
68
 
69
69
  # - provide historical data and last quote for subscribed instruments
70
70
  for i in _new_instr:
71
+ # Check if the instrument was actually subscribed (not filtered out)
72
+ if not self.has_subscription(i, subscription_type):
73
+ continue
74
+
71
75
  h_data = self._data_source.peek_historical_data(i, subscription_type)
72
76
  if h_data:
73
77
  # _s_type = DataType.from_str(subscription_type)[0]
@@ -119,7 +123,7 @@ class SimulatedDataProvider(IDataProvider):
119
123
  end = start - nbarsback * (_timeframe := pd.Timedelta(timeframe))
120
124
  _spec = f"{instrument.exchange}:{instrument.symbol}"
121
125
  return self._convert_records_to_bars(
122
- _reader.read(data_id=_spec, start=start, stop=end, transform=AsDict()), # type: ignore
126
+ _reader.read(data_id=_spec, start=start, stop=end, timeframe=timeframe, transform=AsDict()), # type: ignore
123
127
  time_as_nsec(self.time_provider.time()),
124
128
  _timeframe.asm8.item(),
125
129
  )
qubx/backtester/ome.py CHANGED
@@ -6,17 +6,17 @@ from sortedcontainers import SortedDict
6
6
 
7
7
  from qubx import logger
8
8
  from qubx.core.basics import (
9
+ OPTION_AVOID_STOP_ORDER_PRICE_VALIDATION,
9
10
  OPTION_FILL_AT_SIGNAL_PRICE,
10
11
  OPTION_SIGNAL_PRICE,
11
12
  OPTION_SKIP_PRICE_CROSS_CONTROL,
12
- OPTION_AVOID_STOP_ORDER_PRICE_VALIDATION,
13
13
  Deal,
14
14
  Instrument,
15
15
  ITimeProvider,
16
16
  Order,
17
17
  OrderSide,
18
- OrderType,
19
18
  OrderStatus,
19
+ OrderType,
20
20
  TransactionCostsCalculator,
21
21
  dt_64,
22
22
  )
@@ -208,7 +208,9 @@ class OrdersManagementEngine:
208
208
  **options,
209
209
  ) -> SimulatedExecutionReport:
210
210
  if self.bbo is None:
211
- raise ExchangeError(f"Simulator is not ready for order management - no quote for {self.instrument.symbol}")
211
+ raise SimulationError(
212
+ f"Simulator is not ready for order management - no quote for {self.instrument.symbol}"
213
+ )
212
214
 
213
215
  # - validate order parameters
214
216
  self._validate_order(order_side, order_type, amount, price, time_in_force, options)
qubx/backtester/runner.py CHANGED
@@ -422,6 +422,10 @@ class SimulationRunner:
422
422
  logger.debug(f"[<y>simulator</y>] :: Setting default schedule: {self.data_config.default_trigger_schedule}")
423
423
  ctx.set_event_schedule(self.data_config.default_trigger_schedule)
424
424
 
425
+ if self.setup.enable_funding:
426
+ logger.debug("[<y>simulator</y>] :: Enabling funding rate simulation")
427
+ ctx.subscribe(DataType.FUNDING_PAYMENT)
428
+
425
429
  self.logs_writer = logs_writer
426
430
  self.channel = channel
427
431
  self.time_provider = simulated_clock
@@ -3,11 +3,12 @@ from typing import Any, Iterator
3
3
  import pandas as pd
4
4
 
5
5
  from qubx import logger
6
- from qubx.core.basics import DataType, Instrument, Timestamped
6
+ from qubx.core.basics import DataType, Instrument, MarketType, Timestamped
7
7
  from qubx.core.exceptions import SimulationError
8
8
  from qubx.data.composite import IteratedDataStreamsSlicer
9
9
  from qubx.data.readers import (
10
10
  AsDict,
11
+ AsFundingPayments,
11
12
  AsOrderBook,
12
13
  AsQuotes,
13
14
  AsTrades,
@@ -87,6 +88,11 @@ class DataFetcher:
87
88
  self._producing_data_type = "orderbook"
88
89
  self._transformer = AsOrderBook()
89
90
 
91
+ case DataType.FUNDING_PAYMENT:
92
+ self._requested_data_type = "funding_payment"
93
+ self._producing_data_type = "funding_payment"
94
+ self._transformer = AsFundingPayments()
95
+
90
96
  case _:
91
97
  self._requested_data_type = subtype
92
98
  self._producing_data_type = subtype
@@ -250,10 +256,48 @@ class IterableSimulationData(Iterator):
250
256
  _access_key = f"{_subtype}"
251
257
  return _access_key, _subtype, _params
252
258
 
259
+ def _filter_instruments_for_subscription(self, data_type: str, instruments: list[Instrument]) -> list[Instrument]:
260
+ """
261
+ Filter instruments based on subscription type requirements.
262
+
263
+ For funding payment subscriptions, only SWAP instruments are supported since
264
+ funding payments are specific to perpetual swap contracts.
265
+
266
+ Args:
267
+ data_type: The data type being subscribed to
268
+ instruments: List of instruments to filter
269
+
270
+ Returns:
271
+ Filtered list of instruments appropriate for the subscription type
272
+ """
273
+ # Only funding payments require special filtering
274
+ if data_type == DataType.FUNDING_PAYMENT:
275
+ original_count = len(instruments)
276
+ filtered_instruments = [i for i in instruments if i.market_type == MarketType.SWAP]
277
+ filtered_count = len(filtered_instruments)
278
+
279
+ # Log if instruments were filtered out (debug info)
280
+ if filtered_count < original_count:
281
+ logger.debug(
282
+ f"Filtered {original_count - filtered_count} non-SWAP instruments from funding payment subscription"
283
+ )
284
+
285
+ return filtered_instruments
286
+
287
+ # For all other subscription types, return instruments unchanged
288
+ return instruments
289
+
253
290
  def add_instruments_for_subscription(self, subscription: str, instruments: list[Instrument] | Instrument):
254
291
  instruments = instruments if isinstance(instruments, list) else [instruments]
255
292
  _subt_key, _data_type, _params = self._parse_subscription_spec(subscription)
256
293
 
294
+ # Filter instruments based on subscription type requirements
295
+ instruments = self._filter_instruments_for_subscription(_data_type, instruments)
296
+
297
+ # If no instruments remain after filtering, skip subscription
298
+ if not instruments:
299
+ return
300
+
257
301
  fetcher = self._subtyped_fetchers.get(_subt_key)
258
302
  if not fetcher:
259
303
  _reader = self._readers.get(_data_type)
@@ -4,12 +4,13 @@ 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
8
+ from qubx.core.basics import Instrument
7
9
  from qubx.core.exceptions import SimulationError
8
10
  from qubx.core.metrics import TradingSessionResult
9
11
  from qubx.data.readers import DataReader
12
+ from qubx.emitters.inmemory import InMemoryMetricEmitter
10
13
  from qubx.utils.misc import ProgressParallel, Stopwatch, get_current_user
11
- from qubx.utils.runner.configs import EmissionConfig
12
- from qubx.utils.runner.factory import create_metric_emitters
13
14
  from qubx.utils.time import handle_start_stop
14
15
 
15
16
  from .runner import SimulationRunner
@@ -31,11 +32,11 @@ def simulate(
31
32
  strategies: StrategiesDecls_t,
32
33
  data: DataDecls_t,
33
34
  capital: float | dict[str, float],
34
- instruments: list[SymbolOrInstrument_t] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
35
+ instruments: list[str] | list[Instrument] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
35
36
  commissions: str | dict[str, str | None] | None,
36
37
  start: str | pd.Timestamp,
37
38
  stop: str | pd.Timestamp | None = None,
38
- exchange: ExchangeName_t | None = None,
39
+ exchange: ExchangeName_t | list[ExchangeName_t] | None = None,
39
40
  base_currency: str = "USDT",
40
41
  n_jobs: int = 1,
41
42
  silent: bool = False,
@@ -43,11 +44,13 @@ def simulate(
43
44
  accurate_stop_orders_execution: bool = False,
44
45
  signal_timeframe: str = "1Min",
45
46
  open_close_time_indent_secs=1,
47
+ enable_funding: bool = False,
46
48
  debug: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = "WARNING",
47
49
  show_latency_report: bool = False,
48
50
  portfolio_log_freq: str = "5Min",
49
51
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
50
- emission: EmissionConfig | None = None,
52
+ enable_inmemory_emitter: bool = False,
53
+ emitter_stats_interval: str = "1h",
51
54
  run_separate_instruments: bool = False,
52
55
  ) -> list[TradingSessionResult]:
53
56
  """
@@ -69,11 +72,13 @@ def simulate(
69
72
  - accurate_stop_orders_execution (bool): If True, enables more accurate stop order execution simulation.
70
73
  - signal_timeframe (str): Timeframe for signals, default is "1Min".
71
74
  - open_close_time_indent_secs (int): Time indent in seconds for open/close times, default is 1.
75
+ - enable_funding (bool): If True, enables funding rate simulation, default is False.
72
76
  - debug (Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None): Logging level for debugging.
73
77
  - show_latency_report: If True, shows simulator's latency report.
74
78
  - portfolio_log_freq (str): Frequency for portfolio logging, default is "5Min".
75
79
  - parallel_backend (Literal["loky", "multiprocessing"]): Backend for parallel processing, default is "multiprocessing".
76
- - emission (EmissionConfig | None): Configuration for metric emitters, default is None.
80
+ - enable_inmemory_emitter (bool): If True, attaches an in-memory metric emitter and returns its dataframe in TradingSessionResult.emitter_data.
81
+ - emitter_stats_interval (str): Interval for emitting stats in the in-memory emitter (default: "1h").
77
82
  - run_separate_instruments (bool): If True, creates separate simulation setups for each instrument, default is False.
78
83
 
79
84
  Returns:
@@ -112,6 +117,7 @@ def simulate(
112
117
  signal_timeframe=signal_timeframe,
113
118
  accurate_stop_orders_execution=accurate_stop_orders_execution,
114
119
  run_separate_instruments=run_separate_instruments,
120
+ enable_funding=enable_funding,
115
121
  )
116
122
  if not simulation_setups:
117
123
  logger.error(
@@ -143,7 +149,8 @@ def simulate(
143
149
  show_latency_report=show_latency_report,
144
150
  portfolio_log_freq=portfolio_log_freq,
145
151
  parallel_backend=parallel_backend,
146
- emission=emission,
152
+ enable_inmemory_emitter=enable_inmemory_emitter,
153
+ emitter_stats_interval=emitter_stats_interval,
147
154
  )
148
155
 
149
156
 
@@ -157,7 +164,8 @@ def _run_setups(
157
164
  show_latency_report: bool = False,
158
165
  portfolio_log_freq: str = "5Min",
159
166
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
160
- emission: EmissionConfig | None = None,
167
+ enable_inmemory_emitter: bool = False,
168
+ emitter_stats_interval: str = "1h",
161
169
  ) -> list[TradingSessionResult]:
162
170
  # loggers don't work well with joblib and multiprocessing in general because they contain
163
171
  # open file handlers that cannot be pickled. I found a solution which requires the usage of enqueue=True
@@ -179,7 +187,8 @@ def _run_setups(
179
187
  silent,
180
188
  show_latency_report,
181
189
  portfolio_log_freq,
182
- emission,
190
+ enable_inmemory_emitter,
191
+ emitter_stats_interval,
183
192
  )
184
193
  for id, setup in enumerate(strategies_setups)
185
194
  ]
@@ -197,7 +206,9 @@ def _run_setups(
197
206
  silent,
198
207
  show_latency_report,
199
208
  portfolio_log_freq,
200
- emission,
209
+ enable_inmemory_emitter,
210
+ emitter_stats_interval,
211
+ close_data_readers=True,
201
212
  )
202
213
  for id, setup in enumerate(strategies_setups)
203
214
  )
@@ -213,6 +224,20 @@ def _run_setups(
213
224
  return successful_reports
214
225
 
215
226
 
227
+ def _adjust_start_date_for_min_instrument_onboard(setup: SimulationSetup, start: pd.Timestamp) -> pd.Timestamp:
228
+ """
229
+ Adjust the start date for the simulation to the onboard date of the instrument with the minimum onboard date.
230
+ """
231
+ onboard_dates = [
232
+ pd.Timestamp(instrument.onboard_date).replace(tzinfo=None)
233
+ for instrument in setup.instruments
234
+ if instrument.onboard_date is not None
235
+ ]
236
+ if not onboard_dates:
237
+ return start
238
+ return max(start, min(onboard_dates))
239
+
240
+
216
241
  def _run_setup(
217
242
  setup_id: int,
218
243
  account_id: str,
@@ -223,13 +248,19 @@ def _run_setup(
223
248
  silent: bool,
224
249
  show_latency_report: bool,
225
250
  portfolio_log_freq: str,
226
- emission: EmissionConfig | None = None,
251
+ enable_inmemory_emitter: bool = False,
252
+ emitter_stats_interval: str = "1h",
253
+ close_data_readers: bool = False,
227
254
  ) -> TradingSessionResult | None:
228
255
  try:
229
- # Create metric emitter if configured
230
256
  emitter = None
231
- if emission is not None:
232
- emitter = create_metric_emitters(emission, setup.name)
257
+ emitter_data = None
258
+ if enable_inmemory_emitter:
259
+ emitter = InMemoryMetricEmitter(stats_interval=emitter_stats_interval)
260
+
261
+ # TODO: this can be removed once we add some artificial data stream to move the simulation
262
+ if setup.setup_type in [SetupTypes.SIGNAL, SetupTypes.SIGNAL_AND_TRACKER]:
263
+ start = _adjust_start_date_for_min_instrument_onboard(setup, start)
233
264
 
234
265
  runner = SimulationRunner(
235
266
  setup=setup,
@@ -246,7 +277,7 @@ def _run_setup(
246
277
  level=QubxLogConfig.get_log_level(), custom_formatter=SimulatedLogFormatter(runner.ctx).formatter
247
278
  )
248
279
 
249
- runner.run(silent=silent)
280
+ runner.run(silent=silent, close_data_readers=close_data_readers)
250
281
 
251
282
  # - service latency report
252
283
  if show_latency_report:
@@ -258,6 +289,9 @@ def _run_setup(
258
289
  # Filter out None values to match TradingSessionResult expected type
259
290
  commissions_for_result = {k: v for k, v in commissions_for_result.items() if v is not None}
260
291
 
292
+ if enable_inmemory_emitter and emitter is not None:
293
+ emitter_data = emitter.get_dataframe()
294
+
261
295
  return TradingSessionResult(
262
296
  setup_id,
263
297
  setup.name,
@@ -276,6 +310,7 @@ def _run_setup(
276
310
  parameters=runner.strategy_params,
277
311
  is_simulation=True,
278
312
  author=get_current_user(),
313
+ emitter_data=emitter_data,
279
314
  )
280
315
  except Exception as e:
281
316
  logger.error(f"Simulation setup {setup_id} failed with error: {e}")
qubx/backtester/utils.py CHANGED
@@ -90,6 +90,7 @@ class SimulationSetup:
90
90
  commissions: str | dict[str, str | None] | None = None
91
91
  signal_timeframe: str = "1Min"
92
92
  accurate_stop_orders_execution: bool = False
93
+ enable_funding: bool = False
93
94
 
94
95
  def __str__(self) -> str:
95
96
  return f"{self.name} {self.setup_type} capital {self.capital} {self.base_currency} for [{','.join(map(lambda x: x.symbol, self.instruments))}] @ {self.exchanges}[{self.commissions}]"
@@ -213,38 +214,81 @@ class SignalsProxy(IStrategy):
213
214
  return None
214
215
 
215
216
 
217
+ def _process_single_symbol_or_instrument(
218
+ symbol_or_instrument: SymbolOrInstrument_t,
219
+ default_exchange: ExchangeName_t | None,
220
+ requested_exchange: ExchangeName_t | None,
221
+ ) -> tuple[Instrument | None, str | None]:
222
+ """
223
+ Process a single symbol or instrument and return the resolved instrument and exchange.
224
+
225
+ Returns:
226
+ tuple[Instrument | None, str | None]: (instrument, exchange) or (None, None) if processing failed
227
+ """
228
+ match symbol_or_instrument:
229
+ case str():
230
+ _e, _s = (
231
+ symbol_or_instrument.split(":")
232
+ if ":" in symbol_or_instrument
233
+ else (default_exchange, symbol_or_instrument)
234
+ )
235
+
236
+ if _e is None:
237
+ logger.warning(
238
+ f"Can't extract exchange name from symbol's spec ({symbol_or_instrument}) and exact exchange name is not provided - skip this symbol !"
239
+ )
240
+ return None, None
241
+
242
+ if (
243
+ requested_exchange is not None
244
+ and isinstance(requested_exchange, str)
245
+ and _e.lower() != requested_exchange.lower()
246
+ ):
247
+ logger.warning(
248
+ f"Exchange from symbol's spec ({_e}) is different from requested: {requested_exchange} !"
249
+ )
250
+
251
+ if (instrument := lookup.find_symbol(_e, _s)) is not None:
252
+ return instrument, _e.upper()
253
+ else:
254
+ logger.warning(f"Can't find instrument for specified symbol ({symbol_or_instrument}) - ignoring !")
255
+ return None, None
256
+
257
+ case Instrument():
258
+ return symbol_or_instrument, symbol_or_instrument.exchange
259
+
260
+ case _:
261
+ raise SimulationConfigError(
262
+ f"Unsupported type for {symbol_or_instrument} only str or Instrument instances are allowed!"
263
+ )
264
+
265
+
216
266
  def find_instruments_and_exchanges(
217
267
  instruments: list[SymbolOrInstrument_t] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
218
- exchange: ExchangeName_t | None,
268
+ exchange: ExchangeName_t | list[ExchangeName_t] | None,
219
269
  ) -> tuple[list[Instrument], list[ExchangeName_t]]:
220
270
  _instrs: list[Instrument] = []
221
- _exchanges = [] if exchange is None else [exchange]
222
- for i in instruments:
223
- match i:
224
- case str():
225
- _e, _s = i.split(":") if ":" in i else (exchange, i)
226
- assert _e is not None
271
+ _exchanges = [] if exchange is None else [exchange] if isinstance(exchange, str) else exchange
227
272
 
228
- if exchange is not None and _e.lower() != exchange.lower():
229
- logger.warning("Exchange from symbol's spec ({_e}) is different from requested: {exchange} !")
273
+ # Handle dictionary case where instruments is {exchange: [symbols]}
274
+ if isinstance(instruments, dict):
275
+ for exchange_name, symbol_list in instruments.items():
276
+ if exchange_name not in _exchanges:
277
+ _exchanges.append(exchange_name)
230
278
 
231
- if _e is None:
232
- logger.warning(
233
- "Can't extract exchange name from symbol's spec ({_e}) and exact exchange name is not provided - skip this symbol !"
234
- )
235
-
236
- if (ix := lookup.find_symbol(_e, _s)) is not None:
237
- _exchanges.append(_e.upper())
238
- _instrs.append(ix)
239
- else:
240
- logger.warning(f"Can't find instrument for specified symbol ({i}) - ignoring !")
279
+ for symbol in symbol_list:
280
+ instrument, resolved_exchange = _process_single_symbol_or_instrument(symbol, exchange_name, exchange)
281
+ if instrument is not None and resolved_exchange is not None:
282
+ _instrs.append(instrument)
283
+ _exchanges.append(resolved_exchange)
241
284
 
242
- case Instrument():
243
- _exchanges.append(i.exchange)
244
- _instrs.append(i)
245
-
246
- case _:
247
- raise SimulationConfigError(f"Unsupported type for {i} only str or Instrument instances are allowed!")
285
+ # Handle list case
286
+ else:
287
+ for symbol in instruments:
288
+ instrument, resolved_exchange = _process_single_symbol_or_instrument(symbol, exchange, exchange)
289
+ if instrument is not None and resolved_exchange is not None:
290
+ _instrs.append(instrument)
291
+ _exchanges.append(resolved_exchange)
248
292
 
249
293
  return _instrs, list(set(_exchanges))
250
294
 
@@ -420,6 +464,7 @@ def recognize_simulation_configuration(
420
464
  signal_timeframe: str,
421
465
  accurate_stop_orders_execution: bool,
422
466
  run_separate_instruments: bool = False,
467
+ enable_funding: bool = False,
423
468
  ) -> list[SimulationSetup]:
424
469
  """
425
470
  Recognize and create setups based on the provided simulation configuration.
@@ -440,6 +485,7 @@ def recognize_simulation_configuration(
440
485
  - signal_timeframe (str): Timeframe for generated signals.
441
486
  - accurate_stop_orders_execution (bool): If True, enables more accurate stop order execution simulation.
442
487
  - run_separate_instruments (bool): If True, creates separate setups for each instrument.
488
+ - enable_funding (bool): If True, enables funding rate simulation, default is False.
443
489
 
444
490
  Returns:
445
491
  - list[SimulationSetup]: A list of SimulationSetup objects, each representing a
@@ -460,7 +506,8 @@ def recognize_simulation_configuration(
460
506
  r.extend(
461
507
  recognize_simulation_configuration(
462
508
  _n + n, v, instruments, exchanges, capital, basic_currency, commissions,
463
- signal_timeframe, accurate_stop_orders_execution, run_separate_instruments
509
+ signal_timeframe, accurate_stop_orders_execution, run_separate_instruments,
510
+ enable_funding
464
511
  )
465
512
  )
466
513
 
@@ -481,12 +528,14 @@ def recognize_simulation_configuration(
481
528
  if run_separate_instruments:
482
529
  # Create separate setups for each instrument
483
530
  for instrument in setup_instruments:
531
+ _s1 = c1[instrument.symbol] if isinstance(_s, pd.DataFrame) else _s
484
532
  r.append(
485
533
  SimulationSetup(
486
- _t, f"{name}/{instrument.symbol}", _s, c1, # type: ignore
534
+ _t, f"{name}/{instrument.symbol}", _s1, c1, # type: ignore
487
535
  [instrument],
488
536
  exchanges, capital, basic_currency, commissions,
489
- signal_timeframe, accurate_stop_orders_execution
537
+ signal_timeframe, accurate_stop_orders_execution,
538
+ enable_funding
490
539
  )
491
540
  )
492
541
  else:
@@ -495,7 +544,8 @@ def recognize_simulation_configuration(
495
544
  _t, name, _s, c1, # type: ignore
496
545
  setup_instruments,
497
546
  exchanges, capital, basic_currency, commissions,
498
- signal_timeframe, accurate_stop_orders_execution
547
+ signal_timeframe, accurate_stop_orders_execution,
548
+ enable_funding
499
549
  )
500
550
  )
501
551
  else:
@@ -504,7 +554,8 @@ def recognize_simulation_configuration(
504
554
  recognize_simulation_configuration(
505
555
  # name + "/" + str(j), s, instruments, exchange, capital, basic_currency, commissions
506
556
  name, s, instruments, exchanges, capital, basic_currency, commissions, # type: ignore
507
- signal_timeframe, accurate_stop_orders_execution, run_separate_instruments
557
+ signal_timeframe, accurate_stop_orders_execution, run_separate_instruments,
558
+ enable_funding
508
559
  )
509
560
  )
510
561
 
@@ -517,7 +568,8 @@ def recognize_simulation_configuration(
517
568
  SetupTypes.STRATEGY,
518
569
  f"{name}/{instrument.symbol}", configs, None, [instrument],
519
570
  exchanges, capital, basic_currency, commissions,
520
- signal_timeframe, accurate_stop_orders_execution
571
+ signal_timeframe, accurate_stop_orders_execution,
572
+ enable_funding
521
573
  )
522
574
  )
523
575
  else:
@@ -526,7 +578,8 @@ def recognize_simulation_configuration(
526
578
  SetupTypes.STRATEGY,
527
579
  name, configs, None, instruments,
528
580
  exchanges, capital, basic_currency, commissions,
529
- signal_timeframe, accurate_stop_orders_execution
581
+ signal_timeframe, accurate_stop_orders_execution,
582
+ enable_funding
530
583
  )
531
584
  )
532
585
 
@@ -538,12 +591,14 @@ def recognize_simulation_configuration(
538
591
  if run_separate_instruments:
539
592
  # Create separate setups for each instrument
540
593
  for instrument in setup_instruments:
594
+ _c1 = c1[instrument.symbol] if isinstance(c1, pd.DataFrame) else c1
541
595
  r.append(
542
596
  SimulationSetup(
543
597
  SetupTypes.SIGNAL,
544
- f"{name}/{instrument.symbol}", c1, None, [instrument],
598
+ f"{name}/{instrument.symbol}", _c1, None, [instrument],
545
599
  exchanges, capital, basic_currency, commissions,
546
- signal_timeframe, accurate_stop_orders_execution
600
+ signal_timeframe, accurate_stop_orders_execution,
601
+ enable_funding
547
602
  )
548
603
  )
549
604
  else:
@@ -552,7 +607,8 @@ def recognize_simulation_configuration(
552
607
  SetupTypes.SIGNAL,
553
608
  name, c1, None, setup_instruments,
554
609
  exchanges, capital, basic_currency, commissions,
555
- signal_timeframe, accurate_stop_orders_execution
610
+ signal_timeframe, accurate_stop_orders_execution,
611
+ enable_funding
556
612
  )
557
613
  )
558
614
 
qubx/core/account.py CHANGED
@@ -7,6 +7,7 @@ from qubx.core.basics import (
7
7
  ZERO_COSTS,
8
8
  AssetBalance,
9
9
  Deal,
10
+ FundingPayment,
10
11
  Instrument,
11
12
  ITimeProvider,
12
13
  Order,
@@ -104,8 +105,11 @@ class BasicAccountProcessor(IAccountProcessor):
104
105
  ########################################################
105
106
  def get_leverage(self, instrument: Instrument) -> float:
106
107
  pos = self._positions.get(instrument)
108
+ capital = self.get_total_capital()
109
+ if np.isclose(capital, 0):
110
+ return 0.0
107
111
  if pos is not None:
108
- return pos.notional_value / self.get_total_capital()
112
+ return pos.notional_value / capital
109
113
  return 0.0
110
114
 
111
115
  def get_leverages(self, exchange: str | None = None) -> dict[Instrument, float]:
@@ -234,6 +238,36 @@ class BasicAccountProcessor(IAccountProcessor):
234
238
  self._balances[self.base_currency] -= fee_in_base
235
239
  self._balances[instrument.settle] += realized_pnl
236
240
 
241
+ def process_funding_payment(self, instrument: Instrument, funding_payment: FundingPayment) -> None:
242
+ """Process funding payment for an instrument.
243
+
244
+ Args:
245
+ instrument: Instrument the funding payment applies to
246
+ funding_payment: Funding payment event to process
247
+ """
248
+ pos = self._positions.get(instrument)
249
+
250
+ if pos is None or not instrument.is_futures():
251
+ return
252
+
253
+ # Get current market price for funding calculation
254
+ # We need to get the mark price from the market data, but since we don't have access
255
+ # to market data here, we'll use the current position price as a reasonable fallback
256
+ mark_price = pos.position_avg_price_funds if pos.position_avg_price_funds > 0 else 0.0
257
+
258
+ # Apply funding payment to position
259
+ funding_amount = pos.apply_funding_payment(funding_payment, mark_price)
260
+
261
+ # Update account balance with funding payment
262
+ # For futures contracts, funding affects the settlement currency balance
263
+ self._balances[instrument.settle] += funding_amount
264
+
265
+ logger.debug(
266
+ f" [<y>{self.__class__.__name__}</y>(<g>{instrument}</g>)] :: "
267
+ f"funding payment {funding_amount:.6f} {instrument.settle} "
268
+ f"(rate: {funding_payment.funding_rate:.6f})"
269
+ )
270
+
237
271
  def _fill_missing_fee_info(self, instrument: Instrument, deals: list[Deal]) -> None:
238
272
  for d in deals:
239
273
  if d.fee_amount is None:
@@ -354,16 +388,48 @@ class CompositeAccountProcessor(IAccountProcessor):
354
388
  return self._account_processors[exch].get_capital()
355
389
 
356
390
  def get_total_capital(self, exchange: str | None = None) -> float:
357
- exch = self._get_exchange(exchange)
358
- return self._account_processors[exch].get_total_capital()
391
+ if exchange is not None:
392
+ # Return total capital from specific exchange
393
+ exch = self._get_exchange(exchange)
394
+ return self._account_processors[exch].get_total_capital()
395
+
396
+ # Return aggregated total capital from all exchanges when no exchange is specified
397
+ total_capital = 0.0
398
+ for exch_name, processor in self._account_processors.items():
399
+ total_capital += processor.get_total_capital()
400
+ return total_capital
359
401
 
360
402
  def get_balances(self, exchange: str | None = None) -> dict[str, AssetBalance]:
361
- exch = self._get_exchange(exchange)
362
- return self._account_processors[exch].get_balances()
403
+ if exchange is not None:
404
+ # Return balances from specific exchange
405
+ exch = self._get_exchange(exchange)
406
+ return self._account_processors[exch].get_balances()
407
+
408
+ # Return aggregated balances from all exchanges when no exchange is specified
409
+ all_balances: dict[str, AssetBalance] = defaultdict(lambda: AssetBalance())
410
+ for exch_name, processor in self._account_processors.items():
411
+ exch_balances = processor.get_balances()
412
+ for currency, balance in exch_balances.items():
413
+ if currency not in all_balances:
414
+ all_balances[currency] = AssetBalance(balance.free, balance.locked, balance.total)
415
+ else:
416
+ all_balances[currency].free += balance.free
417
+ all_balances[currency].locked += balance.locked
418
+ all_balances[currency].total += balance.total
419
+ return dict(all_balances)
363
420
 
364
421
  def get_positions(self, exchange: str | None = None) -> dict[Instrument, Position]:
365
- exch = self._get_exchange(exchange)
366
- return self._account_processors[exch].get_positions()
422
+ if exchange is not None:
423
+ # Return positions from specific exchange
424
+ exch = self._get_exchange(exchange)
425
+ return self._account_processors[exch].get_positions()
426
+
427
+ # Return positions from all exchanges when no exchange is specified
428
+ all_positions: dict[Instrument, Position] = {}
429
+ for exch_name, processor in self._account_processors.items():
430
+ exch_positions = processor.get_positions()
431
+ all_positions.update(exch_positions)
432
+ return all_positions
367
433
 
368
434
  def get_position(self, instrument: Instrument) -> Position:
369
435
  exch = self._get_exchange(instrument=instrument)
@@ -455,3 +521,7 @@ class CompositeAccountProcessor(IAccountProcessor):
455
521
  def process_deals(self, instrument: Instrument, deals: list[Deal]) -> None:
456
522
  exch = self._get_exchange(instrument=instrument)
457
523
  self._account_processors[exch].process_deals(instrument, deals)
524
+
525
+ def process_funding_payment(self, instrument: Instrument, funding_payment: FundingPayment) -> None:
526
+ exch = self._get_exchange(instrument=instrument)
527
+ self._account_processors[exch].process_funding_payment(instrument, funding_payment)