Qubx 0.6.62__cp312-cp312-manylinux_2_39_x86_64.whl → 0.6.64__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 (45) hide show
  1. qubx/backtester/data.py +5 -1
  2. qubx/backtester/ome.py +5 -3
  3. qubx/backtester/simulated_data.py +43 -1
  4. qubx/backtester/simulator.py +25 -15
  5. qubx/backtester/utils.py +68 -25
  6. qubx/core/account.py +81 -9
  7. qubx/core/basics.py +97 -3
  8. qubx/core/context.py +8 -3
  9. qubx/core/helpers.py +11 -4
  10. qubx/core/interfaces.py +36 -2
  11. qubx/core/loggers.py +19 -16
  12. qubx/core/lookups.py +7 -7
  13. qubx/core/metrics.py +42 -4
  14. qubx/core/mixins/market.py +22 -12
  15. qubx/core/mixins/processing.py +65 -8
  16. qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
  17. qubx/core/series.pyi +4 -3
  18. qubx/core/series.pyx +34 -12
  19. qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
  20. qubx/core/utils.pyx +3 -0
  21. qubx/data/helpers.py +75 -39
  22. qubx/data/readers.py +224 -15
  23. qubx/data/registry.py +1 -1
  24. qubx/emitters/__init__.py +2 -0
  25. qubx/emitters/base.py +23 -2
  26. qubx/emitters/composite.py +17 -2
  27. qubx/emitters/csv.py +43 -1
  28. qubx/emitters/inmemory.py +244 -0
  29. qubx/emitters/prometheus.py +57 -2
  30. qubx/emitters/questdb.py +131 -2
  31. qubx/features/core.py +11 -8
  32. qubx/features/orderbook.py +2 -1
  33. qubx/features/trades.py +1 -1
  34. qubx/gathering/simplest.py +7 -1
  35. qubx/loggers/inmemory.py +28 -13
  36. qubx/pandaz/ta.py +11 -20
  37. qubx/pandaz/utils.py +11 -0
  38. qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
  39. qubx/trackers/riskctrl.py +3 -4
  40. qubx/utils/time.py +3 -1
  41. {qubx-0.6.62.dist-info → qubx-0.6.64.dist-info}/METADATA +1 -1
  42. {qubx-0.6.62.dist-info → qubx-0.6.64.dist-info}/RECORD +45 -44
  43. {qubx-0.6.62.dist-info → qubx-0.6.64.dist-info}/LICENSE +0 -0
  44. {qubx-0.6.62.dist-info → qubx-0.6.64.dist-info}/WHEEL +0 -0
  45. {qubx-0.6.62.dist-info → qubx-0.6.64.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)
@@ -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,9 +256,45 @@ 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(f"Filtered {original_count - filtered_count} non-SWAP instruments from funding payment subscription")
282
+
283
+ return filtered_instruments
284
+
285
+ # For all other subscription types, return instruments unchanged
286
+ return instruments
287
+
253
288
  def add_instruments_for_subscription(self, subscription: str, instruments: list[Instrument] | Instrument):
254
289
  instruments = instruments if isinstance(instruments, list) else [instruments]
255
290
  _subt_key, _data_type, _params = self._parse_subscription_spec(subscription)
291
+
292
+ # Filter instruments based on subscription type requirements
293
+ instruments = self._filter_instruments_for_subscription(_data_type, instruments)
294
+
295
+ # If no instruments remain after filtering, skip subscription
296
+ if not instruments:
297
+ return
256
298
 
257
299
  fetcher = self._subtyped_fetchers.get(_subt_key)
258
300
  if not fetcher:
@@ -4,12 +4,12 @@ import pandas as pd
4
4
  from joblib import delayed
5
5
 
6
6
  from qubx import QubxLogConfig, logger
7
+ from qubx.core.basics import Instrument
7
8
  from qubx.core.exceptions import SimulationError
8
9
  from qubx.core.metrics import TradingSessionResult
9
10
  from qubx.data.readers import DataReader
11
+ from qubx.emitters.inmemory import InMemoryMetricEmitter
10
12
  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
13
  from qubx.utils.time import handle_start_stop
14
14
 
15
15
  from .runner import SimulationRunner
@@ -31,11 +31,11 @@ def simulate(
31
31
  strategies: StrategiesDecls_t,
32
32
  data: DataDecls_t,
33
33
  capital: float | dict[str, float],
34
- instruments: list[SymbolOrInstrument_t] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
34
+ instruments: list[str] | list[Instrument] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
35
35
  commissions: str | dict[str, str | None] | None,
36
36
  start: str | pd.Timestamp,
37
37
  stop: str | pd.Timestamp | None = None,
38
- exchange: ExchangeName_t | None = None,
38
+ exchange: ExchangeName_t | list[ExchangeName_t] | None = None,
39
39
  base_currency: str = "USDT",
40
40
  n_jobs: int = 1,
41
41
  silent: bool = False,
@@ -47,7 +47,8 @@ def simulate(
47
47
  show_latency_report: bool = False,
48
48
  portfolio_log_freq: str = "5Min",
49
49
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
50
- emission: EmissionConfig | None = None,
50
+ enable_inmemory_emitter: bool = False,
51
+ emitter_stats_interval: str = "1h",
51
52
  run_separate_instruments: bool = False,
52
53
  ) -> list[TradingSessionResult]:
53
54
  """
@@ -73,7 +74,8 @@ def simulate(
73
74
  - show_latency_report: If True, shows simulator's latency report.
74
75
  - portfolio_log_freq (str): Frequency for portfolio logging, default is "5Min".
75
76
  - parallel_backend (Literal["loky", "multiprocessing"]): Backend for parallel processing, default is "multiprocessing".
76
- - emission (EmissionConfig | None): Configuration for metric emitters, default is None.
77
+ - enable_inmemory_emitter (bool): If True, attaches an in-memory metric emitter and returns its dataframe in TradingSessionResult.emitter_data.
78
+ - emitter_stats_interval (str): Interval for emitting stats in the in-memory emitter (default: "1h").
77
79
  - run_separate_instruments (bool): If True, creates separate simulation setups for each instrument, default is False.
78
80
 
79
81
  Returns:
@@ -143,7 +145,8 @@ def simulate(
143
145
  show_latency_report=show_latency_report,
144
146
  portfolio_log_freq=portfolio_log_freq,
145
147
  parallel_backend=parallel_backend,
146
- emission=emission,
148
+ enable_inmemory_emitter=enable_inmemory_emitter,
149
+ emitter_stats_interval=emitter_stats_interval,
147
150
  )
148
151
 
149
152
 
@@ -157,7 +160,8 @@ def _run_setups(
157
160
  show_latency_report: bool = False,
158
161
  portfolio_log_freq: str = "5Min",
159
162
  parallel_backend: Literal["loky", "multiprocessing"] = "multiprocessing",
160
- emission: EmissionConfig | None = None,
163
+ enable_inmemory_emitter: bool = False,
164
+ emitter_stats_interval: str = "1h",
161
165
  ) -> list[TradingSessionResult]:
162
166
  # loggers don't work well with joblib and multiprocessing in general because they contain
163
167
  # open file handlers that cannot be pickled. I found a solution which requires the usage of enqueue=True
@@ -179,7 +183,8 @@ def _run_setups(
179
183
  silent,
180
184
  show_latency_report,
181
185
  portfolio_log_freq,
182
- emission,
186
+ enable_inmemory_emitter,
187
+ emitter_stats_interval,
183
188
  )
184
189
  for id, setup in enumerate(strategies_setups)
185
190
  ]
@@ -197,7 +202,8 @@ def _run_setups(
197
202
  silent,
198
203
  show_latency_report,
199
204
  portfolio_log_freq,
200
- emission,
205
+ enable_inmemory_emitter,
206
+ emitter_stats_interval,
201
207
  )
202
208
  for id, setup in enumerate(strategies_setups)
203
209
  )
@@ -223,14 +229,14 @@ def _run_setup(
223
229
  silent: bool,
224
230
  show_latency_report: bool,
225
231
  portfolio_log_freq: str,
226
- emission: EmissionConfig | None = None,
232
+ enable_inmemory_emitter: bool = False,
233
+ emitter_stats_interval: str = "1h",
227
234
  ) -> TradingSessionResult | None:
228
235
  try:
229
- # Create metric emitter if configured
230
236
  emitter = None
231
- if emission is not None:
232
- emitter = create_metric_emitters(emission, setup.name)
233
-
237
+ emitter_data = None
238
+ if enable_inmemory_emitter:
239
+ emitter = InMemoryMetricEmitter(stats_interval=emitter_stats_interval)
234
240
  runner = SimulationRunner(
235
241
  setup=setup,
236
242
  data_config=data_setup,
@@ -258,6 +264,9 @@ def _run_setup(
258
264
  # Filter out None values to match TradingSessionResult expected type
259
265
  commissions_for_result = {k: v for k, v in commissions_for_result.items() if v is not None}
260
266
 
267
+ if enable_inmemory_emitter and emitter is not None:
268
+ emitter_data = emitter.get_dataframe()
269
+
261
270
  return TradingSessionResult(
262
271
  setup_id,
263
272
  setup.name,
@@ -276,6 +285,7 @@ def _run_setup(
276
285
  parameters=runner.strategy_params,
277
286
  is_simulation=True,
278
287
  author=get_current_user(),
288
+ emitter_data=emitter_data,
279
289
  )
280
290
  except Exception as e:
281
291
  logger.error(f"Simulation setup {setup_id} failed with error: {e}")
qubx/backtester/utils.py CHANGED
@@ -213,38 +213,81 @@ class SignalsProxy(IStrategy):
213
213
  return None
214
214
 
215
215
 
216
+ def _process_single_symbol_or_instrument(
217
+ symbol_or_instrument: SymbolOrInstrument_t,
218
+ default_exchange: ExchangeName_t | None,
219
+ requested_exchange: ExchangeName_t | None,
220
+ ) -> tuple[Instrument | None, str | None]:
221
+ """
222
+ Process a single symbol or instrument and return the resolved instrument and exchange.
223
+
224
+ Returns:
225
+ tuple[Instrument | None, str | None]: (instrument, exchange) or (None, None) if processing failed
226
+ """
227
+ match symbol_or_instrument:
228
+ case str():
229
+ _e, _s = (
230
+ symbol_or_instrument.split(":")
231
+ if ":" in symbol_or_instrument
232
+ else (default_exchange, symbol_or_instrument)
233
+ )
234
+
235
+ if _e is None:
236
+ logger.warning(
237
+ f"Can't extract exchange name from symbol's spec ({symbol_or_instrument}) and exact exchange name is not provided - skip this symbol !"
238
+ )
239
+ return None, None
240
+
241
+ if (
242
+ requested_exchange is not None
243
+ and isinstance(requested_exchange, str)
244
+ and _e.lower() != requested_exchange.lower()
245
+ ):
246
+ logger.warning(
247
+ f"Exchange from symbol's spec ({_e}) is different from requested: {requested_exchange} !"
248
+ )
249
+
250
+ if (instrument := lookup.find_symbol(_e, _s)) is not None:
251
+ return instrument, _e.upper()
252
+ else:
253
+ logger.warning(f"Can't find instrument for specified symbol ({symbol_or_instrument}) - ignoring !")
254
+ return None, None
255
+
256
+ case Instrument():
257
+ return symbol_or_instrument, symbol_or_instrument.exchange
258
+
259
+ case _:
260
+ raise SimulationConfigError(
261
+ f"Unsupported type for {symbol_or_instrument} only str or Instrument instances are allowed!"
262
+ )
263
+
264
+
216
265
  def find_instruments_and_exchanges(
217
266
  instruments: list[SymbolOrInstrument_t] | dict[ExchangeName_t, list[SymbolOrInstrument_t]],
218
- exchange: ExchangeName_t | None,
267
+ exchange: ExchangeName_t | list[ExchangeName_t] | None,
219
268
  ) -> tuple[list[Instrument], list[ExchangeName_t]]:
220
269
  _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
270
+ _exchanges = [] if exchange is None else [exchange] if isinstance(exchange, str) else exchange
227
271
 
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} !")
272
+ # Handle dictionary case where instruments is {exchange: [symbols]}
273
+ if isinstance(instruments, dict):
274
+ for exchange_name, symbol_list in instruments.items():
275
+ if exchange_name not in _exchanges:
276
+ _exchanges.append(exchange_name)
230
277
 
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 !")
278
+ for symbol in symbol_list:
279
+ instrument, resolved_exchange = _process_single_symbol_or_instrument(symbol, exchange_name, exchange)
280
+ if instrument is not None and resolved_exchange is not None:
281
+ _instrs.append(instrument)
282
+ _exchanges.append(resolved_exchange)
241
283
 
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!")
284
+ # Handle list case
285
+ else:
286
+ for symbol in instruments:
287
+ instrument, resolved_exchange = _process_single_symbol_or_instrument(symbol, exchange, exchange)
288
+ if instrument is not None and resolved_exchange is not None:
289
+ _instrs.append(instrument)
290
+ _exchanges.append(resolved_exchange)
248
291
 
249
292
  return _instrs, list(set(_exchanges))
250
293
 
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,18 +105,23 @@ 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]:
112
116
  return {s: self.get_leverage(s) for s in self._positions.keys()}
113
117
 
114
118
  def get_net_leverage(self, exchange: str | None = None) -> float:
115
- return sum(self.get_leverages(exchange).values())
119
+ leverages = self.get_leverages(exchange).values()
120
+ return sum(lev for lev in leverages if lev is not None and not np.isnan(lev))
116
121
 
117
122
  def get_gross_leverage(self, exchange: str | None = None) -> float:
118
- return sum(map(abs, self.get_leverages(exchange).values()))
123
+ leverages = self.get_leverages(exchange).values()
124
+ return sum(abs(lev) for lev in leverages if lev is not None and not np.isnan(lev))
119
125
 
120
126
  ########################################################
121
127
  # Margin information
@@ -232,6 +238,36 @@ class BasicAccountProcessor(IAccountProcessor):
232
238
  self._balances[self.base_currency] -= fee_in_base
233
239
  self._balances[instrument.settle] += realized_pnl
234
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
+
235
271
  def _fill_missing_fee_info(self, instrument: Instrument, deals: list[Deal]) -> None:
236
272
  for d in deals:
237
273
  if d.fee_amount is None:
@@ -352,16 +388,48 @@ class CompositeAccountProcessor(IAccountProcessor):
352
388
  return self._account_processors[exch].get_capital()
353
389
 
354
390
  def get_total_capital(self, exchange: str | None = None) -> float:
355
- exch = self._get_exchange(exchange)
356
- 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
357
401
 
358
402
  def get_balances(self, exchange: str | None = None) -> dict[str, AssetBalance]:
359
- exch = self._get_exchange(exchange)
360
- 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)
361
420
 
362
421
  def get_positions(self, exchange: str | None = None) -> dict[Instrument, Position]:
363
- exch = self._get_exchange(exchange)
364
- 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
365
433
 
366
434
  def get_position(self, instrument: Instrument) -> Position:
367
435
  exch = self._get_exchange(instrument=instrument)
@@ -453,3 +521,7 @@ class CompositeAccountProcessor(IAccountProcessor):
453
521
  def process_deals(self, instrument: Instrument, deals: list[Deal]) -> None:
454
522
  exch = self._get_exchange(instrument=instrument)
455
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)
qubx/core/basics.py CHANGED
@@ -44,6 +44,30 @@ class FundingRate:
44
44
  index_price: float | None = None
45
45
 
46
46
 
47
+ @dataclass
48
+ class FundingPayment:
49
+ """
50
+ Represents a funding payment for a perpetual swap position.
51
+
52
+ Based on QuestDB schema: timestamp, symbol, funding_rate, funding_interval_hours
53
+ """
54
+ time: dt_64
55
+ symbol: str
56
+ funding_rate: float
57
+ funding_interval_hours: int
58
+
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
+ if abs(self.funding_rate) > 1.0:
65
+ raise ValueError(f"Invalid funding rate: {self.funding_rate} (must be between -1.0 and 1.0)")
66
+
67
+ if self.funding_interval_hours <= 0:
68
+ raise ValueError(f"Invalid funding interval: {self.funding_interval_hours} (must be positive)")
69
+
70
+
47
71
  @dataclass
48
72
  class TimestampedDict:
49
73
  """
@@ -69,7 +93,7 @@ class ITimeProvider:
69
93
 
70
94
 
71
95
  # Alias for timestamped data types used in Qubx
72
- Timestamped: TypeAlias = Quote | Trade | Bar | OrderBook | TimestampedDict | FundingRate | Liquidation
96
+ Timestamped: TypeAlias = Quote | Trade | Bar | OrderBook | TimestampedDict | FundingRate | Liquidation | FundingPayment
73
97
 
74
98
 
75
99
  @dataclass
@@ -548,6 +572,11 @@ class Position:
548
572
  # margin requirements
549
573
  maint_margin: float = 0.0
550
574
 
575
+ # funding payment tracking
576
+ cumulative_funding: float = 0.0 # cumulative funding paid (negative) or received (positive)
577
+ funding_payments: list[FundingPayment] # history of funding payments
578
+ last_funding_time: dt_64 = np.datetime64('NaT') # last funding payment time
579
+
551
580
  # - helpers for position processing
552
581
  _qty_multiplier: float = 1.0
553
582
  __pos_incr_qty: float = 0
@@ -560,6 +589,7 @@ class Position:
560
589
  r_pnl: float = 0.0,
561
590
  ) -> None:
562
591
  self.instrument = instrument
592
+ self.funding_payments = [] # Initialize funding payments list
563
593
 
564
594
  self.reset()
565
595
  if quantity != 0.0 and pos_average_price > 0.0:
@@ -584,6 +614,9 @@ class Position:
584
614
  self.last_update_price = np.nan
585
615
  self.last_update_conversion_rate = np.nan
586
616
  self.maint_margin = 0.0
617
+ self.cumulative_funding = 0.0
618
+ self.funding_payments = []
619
+ self.last_funding_time = np.datetime64('NaT') # type: ignore
587
620
  self.__pos_incr_qty = 0
588
621
  self._qty_multiplier = self.instrument.contract_size
589
622
 
@@ -600,6 +633,9 @@ class Position:
600
633
  self.last_update_price = pos.last_update_price
601
634
  self.last_update_conversion_rate = pos.last_update_conversion_rate
602
635
  self.maint_margin = pos.maint_margin
636
+ self.cumulative_funding = pos.cumulative_funding
637
+ self.funding_payments = pos.funding_payments.copy() if hasattr(pos, 'funding_payments') else []
638
+ self.last_funding_time = pos.last_funding_time if hasattr(pos, 'last_funding_time') else np.datetime64('NaT')
603
639
  self.__pos_incr_qty = pos.__pos_incr_qty
604
640
 
605
641
  @property
@@ -731,6 +767,51 @@ class Position:
731
767
  return self.quantity * (self.last_update_price - self.position_avg_price) / self.last_update_conversion_rate # type: ignore
732
768
  return 0.0
733
769
 
770
+ def apply_funding_payment(self, funding_payment: FundingPayment, mark_price: float) -> float:
771
+ """
772
+ Apply a funding payment to this position.
773
+
774
+ For perpetual swaps:
775
+ - Positive funding rate: longs pay shorts
776
+ - Negative funding rate: shorts pay longs
777
+
778
+ Args:
779
+ funding_payment: The funding payment event
780
+ mark_price: The mark price at the time of funding
781
+
782
+ Returns:
783
+ The funding amount (negative if paying, positive if receiving)
784
+ """
785
+ if abs(self.quantity) < self.instrument.min_size:
786
+ return 0.0
787
+
788
+ # Calculate funding amount
789
+ # Funding = Position Size * Mark Price * Funding Rate
790
+ funding_amount = self.quantity * mark_price * funding_payment.funding_rate
791
+
792
+ # For long positions with positive funding rate, amount is negative (paying)
793
+ # For short positions with positive funding rate, amount is positive (receiving)
794
+ funding_amount = -funding_amount
795
+
796
+ # Update position state
797
+ self.cumulative_funding += funding_amount
798
+ self.r_pnl += funding_amount # Funding affects realized PnL
799
+ self.pnl += funding_amount # And total PnL
800
+
801
+ # Track funding payment history (limit to last 100)
802
+ self.funding_payments.append(funding_payment)
803
+ if len(self.funding_payments) > 100:
804
+ self.funding_payments = self.funding_payments[-100:]
805
+
806
+ self.last_funding_time = funding_payment.time
807
+
808
+ return funding_amount
809
+
810
+ def get_funding_pnl(self) -> float:
811
+ """Get cumulative funding PnL for this position."""
812
+ return self.cumulative_funding
813
+
814
+
734
815
  def is_open(self) -> bool:
735
816
  return abs(self.quantity) > self.instrument.min_size
736
817
 
@@ -833,6 +914,7 @@ class DataType(StrEnum):
833
914
  ORDERBOOK = "orderbook"
834
915
  LIQUIDATION = "liquidation"
835
916
  FUNDING_RATE = "funding_rate"
917
+ FUNDING_PAYMENT = "funding_payment"
836
918
  OHLC_QUOTES = "ohlc_quotes" # when we want to emulate quotes from OHLC data
837
919
  OHLC_TRADES = "ohlc_trades" # when we want to emulate trades from OHLC data
838
920
  RECORD = "record" # arbitrary timestamped data (actually liquidation and funding rates fall into this type)
@@ -966,14 +1048,20 @@ class InstrumentsLookup:
966
1048
  self,
967
1049
  exchange: str,
968
1050
  base: str,
969
- quote: str,
1051
+ quote: str | None = None,
970
1052
  settle: str | None = None,
971
1053
  market_type: MarketType | None = None,
972
1054
  ) -> Instrument | None:
973
1055
  for i in self.get_lookup().values():
974
1056
  if (
975
1057
  i.exchange == exchange
976
- and ((i.base == base and i.quote == quote) or (i.base == quote and i.quote == base))
1058
+ and (
1059
+ (
1060
+ quote is not None
1061
+ and ((i.base == base and i.quote == quote) or (i.base == quote and i.quote == base))
1062
+ )
1063
+ or (quote is None and i.base == base)
1064
+ )
977
1065
  and (market_type is None or i.market_type == market_type)
978
1066
  ):
979
1067
  if settle is not None and i.settle is not None:
@@ -997,6 +1085,7 @@ class InstrumentsLookup:
997
1085
  def find_instruments(
998
1086
  self,
999
1087
  exchange: str,
1088
+ base: str | None = None,
1000
1089
  quote: str | None = None,
1001
1090
  market_type: MarketType | None = None,
1002
1091
  as_of: str | pd.Timestamp | None = None,
@@ -1005,6 +1094,7 @@ class InstrumentsLookup:
1005
1094
  Find instruments by exchange, quote, market type and as of date.
1006
1095
  If as_of is not None, then only instruments that are not delisted after as_of date will be returned.
1007
1096
  - exchange: str - exchange name
1097
+ - base: str | None - base currency
1008
1098
  - quote: str | None - quote currency
1009
1099
  - market_type: MarketType | None - market type
1010
1100
  - as_of is a string in format YYYY-MM-DD or pd.Timestamp or None
@@ -1014,8 +1104,12 @@ class InstrumentsLookup:
1014
1104
  i
1015
1105
  for i in self.get_lookup().values()
1016
1106
  if i.exchange == exchange
1107
+ and (
1108
+ base is None or (i.base == base or i.base == f"1000{base}")
1109
+ ) # this is a hack to support 1000DOGEUSDT and others
1017
1110
  and (quote is None or i.quote == quote)
1018
1111
  and (market_type is None or i.market_type == market_type)
1112
+ and (i.onboard_date is not None and pd.Timestamp(i.onboard_date).tz_localize(None) <= _limit_time)
1019
1113
  and (
1020
1114
  _limit_time is None
1021
1115
  or (i.delist_date is None)