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.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. 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