quantbt-engine 1.0.7__py3-none-any.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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NautilusTrader backend adapter.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Dict, List, Optional, Sequence
|
|
10
|
+
|
|
11
|
+
import pandas as pd
|
|
12
|
+
|
|
13
|
+
from ...core.orders import OrderIntent
|
|
14
|
+
from ...core.results import BacktestResultV2
|
|
15
|
+
from ...core.schema import OrderSide
|
|
16
|
+
from ._dependency import require_nautilus
|
|
17
|
+
from .instruments import ensure_utc_ohlcv, make_binance_perpetual, timeframe_to_nautilus
|
|
18
|
+
from .reports import result_from_nautilus_reports
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class NautilusBackendConfig:
|
|
23
|
+
instrument_id: str = "BTCUSDT-PERP.BINANCE"
|
|
24
|
+
timeframe: str = "1h"
|
|
25
|
+
starting_balance: float = 10_000.0
|
|
26
|
+
trade_notional: float = 1_000.0
|
|
27
|
+
sizing_mode: str = "signal_notional"
|
|
28
|
+
use_pyramiding: bool = True
|
|
29
|
+
strategy_id: str = "QuantBT-001"
|
|
30
|
+
trader_id: str = "BACKTESTER-001"
|
|
31
|
+
log_level: str = "ERROR"
|
|
32
|
+
bypass_logging: bool = True
|
|
33
|
+
bypass_risk: bool = False
|
|
34
|
+
close_positions_on_stop: bool = False
|
|
35
|
+
force_flat_on_stop: Optional[bool] = None
|
|
36
|
+
use_test_instrument: bool = True
|
|
37
|
+
metadata: Dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.force_flat_on_stop is not None:
|
|
41
|
+
object.__setattr__(self, "close_positions_on_stop", bool(self.force_flat_on_stop))
|
|
42
|
+
if self.starting_balance <= 0.0:
|
|
43
|
+
raise ValueError("starting_balance must be > 0")
|
|
44
|
+
if self.trade_notional < 0.0:
|
|
45
|
+
raise ValueError("trade_notional must be >= 0")
|
|
46
|
+
sizing = self.sizing_mode.lower().strip()
|
|
47
|
+
if sizing in ("dca_ladder", "dca"):
|
|
48
|
+
raise NotImplementedError(
|
|
49
|
+
"Use QuantBTEndpoint.nautilus_dca_grid(...) for DCA/grid structured-order validation; "
|
|
50
|
+
"NautilusBackendConfig.sizing_mode is only for signal-series sizing."
|
|
51
|
+
)
|
|
52
|
+
if sizing not in {"signal_notional", "signal", "notional", "unit", "%_equity", "pct_equity"}:
|
|
53
|
+
raise ValueError("sizing_mode must be one of signal_notional, notional, unit, or %_equity")
|
|
54
|
+
if "-" not in self.strategy_id:
|
|
55
|
+
raise ValueError("strategy_id must contain '-' for Nautilus order_id_tag extraction")
|
|
56
|
+
if "-" not in self.trader_id:
|
|
57
|
+
raise ValueError("trader_id must contain '-'")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NautilusBacktestEngine:
|
|
61
|
+
"""
|
|
62
|
+
Optional high-fidelity backend powered by NautilusTrader.
|
|
63
|
+
|
|
64
|
+
This adapter is intended as a validation/reference backend. It accepts a
|
|
65
|
+
precomputed scalar signal series and submits market delta orders to reach a
|
|
66
|
+
target notional. Research-scale optimizer runs should prefer native quantbt
|
|
67
|
+
backends.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, config: NautilusBackendConfig):
|
|
71
|
+
self.config = config
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def check_available() -> bool:
|
|
75
|
+
require_nautilus()
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
def run_signal_series(
|
|
79
|
+
self,
|
|
80
|
+
data: pd.DataFrame,
|
|
81
|
+
signal: pd.Series,
|
|
82
|
+
params: Optional[Dict] = None,
|
|
83
|
+
) -> BacktestResultV2:
|
|
84
|
+
nt = require_nautilus()
|
|
85
|
+
df = ensure_utc_ohlcv(data)
|
|
86
|
+
sig = self._align_signal(signal, df.index)
|
|
87
|
+
|
|
88
|
+
engine = nt.BacktestEngine(
|
|
89
|
+
config=nt.BacktestEngineConfig(
|
|
90
|
+
trader_id=nt.TraderId(self.config.trader_id),
|
|
91
|
+
logging=nt.LoggingConfig(
|
|
92
|
+
log_level=self.config.log_level,
|
|
93
|
+
bypass_logging=self.config.bypass_logging,
|
|
94
|
+
),
|
|
95
|
+
risk_engine=nt.RiskEngineConfig(bypass=self.config.bypass_risk),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
instrument = self._make_instrument(nt)
|
|
100
|
+
engine.add_venue(
|
|
101
|
+
venue=nt.BINANCE_VENUE,
|
|
102
|
+
oms_type=nt.OmsType.NETTING,
|
|
103
|
+
account_type=nt.AccountType.MARGIN,
|
|
104
|
+
base_currency=nt.USDT,
|
|
105
|
+
starting_balances=[nt.Money(self.config.starting_balance, nt.USDT)],
|
|
106
|
+
fee_model=nt.MakerTakerFeeModel(),
|
|
107
|
+
bar_execution=True,
|
|
108
|
+
)
|
|
109
|
+
engine.add_instrument(instrument)
|
|
110
|
+
|
|
111
|
+
bar_type = nt.BarType.from_str(
|
|
112
|
+
f"{instrument.id}-{timeframe_to_nautilus(self.config.timeframe)}-LAST-EXTERNAL"
|
|
113
|
+
)
|
|
114
|
+
wrangler = nt.BarDataWrangler(bar_type=bar_type, instrument=instrument)
|
|
115
|
+
bars = wrangler.process(df)
|
|
116
|
+
engine.add_data(bars)
|
|
117
|
+
|
|
118
|
+
strategy_cls, config_cls = self._make_signal_strategy_classes(nt)
|
|
119
|
+
strategy = strategy_cls(
|
|
120
|
+
config=config_cls(
|
|
121
|
+
strategy_id=self.config.strategy_id,
|
|
122
|
+
instrument_id=str(instrument.id),
|
|
123
|
+
bar_type=str(bar_type),
|
|
124
|
+
trade_notional=Decimal(str(self.config.trade_notional)),
|
|
125
|
+
starting_balance=Decimal(str(self.config.starting_balance)),
|
|
126
|
+
sizing_mode=self.config.sizing_mode,
|
|
127
|
+
use_pyramiding=self.config.use_pyramiding,
|
|
128
|
+
signals={int(ts.value): float(v) for ts, v in sig.items()},
|
|
129
|
+
close_positions_on_stop=self.config.close_positions_on_stop,
|
|
130
|
+
order_id_tag=self.config.strategy_id.rsplit("-", 1)[-1],
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
engine.add_strategy(strategy=strategy)
|
|
135
|
+
engine.run()
|
|
136
|
+
|
|
137
|
+
account_report = engine.trader.generate_account_report(nt.BINANCE_VENUE)
|
|
138
|
+
orders_report = engine.trader.generate_orders_report()
|
|
139
|
+
positions_report = engine.trader.generate_positions_report()
|
|
140
|
+
fills_report = None
|
|
141
|
+
if hasattr(engine.trader, "generate_order_fills_report"):
|
|
142
|
+
fills_report = engine.trader.generate_order_fills_report()
|
|
143
|
+
|
|
144
|
+
return result_from_nautilus_reports(
|
|
145
|
+
account_report=account_report,
|
|
146
|
+
orders_report=orders_report,
|
|
147
|
+
fills_report=fills_report,
|
|
148
|
+
positions_report=positions_report,
|
|
149
|
+
symbols=[str(instrument.id)],
|
|
150
|
+
initial_capital=self.config.starting_balance,
|
|
151
|
+
closes={str(instrument.id): df["close"]},
|
|
152
|
+
metadata={
|
|
153
|
+
"instrument_id": str(instrument.id),
|
|
154
|
+
"bar_type": str(bar_type),
|
|
155
|
+
"sizing_mode": self.config.sizing_mode,
|
|
156
|
+
"trade_notional": self.config.trade_notional,
|
|
157
|
+
"use_pyramiding": self.config.use_pyramiding,
|
|
158
|
+
"close_positions_on_stop": self.config.close_positions_on_stop,
|
|
159
|
+
**self._instrument_constraint_metadata(instrument),
|
|
160
|
+
**self.config.metadata,
|
|
161
|
+
**(params or {}),
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
finally:
|
|
165
|
+
engine.reset()
|
|
166
|
+
engine.dispose()
|
|
167
|
+
|
|
168
|
+
def run_order_packages(
|
|
169
|
+
self,
|
|
170
|
+
data: Dict[str, pd.DataFrame],
|
|
171
|
+
orders: Sequence[OrderIntent],
|
|
172
|
+
symbols: Optional[Sequence[str]] = None,
|
|
173
|
+
params: Optional[Dict] = None,
|
|
174
|
+
) -> BacktestResultV2:
|
|
175
|
+
"""
|
|
176
|
+
Run explicit component package orders through NautilusTrader.
|
|
177
|
+
|
|
178
|
+
Orders are submitted as market IOC component orders at their original
|
|
179
|
+
timestamps. The returned result exposes raw Nautilus reports plus a
|
|
180
|
+
stable `package_order_map` linking quantbt package intents to symbols,
|
|
181
|
+
target units, and original package metadata.
|
|
182
|
+
"""
|
|
183
|
+
if not orders:
|
|
184
|
+
raise ValueError("run_order_packages requires at least one OrderIntent")
|
|
185
|
+
nt = require_nautilus()
|
|
186
|
+
symbol_list = list(symbols or data.keys())
|
|
187
|
+
if not symbol_list:
|
|
188
|
+
raise ValueError("symbols are required")
|
|
189
|
+
missing = sorted(set(symbol_list) - set(data.keys()))
|
|
190
|
+
if missing:
|
|
191
|
+
raise ValueError(f"missing Nautilus data for symbols: {missing}")
|
|
192
|
+
|
|
193
|
+
frames = {symbol: ensure_utc_ohlcv(data[symbol]) for symbol in symbol_list}
|
|
194
|
+
engine = nt.BacktestEngine(
|
|
195
|
+
config=nt.BacktestEngineConfig(
|
|
196
|
+
trader_id=nt.TraderId(self.config.trader_id),
|
|
197
|
+
logging=nt.LoggingConfig(
|
|
198
|
+
log_level=self.config.log_level,
|
|
199
|
+
bypass_logging=self.config.bypass_logging,
|
|
200
|
+
),
|
|
201
|
+
risk_engine=nt.RiskEngineConfig(bypass=self.config.bypass_risk),
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
instruments = {symbol: make_binance_perpetual(symbol, nt) for symbol in symbol_list}
|
|
206
|
+
instrument_ids = {symbol: str(instrument.id) for symbol, instrument in instruments.items()}
|
|
207
|
+
package_order_map = build_nautilus_package_order_table(orders, instrument_ids=instrument_ids)
|
|
208
|
+
engine.add_venue(
|
|
209
|
+
venue=nt.BINANCE_VENUE,
|
|
210
|
+
oms_type=nt.OmsType.NETTING,
|
|
211
|
+
account_type=nt.AccountType.MARGIN,
|
|
212
|
+
base_currency=nt.USDT,
|
|
213
|
+
starting_balances=[nt.Money(self.config.starting_balance, nt.USDT)],
|
|
214
|
+
fee_model=nt.MakerTakerFeeModel(),
|
|
215
|
+
bar_execution=True,
|
|
216
|
+
)
|
|
217
|
+
for instrument in instruments.values():
|
|
218
|
+
engine.add_instrument(instrument)
|
|
219
|
+
|
|
220
|
+
bar_types = {}
|
|
221
|
+
for symbol, instrument in instruments.items():
|
|
222
|
+
bar_type = nt.BarType.from_str(
|
|
223
|
+
f"{instrument.id}-{timeframe_to_nautilus(self.config.timeframe)}-LAST-EXTERNAL"
|
|
224
|
+
)
|
|
225
|
+
wrangler = nt.BarDataWrangler(bar_type=bar_type, instrument=instrument)
|
|
226
|
+
engine.add_data(wrangler.process(frames[symbol]))
|
|
227
|
+
bar_types[symbol] = str(bar_type)
|
|
228
|
+
|
|
229
|
+
strategy_cls, config_cls = self._make_package_strategy_classes(nt)
|
|
230
|
+
strategy = strategy_cls(
|
|
231
|
+
config=config_cls(
|
|
232
|
+
strategy_id=self.config.strategy_id,
|
|
233
|
+
instrument_ids=[str(instruments[symbol].id) for symbol in symbol_list],
|
|
234
|
+
bar_types=bar_types,
|
|
235
|
+
package_orders=_orders_payload(orders, instrument_ids=instrument_ids),
|
|
236
|
+
close_positions_on_stop=self.config.close_positions_on_stop,
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
try:
|
|
240
|
+
engine.add_strategy(strategy=strategy)
|
|
241
|
+
engine.run()
|
|
242
|
+
|
|
243
|
+
account_report = engine.trader.generate_account_report(nt.BINANCE_VENUE)
|
|
244
|
+
orders_report = engine.trader.generate_orders_report()
|
|
245
|
+
positions_report = engine.trader.generate_positions_report()
|
|
246
|
+
fills_report = None
|
|
247
|
+
if hasattr(engine.trader, "generate_order_fills_report"):
|
|
248
|
+
fills_report = engine.trader.generate_order_fills_report()
|
|
249
|
+
|
|
250
|
+
instrument_symbols = [str(instruments[symbol].id) for symbol in symbol_list]
|
|
251
|
+
close_map = {str(instruments[symbol].id): frames[symbol]["close"] for symbol in symbol_list}
|
|
252
|
+
return result_from_nautilus_reports(
|
|
253
|
+
account_report=account_report,
|
|
254
|
+
orders_report=orders_report,
|
|
255
|
+
fills_report=fills_report,
|
|
256
|
+
positions_report=positions_report,
|
|
257
|
+
symbols=instrument_symbols,
|
|
258
|
+
initial_capital=self.config.starting_balance,
|
|
259
|
+
closes=close_map,
|
|
260
|
+
metadata={
|
|
261
|
+
"backend": "nautilus",
|
|
262
|
+
"engine": "nautilus_package_orders",
|
|
263
|
+
"input_mode": "order_packages",
|
|
264
|
+
"instrument_id": instrument_symbols[0] if len(instrument_symbols) == 1 else None,
|
|
265
|
+
"instrument_ids": instrument_symbols,
|
|
266
|
+
"bar_types": bar_types,
|
|
267
|
+
"sizing_mode": self.config.sizing_mode,
|
|
268
|
+
"trade_notional": self.config.trade_notional,
|
|
269
|
+
"use_pyramiding": self.config.use_pyramiding,
|
|
270
|
+
"package_order_map": package_order_map,
|
|
271
|
+
"package_orders_count": int(len(package_order_map)),
|
|
272
|
+
"oco_cancellation_policy": "cancel_sibling_on_first_exit_fill",
|
|
273
|
+
"oco_cancellations": list(getattr(strategy, "canceled_siblings", [])),
|
|
274
|
+
"close_positions_on_stop": self.config.close_positions_on_stop,
|
|
275
|
+
**self.config.metadata,
|
|
276
|
+
**(params or {}),
|
|
277
|
+
},
|
|
278
|
+
)
|
|
279
|
+
finally:
|
|
280
|
+
engine.reset()
|
|
281
|
+
engine.dispose()
|
|
282
|
+
|
|
283
|
+
def _make_instrument(self, nt):
|
|
284
|
+
if not self.config.use_test_instrument:
|
|
285
|
+
raise NotImplementedError("custom Nautilus instruments are not wired yet")
|
|
286
|
+
return make_binance_perpetual(self.config.instrument_id, nt)
|
|
287
|
+
|
|
288
|
+
@staticmethod
|
|
289
|
+
def _instrument_constraint_metadata(instrument) -> Dict:
|
|
290
|
+
size_increment = getattr(instrument, "size_increment", None)
|
|
291
|
+
min_quantity = getattr(instrument, "min_quantity", None)
|
|
292
|
+
min_notional = getattr(instrument, "min_notional", None)
|
|
293
|
+
price_increment = getattr(instrument, "price_increment", None)
|
|
294
|
+
return {
|
|
295
|
+
"qty_step": None if size_increment is None else str(size_increment),
|
|
296
|
+
"lot_size": None if size_increment is None else str(size_increment),
|
|
297
|
+
"min_qty": None if min_quantity is None else str(min_quantity),
|
|
298
|
+
"min_notional": None if min_notional is None else str(min_notional),
|
|
299
|
+
"price_increment": None if price_increment is None else str(price_increment),
|
|
300
|
+
"quantity_constraint_note": "lot_size/qty_step controls fractional crypto order acceptance; contract_size remains multiplier",
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
@staticmethod
|
|
304
|
+
def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
|
|
305
|
+
sig = signal.copy()
|
|
306
|
+
if sig.index.tz is None:
|
|
307
|
+
sig.index = sig.index.tz_localize("UTC")
|
|
308
|
+
else:
|
|
309
|
+
sig.index = sig.index.tz_convert("UTC")
|
|
310
|
+
return sig.reindex(idx, method="ffill").fillna(0.0)
|
|
311
|
+
|
|
312
|
+
@staticmethod
|
|
313
|
+
def _make_signal_strategy_classes(nt):
|
|
314
|
+
class QuantBTSignalConfig(nt.StrategyConfig, frozen=True):
|
|
315
|
+
instrument_id: str
|
|
316
|
+
bar_type: str
|
|
317
|
+
trade_notional: Decimal
|
|
318
|
+
starting_balance: Decimal
|
|
319
|
+
signals: Dict[int, float]
|
|
320
|
+
sizing_mode: str = "signal_notional"
|
|
321
|
+
use_pyramiding: bool = True
|
|
322
|
+
close_positions_on_stop: bool = False
|
|
323
|
+
|
|
324
|
+
class QuantBTSignalStrategy(nt.Strategy):
|
|
325
|
+
def __init__(self, config: QuantBTSignalConfig):
|
|
326
|
+
super().__init__(config)
|
|
327
|
+
self.instrument_id = nt.InstrumentId.from_str(config.instrument_id)
|
|
328
|
+
self.bar_type = nt.BarType.from_str(config.bar_type)
|
|
329
|
+
self.trade_notional = config.trade_notional
|
|
330
|
+
self.starting_balance = config.starting_balance
|
|
331
|
+
self.sizing_mode = config.sizing_mode.lower().strip()
|
|
332
|
+
self.use_pyramiding = bool(config.use_pyramiding)
|
|
333
|
+
self.signals = config.signals
|
|
334
|
+
self.instrument = None
|
|
335
|
+
self.current_signal = 0.0
|
|
336
|
+
self.first_price = 0.0
|
|
337
|
+
|
|
338
|
+
def on_start(self):
|
|
339
|
+
self.instrument = self.cache.instrument(self.instrument_id)
|
|
340
|
+
if self.instrument is None:
|
|
341
|
+
self.stop()
|
|
342
|
+
return
|
|
343
|
+
self.subscribe_bars(self.bar_type)
|
|
344
|
+
|
|
345
|
+
def on_bar(self, bar):
|
|
346
|
+
raw_signal = float(self.signals.get(int(bar.ts_event), self.current_signal))
|
|
347
|
+
signal = raw_signal if self.use_pyramiding else self._sign(raw_signal)
|
|
348
|
+
signal_changed = signal != self.current_signal
|
|
349
|
+
if self.sizing_mode not in ("notional",) and not signal_changed:
|
|
350
|
+
return
|
|
351
|
+
price = self.cache.price(self.instrument_id, nt.PriceType.LAST)
|
|
352
|
+
if price is None:
|
|
353
|
+
return
|
|
354
|
+
price_value = float(price)
|
|
355
|
+
if self.first_price <= 0.0:
|
|
356
|
+
self.first_price = price_value
|
|
357
|
+
current_qty = self._current_qty()
|
|
358
|
+
target_qty = self._target_qty(signal=signal, price=price_value)
|
|
359
|
+
delta = target_qty - current_qty
|
|
360
|
+
if abs(delta) < float(self.instrument.size_increment):
|
|
361
|
+
self.current_signal = signal
|
|
362
|
+
return
|
|
363
|
+
side = nt.OrderSide.BUY if delta > 0.0 else nt.OrderSide.SELL
|
|
364
|
+
order = self.order_factory.market(
|
|
365
|
+
instrument_id=self.instrument_id,
|
|
366
|
+
order_side=side,
|
|
367
|
+
quantity=self.instrument.make_qty(abs(delta)),
|
|
368
|
+
time_in_force=nt.TimeInForce.IOC,
|
|
369
|
+
)
|
|
370
|
+
self.submit_order(order)
|
|
371
|
+
self.current_signal = signal
|
|
372
|
+
|
|
373
|
+
def _target_qty(self, signal: float, price: float) -> float:
|
|
374
|
+
if signal == 0.0 or price <= 0.0:
|
|
375
|
+
return 0.0
|
|
376
|
+
sizing = self.sizing_mode
|
|
377
|
+
allocation = float(self.trade_notional)
|
|
378
|
+
if sizing in ("signal_notional", "signal", "notional"):
|
|
379
|
+
return allocation * signal / price
|
|
380
|
+
if sizing == "unit":
|
|
381
|
+
return 0.0 if self.first_price <= 0.0 else allocation * signal / self.first_price
|
|
382
|
+
if sizing in ("%_equity", "pct_equity"):
|
|
383
|
+
alloc_pct = allocation / 100.0 if allocation > 1.0 else allocation
|
|
384
|
+
return self._equity() * alloc_pct * signal / price
|
|
385
|
+
raise RuntimeError(f"unsupported Nautilus sizing_mode={self.sizing_mode!r}")
|
|
386
|
+
|
|
387
|
+
def _equity(self) -> float:
|
|
388
|
+
equity = self.portfolio.equity(venue=self.instrument_id.venue)
|
|
389
|
+
if equity is None:
|
|
390
|
+
return float(self.starting_balance)
|
|
391
|
+
if isinstance(equity, dict):
|
|
392
|
+
if not equity:
|
|
393
|
+
return float(self.starting_balance)
|
|
394
|
+
equity = next(iter(equity.values()))
|
|
395
|
+
try:
|
|
396
|
+
return float(equity)
|
|
397
|
+
except (TypeError, ValueError):
|
|
398
|
+
text = str(equity).replace(",", "").strip()
|
|
399
|
+
return float(text.split()[0])
|
|
400
|
+
|
|
401
|
+
@staticmethod
|
|
402
|
+
def _sign(value: float) -> float:
|
|
403
|
+
if value > 0.0:
|
|
404
|
+
return 1.0
|
|
405
|
+
if value < 0.0:
|
|
406
|
+
return -1.0
|
|
407
|
+
return 0.0
|
|
408
|
+
|
|
409
|
+
def _current_qty(self) -> float:
|
|
410
|
+
positions = self.cache.positions_open(instrument_id=self.instrument_id)
|
|
411
|
+
if not positions:
|
|
412
|
+
return 0.0
|
|
413
|
+
pos = positions[0]
|
|
414
|
+
if pos.side == nt.PositionSide.LONG:
|
|
415
|
+
return float(pos.quantity)
|
|
416
|
+
if pos.side == nt.PositionSide.SHORT:
|
|
417
|
+
return -float(pos.quantity)
|
|
418
|
+
return 0.0
|
|
419
|
+
|
|
420
|
+
def on_stop(self):
|
|
421
|
+
self.cancel_all_orders(self.instrument_id)
|
|
422
|
+
if self.config.close_positions_on_stop:
|
|
423
|
+
self.close_all_positions(self.instrument_id)
|
|
424
|
+
|
|
425
|
+
return QuantBTSignalStrategy, QuantBTSignalConfig
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _make_package_strategy_classes(nt):
|
|
429
|
+
class QuantBTPackageConfig(nt.StrategyConfig, frozen=True):
|
|
430
|
+
instrument_ids: List[str]
|
|
431
|
+
bar_types: Dict[str, str]
|
|
432
|
+
package_orders: Dict[int, List[Dict]]
|
|
433
|
+
close_positions_on_stop: bool = False
|
|
434
|
+
|
|
435
|
+
class QuantBTPackageStrategy(nt.Strategy):
|
|
436
|
+
def __init__(self, config: QuantBTPackageConfig):
|
|
437
|
+
super().__init__(config)
|
|
438
|
+
self.instrument_ids = [nt.InstrumentId.from_str(value) for value in config.instrument_ids]
|
|
439
|
+
self.bar_types = [nt.BarType.from_str(value) for value in config.bar_types.values()]
|
|
440
|
+
self.package_orders = config.package_orders
|
|
441
|
+
self.submitted_timestamps = set()
|
|
442
|
+
self.instruments = {}
|
|
443
|
+
self.order_groups = {}
|
|
444
|
+
self.client_to_group = {}
|
|
445
|
+
self.client_to_role = {}
|
|
446
|
+
self.canceled_siblings = []
|
|
447
|
+
|
|
448
|
+
def on_start(self):
|
|
449
|
+
for instrument_id in self.instrument_ids:
|
|
450
|
+
instrument = self.cache.instrument(instrument_id)
|
|
451
|
+
if instrument is None:
|
|
452
|
+
self.stop()
|
|
453
|
+
return
|
|
454
|
+
self.instruments[str(instrument_id)] = instrument
|
|
455
|
+
for bar_type in self.bar_types:
|
|
456
|
+
self.subscribe_bars(bar_type)
|
|
457
|
+
|
|
458
|
+
def on_bar(self, bar):
|
|
459
|
+
ts_event = int(bar.ts_event)
|
|
460
|
+
if ts_event in self.submitted_timestamps:
|
|
461
|
+
return
|
|
462
|
+
payload = self.package_orders.get(ts_event)
|
|
463
|
+
if not payload:
|
|
464
|
+
return
|
|
465
|
+
for item in payload:
|
|
466
|
+
instrument_id = nt.InstrumentId.from_str(item["instrument_id"])
|
|
467
|
+
instrument = self.instruments.get(str(instrument_id))
|
|
468
|
+
if instrument is None:
|
|
469
|
+
continue
|
|
470
|
+
side = nt.OrderSide.BUY if item["side"] == "buy" else nt.OrderSide.SELL
|
|
471
|
+
order = self._make_order(item, instrument_id, instrument, side)
|
|
472
|
+
self._register_group_order(item, order)
|
|
473
|
+
self.submit_order(order)
|
|
474
|
+
self.submitted_timestamps.add(ts_event)
|
|
475
|
+
|
|
476
|
+
def _register_group_order(self, item, order):
|
|
477
|
+
group_id = item.get("oco_group_id")
|
|
478
|
+
role = item.get("leg_role")
|
|
479
|
+
if not group_id:
|
|
480
|
+
return
|
|
481
|
+
client_order_id = str(order.client_order_id)
|
|
482
|
+
self.order_groups.setdefault(group_id, {})[client_order_id] = order
|
|
483
|
+
self.client_to_group[client_order_id] = group_id
|
|
484
|
+
self.client_to_role[client_order_id] = role
|
|
485
|
+
|
|
486
|
+
def on_order_filled(self, event):
|
|
487
|
+
client_order_id = str(event.client_order_id)
|
|
488
|
+
role = self.client_to_role.get(client_order_id)
|
|
489
|
+
if role not in {"take_profit", "stop_loss"}:
|
|
490
|
+
return
|
|
491
|
+
group_id = self.client_to_group.get(client_order_id)
|
|
492
|
+
if not group_id:
|
|
493
|
+
return
|
|
494
|
+
for sibling_id, sibling_order in list(self.order_groups.get(group_id, {}).items()):
|
|
495
|
+
if sibling_id == client_order_id:
|
|
496
|
+
continue
|
|
497
|
+
sibling_role = self.client_to_role.get(sibling_id)
|
|
498
|
+
if sibling_role not in {"take_profit", "stop_loss"}:
|
|
499
|
+
continue
|
|
500
|
+
try:
|
|
501
|
+
self.cancel_order(sibling_order)
|
|
502
|
+
self.canceled_siblings.append(
|
|
503
|
+
{
|
|
504
|
+
"oco_group_id": group_id,
|
|
505
|
+
"filled_client_order_id": client_order_id,
|
|
506
|
+
"canceled_client_order_id": sibling_id,
|
|
507
|
+
}
|
|
508
|
+
)
|
|
509
|
+
except Exception:
|
|
510
|
+
continue
|
|
511
|
+
|
|
512
|
+
def _make_order(self, item, instrument_id, instrument, side):
|
|
513
|
+
quantity = instrument.make_qty(Decimal(str(item["qty"])))
|
|
514
|
+
tif = self._time_in_force(item.get("tif", "gtc"))
|
|
515
|
+
order_type = str(item.get("order_type", "market")).lower().strip()
|
|
516
|
+
kwargs = {
|
|
517
|
+
"instrument_id": instrument_id,
|
|
518
|
+
"order_side": side,
|
|
519
|
+
"quantity": quantity,
|
|
520
|
+
"time_in_force": tif,
|
|
521
|
+
"reduce_only": bool(item.get("reduce_only", False)),
|
|
522
|
+
"tags": [item["tag"]] if item.get("tag") else None,
|
|
523
|
+
}
|
|
524
|
+
if order_type == "market":
|
|
525
|
+
return self.order_factory.market(**kwargs)
|
|
526
|
+
if order_type == "limit":
|
|
527
|
+
return self.order_factory.limit(
|
|
528
|
+
price=instrument.make_price(Decimal(str(item["price"]))),
|
|
529
|
+
**kwargs,
|
|
530
|
+
)
|
|
531
|
+
if order_type == "stop_market":
|
|
532
|
+
return self.order_factory.stop_market(
|
|
533
|
+
trigger_price=instrument.make_price(Decimal(str(item["trigger_price"]))),
|
|
534
|
+
**kwargs,
|
|
535
|
+
)
|
|
536
|
+
if order_type == "stop_limit":
|
|
537
|
+
return self.order_factory.stop_limit(
|
|
538
|
+
price=instrument.make_price(Decimal(str(item["price"]))),
|
|
539
|
+
trigger_price=instrument.make_price(Decimal(str(item["trigger_price"]))),
|
|
540
|
+
**kwargs,
|
|
541
|
+
)
|
|
542
|
+
raise NotImplementedError(f"unsupported Nautilus explicit order_type={order_type!r}")
|
|
543
|
+
|
|
544
|
+
@staticmethod
|
|
545
|
+
def _time_in_force(value):
|
|
546
|
+
key = str(value).upper().strip()
|
|
547
|
+
if key in {"GOOD_TIL_CANCEL", "GOOD_TILL_CANCEL"}:
|
|
548
|
+
key = "GTC"
|
|
549
|
+
try:
|
|
550
|
+
return getattr(nt.TimeInForce, key)
|
|
551
|
+
except AttributeError as exc:
|
|
552
|
+
raise NotImplementedError(f"unsupported Nautilus time_in_force={value!r}") from exc
|
|
553
|
+
|
|
554
|
+
def on_stop(self):
|
|
555
|
+
for instrument_id in self.instrument_ids:
|
|
556
|
+
self.cancel_all_orders(instrument_id)
|
|
557
|
+
if self.config.close_positions_on_stop:
|
|
558
|
+
self.close_all_positions(instrument_id)
|
|
559
|
+
|
|
560
|
+
return QuantBTPackageStrategy, QuantBTPackageConfig
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def build_nautilus_package_order_table(
|
|
564
|
+
orders: Sequence[OrderIntent],
|
|
565
|
+
instrument_ids: Optional[Dict[str, str]] = None,
|
|
566
|
+
) -> pd.DataFrame:
|
|
567
|
+
rows = []
|
|
568
|
+
for idx, order in enumerate(orders):
|
|
569
|
+
timestamp = pd.Timestamp(order.timestamp)
|
|
570
|
+
if timestamp.tz is None:
|
|
571
|
+
timestamp = timestamp.tz_localize("UTC")
|
|
572
|
+
else:
|
|
573
|
+
timestamp = timestamp.tz_convert("UTC")
|
|
574
|
+
instrument_id = (instrument_ids or {}).get(order.symbol, order.symbol)
|
|
575
|
+
rows.append(
|
|
576
|
+
{
|
|
577
|
+
"package_order_index": idx,
|
|
578
|
+
"timestamp": timestamp,
|
|
579
|
+
"symbol": order.symbol,
|
|
580
|
+
"instrument_id": instrument_id,
|
|
581
|
+
"side": order.side.value if isinstance(order.side, OrderSide) else str(order.side),
|
|
582
|
+
"qty": float(order.qty),
|
|
583
|
+
"order_type": getattr(order.order_type, "value", str(order.order_type)),
|
|
584
|
+
"price": order.price,
|
|
585
|
+
"trigger_price": order.trigger_price,
|
|
586
|
+
"tif": getattr(order.tif, "value", str(order.tif)),
|
|
587
|
+
"reduce_only": bool(order.reduce_only),
|
|
588
|
+
"order_id": order.order_id,
|
|
589
|
+
"tag": order.tag,
|
|
590
|
+
"arb_id": order.metadata.get("arb_id"),
|
|
591
|
+
"arb_type": order.metadata.get("arb_type"),
|
|
592
|
+
"package_policy": order.metadata.get("package_policy"),
|
|
593
|
+
"package_id": order.metadata.get("package_id"),
|
|
594
|
+
"package_type": order.metadata.get("package_type"),
|
|
595
|
+
"structured_type": order.metadata.get("structured_type"),
|
|
596
|
+
"leg_role": order.metadata.get("leg_role"),
|
|
597
|
+
"oco_group_id": order.metadata.get("oco_group_id"),
|
|
598
|
+
"parent_tag": order.metadata.get("parent_tag"),
|
|
599
|
+
"ladder_level": order.metadata.get("ladder_level"),
|
|
600
|
+
"target_units": order.metadata.get("target_units"),
|
|
601
|
+
"previous_units": order.metadata.get("previous_units"),
|
|
602
|
+
}
|
|
603
|
+
)
|
|
604
|
+
return pd.DataFrame(rows)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _orders_payload(
|
|
608
|
+
orders: Sequence[OrderIntent],
|
|
609
|
+
instrument_ids: Optional[Dict[str, str]] = None,
|
|
610
|
+
) -> Dict[int, List[Dict]]:
|
|
611
|
+
payload: Dict[int, List[Dict]] = {}
|
|
612
|
+
for order in orders:
|
|
613
|
+
timestamp = pd.Timestamp(order.timestamp)
|
|
614
|
+
if timestamp.tz is None:
|
|
615
|
+
timestamp = timestamp.tz_localize("UTC")
|
|
616
|
+
else:
|
|
617
|
+
timestamp = timestamp.tz_convert("UTC")
|
|
618
|
+
side = order.side.value if isinstance(order.side, OrderSide) else str(order.side)
|
|
619
|
+
item = {
|
|
620
|
+
"symbol": order.symbol,
|
|
621
|
+
"instrument_id": (instrument_ids or {}).get(order.symbol, order.symbol),
|
|
622
|
+
"side": side,
|
|
623
|
+
"qty": float(order.qty),
|
|
624
|
+
"order_type": getattr(order.order_type, "value", str(order.order_type)),
|
|
625
|
+
"price": order.price,
|
|
626
|
+
"trigger_price": order.trigger_price,
|
|
627
|
+
"tif": getattr(order.tif, "value", str(order.tif)),
|
|
628
|
+
"reduce_only": bool(order.reduce_only),
|
|
629
|
+
"order_id": order.order_id,
|
|
630
|
+
"tag": order.tag,
|
|
631
|
+
"package_id": order.metadata.get("package_id"),
|
|
632
|
+
"package_type": order.metadata.get("package_type"),
|
|
633
|
+
"structured_type": order.metadata.get("structured_type"),
|
|
634
|
+
"leg_role": order.metadata.get("leg_role"),
|
|
635
|
+
"oco_group_id": order.metadata.get("oco_group_id"),
|
|
636
|
+
"parent_tag": order.metadata.get("parent_tag"),
|
|
637
|
+
}
|
|
638
|
+
payload.setdefault(int(timestamp.value), []).append(item)
|
|
639
|
+
return payload
|