Qubx 0.6.63__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.
- qubx/backtester/data.py +5 -1
- qubx/backtester/ome.py +5 -3
- qubx/backtester/simulated_data.py +43 -1
- qubx/backtester/simulator.py +25 -15
- qubx/backtester/utils.py +68 -25
- qubx/core/account.py +77 -7
- qubx/core/basics.py +97 -3
- qubx/core/context.py +8 -3
- qubx/core/helpers.py +11 -4
- qubx/core/interfaces.py +16 -2
- qubx/core/loggers.py +19 -16
- qubx/core/lookups.py +7 -7
- qubx/core/metrics.py +42 -4
- qubx/core/mixins/market.py +22 -12
- qubx/core/mixins/processing.py +52 -1
- qubx/core/series.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/series.pyi +4 -3
- qubx/core/series.pyx +34 -12
- qubx/core/utils.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/core/utils.pyx +3 -0
- qubx/data/helpers.py +75 -39
- qubx/data/readers.py +224 -15
- qubx/data/registry.py +1 -1
- qubx/emitters/__init__.py +2 -0
- qubx/emitters/inmemory.py +244 -0
- qubx/features/core.py +11 -8
- qubx/features/orderbook.py +2 -1
- qubx/features/trades.py +1 -1
- qubx/gathering/simplest.py +7 -1
- qubx/loggers/inmemory.py +28 -13
- qubx/pandaz/ta.py +4 -3
- qubx/pandaz/utils.py +11 -0
- qubx/ta/indicators.cpython-312-x86_64-linux-gnu.so +0 -0
- qubx/utils/time.py +3 -1
- {qubx-0.6.63.dist-info → qubx-0.6.64.dist-info}/METADATA +1 -1
- {qubx-0.6.63.dist-info → qubx-0.6.64.dist-info}/RECORD +39 -38
- {qubx-0.6.63.dist-info → qubx-0.6.64.dist-info}/LICENSE +0 -0
- {qubx-0.6.63.dist-info → qubx-0.6.64.dist-info}/WHEEL +0 -0
- {qubx-0.6.63.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
|
|
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:
|
qubx/backtester/simulator.py
CHANGED
|
@@ -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[
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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,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 /
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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)
|
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 (
|
|
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)
|