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.
- qubx/backtester/data.py +5 -1
- qubx/backtester/ome.py +5 -3
- qubx/backtester/runner.py +4 -0
- qubx/backtester/simulated_data.py +45 -1
- qubx/backtester/simulator.py +50 -15
- qubx/backtester/utils.py +91 -35
- 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 +148 -5
- qubx/core/mixins/market.py +22 -12
- qubx/core/mixins/processing.py +58 -5
- 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 +242 -18
- 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/trackers/__init__.py +2 -0
- qubx/trackers/sizers.py +56 -0
- qubx/utils/time.py +3 -1
- {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/METADATA +1 -1
- {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/RECORD +42 -41
- {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/LICENSE +0 -0
- {qubx-0.6.63.dist-info → qubx-0.6.65.dist-info}/WHEEL +0 -0
- {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
|
|
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)
|
qubx/backtester/simulator.py
CHANGED
|
@@ -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[
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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 !")
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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}",
|
|
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}",
|
|
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 /
|
|
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)
|