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
quantbt/engines.py ADDED
@@ -0,0 +1,1014 @@
1
+ """
2
+ Public V2 engine facades.
3
+
4
+ These classes keep the old public API untouched while giving new notebooks a
5
+ single backend selector for native vectorized, native event-driven, and optional
6
+ Nautilus validation runs.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import replace
12
+ from typing import Dict, List, Optional, Sequence, Tuple, Union
13
+
14
+ import pandas as pd
15
+
16
+ from .backends import (
17
+ NativeEventBackend,
18
+ NativeEventConfig,
19
+ NativeOptionBackend,
20
+ NativeOptionConfig,
21
+ NativePortfolioBackend,
22
+ NativePortfolioConfig,
23
+ NativeVectorizedBackend,
24
+ NativeVectorizedConfig,
25
+ )
26
+ from .core.orders import OrderAction, OrderCommand, OrderIntent, order_intents_to_lifecycle_commands
27
+ from .core.preprocessor import validate_datetime
28
+ from .core.results import BacktestResultV2, OptionBacktestResult
29
+ from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce
30
+ from .options.cache import OptionPreparedRunCache
31
+ from .options.hedging import OptionHedgeConfig
32
+ from .options.packages import OptionPackageIntent
33
+ from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
34
+ from .options.strategy import OptionStrategyRun
35
+ from .portfolio import MultiSymbolPortfolio
36
+ from .sizing.modes import compute_target_units
37
+
38
+
39
+ SeriesMap = Dict[str, pd.Series]
40
+
41
+
42
+ class BacktestEngineV2:
43
+ """
44
+ Backend-selecting facade for upgraded quantbt engines.
45
+
46
+ Parameters can be supplied in a dataframe-oriented style (`data` and
47
+ `signals`) or an explicit dictionary style (`closes`, `highs`, `lows`,
48
+ `positions`, `target_units`, `orders`).
49
+ """
50
+
51
+ VALID_BACKENDS = {"native_vectorized", "native_event", "nautilus"}
52
+
53
+ def __init__(
54
+ self,
55
+ data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]] = None,
56
+ signals: Optional[Union[pd.Series, SeriesMap]] = None,
57
+ backend: str = "native_vectorized",
58
+ native_backend: Optional[str] = None,
59
+ account: Optional[AccountConfig] = None,
60
+ execution: Optional[ExecutionConfig] = None,
61
+ fee_rate: float = 0.0,
62
+ use_funding: bool = True,
63
+ alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
64
+ hedge_type: str = "signal_notional",
65
+ use_pyramiding: bool = True,
66
+ positions: Optional[Union[pd.Series, SeriesMap]] = None,
67
+ target_units: Optional[Union[pd.Series, SeriesMap]] = None,
68
+ orders: Optional[Sequence[OrderIntent]] = None,
69
+ order_commands: Optional[Sequence[OrderCommand]] = None,
70
+ strategy=None,
71
+ event_engine_version: str = "v1",
72
+ reactive_execution_mode: str = "fast",
73
+ reactive_kernel_mode: str = "replay_certified",
74
+ report_level: str = "audit",
75
+ audit_sink: str = "memory",
76
+ audit_sink_path: Optional[str] = None,
77
+ datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None,
78
+ closes: Optional[SeriesMap] = None,
79
+ highs: Optional[SeriesMap] = None,
80
+ lows: Optional[SeriesMap] = None,
81
+ funding_rate: Union[float, pd.Series, Dict] = 0.0,
82
+ contract_size: Union[float, Dict[str, float]] = 1.0,
83
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
84
+ symbols: Optional[List[str]] = None,
85
+ basket: Optional[BasketSpec] = None,
86
+ signal: Optional[pd.Series] = None,
87
+ hedge_ratios: Optional[SeriesMap] = None,
88
+ nautilus_config=None,
89
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
90
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
91
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
92
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
93
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
94
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
95
+ auto_run: bool = True,
96
+ ):
97
+ self.backend = backend.lower().strip()
98
+ if self.backend not in self.VALID_BACKENDS:
99
+ raise ValueError(f"backend must be one of {sorted(self.VALID_BACKENDS)}")
100
+
101
+ self.data = data
102
+ self.native_backend = native_backend
103
+ self.signals = signals
104
+ self.account = account or AccountConfig(initial_capital=100_000.0)
105
+ self.execution = execution or ExecutionConfig()
106
+ self.fee_rate = float(fee_rate)
107
+ self.use_funding = bool(use_funding)
108
+ self.alloc_per_trade = alloc_per_trade
109
+ self.hedge_type = hedge_type
110
+ self.use_pyramiding = use_pyramiding
111
+ self.positions = positions
112
+ self.target_units = target_units
113
+ self.orders = tuple(orders or ())
114
+ self.order_commands = tuple(order_commands or ())
115
+ self.strategy = strategy
116
+ self.event_engine_version = str(event_engine_version).lower().strip()
117
+ self.reactive_execution_mode = str(reactive_execution_mode).lower().strip()
118
+ self.reactive_kernel_mode = str(reactive_kernel_mode).lower().strip()
119
+ self.report_level = str(report_level)
120
+ self.audit_sink = str(audit_sink)
121
+ self.audit_sink_path = audit_sink_path
122
+ self.datetime_index = datetime_index
123
+ self.closes = closes
124
+ self.highs = highs
125
+ self.lows = lows
126
+ self.funding_rate = funding_rate
127
+ self.contract_size = contract_size
128
+ self.leverage = leverage
129
+ self.symbols = symbols
130
+ self.basket = basket
131
+ self.signal = signal
132
+ self.hedge_ratios = hedge_ratios
133
+ self.nautilus_config = nautilus_config
134
+ self.instruments = instruments
135
+ self.qty_step = qty_step
136
+ self.lot_size = lot_size
137
+ self.slot_size = slot_size
138
+ self.min_qty = min_qty
139
+ self.min_notional = min_notional
140
+ self.result: Optional[BacktestResultV2] = None
141
+
142
+ if auto_run:
143
+ self.run()
144
+
145
+ def run(self) -> BacktestResultV2:
146
+ if self.backend == "native_vectorized":
147
+ self.result = self._run_native_vectorized()
148
+ elif self.backend == "native_event":
149
+ self.result = self._run_native_event()
150
+ else:
151
+ self.result = self._run_nautilus()
152
+ return self.result
153
+
154
+ def _run_native_vectorized(self) -> BacktestResultV2:
155
+ idx, closes, highs, lows, symbols = self._market_data()
156
+ backend = NativeVectorizedBackend(
157
+ NativeVectorizedConfig(
158
+ account=self.account,
159
+ execution=self.execution,
160
+ fee_rate=self.fee_rate,
161
+ use_funding=self.use_funding,
162
+ )
163
+ )
164
+
165
+ if self.target_units is not None:
166
+ target_units = _as_series_map(self.target_units, symbols)
167
+ return backend.run_target_units(
168
+ datetime_index=idx,
169
+ target_units=target_units,
170
+ closes=closes,
171
+ highs=highs,
172
+ lows=lows,
173
+ funding_rate=self.funding_rate,
174
+ contract_size=self.contract_size,
175
+ leverage=self.leverage,
176
+ symbols=symbols,
177
+ instruments=self.instruments,
178
+ qty_step=self.qty_step,
179
+ lot_size=self.lot_size,
180
+ slot_size=self.slot_size,
181
+ min_qty=self.min_qty,
182
+ min_notional=self.min_notional,
183
+ )
184
+
185
+ raw_positions = self.positions if self.positions is not None else self.signals
186
+ if raw_positions is None:
187
+ raise ValueError("native_vectorized requires signals, positions, or target_units")
188
+
189
+ return backend.run_signals(
190
+ datetime_index=idx,
191
+ positions=_as_series_map(raw_positions, symbols),
192
+ closes=closes,
193
+ highs=highs,
194
+ lows=lows,
195
+ funding_rate=self.funding_rate,
196
+ contract_size=self.contract_size,
197
+ leverage=self.leverage,
198
+ alloc_per_trade=self.alloc_per_trade,
199
+ hedge_type=self.hedge_type,
200
+ use_pyramiding=self.use_pyramiding,
201
+ symbols=symbols,
202
+ instruments=self.instruments,
203
+ qty_step=self.qty_step,
204
+ lot_size=self.lot_size,
205
+ slot_size=self.slot_size,
206
+ min_qty=self.min_qty,
207
+ min_notional=self.min_notional,
208
+ )
209
+
210
+ def _run_native_event(self) -> BacktestResultV2:
211
+ idx, closes, highs, lows, symbols = self._market_data()
212
+ backend = NativeEventBackend(
213
+ NativeEventConfig(
214
+ account=self.account,
215
+ execution=self.execution,
216
+ fee_rate=self.fee_rate,
217
+ use_funding=self.use_funding,
218
+ report_level=self.report_level,
219
+ audit_sink=self.audit_sink,
220
+ audit_sink_path=self.audit_sink_path,
221
+ reactive_kernel_mode=self.reactive_kernel_mode,
222
+ native_backend=self.native_backend,
223
+ )
224
+ )
225
+
226
+ if self.strategy is not None:
227
+ opens, volumes = _market_open_volume(
228
+ data=self.data,
229
+ datetime_index=idx,
230
+ closes=closes,
231
+ symbols=symbols,
232
+ )
233
+ return backend.run_strategy(
234
+ datetime_index=idx,
235
+ strategy=self.strategy,
236
+ closes=closes,
237
+ highs=highs,
238
+ lows=lows,
239
+ opens=opens,
240
+ volumes=volumes,
241
+ funding_rate=self.funding_rate,
242
+ contract_size=self.contract_size,
243
+ leverage=self.leverage,
244
+ fee_rate=self.fee_rate,
245
+ symbols=symbols,
246
+ instruments=self.instruments,
247
+ qty_step=self.qty_step,
248
+ lot_size=self.lot_size,
249
+ slot_size=self.slot_size,
250
+ min_qty=self.min_qty,
251
+ min_notional=self.min_notional,
252
+ execution_mode=self.reactive_execution_mode,
253
+ reactive_kernel_mode=self.reactive_kernel_mode,
254
+ report_level=self.report_level,
255
+ audit_sink=self.audit_sink,
256
+ audit_sink_path=self.audit_sink_path,
257
+ )
258
+
259
+ if self.basket is not None:
260
+ basket_signal = self.signal if self.signal is not None else _first_signal(self.signals)
261
+ if basket_signal is None:
262
+ raise ValueError("basket event backtest requires signal or signals")
263
+ return backend.run_basket(
264
+ datetime_index=idx,
265
+ basket=self.basket,
266
+ signal=basket_signal,
267
+ closes=closes,
268
+ highs=highs,
269
+ lows=lows,
270
+ hedge_ratios=self.hedge_ratios,
271
+ funding_rate=self.funding_rate,
272
+ contract_size=self.contract_size,
273
+ leverage=self.leverage,
274
+ symbols=symbols,
275
+ instruments=self.instruments,
276
+ qty_step=self.qty_step,
277
+ lot_size=self.lot_size,
278
+ slot_size=self.slot_size,
279
+ min_qty=self.min_qty,
280
+ min_notional=self.min_notional,
281
+ )
282
+
283
+ if self.order_commands or self.event_engine_version in {"v2", "event_v2", "lifecycle", "lifecycle_v2"}:
284
+ commands = self.order_commands
285
+ if not commands and self.orders:
286
+ commands = order_intents_to_lifecycle_commands(self.orders)
287
+ if not commands:
288
+ raw_positions = self.positions if self.positions is not None else self.signals
289
+ if raw_positions is None:
290
+ raise ValueError("native_event v2 requires order_commands, orders, signals, positions, or a basket")
291
+ generated_orders = tuple(
292
+ _build_market_rebalance_orders(
293
+ datetime_index=idx,
294
+ positions=_as_series_map(raw_positions, symbols),
295
+ closes=closes,
296
+ alloc_per_trade=self.alloc_per_trade,
297
+ hedge_type=self.hedge_type,
298
+ use_pyramiding=self.use_pyramiding,
299
+ symbols=symbols,
300
+ )
301
+ )
302
+ commands = order_intents_to_lifecycle_commands(generated_orders)
303
+ return backend.run_order_commands(
304
+ datetime_index=idx,
305
+ commands=commands,
306
+ closes=closes,
307
+ highs=highs,
308
+ lows=lows,
309
+ funding_rate=self.funding_rate,
310
+ contract_size=self.contract_size,
311
+ leverage=self.leverage,
312
+ symbols=symbols,
313
+ instruments=self.instruments,
314
+ qty_step=self.qty_step,
315
+ lot_size=self.lot_size,
316
+ slot_size=self.slot_size,
317
+ min_qty=self.min_qty,
318
+ min_notional=self.min_notional,
319
+ report_level=self.report_level,
320
+ audit_sink=self.audit_sink,
321
+ audit_sink_path=self.audit_sink_path,
322
+ )
323
+
324
+ orders = self.orders
325
+ if not orders:
326
+ raw_positions = self.positions if self.positions is not None else self.signals
327
+ if raw_positions is None:
328
+ raise ValueError("native_event requires explicit orders, signals, positions, or a basket")
329
+ orders = tuple(
330
+ _build_market_rebalance_orders(
331
+ datetime_index=idx,
332
+ positions=_as_series_map(raw_positions, symbols),
333
+ closes=closes,
334
+ alloc_per_trade=self.alloc_per_trade,
335
+ hedge_type=self.hedge_type,
336
+ use_pyramiding=self.use_pyramiding,
337
+ symbols=symbols,
338
+ )
339
+ )
340
+ return backend.run_orders(
341
+ datetime_index=idx,
342
+ orders=orders,
343
+ closes=closes,
344
+ highs=highs,
345
+ lows=lows,
346
+ funding_rate=self.funding_rate,
347
+ contract_size=self.contract_size,
348
+ leverage=self.leverage,
349
+ symbols=symbols,
350
+ instruments=self.instruments,
351
+ qty_step=self.qty_step,
352
+ lot_size=self.lot_size,
353
+ slot_size=self.slot_size,
354
+ min_qty=self.min_qty,
355
+ min_notional=self.min_notional,
356
+ )
357
+
358
+ def _run_nautilus(self) -> BacktestResultV2:
359
+ from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
360
+
361
+ symbol_override = self.symbols[0] if self.symbols else None
362
+ trade_notional = self.alloc_per_trade if not isinstance(self.alloc_per_trade, dict) else next(
363
+ iter(self.alloc_per_trade.values())
364
+ )
365
+ if self.orders or self.order_commands:
366
+ idx, closes, highs, lows, symbols = self._market_data()
367
+ package_orders = self.orders
368
+ input_mode = "explicit_orders"
369
+ if self.order_commands:
370
+ package_orders = _commands_to_package_order_intents(self.order_commands)
371
+ input_mode = "lifecycle_commands"
372
+ data = _frames_for_nautilus(
373
+ data=self.data,
374
+ datetime_index=idx,
375
+ closes=closes,
376
+ highs=highs,
377
+ lows=lows,
378
+ symbols=symbols,
379
+ )
380
+ config = self.nautilus_config
381
+ updates = {
382
+ "starting_balance": self.account.initial_capital,
383
+ "trade_notional": 0.0,
384
+ "sizing_mode": "notional",
385
+ }
386
+ if symbol_override:
387
+ updates["instrument_id"] = symbol_override
388
+ if config is None:
389
+ config = NautilusBackendConfig(
390
+ instrument_id=symbol_override or symbols[0],
391
+ starting_balance=self.account.initial_capital,
392
+ trade_notional=0.0,
393
+ sizing_mode="notional",
394
+ )
395
+ else:
396
+ config = replace(config, **updates)
397
+ package_params = {
398
+ "input_mode": input_mode,
399
+ "order_count_input": int(len(package_orders)),
400
+ }
401
+ if self.order_commands:
402
+ package_params["command_count_input"] = int(len(self.order_commands))
403
+ return NautilusBacktestEngine(config).run_order_packages(
404
+ data=data,
405
+ orders=package_orders,
406
+ symbols=symbols,
407
+ params=package_params,
408
+ )
409
+
410
+ data = _single_frame(self.data)
411
+ if data is None:
412
+ idx, closes, highs, lows, symbols = self._market_data()
413
+ symbol = symbols[0]
414
+ data = pd.DataFrame(
415
+ {
416
+ "open": closes[symbol],
417
+ "high": highs[symbol],
418
+ "low": lows[symbol],
419
+ "close": closes[symbol],
420
+ "volume": 0.0,
421
+ },
422
+ index=idx,
423
+ )
424
+
425
+ signal = _first_signal(self.positions if self.positions is not None else self.signals)
426
+ if signal is None:
427
+ raise ValueError("nautilus backend requires a single signal series")
428
+
429
+ config = self.nautilus_config
430
+ if config is None:
431
+ config = NautilusBackendConfig(
432
+ instrument_id=symbol_override or "BTCUSDT-PERP.BINANCE",
433
+ starting_balance=self.account.initial_capital,
434
+ trade_notional=float(trade_notional),
435
+ sizing_mode=self.hedge_type,
436
+ use_pyramiding=self.use_pyramiding,
437
+ )
438
+ else:
439
+ updates = {
440
+ "starting_balance": self.account.initial_capital,
441
+ "trade_notional": float(trade_notional),
442
+ "sizing_mode": self.hedge_type,
443
+ "use_pyramiding": self.use_pyramiding,
444
+ }
445
+ if symbol_override and config.instrument_id == "BTCUSDT-PERP.BINANCE":
446
+ updates["instrument_id"] = symbol_override
447
+ config = replace(config, **updates)
448
+ return NautilusBacktestEngine(config).run_signal_series(data=data, signal=signal)
449
+
450
+ def _market_data(self) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]:
451
+ return _market_data(
452
+ data=self.data,
453
+ datetime_index=self.datetime_index,
454
+ closes=self.closes,
455
+ highs=self.highs,
456
+ lows=self.lows,
457
+ symbols=self.symbols,
458
+ )
459
+
460
+
461
+ class EventDrivenBacktestEngine(BacktestEngineV2):
462
+ """Convenience facade pinned to the native event-driven backend."""
463
+
464
+ def __init__(self, *args, **kwargs):
465
+ kwargs["backend"] = "native_event"
466
+ super().__init__(*args, **kwargs)
467
+
468
+
469
+ class OptionBacktestEngine:
470
+ """
471
+ Native option facade returning `OptionBacktestResult`.
472
+
473
+ Parameters
474
+ ----------
475
+ chain:
476
+ Canonical long-form option chain rows.
477
+ instruments:
478
+ Option instrument registry, sequence, or mapping.
479
+ packages:
480
+ Option package intents generated by a strategy/template layer.
481
+ config:
482
+ Native option backend configuration.
483
+ """
484
+
485
+ def __init__(
486
+ self,
487
+ *,
488
+ chain: Optional[pd.DataFrame] = None,
489
+ instruments: Optional[OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Dict[str, OptionInstrumentSpec]] = None,
490
+ packages: Sequence[OptionPackageIntent] = (),
491
+ strategy_run: Optional[OptionStrategyRun] = None,
492
+ underlying: Optional[Union[pd.DataFrame, pd.Series]] = None,
493
+ hedge_policy: Optional[OptionHedgeConfig] = None,
494
+ net_option_delta: Optional[pd.Series] = None,
495
+ config: Optional[NativeOptionConfig] = None,
496
+ settlement_events: Optional[Sequence] = None,
497
+ conversion_rates: Optional[Dict[str, float]] = None,
498
+ prepared_cache: Optional[OptionPreparedRunCache] = None,
499
+ auto_run: bool = True,
500
+ ):
501
+ self.chain = chain
502
+ self.instruments = instruments
503
+ self.strategy_run = strategy_run
504
+ self.packages = tuple(packages or (strategy_run.packages if strategy_run is not None else ()))
505
+ self.underlying = underlying
506
+ self.hedge_policy = hedge_policy or (strategy_run.hedge_policy if strategy_run is not None else None)
507
+ self.net_option_delta = net_option_delta
508
+ self.config = config or NativeOptionConfig()
509
+ self.settlement_events = tuple(settlement_events or ())
510
+ self.conversion_rates = conversion_rates
511
+ self.prepared_cache = prepared_cache
512
+ self.backend = NativeOptionBackend(self.config)
513
+ self.result: Optional[OptionBacktestResult] = None
514
+
515
+ if auto_run:
516
+ self.run()
517
+
518
+ def run(self) -> OptionBacktestResult:
519
+ if self.chain is None:
520
+ raise ValueError("OptionBacktestEngine requires chain")
521
+ if self.instruments is None:
522
+ raise ValueError("OptionBacktestEngine requires instruments")
523
+ self.result = self.backend.run(
524
+ chain=self.chain,
525
+ instruments=self.instruments,
526
+ packages=self.packages,
527
+ settlement_events=self.settlement_events,
528
+ conversion_rates=self.conversion_rates,
529
+ prepared_cache=self.prepared_cache,
530
+ underlying=self.underlying,
531
+ hedge_policy=self.hedge_policy,
532
+ net_option_delta=self.net_option_delta,
533
+ )
534
+ if self.strategy_run is not None:
535
+ self.result.metadata["strategy_run"] = self.strategy_run.metadata
536
+ self.result.metadata["selected_contracts"] = self.strategy_run.selected_contracts
537
+ self.result.run_manifest["strategy_run"] = self.strategy_run.metadata
538
+ self.result.metadata["run_manifest"] = self.result.run_manifest
539
+ return self.result
540
+
541
+
542
+ class PortfolioBacktestEngine:
543
+ """
544
+ V2-compatible multi-symbol portfolio facade.
545
+
546
+ The default backend intentionally wraps the existing `MultiSymbolPortfolio`
547
+ so old portfolio mode semantics stay unchanged while returning
548
+ `BacktestResultV2` to new metrics and migration code.
549
+ """
550
+
551
+ def __init__(
552
+ self,
553
+ positions: Dict[str, pd.Series],
554
+ closes: Dict[str, pd.Series],
555
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
556
+ mode: str = "longshort",
557
+ backend: str = "native_portfolio",
558
+ account: Optional[AccountConfig] = None,
559
+ execution: Optional[ExecutionConfig] = None,
560
+ fee_rate: Optional[float] = None,
561
+ alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
562
+ contract_size: Union[float, Dict[str, float], None] = None,
563
+ hedge_type: str = "notional",
564
+ asset_type: str = "crypto",
565
+ use_funding: Optional[bool] = None,
566
+ funding_rate: Union[float, Dict[str, float], None] = None,
567
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
568
+ maintenance_ratio: Optional[float] = None,
569
+ highs: Optional[Dict[str, pd.Series]] = None,
570
+ lows: Optional[Dict[str, pd.Series]] = None,
571
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
572
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
573
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
574
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
575
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
576
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
577
+ report_level: str = "full",
578
+ auto_run: bool = True,
579
+ **kwargs,
580
+ ):
581
+ legacy_slippage = kwargs.pop("slippage", None)
582
+ if execution is None and legacy_slippage is not None:
583
+ execution = ExecutionConfig(slippage_bps=float(legacy_slippage) * 10_000.0)
584
+ self.positions = positions
585
+ self.closes = closes
586
+ self.datetime_index = datetime_index
587
+ self.mode = mode
588
+ self.backend = backend.lower().strip()
589
+ self.account = account or AccountConfig(initial_capital=100_000.0)
590
+ self.execution = execution or ExecutionConfig()
591
+ self.fee_rate = fee_rate
592
+ self.alloc_per_trade = alloc_per_trade
593
+ self.contract_size = contract_size
594
+ self.hedge_type = hedge_type
595
+ self.asset_type = asset_type
596
+ self.use_funding = use_funding if use_funding is not None else asset_type.lower() == "crypto"
597
+ self.funding_rate = funding_rate
598
+ self.leverage = leverage if leverage is not None else self.account.leverage
599
+ self.maintenance_ratio = (
600
+ maintenance_ratio if maintenance_ratio is not None else self.account.maintenance_ratio
601
+ )
602
+ self.highs = highs
603
+ self.lows = lows
604
+ self.instruments = instruments
605
+ self.qty_step = qty_step
606
+ self.lot_size = lot_size
607
+ self.slot_size = slot_size
608
+ self.min_qty = min_qty
609
+ self.min_notional = min_notional
610
+ self.report_level = report_level
611
+ self.kwargs = kwargs
612
+ self.portfolio: Optional[MultiSymbolPortfolio] = None
613
+ self.result: Optional[BacktestResultV2] = None
614
+
615
+ if auto_run:
616
+ self.run()
617
+
618
+ def run(self) -> BacktestResultV2:
619
+ if self.backend in {"legacy", "legacy_portfolio", "portfolio"}:
620
+ asset_type = self.asset_type.lower()
621
+ default_fee = 0.0004 if asset_type == "crypto" else 0.0001
622
+ fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0
623
+ legacy_kwargs = {
624
+ key: value
625
+ for key, value in self.kwargs.items()
626
+ if key not in {"use_pyramiding", "betas", "risk_lookback"}
627
+ }
628
+ self.portfolio = MultiSymbolPortfolio(
629
+ positions=self.positions,
630
+ closes=self.closes,
631
+ datetime_index=self.datetime_index,
632
+ mode=self.mode,
633
+ fee_rate=fee_oneway,
634
+ alloc_per_trade=self.alloc_per_trade,
635
+ contract_size=self.contract_size,
636
+ hedge_type=self.hedge_type,
637
+ initial_capital=self.account.initial_capital,
638
+ asset_type=self.asset_type,
639
+ use_funding=self.use_funding,
640
+ funding_rate=self.funding_rate,
641
+ leverage=self.leverage,
642
+ maintenance_ratio=self.maintenance_ratio,
643
+ highs=self.highs,
644
+ lows=self.lows,
645
+ **legacy_kwargs,
646
+ )
647
+ self.result = BacktestResultV2.from_legacy(self.portfolio.result)
648
+ self.result.metadata["backend"] = "legacy_portfolio"
649
+ return self.result
650
+
651
+ if self.backend == "native_vectorized":
652
+ engine = BacktestEngineV2(
653
+ positions=self.positions,
654
+ closes=self.closes,
655
+ highs=self.highs,
656
+ lows=self.lows,
657
+ datetime_index=self.datetime_index,
658
+ backend="native_vectorized",
659
+ account=self.account,
660
+ execution=self.execution,
661
+ fee_rate=self.fee_rate or 0.0,
662
+ use_funding=bool(self.use_funding),
663
+ alloc_per_trade=self.alloc_per_trade,
664
+ hedge_type=self.hedge_type,
665
+ contract_size=self.contract_size or 1.0,
666
+ leverage=self.leverage,
667
+ instruments=self.instruments,
668
+ qty_step=self.qty_step,
669
+ lot_size=self.lot_size,
670
+ slot_size=self.slot_size,
671
+ min_qty=self.min_qty,
672
+ min_notional=self.min_notional,
673
+ )
674
+ self.result = engine.result
675
+ return self.result
676
+
677
+ if self.backend == "native_portfolio":
678
+ asset_type = self.asset_type.lower()
679
+ default_fee = 0.0004 if asset_type == "crypto" else 0.0001
680
+ fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0
681
+ default_contract = 1.0 if asset_type == "crypto" else 100.0
682
+ backend = NativePortfolioBackend(
683
+ NativePortfolioConfig(
684
+ account=self.account,
685
+ execution=self.execution,
686
+ fee_rate=fee_oneway,
687
+ use_funding=bool(self.use_funding),
688
+ report_level=self.report_level,
689
+ )
690
+ )
691
+ self.result = backend.run_signals(
692
+ positions=self.positions,
693
+ closes=self.closes,
694
+ highs=self.highs,
695
+ lows=self.lows,
696
+ datetime_index=self.datetime_index,
697
+ mode=self.mode,
698
+ alloc_per_trade=self.alloc_per_trade,
699
+ contract_size=self.contract_size if self.contract_size is not None else default_contract,
700
+ hedge_type=self.hedge_type,
701
+ funding_rate=self.funding_rate if self.funding_rate is not None else 0.0001,
702
+ leverage=self.leverage,
703
+ maintenance_ratio=self.maintenance_ratio,
704
+ asset_type=self.asset_type,
705
+ use_pyramiding=bool(self.kwargs.get("use_pyramiding", True)),
706
+ betas=self.kwargs.get("betas"),
707
+ risk_lookback=int(self.kwargs.get("risk_lookback", 60)),
708
+ instruments=self.instruments,
709
+ qty_step=self.qty_step,
710
+ lot_size=self.lot_size,
711
+ slot_size=self.slot_size,
712
+ min_qty=self.min_qty,
713
+ min_notional=self.min_notional,
714
+ report_level=self.report_level,
715
+ )
716
+ return self.result
717
+
718
+ raise ValueError("PortfolioBacktestEngine backend must be legacy_portfolio, native_vectorized, or native_portfolio")
719
+
720
+
721
+ def _market_data(
722
+ data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
723
+ datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]],
724
+ closes: Optional[SeriesMap],
725
+ highs: Optional[SeriesMap],
726
+ lows: Optional[SeriesMap],
727
+ symbols: Optional[List[str]],
728
+ ) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]:
729
+ if closes is not None:
730
+ symbol_list = symbols or list(closes.keys())
731
+ idx = validate_datetime(datetime_index if datetime_index is not None else closes[symbol_list[0]].index)
732
+ close_map = {s: closes[s] for s in symbol_list}
733
+ high_map = {s: highs[s] for s in symbol_list} if highs is not None else close_map
734
+ low_map = {s: lows[s] for s in symbol_list} if lows is not None else close_map
735
+ return idx, close_map, high_map, low_map, symbol_list
736
+
737
+ if data is None:
738
+ raise ValueError("market data is required")
739
+
740
+ if isinstance(data, pd.DataFrame):
741
+ symbol = symbols[0] if symbols else "asset"
742
+ idx, close, high, low = _extract_frame_ohlc(data, datetime_index)
743
+ return idx, {symbol: close}, {symbol: high}, {symbol: low}, [symbol]
744
+
745
+ symbol_list = symbols or list(data.keys())
746
+ close_map: SeriesMap = {}
747
+ high_map: SeriesMap = {}
748
+ low_map: SeriesMap = {}
749
+ idx = None
750
+ for symbol in symbol_list:
751
+ value = data[symbol]
752
+ if isinstance(value, pd.Series):
753
+ close = value
754
+ high = value
755
+ low = value
756
+ local_idx = validate_datetime(datetime_index if datetime_index is not None else value.index)
757
+ else:
758
+ local_idx, close, high, low = _extract_frame_ohlc(value, datetime_index)
759
+ idx = local_idx if idx is None else idx
760
+ close_map[symbol] = close
761
+ high_map[symbol] = high
762
+ low_map[symbol] = low
763
+ return idx, close_map, high_map, low_map, symbol_list
764
+
765
+
766
+ def _market_open_volume(
767
+ data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
768
+ datetime_index: pd.DatetimeIndex,
769
+ closes: SeriesMap,
770
+ symbols: List[str],
771
+ ) -> Tuple[SeriesMap, SeriesMap]:
772
+ opens: SeriesMap = {}
773
+ volumes: SeriesMap = {}
774
+ if isinstance(data, pd.DataFrame):
775
+ if len(symbols) != 1:
776
+ raise ValueError("single DataFrame reactive run requires one symbol")
777
+ frame = _extract_frame_ohlcv(data, datetime_index)
778
+ opens[symbols[0]] = frame["open"]
779
+ volumes[symbols[0]] = frame["volume"]
780
+ return opens, volumes
781
+ if isinstance(data, dict):
782
+ for symbol in symbols:
783
+ value = data[symbol]
784
+ if isinstance(value, pd.DataFrame):
785
+ frame = _extract_frame_ohlcv(value, datetime_index)
786
+ opens[symbol] = frame["open"]
787
+ volumes[symbol] = frame["volume"]
788
+ else:
789
+ close = closes[symbol]
790
+ opens[symbol] = close
791
+ volumes[symbol] = pd.Series(0.0, index=close.index, name="volume")
792
+ return opens, volumes
793
+ for symbol in symbols:
794
+ close = closes[symbol]
795
+ opens[symbol] = close
796
+ volumes[symbol] = pd.Series(0.0, index=close.index, name="volume")
797
+ return opens, volumes
798
+
799
+
800
+ def _extract_frame_ohlc(
801
+ data: pd.DataFrame,
802
+ datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]],
803
+ ) -> Tuple[pd.DatetimeIndex, pd.Series, pd.Series, pd.Series]:
804
+ frame = data.copy()
805
+ rename = {
806
+ "Datetime": "timestamp",
807
+ "Date": "timestamp",
808
+ "Timestamp": "timestamp",
809
+ "Open": "open",
810
+ "High": "high",
811
+ "Low": "low",
812
+ "Close": "close",
813
+ "Volume": "volume",
814
+ }
815
+ frame = frame.rename(columns=rename)
816
+ if datetime_index is not None:
817
+ frame.index = validate_datetime(datetime_index)
818
+ elif "timestamp" in frame.columns:
819
+ frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce", utc=True)
820
+ frame = frame.dropna(subset=["timestamp"]).set_index("timestamp")
821
+ else:
822
+ frame.index = validate_datetime(frame.index)
823
+ frame = frame[~frame.index.duplicated(keep="first")].sort_index()
824
+ idx = validate_datetime(frame.index)
825
+ if "close" not in frame.columns:
826
+ raise ValueError("data frame must contain close/Close")
827
+ close = pd.Series(frame["close"].to_numpy(), index=idx, name="close")
828
+ high = pd.Series(frame["high"].to_numpy(), index=idx, name="high") if "high" in frame.columns else close
829
+ low = pd.Series(frame["low"].to_numpy(), index=idx, name="low") if "low" in frame.columns else close
830
+ return idx, close, high, low
831
+
832
+
833
+ def _frames_for_nautilus(
834
+ data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
835
+ datetime_index: pd.DatetimeIndex,
836
+ closes: SeriesMap,
837
+ highs: SeriesMap,
838
+ lows: SeriesMap,
839
+ symbols: List[str],
840
+ ) -> Dict[str, pd.DataFrame]:
841
+ if isinstance(data, pd.DataFrame):
842
+ if len(symbols) != 1:
843
+ raise ValueError("single DataFrame Nautilus order replay requires one symbol")
844
+ return {symbols[0]: _extract_frame_ohlcv(data, datetime_index)}
845
+ if isinstance(data, dict):
846
+ frames = {}
847
+ for symbol in symbols:
848
+ value = data[symbol]
849
+ if isinstance(value, pd.DataFrame):
850
+ frames[symbol] = _extract_frame_ohlcv(value, datetime_index)
851
+ else:
852
+ close = pd.Series(value.to_numpy(), index=datetime_index, name="close")
853
+ frames[symbol] = _frame_from_ohlc(close, close, close)
854
+ return frames
855
+ return {symbol: _frame_from_ohlc(closes[symbol], highs[symbol], lows[symbol]) for symbol in symbols}
856
+
857
+
858
+ def _extract_frame_ohlcv(data: pd.DataFrame, datetime_index: pd.DatetimeIndex) -> pd.DataFrame:
859
+ frame = data.copy()
860
+ frame = frame.rename(
861
+ columns={
862
+ "Datetime": "timestamp",
863
+ "Date": "timestamp",
864
+ "Timestamp": "timestamp",
865
+ "Open": "open",
866
+ "High": "high",
867
+ "Low": "low",
868
+ "Close": "close",
869
+ "Volume": "volume",
870
+ }
871
+ )
872
+ frame.index = datetime_index
873
+ if "close" not in frame.columns:
874
+ raise ValueError("data frame must contain close/Close")
875
+ for col in ("open", "high", "low"):
876
+ if col not in frame.columns:
877
+ frame[col] = frame["close"]
878
+ if "volume" not in frame.columns:
879
+ frame["volume"] = 0.0
880
+ return frame[["open", "high", "low", "close", "volume"]].copy()
881
+
882
+
883
+ def _frame_from_ohlc(close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
884
+ return pd.DataFrame(
885
+ {
886
+ "open": close,
887
+ "high": high,
888
+ "low": low,
889
+ "close": close,
890
+ "volume": 0.0,
891
+ },
892
+ index=close.index,
893
+ )
894
+
895
+
896
+ def _as_series_map(value: Union[pd.Series, SeriesMap], symbols: List[str]) -> SeriesMap:
897
+ if isinstance(value, pd.Series):
898
+ if len(symbols) != 1:
899
+ raise ValueError("single series input requires exactly one symbol")
900
+ return {symbols[0]: value}
901
+ return {s: value[s] for s in symbols}
902
+
903
+
904
+ def _build_market_rebalance_orders(
905
+ datetime_index: pd.DatetimeIndex,
906
+ positions: SeriesMap,
907
+ closes: SeriesMap,
908
+ alloc_per_trade: Union[float, Dict[str, float]],
909
+ hedge_type: str,
910
+ use_pyramiding: bool,
911
+ symbols: List[str],
912
+ ) -> List[OrderIntent]:
913
+ ht = hedge_type.lower().strip()
914
+ if ht in ("%_equity", "pct_equity", "dca_ladder", "dca"):
915
+ raise NotImplementedError(
916
+ "native_event signal adapter supports pre-scalable target-unit modes "
917
+ "('signal_notional', 'notional', 'unit'). Use explicit orders for "
918
+ f"hedge_type={hedge_type!r}."
919
+ )
920
+
921
+ alloc = _per_symbol_mapping(alloc_per_trade, symbols, default=100_000.0)
922
+ orders: List[OrderIntent] = []
923
+ for symbol in symbols:
924
+ signal = positions[symbol].copy()
925
+ close = closes[symbol].copy()
926
+ if isinstance(signal.index, pd.DatetimeIndex):
927
+ signal.index = signal.index.tz_localize("UTC") if signal.index.tz is None else signal.index.tz_convert("UTC")
928
+ if isinstance(close.index, pd.DatetimeIndex):
929
+ close.index = close.index.tz_localize("UTC") if close.index.tz is None else close.index.tz_convert("UTC")
930
+ signal = signal[~signal.index.duplicated(keep="first")].reindex(datetime_index, method="ffill").fillna(0.0)
931
+ close = close[~close.index.duplicated(keep="first")].reindex(datetime_index, method="ffill")
932
+ target_units = compute_target_units(
933
+ hedge_type=hedge_type,
934
+ signal=signal,
935
+ close=close,
936
+ alloc=alloc[symbol],
937
+ use_pyramiding=use_pyramiding,
938
+ ).fillna(0.0)
939
+ prev = 0.0
940
+ for ts, target in target_units.items():
941
+ target = float(target)
942
+ delta = target - prev
943
+ if abs(delta) > 1e-12:
944
+ orders.append(
945
+ OrderIntent(
946
+ timestamp=ts,
947
+ symbol=symbol,
948
+ side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
949
+ order_type=OrderType.MARKET,
950
+ qty=abs(delta),
951
+ tif=TimeInForce.IOC,
952
+ tag=f"signal_rebalance:{hedge_type}",
953
+ )
954
+ )
955
+ prev = target
956
+ return sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value)
957
+
958
+
959
+ def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, float]:
960
+ if isinstance(value, dict):
961
+ return {s: float(value.get(s, default)) for s in symbols}
962
+ return {s: float(value) for s in symbols}
963
+
964
+
965
+ def _commands_to_package_order_intents(commands: Sequence[OrderCommand]) -> Tuple[OrderIntent, ...]:
966
+ orders: List[OrderIntent] = []
967
+ for command in commands:
968
+ if command.action not in (OrderAction.PLACE, OrderAction.REPLACE):
969
+ continue
970
+ if command.symbol is None or command.side is None or command.order_type is None or command.qty is None:
971
+ continue
972
+ metadata = {
973
+ **dict(command.metadata),
974
+ "command_action": command.action.value,
975
+ "target_order_id": command.target_order_id,
976
+ "parent_order_id": command.parent_order_id,
977
+ "group_id": command.group_id,
978
+ "oco_group_id": command.oco_group_id,
979
+ "activation_policy": command.activation_policy.value,
980
+ }
981
+ orders.append(
982
+ OrderIntent(
983
+ timestamp=command.timestamp,
984
+ symbol=command.symbol,
985
+ side=command.side,
986
+ order_type=command.order_type,
987
+ qty=float(command.qty),
988
+ price=command.price,
989
+ trigger_price=command.trigger_price,
990
+ tif=command.tif,
991
+ reduce_only=command.reduce_only,
992
+ order_id=command.order_id,
993
+ tag=command.tag,
994
+ metadata=metadata,
995
+ )
996
+ )
997
+ return tuple(orders)
998
+
999
+
1000
+ def _first_signal(value: Optional[Union[pd.Series, SeriesMap]]) -> Optional[pd.Series]:
1001
+ if value is None:
1002
+ return None
1003
+ if isinstance(value, pd.Series):
1004
+ return value
1005
+ return next(iter(value.values()))
1006
+
1007
+
1008
+ def _single_frame(data) -> Optional[pd.DataFrame]:
1009
+ if isinstance(data, pd.DataFrame):
1010
+ return data
1011
+ if isinstance(data, dict) and data:
1012
+ first = next(iter(data.values()))
1013
+ return first if isinstance(first, pd.DataFrame) else None
1014
+ return None