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/endpoint.py ADDED
@@ -0,0 +1,4465 @@
1
+ """
2
+ Unified public endpoint for notebooks and services.
3
+
4
+ `QuantBTEndpoint` is the stable integration surface above legacy and V2
5
+ backtest engines. It stores *how* to run a backtest at construction time, while
6
+ `backtest()` / `simulate()` receive the actual data, signals, orders, or basket
7
+ objects.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass, field, is_dataclass, replace
13
+ from enum import Enum
14
+ import hashlib
15
+ import json
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union
18
+ import warnings
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from .backtester import BacktestEngine
24
+ from .backends import (
25
+ NativeEventBackend,
26
+ NativeEventConfig,
27
+ NativeEventScoreRequirements,
28
+ NativeOptionConfig,
29
+ NativePortfolioBackend,
30
+ NativePortfolioConfig,
31
+ NativeVectorizedBackend,
32
+ NativeVectorizedConfig,
33
+ )
34
+ from .core.arbitrage import (
35
+ BasisArbitrageSpec,
36
+ CalendarSpreadSpec,
37
+ CrossExchangeArbSpec,
38
+ FundingArbitrageSpec,
39
+ IndexBasketArbSpec,
40
+ OptionsVolArbSpec,
41
+ SpotPerpCashCarrySpec,
42
+ StatArbPairSpec,
43
+ TriangularArbSpec,
44
+ build_arbitrage_order_plan,
45
+ )
46
+ from .core.basket import build_frozen_basket_orders
47
+ from .core.execution_depth import (
48
+ NautilusExecutionDepthConfig,
49
+ simulate_nautilus_order_package_depth,
50
+ )
51
+ from .core.execution_contract import ExecutionContract
52
+ from .core.constraints import build_quantity_constraints
53
+ from .core.intrabar_reference import (
54
+ IntrabarIntentTape,
55
+ IntrabarLevelMode,
56
+ IntrabarSizingMode,
57
+ run_intrabar_reference,
58
+ )
59
+ from .core.intrabar_session import IntrabarSessionTape, SessionExecutionPolicy
60
+ from .core.intrabar_kernel import FillReplayTape, run_fill_replay_kernel, run_intrabar_kernel, run_intrabar_session_kernel
61
+ from .core.market_tape import PreparedMarketTape, prepare_market_tape
62
+ from .core.orders import OrderCommand, OrderIntent, order_intents_to_lifecycle_commands
63
+ from .core.results import (
64
+ BacktestResultV2,
65
+ NativeEventScalarScoreResult,
66
+ NativeEventScoreResult,
67
+ )
68
+ from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce
69
+ from .core.structured_orders import (
70
+ BracketOrderSpec,
71
+ DcaGridSpec,
72
+ build_bracket_order_plan,
73
+ build_dca_grid_order_plan,
74
+ )
75
+ from .core.types import BacktestResult
76
+ from .engines import BacktestEngineV2, OptionBacktestEngine, PortfolioBacktestEngine
77
+ from .sizing.modes import compute_target_units
78
+ from .options.execution import OptionExecutionConfig
79
+ from .options.fees import OptionFeeSchedule
80
+ from .options.hedging import OptionHedgeConfig
81
+ from .options.margin import OptionMarginConfig
82
+ from .options.cache import OptionPreparedRunCache
83
+ from .options.packages import OptionPackageIntent
84
+ from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
85
+ from .options.strategy import OptionStrategyRun
86
+
87
+ if TYPE_CHECKING:
88
+ from .walkforward import WalkForwardConfig
89
+
90
+
91
+ SeriesMap = Dict[str, pd.Series]
92
+ FrameMap = Dict[str, pd.DataFrame]
93
+
94
+
95
+ class NativeEventProfile(str, Enum):
96
+ """Stable high-level retention/execution profile for event-driven runs."""
97
+
98
+ RESEARCH = "research"
99
+ OPTIMIZE = "optimize"
100
+ AUDIT = "audit"
101
+
102
+
103
+ _NATIVE_EVENT_PROFILE_OPTIONS = {
104
+ NativeEventProfile.RESEARCH: {
105
+ "reactive_execution_mode": "fast",
106
+ "reactive_kernel_mode": "single_pass",
107
+ "report_level": "minimal",
108
+ "audit_sink": "none",
109
+ },
110
+ NativeEventProfile.OPTIMIZE: {
111
+ "reactive_execution_mode": "fast",
112
+ "reactive_kernel_mode": "single_pass",
113
+ "report_level": "score",
114
+ "audit_sink": "none",
115
+ },
116
+ NativeEventProfile.AUDIT: {
117
+ "reactive_execution_mode": "audit",
118
+ "reactive_kernel_mode": "replay_certified",
119
+ "report_level": "audit",
120
+ "audit_sink": "memory",
121
+ },
122
+ }
123
+ _NATIVE_EVENT_PUBLIC_BACKENDS = frozenset({"auto", "python", "rust"})
124
+
125
+
126
+ def _normalize_native_event_profile(profile: Union[str, NativeEventProfile]) -> NativeEventProfile:
127
+ value = profile.value if isinstance(profile, NativeEventProfile) else str(profile).lower().strip()
128
+ try:
129
+ return NativeEventProfile(value)
130
+ except ValueError as exc:
131
+ valid = ", ".join(item.value for item in NativeEventProfile)
132
+ raise ValueError(f"profile must be one of: {valid}; received {profile!r}") from exc
133
+
134
+
135
+ def _resolve_event_driven_kwargs(
136
+ *,
137
+ input_mode: str,
138
+ profile: Union[str, NativeEventProfile],
139
+ backend: str,
140
+ kwargs: Dict,
141
+ ) -> Dict:
142
+ """Resolve the small public facade into one legacy endpoint config.
143
+
144
+ This function only resolves configuration. Matching, accounting, and
145
+ result construction remain owned by the existing endpoint constructors.
146
+ """
147
+
148
+ mode = str(input_mode).lower().strip()
149
+ if mode not in {"strategy", "orders"}:
150
+ raise ValueError("input_mode must be 'strategy' or 'orders'")
151
+
152
+ profile_value = _normalize_native_event_profile(profile)
153
+ public_backend = str(backend).lower().strip()
154
+ if public_backend not in _NATIVE_EVENT_PUBLIC_BACKENDS:
155
+ raise ValueError("backend must be one of: auto, python, rust")
156
+
157
+ resolved = dict(kwargs)
158
+ profile_options = _NATIVE_EVENT_PROFILE_OPTIONS[profile_value]
159
+ for key, value in profile_options.items():
160
+ if key in resolved:
161
+ raise ValueError(
162
+ f"profile='{profile_value.value}' controls {key}; "
163
+ f"use the advanced native_event_{'strategy' if mode == 'strategy' else 'lifecycle'} "
164
+ "constructor for custom low-level combinations"
165
+ )
166
+ resolved[key] = value
167
+
168
+ advanced_backend = resolved.get("native_backend")
169
+ if advanced_backend is not None and public_backend != "auto":
170
+ raise ValueError("pass either backend=... or advanced native_backend=..., not both")
171
+ if advanced_backend is None:
172
+ resolved["native_backend"] = public_backend
173
+
174
+ metadata = dict(resolved.pop("metadata", {}) or {})
175
+ metadata.setdefault(
176
+ "event_driven_facade",
177
+ {
178
+ "input_mode": mode,
179
+ "profile": profile_value.value,
180
+ "backend": public_backend,
181
+ },
182
+ )
183
+ resolved["metadata"] = metadata
184
+ return resolved
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class EndpointConfig:
189
+ """
190
+ Configuration for `QuantBTEndpoint`.
191
+
192
+ Parameters
193
+ ----------
194
+ mode:
195
+ Strategy integration mode. Supported values are `single_signal`,
196
+ `pct_equity`, `signal_notional`, `dca_ladder`, `orders`, `basket`,
197
+ `portfolio`, `arbitrage`, `options`, `walk_forward`, and
198
+ `nautilus_validation`.
199
+ backend:
200
+ Engine selector. Use `auto` for domain-safe defaults, or explicitly set
201
+ `legacy`, `native_vectorized`, `native_event`, or `nautilus`.
202
+ native_backend:
203
+ Native-event implementation selector: `python`, `rust`, `auto`, or
204
+ `replay_certified`. It is only consulted by native-event backends;
205
+ omitted means preserve the environment/default selection policy.
206
+ sizing:
207
+ Position sizing contract for signal modes. Examples: `%_equity`,
208
+ `signal_notional`, `notional`, `unit`, `dca_ladder`.
209
+ account:
210
+ Account/margin config used by V2 engines. Legacy runs also read
211
+ `initial_capital`, `leverage`, and `maintenance_ratio` from this object.
212
+ execution:
213
+ Execution/slippage config used by V2 engines. Legacy runs use the
214
+ endpoint `slippage` fraction.
215
+ fee:
216
+ Legacy compatibility round-trip fee. It is converted to canonical
217
+ one-way `fee_rate` at the endpoint boundary when explicit `fee_rate`
218
+ is omitted.
219
+ fee_rate:
220
+ Canonical one-way fee. If supplied, it has priority over `fee`.
221
+ alloc_per_trade:
222
+ Notional allocation for notional sizing modes, or equity fraction for
223
+ `%_equity`.
224
+ use_pyramiding:
225
+ If false, raw signals are snapped to {-1, 0, 1}.
226
+ use_funding:
227
+ Whether funding should be applied where the selected engine supports it.
228
+ funding_rate:
229
+ Scalar, series, or per-symbol mapping of funding rates.
230
+ contract_size:
231
+ Contract multiplier, scalar or per-symbol mapping.
232
+ slippage:
233
+ Legacy execution slippage fraction. Example: `0.0001` is 1 bp.
234
+ portfolio_mode:
235
+ Multi-symbol allocation mode for portfolio endpoint.
236
+ asset_type:
237
+ Asset type used by portfolio endpoint defaults.
238
+ basket:
239
+ Optional basket spec stored at construction time for basket simulations.
240
+ arbitrage_spec:
241
+ Optional arbitrage spec stored at construction time for the future
242
+ ArbitrageBacktestEngine.
243
+ structured_order_spec:
244
+ Optional DCA/grid or bracket/OCO package spec compiled into explicit
245
+ orders for Nautilus structured-order validation.
246
+ symbols:
247
+ Optional symbol list. Single-symbol endpoints use the first symbol.
248
+ dca_kwargs:
249
+ Extra DCA ladder parameters forwarded to legacy `BacktestEngine`.
250
+ nautilus_config:
251
+ Optional `NautilusBackendConfig` instance for Nautilus validation runs.
252
+ nautilus_depth_config:
253
+ Optional `NautilusExecutionDepthConfig` for package-order preflight.
254
+ Existing endpoints are unchanged when this is omitted.
255
+ report_level:
256
+ Native portfolio artifact policy. `full` preserves all audit reports;
257
+ `standard` keeps core audit tables; `minimal` keeps accounting outputs
258
+ for optimizer/service loops. Existing calls default to `full`.
259
+ option_config:
260
+ Optional `NativeOptionConfig` for native option simulations.
261
+ strategy_class:
262
+ Optional strategy callable/class for `walk_forward` mode. The strategy
263
+ must return a Series, DataFrame, or `{symbol: Series}` OOS output.
264
+ walkforward_config:
265
+ Optional `WalkForwardConfig` for split/stitch behavior.
266
+ metadata:
267
+ Free-form service metadata carried by the endpoint.
268
+ """
269
+
270
+ mode: str = "single_signal"
271
+ backend: str = "auto"
272
+ native_backend: Optional[str] = None
273
+ sizing: str = "signal_notional"
274
+ account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0))
275
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
276
+ fee: float = 0.0004
277
+ fee_rate: Optional[float] = None
278
+ alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0
279
+ use_pyramiding: bool = True
280
+ use_funding: bool = True
281
+ funding_rate: Union[float, pd.Series, Dict] = 0.0
282
+ contract_size: Union[float, Dict[str, float]] = 1.0
283
+ instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None
284
+ qty_step: Optional[Union[float, Dict[str, float]]] = None
285
+ lot_size: Optional[Union[float, Dict[str, float]]] = None
286
+ slot_size: Optional[Union[float, Dict[str, float]]] = None
287
+ min_qty: Optional[Union[float, Dict[str, float]]] = None
288
+ min_notional: Optional[Union[float, Dict[str, float]]] = None
289
+ slippage: float = 0.0001
290
+ portfolio_mode: str = "longshort"
291
+ betas: Union[float, Dict[str, float], None] = None
292
+ risk_lookback: int = 60
293
+ asset_type: str = "crypto"
294
+ basket: Optional[BasketSpec] = None
295
+ arbitrage_spec: object = None
296
+ structured_order_spec: object = None
297
+ event_engine_version: str = "v1"
298
+ reactive_execution_mode: str = "fast"
299
+ reactive_kernel_mode: str = "replay_certified"
300
+ symbols: Optional[Sequence[str]] = None
301
+ dca_kwargs: Dict = field(default_factory=dict)
302
+ nautilus_config: object = None
303
+ nautilus_depth_config: Optional[NautilusExecutionDepthConfig] = None
304
+ option_config: object = None
305
+ report_level: str = "full"
306
+ audit_sink: str = "memory"
307
+ audit_sink_path: Optional[str] = None
308
+ strategy_class: object = None
309
+ walkforward_config: Optional[WalkForwardConfig] = None
310
+ walkforward_target_mode: str = "signal_notional"
311
+ metadata: Dict = field(default_factory=dict)
312
+
313
+ @property
314
+ def v2_fee_rate(self) -> float:
315
+ return self.fee / 2.0 if self.fee_rate is None else float(self.fee_rate)
316
+
317
+ @property
318
+ def canonical_one_way_fee_rate(self) -> float:
319
+ return self.v2_fee_rate
320
+
321
+
322
+ @dataclass(frozen=True)
323
+ class PreparedIntrabarRunner:
324
+ """Prepared single-symbol intrabar runner for repeated WFO/Optuna runs."""
325
+
326
+ endpoint: "QuantBTEndpoint"
327
+ tape: PreparedMarketTape
328
+ symbol: str
329
+ contract: ExecutionContract
330
+ profile_metadata: Dict
331
+ session_policy: Optional[SessionExecutionPolicy] = None
332
+ session_tape: Optional[IntrabarSessionTape] = None
333
+
334
+ @property
335
+ def market(self) -> PreparedMarketTape:
336
+ return self.tape
337
+
338
+ def run(self, intent: IntrabarIntentTape, *, report_level: Optional[str] = None) -> BacktestResultV2:
339
+ config = self.endpoint.config
340
+ level = report_level or config.report_level
341
+ kwargs = {
342
+ "tape": self.tape,
343
+ "intent": intent,
344
+ "account": config.account,
345
+ "contract": self.contract,
346
+ "fee_rate": config.v2_fee_rate,
347
+ "slippage_rate": float(config.execution.slippage_rate),
348
+ "contract_size": _scalar_for_symbol(config.contract_size, self.symbol),
349
+ **self.endpoint._intrabar_execution_kwargs(self.symbol),
350
+ "report_level": level,
351
+ }
352
+ if self.session_policy is not None:
353
+ kernel = run_intrabar_session_kernel(
354
+ **kwargs,
355
+ session_policy=self.session_policy,
356
+ session_tape=self.session_tape,
357
+ )
358
+ else:
359
+ kernel = run_intrabar_kernel(**kwargs)
360
+ idx = kernel.equity.index
361
+ returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
362
+ diagnostics = pd.DataFrame(
363
+ {
364
+ "average_entry": kernel.average_entry,
365
+ "active_stop": kernel.active_stop,
366
+ "active_take_profit": kernel.active_take_profit,
367
+ "event_flags": kernel.event_flags,
368
+ "initial_margin": kernel.initial_margin,
369
+ "maintenance_margin": kernel.maintenance_margin,
370
+ "fees": kernel.fees,
371
+ "funding": kernel.funding,
372
+ },
373
+ index=idx,
374
+ )
375
+ metadata = {
376
+ **kernel.metadata,
377
+ "input_mode": "intrabar_intent",
378
+ "symbol": self.symbol,
379
+ "phase": "31F_prepared_intrabar_runner",
380
+ "prepared_runner": True,
381
+ "profile_metadata": dict(self.profile_metadata),
382
+ "fills_report": kernel.fills_report,
383
+ "positions_report": pd.DataFrame({f"Position_{self.symbol}": kernel.position}, index=idx),
384
+ }
385
+ result = BacktestResultV2(
386
+ equity=kernel.equity,
387
+ returns=returns,
388
+ positions=pd.DataFrame({f"Position_{self.symbol}": kernel.position.to_numpy(dtype=float)}, index=idx),
389
+ closes=pd.DataFrame({f"Close_{self.symbol}": self.tape.closes[:, 0]}, index=idx),
390
+ symbols=[self.symbol],
391
+ initial_capital=float(config.account.initial_capital),
392
+ leverage=float(config.account.leverage),
393
+ liquidated=bool(kernel.liquidated),
394
+ liquidation_bar=int(kernel.liquidation_bar),
395
+ fills=kernel.fills,
396
+ fees=kernel.fees,
397
+ funding=kernel.funding,
398
+ margin=diagnostics[["initial_margin", "maintenance_margin"]],
399
+ diagnostics=diagnostics,
400
+ metadata=metadata,
401
+ )
402
+ self.endpoint.engine = kernel
403
+ self.endpoint._store_result(result)
404
+ return self.endpoint.result
405
+
406
+
407
+ @dataclass(frozen=True)
408
+ class PreparedNativeEventStrategyRunner:
409
+ """Prepared native-event reactive runner for repeated strategy scoring."""
410
+
411
+ endpoint: "QuantBTEndpoint"
412
+ idx: pd.DatetimeIndex
413
+ symbols: list
414
+ close_map: SeriesMap
415
+ high_map: SeriesMap
416
+ low_map: SeriesMap
417
+ opens_arr: np.ndarray
418
+ volumes_arr: np.ndarray
419
+ market_arrays: object
420
+ backend: NativeEventBackend
421
+ profile_metadata: Dict
422
+ runs: int = 0
423
+ scores: int = 0
424
+
425
+ def run(self, strategy, *, report_level: Optional[str] = None) -> BacktestResultV2:
426
+ """Run the prepared strategy and return the public BacktestResultV2."""
427
+ if strategy is None:
428
+ raise ValueError("prepared native-event runner requires strategy=...")
429
+ config = self.endpoint.config
430
+ level = report_level or config.report_level
431
+ result = self.backend.run_strategy(
432
+ datetime_index=self.idx,
433
+ strategy=strategy,
434
+ closes=self.close_map,
435
+ highs=self.high_map,
436
+ lows=self.low_map,
437
+ opens=None,
438
+ volumes=None,
439
+ funding_rate=config.funding_rate,
440
+ contract_size=config.contract_size,
441
+ leverage=config.account.leverage,
442
+ fee_rate=config.v2_fee_rate,
443
+ symbols=self.symbols,
444
+ instruments=config.instruments,
445
+ qty_step=config.qty_step,
446
+ lot_size=config.lot_size,
447
+ slot_size=config.slot_size,
448
+ min_qty=config.min_qty,
449
+ min_notional=config.min_notional,
450
+ execution_mode=config.reactive_execution_mode,
451
+ reactive_kernel_mode=config.reactive_kernel_mode,
452
+ report_level=level,
453
+ audit_sink=config.audit_sink,
454
+ audit_sink_path=config.audit_sink_path,
455
+ market_arrays=self.market_arrays,
456
+ opens_arr=self.opens_arr,
457
+ volumes_arr=self.volumes_arr,
458
+ )
459
+ result.metadata.setdefault("prepared_native_event_strategy", self.metadata)
460
+ object.__setattr__(self, "runs", self.runs + 1)
461
+ self.endpoint._store_result(result)
462
+ return self.endpoint.result
463
+
464
+ simulate = run
465
+
466
+ def score(
467
+ self,
468
+ strategy,
469
+ *,
470
+ trading_days: int = 365,
471
+ score_requirements: Optional[NativeEventScoreRequirements] = None,
472
+ ) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]:
473
+ """
474
+ Run the prepared strategy through the direct score path.
475
+
476
+ The default compatibility contract stores ndarray accounting arrays and
477
+ scalar metrics. Passing ``NativeEventScoreRequirements.scalar_score_contract()``
478
+ returns the low-retention scalar result instead. Neither form updates
479
+ ``endpoint.result``.
480
+ """
481
+ if strategy is None:
482
+ raise ValueError("prepared native-event score requires strategy=...")
483
+ config = self.endpoint.config
484
+ score = self.backend.run_strategy_score(
485
+ datetime_index=self.idx,
486
+ strategy=strategy,
487
+ closes=self.close_map,
488
+ highs=self.high_map,
489
+ lows=self.low_map,
490
+ opens=None,
491
+ volumes=None,
492
+ funding_rate=config.funding_rate,
493
+ contract_size=config.contract_size,
494
+ leverage=config.account.leverage,
495
+ fee_rate=config.v2_fee_rate,
496
+ symbols=self.symbols,
497
+ instruments=config.instruments,
498
+ qty_step=config.qty_step,
499
+ lot_size=config.lot_size,
500
+ slot_size=config.slot_size,
501
+ min_qty=config.min_qty,
502
+ min_notional=config.min_notional,
503
+ execution_mode=config.reactive_execution_mode,
504
+ market_arrays=self.market_arrays,
505
+ opens_arr=self.opens_arr,
506
+ volumes_arr=self.volumes_arr,
507
+ trading_days=trading_days,
508
+ score_requirements=score_requirements,
509
+ )
510
+ object.__setattr__(self, "scores", self.scores + 1)
511
+ return replace(
512
+ score,
513
+ metadata={
514
+ **dict(score.metadata),
515
+ "prepared_native_event_strategy": self.metadata,
516
+ },
517
+ )
518
+
519
+ @property
520
+ def metadata(self) -> Dict[str, object]:
521
+ return {
522
+ **self.profile_metadata,
523
+ "runs": int(self.runs),
524
+ "scores": int(self.scores),
525
+ "market_signature": self.market_arrays.signature,
526
+ }
527
+
528
+
529
+ class QuantBTEndpoint:
530
+ """
531
+ Stable notebook/service facade for all QuantBT backtest modes.
532
+
533
+ Create the endpoint with a factory constructor such as
534
+ `QuantBTEndpoint.pct_equity(...)`, then pass market data and signals to
535
+ `backtest()`. The instance stores the latest `result` and exposes report and
536
+ visualization helpers.
537
+ """
538
+
539
+ def __init__(self, config: Optional[EndpointConfig] = None, **kwargs):
540
+ """
541
+ Build an endpoint from an `EndpointConfig` or keyword arguments.
542
+
543
+ Examples
544
+ --------
545
+ >>> endpoint = QuantBTEndpoint(mode="single_signal", sizing="signal_notional")
546
+ >>> result = endpoint.backtest(data=df, signal_col="position")
547
+ """
548
+ self.config = config or _config_from_kwargs(**kwargs)
549
+ self.result: Optional[Union[BacktestResult, BacktestResultV2]] = None
550
+ self.engine = None
551
+
552
+ def prepare_service_context(
553
+ self,
554
+ *,
555
+ data=None,
556
+ closes=None,
557
+ highs=None,
558
+ lows=None,
559
+ datetime_index=None,
560
+ symbols=None,
561
+ ) -> "QuantBTPreparedContext":
562
+ """
563
+ Normalize market data once for repeated service/WFO-style replays.
564
+
565
+ This is an opt-in performance helper. It does not change normal
566
+ `backtest(...)` behavior and only supports routes whose prepared-array
567
+ parity is locked by tests: single-symbol `signal_notional` with
568
+ `native_vectorized`, and `portfolio` with `native_portfolio`.
569
+ """
570
+ return QuantBTPreparedContext.from_endpoint(
571
+ self,
572
+ data=data,
573
+ closes=closes,
574
+ highs=highs,
575
+ lows=lows,
576
+ datetime_index=datetime_index,
577
+ symbols=symbols,
578
+ )
579
+
580
+ def prepare_intrabar(
581
+ self,
582
+ *,
583
+ data,
584
+ datetime_index=None,
585
+ symbols: Optional[Sequence[str]] = None,
586
+ session_tape: Optional[IntrabarSessionTape] = None,
587
+ funding_event_timestamps=None,
588
+ funding_event_rates=None,
589
+ ) -> PreparedIntrabarRunner:
590
+ """
591
+ Prepare strict intrabar market tape once and reuse it for many intents.
592
+
593
+ This is an opt-in service/WFO helper. Normal `.backtest(...)` remains
594
+ backward-compatible, while optimizer loops can avoid rebuilding OHLCV,
595
+ funding, validation certificate, data signature, and quantity profiles
596
+ on every trial.
597
+ """
598
+ symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
599
+ if len(symbol_list) != 1:
600
+ raise ValueError("prepare_intrabar currently supports exactly one symbol")
601
+ tape = prepare_market_tape(
602
+ data=data,
603
+ datetime_index=datetime_index,
604
+ symbols=symbol_list,
605
+ funding_rate=self.config.funding_rate,
606
+ funding_event_timestamps=funding_event_timestamps,
607
+ funding_event_rates=funding_event_rates,
608
+ use_funding=self.config.use_funding,
609
+ validation_mode="strict",
610
+ missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")),
611
+ source_timezone=self.config.metadata.get("source_timezone"),
612
+ bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
613
+ )
614
+ contract = _execution_contract_from_config(self.config)
615
+ session_policy = _session_policy_from_config(self.config)
616
+ if session_policy is not None and session_tape is None:
617
+ raise ValueError("session_tape is required when session_policy is configured")
618
+ if session_tape is not None and len(session_tape.session_id) != tape.n_bars:
619
+ raise ValueError("session_tape length must match prepared market tape length")
620
+ symbol = symbol_list[0]
621
+ profile = {
622
+ "mode": self.config.mode,
623
+ "backend": self.config.backend,
624
+ "account": asdict(self.config.account),
625
+ "execution": asdict(self.config.execution),
626
+ "fee_rate": self.config.v2_fee_rate,
627
+ "contract_size": _scalar_for_symbol(self.config.contract_size, symbol),
628
+ "intrabar": self._intrabar_execution_kwargs(symbol),
629
+ "data_signature": tape.signature,
630
+ "session_policy": None if session_policy is None else session_policy.to_metadata(),
631
+ "session_tape_signature": None if session_tape is None else session_tape.signature,
632
+ }
633
+ profile["prepared_signature"] = _prepared_profile_signature(tape.signature, profile)
634
+ return PreparedIntrabarRunner(
635
+ endpoint=self,
636
+ tape=tape,
637
+ symbol=symbol,
638
+ contract=contract,
639
+ profile_metadata=profile,
640
+ session_policy=session_policy,
641
+ session_tape=session_tape,
642
+ )
643
+
644
+ def prepare_native_event_strategy(
645
+ self,
646
+ *,
647
+ data=None,
648
+ closes=None,
649
+ highs=None,
650
+ lows=None,
651
+ datetime_index=None,
652
+ symbols: Optional[Sequence[str]] = None,
653
+ ) -> PreparedNativeEventStrategyRunner:
654
+ """
655
+ Prepare native-event reactive market state once for repeated scoring.
656
+
657
+ Normal `native_event_strategy(...).simulate(...)` remains unchanged.
658
+ This helper is for WFO/Optuna/service loops where the same market tape
659
+ is replayed many times with different strategy parameters.
660
+ """
661
+ config = self.config
662
+ if str(config.backend).lower().strip() not in {"native_event", "auto"}:
663
+ raise ValueError("prepare_native_event_strategy requires backend='native_event' or auto")
664
+ symbol_list = list(symbols or config.symbols or (closes.keys() if closes is not None else []))
665
+ if data is not None and not isinstance(data, dict) and not symbol_list:
666
+ symbol_list = ["asset"]
667
+ if not symbol_list:
668
+ raise ValueError("prepare_native_event_strategy requires symbols")
669
+ if data is not None and not isinstance(data, dict):
670
+ if len(symbol_list) != 1:
671
+ raise ValueError("single DataFrame native-event preparation requires exactly one symbol")
672
+ frame = _standardize_frame(data, datetime_index=datetime_index)
673
+ symbol = symbol_list[0]
674
+ idx = frame.index
675
+ close_map = {symbol: frame["close"]}
676
+ high_map = {symbol: frame.get("high", frame["close"])}
677
+ low_map = {symbol: frame.get("low", frame["close"])}
678
+ opens_arr = np.ascontiguousarray(frame[["open"]].to_numpy(dtype=np.float64))
679
+ volumes_arr = np.ascontiguousarray(frame[["volume"]].to_numpy(dtype=np.float64))
680
+ else:
681
+ close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
682
+ data=data,
683
+ closes=closes,
684
+ highs=highs,
685
+ lows=lows,
686
+ datetime_index=datetime_index,
687
+ symbols=symbol_list,
688
+ )
689
+ opens_arr, volumes_arr = _prepared_native_event_open_volume_arrays(data, idx, symbol_list, close_map)
690
+ backend = NativeEventBackend(
691
+ NativeEventConfig(
692
+ account=config.account,
693
+ execution=config.execution,
694
+ fee_rate=config.v2_fee_rate,
695
+ use_funding=bool(config.use_funding),
696
+ report_level=config.report_level,
697
+ audit_sink=config.audit_sink,
698
+ audit_sink_path=config.audit_sink_path,
699
+ reactive_kernel_mode=config.reactive_kernel_mode,
700
+ native_backend=config.native_backend,
701
+ )
702
+ )
703
+ market = backend.prepare_market_arrays(
704
+ datetime_index=idx,
705
+ closes=close_map,
706
+ highs=high_map,
707
+ lows=low_map,
708
+ funding_rate=config.funding_rate,
709
+ symbols=symbol_list,
710
+ )
711
+ profile = {
712
+ "mode": config.mode,
713
+ "backend": "native_event",
714
+ "event_engine_version": "v2",
715
+ "reactive_execution_mode": config.reactive_execution_mode,
716
+ "reactive_kernel_mode": config.reactive_kernel_mode,
717
+ "account": asdict(config.account),
718
+ "execution": asdict(config.execution),
719
+ "fee_rate": config.v2_fee_rate,
720
+ "report_level": config.report_level,
721
+ "symbols": tuple(symbol_list),
722
+ "bars": int(len(idx)),
723
+ "data_signature": market.signature,
724
+ }
725
+ return PreparedNativeEventStrategyRunner(
726
+ endpoint=self,
727
+ idx=idx,
728
+ symbols=list(symbol_list),
729
+ close_map=close_map,
730
+ high_map=high_map,
731
+ low_map=low_map,
732
+ opens_arr=opens_arr,
733
+ volumes_arr=volumes_arr,
734
+ market_arrays=market,
735
+ backend=backend,
736
+ profile_metadata=profile,
737
+ )
738
+
739
+ @classmethod
740
+ def pct_equity(cls, **kwargs) -> "QuantBTEndpoint":
741
+ """
742
+ Create a legacy `%_equity` endpoint.
743
+
744
+ Use this for strategies whose signal is a direction/weight and whose
745
+ order notional should be recomputed from live equity on signal changes.
746
+ `alloc_per_trade` is interpreted as an equity fraction when <= 1.0
747
+ (`0.5` means 50% of current equity), or as a percent when > 1.0.
748
+
749
+ Data requirement for `backtest()`:
750
+ a single OHLCV DataFrame with a DatetimeIndex and `close`; `high` and
751
+ `low` are strongly recommended for liquidation checks.
752
+ """
753
+ return cls(_config_from_kwargs(mode="pct_equity", sizing="%_equity", backend="legacy", **kwargs))
754
+
755
+ @classmethod
756
+ def signal_notional(cls, backend: str = "native_vectorized", **kwargs) -> "QuantBTEndpoint":
757
+ """
758
+ Create a signal-notional endpoint.
759
+
760
+ Signal changes anchor target units at the current price. Between signal
761
+ changes, units are frozen, avoiding price-drift micro-rebalancing. This
762
+ is the recommended default for systematic single-symbol alpha research.
763
+
764
+ `backend` can be `native_vectorized` for speed or `native_event` when
765
+ you want generated market rebalance orders and fill records.
766
+ """
767
+ return cls(_config_from_kwargs(mode="signal_notional", sizing="signal_notional", backend=backend, **kwargs))
768
+
769
+ @classmethod
770
+ def intrabar_bracket_reference(
771
+ cls,
772
+ *,
773
+ level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE,
774
+ intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS,
775
+ close_on_last_bar: bool = True,
776
+ execution_contract: Optional[ExecutionContract] = None,
777
+ session_policy: Optional[SessionExecutionPolicy] = None,
778
+ **kwargs,
779
+ ) -> "QuantBTEndpoint":
780
+ """
781
+ Create the Phase 31B readable intrabar reference endpoint.
782
+
783
+ This endpoint is the causal Python oracle for `intrabar_bracket_v1`.
784
+ Strategy output can stay compact: pass a signed `signal`/`signal_col`
785
+ where positive means long entry size, negative means short entry size,
786
+ and zero means no new entry. Optional stop, take-profit, trailing, and
787
+ technical-exit arrays are supplied through `intent_cols` at run time.
788
+
789
+ It is intentionally not the future Numba production kernel. Use it to
790
+ verify SL/TP/trailing/reversal semantics and audit fill timing before
791
+ promoting an alpha to the fast intrabar backend.
792
+ """
793
+ metadata = dict(kwargs.pop("metadata", {}))
794
+ mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode)
795
+ metadata.setdefault("intrabar_level_mode", mode_value)
796
+ metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value)
797
+ metadata.setdefault("execution_contract_id", "intrabar_bracket_v1")
798
+ contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar)
799
+ metadata.setdefault("execution_contract", contract.to_metadata())
800
+ if session_policy is not None:
801
+ metadata["session_policy"] = session_policy.to_metadata()
802
+ return cls(
803
+ _config_from_kwargs(
804
+ mode="intrabar_bracket_reference",
805
+ backend="intrabar_reference",
806
+ sizing="intrabar_intent",
807
+ metadata=metadata,
808
+ **kwargs,
809
+ )
810
+ )
811
+
812
+ @classmethod
813
+ def intrabar_bracket(
814
+ cls,
815
+ *,
816
+ level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE,
817
+ intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS,
818
+ close_on_last_bar: bool = True,
819
+ execution_contract: Optional[ExecutionContract] = None,
820
+ session_policy: Optional[SessionExecutionPolicy] = None,
821
+ report_level: str = "standard",
822
+ **kwargs,
823
+ ) -> "QuantBTEndpoint":
824
+ """
825
+ Create the Phase 31C fast Numba intrabar bracket endpoint.
826
+
827
+ Use the same compact input contract as
828
+ `intrabar_bracket_reference(...)`. `report_level="minimal"` is meant
829
+ for optimizers, `standard` returns diagnostics, and `audit` runs a
830
+ deterministic second pass to materialize exact sparse fills.
831
+ """
832
+ metadata = dict(kwargs.pop("metadata", {}))
833
+ mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode)
834
+ metadata.setdefault("intrabar_level_mode", mode_value)
835
+ metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value)
836
+ metadata.setdefault("execution_contract_id", "intrabar_bracket_v1")
837
+ contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar)
838
+ metadata.setdefault("execution_contract", contract.to_metadata())
839
+ if session_policy is not None:
840
+ metadata["session_policy"] = session_policy.to_metadata()
841
+ return cls(
842
+ _config_from_kwargs(
843
+ mode="intrabar_bracket",
844
+ backend="native_intrabar",
845
+ sizing="intrabar_intent",
846
+ report_level=report_level,
847
+ metadata=metadata,
848
+ **kwargs,
849
+ )
850
+ )
851
+
852
+ @classmethod
853
+ def fill_replay(cls, *, report_level: str = "audit", **kwargs) -> "QuantBTEndpoint":
854
+ """
855
+ Create a fast accounting replay endpoint for explicit fills.
856
+
857
+ Use `backtest(data=df, fill_replay=FillReplayTape_or_DataFrame)`. This
858
+ certifies accounting from supplied fills but does not certify how those
859
+ fills were generated.
860
+ """
861
+ metadata = dict(kwargs.pop("metadata", {}))
862
+ metadata.setdefault("execution_contract_id", "fill_replay_v1")
863
+ metadata.setdefault("execution_contract", ExecutionContract.fill_replay().to_metadata())
864
+ return cls(
865
+ _config_from_kwargs(
866
+ mode="fill_replay",
867
+ backend="native_intrabar",
868
+ sizing="explicit_fills",
869
+ report_level=report_level,
870
+ metadata=metadata,
871
+ **kwargs,
872
+ )
873
+ )
874
+
875
+ @classmethod
876
+ def dca_ladder(cls, **kwargs) -> "QuantBTEndpoint":
877
+ """
878
+ Create a structural DCA/grid ladder endpoint.
879
+
880
+ `signal` is a structural level series, not a target weight:
881
+ 0 is flat, +1 is base long, +2 allows the first safety order, and so on.
882
+ Negative levels model short ladders. `high` and `low` are required
883
+ because safety orders are simulated as limit fills at grid trigger
884
+ prices.
885
+ """
886
+ return cls(_config_from_kwargs(mode="dca_ladder", sizing="dca_ladder", backend="legacy", **kwargs))
887
+
888
+ @classmethod
889
+ def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
890
+ """
891
+ Create an explicit order simulation endpoint.
892
+
893
+ Use `simulate(data=df, orders=[OrderIntent(...), ...])`. Orders are run
894
+ through the selected event backend with market/limit fill lifecycle, TIF
895
+ handling, fees, margin checks, and fills in `result.fills`.
896
+ """
897
+ return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs))
898
+
899
+ @classmethod
900
+ def event_driven(
901
+ cls,
902
+ *,
903
+ input_mode: str = "strategy",
904
+ profile: Union[str, NativeEventProfile] = NativeEventProfile.RESEARCH,
905
+ backend: str = "auto",
906
+ **kwargs,
907
+ ) -> "QuantBTEndpoint":
908
+ """Create the stable public native-event facade.
909
+
910
+ Parameters
911
+ ----------
912
+ input_mode:
913
+ ``"strategy"`` for a stateful strategy implementing the reactive
914
+ callback protocol, or ``"orders"`` for an explicit
915
+ ``OrderCommand``/``OrderIntent`` tape.
916
+ profile:
917
+ ``"research"`` keeps a compact public result, ``"optimize"``
918
+ selects the scalar score retention contract, and ``"audit"``
919
+ retains replay-certified accounting and event artifacts.
920
+ backend:
921
+ ``"auto"`` follows the release policy (currently Python),
922
+ ``"python"`` selects the canonical backend, or ``"rust"``
923
+ explicitly requests the capability-gated native wheel.
924
+
925
+ The facade resolves profiles and delegates to
926
+ :meth:`native_event_strategy` or :meth:`native_event_lifecycle`.
927
+ It does not implement a second matcher or accounting engine. Advanced
928
+ callers may continue using those constructors directly when they need
929
+ custom ``reactive_execution_mode``, ``reactive_kernel_mode``,
930
+ ``report_level``, or ``audit_sink`` combinations.
931
+
932
+ Examples
933
+ --------
934
+ >>> endpoint = QuantBTEndpoint.event_driven(
935
+ ... profile="research", backend="auto", initial_capital=20_000,
936
+ ... )
937
+ >>> result = endpoint.simulate(data=data, strategy=strategy)
938
+
939
+ >>> endpoint = QuantBTEndpoint.event_driven(
940
+ ... input_mode="orders", profile="audit", backend="python",
941
+ ... initial_capital=20_000,
942
+ ... )
943
+ >>> result = endpoint.simulate(data=data, order_commands=commands)
944
+ """
945
+
946
+ resolved = _resolve_event_driven_kwargs(
947
+ input_mode=input_mode,
948
+ profile=profile,
949
+ backend=backend,
950
+ kwargs=kwargs,
951
+ )
952
+ if str(input_mode).lower().strip() == "strategy":
953
+ return cls.native_event_strategy(**resolved)
954
+ return cls.native_event_lifecycle(**resolved)
955
+
956
+ @classmethod
957
+ def native_event_lifecycle(cls, **kwargs) -> "QuantBTEndpoint":
958
+ """
959
+ Create an explicit native-event v2 lifecycle endpoint.
960
+
961
+ Use `simulate(..., order_commands=[OrderCommand(...), ...])` for
962
+ cancel/replace/amend/OCO/parent/stop/GTD lifecycle simulations. Passing
963
+ legacy `orders=[OrderIntent(...)]` is also accepted and converted to
964
+ immediate PLACE commands.
965
+ """
966
+ return cls(
967
+ _config_from_kwargs(
968
+ mode="orders",
969
+ backend="native_event",
970
+ event_engine_version="v2",
971
+ **kwargs,
972
+ )
973
+ )
974
+
975
+ @classmethod
976
+ def native_event_strategy(cls, **kwargs) -> "QuantBTEndpoint":
977
+ """
978
+ Create a reactive native-event v2 strategy endpoint.
979
+
980
+ Use `simulate(data=df, strategy=obj)` where `obj` optionally implements
981
+ `initialize(context)`, `on_bar_close(context)`, and `finalize(context)`.
982
+ Commands emitted by callbacks become effective from the next bar.
983
+ """
984
+ return cls(
985
+ _config_from_kwargs(
986
+ mode="native_event_strategy",
987
+ backend="native_event",
988
+ event_engine_version="v2",
989
+ **kwargs,
990
+ )
991
+ )
992
+
993
+ @classmethod
994
+ def options(
995
+ cls,
996
+ backend: str = "native_option",
997
+ *,
998
+ option_config: Optional[NativeOptionConfig] = None,
999
+ option_execution: Optional[OptionExecutionConfig] = None,
1000
+ option_margin: Optional[OptionMarginConfig] = None,
1001
+ fee_schedule: Optional[OptionFeeSchedule] = None,
1002
+ reporting_currency: str = "USD",
1003
+ initial_balances: Optional[Dict[str, float]] = None,
1004
+ conversion_rates: Optional[Dict[str, float]] = None,
1005
+ settle_expired: bool = False,
1006
+ max_spread_bps: Optional[float] = None,
1007
+ max_source_latency_ns: Optional[int] = None,
1008
+ **kwargs,
1009
+ ) -> "QuantBTEndpoint":
1010
+ """
1011
+ Create a native option simulation endpoint.
1012
+
1013
+ Strategy/template code supplies canonical option-chain rows,
1014
+ `OptionInstrumentSpec` definitions, and `OptionPackageIntent` packages
1015
+ to `backtest(...)` or `simulate(...)`. The endpoint routes packages
1016
+ through snapshot-level option execution, applies fills to the
1017
+ multi-currency option ledger, calculates margin, and returns an
1018
+ `OptionBacktestResult` with fills/packages/cash/marks/Greeks/settlement
1019
+ artifacts.
1020
+
1021
+ Required `backtest()` inputs:
1022
+ `chain`, `instruments`, and optional `packages`.
1023
+ """
1024
+ if backend.lower().strip() != "native_option":
1025
+ raise ValueError("options endpoint currently supports backend='native_option' only")
1026
+ metadata = dict(kwargs.pop("metadata", {}))
1027
+ metadata.setdefault("mode_family", "options")
1028
+ endpoint_config = _config_from_kwargs(mode="options", backend=backend, metadata=metadata, **kwargs)
1029
+ if option_config is None:
1030
+ option_config = NativeOptionConfig(
1031
+ account=endpoint_config.account,
1032
+ execution=endpoint_config.execution,
1033
+ option_execution=option_execution
1034
+ or OptionExecutionConfig(fee_rate=endpoint_config.v2_fee_rate, metadata={"source": "QuantBTEndpoint.options"}),
1035
+ margin=option_margin or OptionMarginConfig(),
1036
+ fee_schedule=fee_schedule,
1037
+ reporting_currency=reporting_currency,
1038
+ initial_balances=initial_balances,
1039
+ conversion_rates=dict(conversion_rates or {}),
1040
+ settle_expired=settle_expired,
1041
+ max_spread_bps=max_spread_bps,
1042
+ max_source_latency_ns=max_source_latency_ns,
1043
+ metadata=metadata,
1044
+ )
1045
+ endpoint_config = replace(endpoint_config, option_config=option_config)
1046
+ return cls(endpoint_config)
1047
+
1048
+ @classmethod
1049
+ def nautilus_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint":
1050
+ """
1051
+ Create a Nautilus DCA/grid structured-order validation endpoint.
1052
+
1053
+ The endpoint compiles a `DcaGridSpec` into explicit orders:
1054
+ base market entry, safety limit orders, and optional reduce-only
1055
+ TP/SL exits. The resulting orders are replayed by Nautilus through the
1056
+ same package-order adapter as `orders(backend="nautilus")`.
1057
+ """
1058
+ if spec is None:
1059
+ spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec))
1060
+ return cls(
1061
+ _config_from_kwargs(
1062
+ mode="nautilus_dca_grid",
1063
+ backend="nautilus",
1064
+ structured_order_spec=spec,
1065
+ symbols=[spec.symbol],
1066
+ **kwargs,
1067
+ )
1068
+ )
1069
+
1070
+ @classmethod
1071
+ def nautilus_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint":
1072
+ """
1073
+ Create a Nautilus bracket/OCO structured-order validation endpoint.
1074
+
1075
+ The endpoint compiles a `BracketOrderSpec` into entry plus linked
1076
+ reduce-only take-profit and/or stop-loss exits. OCO group metadata is
1077
+ preserved and Nautilus cancels sibling exit orders on first exit fill.
1078
+ """
1079
+ if spec is None:
1080
+ spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec))
1081
+ return cls(
1082
+ _config_from_kwargs(
1083
+ mode="nautilus_bracket_orders",
1084
+ backend="nautilus",
1085
+ structured_order_spec=spec,
1086
+ symbols=[spec.symbol],
1087
+ **kwargs,
1088
+ )
1089
+ )
1090
+
1091
+ @classmethod
1092
+ def native_event_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint":
1093
+ """
1094
+ Create a native-event v2 DCA/grid lifecycle endpoint.
1095
+
1096
+ The structured package is compiled into `OrderCommand` records so base,
1097
+ safety orders, reduce-only exits, and OCO metadata are audited in
1098
+ `command_report` and `order_events`.
1099
+ """
1100
+ if spec is None:
1101
+ spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec))
1102
+ return cls(
1103
+ _config_from_kwargs(
1104
+ mode="native_event_dca_grid",
1105
+ backend="native_event",
1106
+ event_engine_version="v2",
1107
+ structured_order_spec=spec,
1108
+ symbols=[spec.symbol],
1109
+ **kwargs,
1110
+ )
1111
+ )
1112
+
1113
+ @classmethod
1114
+ def native_event_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint":
1115
+ """
1116
+ Create a native-event v2 bracket/OCO lifecycle endpoint.
1117
+
1118
+ Entry, take-profit, and stop-loss legs are linked through parent/OCO
1119
+ command fields and simulated by the deterministic OHLC lifecycle kernel.
1120
+ """
1121
+ if spec is None:
1122
+ spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec))
1123
+ return cls(
1124
+ _config_from_kwargs(
1125
+ mode="native_event_bracket_orders",
1126
+ backend="native_event",
1127
+ event_engine_version="v2",
1128
+ structured_order_spec=spec,
1129
+ symbols=[spec.symbol],
1130
+ **kwargs,
1131
+ )
1132
+ )
1133
+
1134
+ @classmethod
1135
+ def basket(cls, basket: Optional[BasketSpec] = None, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
1136
+ """
1137
+ Create a basket/pair endpoint.
1138
+
1139
+ Use for pair trades and frozen hedge-ratio baskets. Provide a
1140
+ `BasketSpec` either here or to `simulate(..., basket=...)`, then pass a
1141
+ scalar entry/exit signal and per-symbol price data.
1142
+ """
1143
+ return cls(_config_from_kwargs(mode="basket", backend=backend, basket=basket, **kwargs))
1144
+
1145
+ @classmethod
1146
+ def arbitrage(cls, arb_type: str, spec, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
1147
+ """
1148
+ Create an arbitrage endpoint.
1149
+
1150
+ Supported today:
1151
+
1152
+ - `BasisArbitrageSpec`: native event, native vectorized, Nautilus
1153
+ package-order validation.
1154
+ - `StatArbPairSpec`: native event, native vectorized, Nautilus
1155
+ package-order validation.
1156
+ - `CalendarSpreadSpec`, `FundingArbitrageSpec`,
1157
+ `SpotPerpCashCarrySpec`, and `IndexBasketArbSpec`: native event and
1158
+ native vectorized package-style execution.
1159
+
1160
+ `CrossExchangeArbSpec`, `TriangularArbSpec`, and `OptionsVolArbSpec`
1161
+ are schema-validated but intentionally not executable through the
1162
+ generic package route because they require specialized account,
1163
+ sequence, latency, or Greek-aware engines.
1164
+ """
1165
+ metadata = dict(kwargs.pop("metadata", {}))
1166
+ metadata["arb_type"] = arb_type
1167
+ return cls(
1168
+ _config_from_kwargs(
1169
+ mode="arbitrage",
1170
+ backend=backend,
1171
+ arbitrage_spec=spec,
1172
+ metadata=metadata,
1173
+ **kwargs,
1174
+ )
1175
+ )
1176
+
1177
+ @staticmethod
1178
+ def arbitrage_support_matrix() -> Dict[str, Dict[str, str]]:
1179
+ """
1180
+ Return the public arbitrage endpoint support matrix.
1181
+
1182
+ Services can call this helper to decide which spec/backend pair is safe
1183
+ before constructing a run. A status of `supported` means the endpoint
1184
+ can execute the spec. A status of `schema_only` means the dataclass and
1185
+ validation exist, but execution should wait for a specialized engine.
1186
+ """
1187
+ return {
1188
+ "BasisArbitrageSpec": {
1189
+ "status": "supported",
1190
+ "backends": "native_event,native_vectorized,nautilus",
1191
+ "route": "run_basis_arbitrage",
1192
+ "sizing": "target_notional_to_base_qty or target_base_qty; linear contracts only",
1193
+ },
1194
+ "StatArbPairSpec": {
1195
+ "status": "supported",
1196
+ "backends": "native_event,native_vectorized,nautilus",
1197
+ "route": "run_stat_arb_pair_arbitrage",
1198
+ "sizing": "target_gross_notional; optional dynamic hedge_ratios",
1199
+ },
1200
+ "CalendarSpreadSpec": {
1201
+ "status": "supported",
1202
+ "backends": "native_event,native_vectorized",
1203
+ "route": "run_package_arbitrage",
1204
+ "sizing": "target_notional_to_base_qty or target_base_qty",
1205
+ },
1206
+ "FundingArbitrageSpec": {
1207
+ "status": "supported",
1208
+ "backends": "native_event,native_vectorized",
1209
+ "route": "run_package_arbitrage",
1210
+ "sizing": "target_notional_to_base_qty or target_base_qty",
1211
+ },
1212
+ "SpotPerpCashCarrySpec": {
1213
+ "status": "supported",
1214
+ "backends": "native_event,native_vectorized",
1215
+ "route": "run_package_arbitrage",
1216
+ "sizing": "target_notional_to_base_qty or target_base_qty",
1217
+ },
1218
+ "IndexBasketArbSpec": {
1219
+ "status": "supported",
1220
+ "backends": "native_event,native_vectorized",
1221
+ "route": "run_package_arbitrage",
1222
+ "sizing": "target_gross_notional",
1223
+ },
1224
+ "CrossExchangeArbSpec": {
1225
+ "status": "schema_only",
1226
+ "backends": "none",
1227
+ "route": "needs venue/account split engine",
1228
+ "sizing": "not executable yet",
1229
+ },
1230
+ "TriangularArbSpec": {
1231
+ "status": "schema_only",
1232
+ "backends": "none",
1233
+ "route": "needs sequenced path execution engine",
1234
+ "sizing": "not executable yet",
1235
+ },
1236
+ "OptionsVolArbSpec": {
1237
+ "status": "specialized_route",
1238
+ "backends": "native_option",
1239
+ "route": "QuantBTEndpoint.options(...) with OptionPackageIntent and Greeks reports",
1240
+ "sizing": "option package quantities; Greeks-aware risk belongs to option route",
1241
+ },
1242
+ }
1243
+
1244
+ @staticmethod
1245
+ def options_support_matrix() -> Dict[str, Dict[str, str]]:
1246
+ """
1247
+ Return the native option endpoint support matrix.
1248
+
1249
+ `supported` means the Phase 7 endpoint can execute the workflow through
1250
+ current native option components. `future` means the public schema is
1251
+ intentionally reserved but should wait for later phases.
1252
+ """
1253
+ return {
1254
+ "canonical_chain_tape": {
1255
+ "status": "supported",
1256
+ "backend": "native_option",
1257
+ "route": "prepare_option_tape",
1258
+ "notes": "long-form option chain with bid/ask/mark/IV/Greeks columns",
1259
+ },
1260
+ "option_packages": {
1261
+ "status": "supported",
1262
+ "backend": "native_option",
1263
+ "route": "execute_option_package -> OptionLedger",
1264
+ "notes": "atomic_all_or_none, best_effort, sequential, hedge_after_primary, rebalance_only",
1265
+ },
1266
+ "multi_currency_ledger": {
1267
+ "status": "supported",
1268
+ "backend": "native_option",
1269
+ "route": "OptionLedger",
1270
+ "notes": "premium cash, fees, realized PnL, settlement cashflow and marked equity",
1271
+ },
1272
+ "margin": {
1273
+ "status": "supported_approx",
1274
+ "backend": "native_option",
1275
+ "route": "calculate_option_margin",
1276
+ "notes": "venue-exact margin requires external validator or later Nautilus/venue adapter",
1277
+ },
1278
+ "OptionsVolArbSpec": {
1279
+ "status": "specialized_route",
1280
+ "backend": "native_option",
1281
+ "route": "strategy/template emits option packages; endpoint returns Greeks and attribution reports",
1282
+ "notes": "not executable through generic arbitrage package route",
1283
+ },
1284
+ "nautilus_options": {
1285
+ "status": "experimental",
1286
+ "backend": "nautilus",
1287
+ "route": "quantbt.adapters.nautilus.options.validate_option_packages_with_nautilus",
1288
+ "notes": "Phase 9 pins Nautilus option constructors and BBO quote semantics; full Nautilus option engine replay remains future",
1289
+ },
1290
+ }
1291
+
1292
+ @staticmethod
1293
+ def nautilus_support_matrix() -> Dict[str, Dict[str, str]]:
1294
+ """
1295
+ Return the public Nautilus adapter support matrix.
1296
+
1297
+ `supported` means the route is executable through current QuantBT
1298
+ endpoints. `planned` means the endpoint contract is reserved in the
1299
+ roadmap but runtime execution should not be used yet. `experimental`
1300
+ means the route exists for controlled validation, usually with a
1301
+ narrower instrument/order scope than native engines.
1302
+ """
1303
+ return {
1304
+ "signal_series": {
1305
+ "status": "supported",
1306
+ "endpoint": "QuantBTEndpoint.nautilus_validation(...)",
1307
+ "scope": "single-symbol target signal replay",
1308
+ "order_types": "market delta orders generated by adapter",
1309
+ "notes": "supports signal_notional, notional, unit, and %_equity sizing",
1310
+ },
1311
+ "explicit_orders": {
1312
+ "status": "supported",
1313
+ "endpoint": "QuantBTEndpoint.orders(backend='nautilus', ...)",
1314
+ "scope": "single-symbol OrderIntent replay",
1315
+ "order_types": "market, limit, stop_market, stop_limit",
1316
+ "notes": "preserves TIF, reduce_only, tags, price and trigger_price where Nautilus supports them",
1317
+ },
1318
+ "lifecycle_commands": {
1319
+ "status": "supported_native_event_adapter_aligned",
1320
+ "endpoint": "QuantBTEndpoint.native_event_lifecycle(...) or QuantBTEndpoint.orders(event_engine_version='v2', ...)",
1321
+ "scope": "native-event v2 command lifecycle; Nautilus package adapter accepts executable PLACE/REPLACE payloads",
1322
+ "order_types": "market, limit, stop_market, stop_limit plus cancel/replace/amend/cancel_all in native-event v2",
1323
+ "notes": "Nautilus command path is payload-aligned, not exchange-native cancel/amend parity yet",
1324
+ },
1325
+ "reactive_strategy": {
1326
+ "status": "supported_native_event_mvp",
1327
+ "endpoint": "QuantBTEndpoint.native_event_strategy(...)",
1328
+ "scope": "on_bar_close strategy callbacks emitting next-bar OrderCommand objects",
1329
+ "order_types": "native-event v2 lifecycle commands",
1330
+ "notes": "Phase 30D replay-backed MVP with captured command tape and static replay parity; incremental session is Phase 30E",
1331
+ },
1332
+ "dca_grid": {
1333
+ "status": "experimental",
1334
+ "endpoint": "QuantBTEndpoint.nautilus_dca_grid(...)",
1335
+ "scope": "base order, safety limit orders, TP/SL package",
1336
+ "order_types": "market, limit, bracket/OCO exits",
1337
+ "notes": "Phase 5.2C; compiles to explicit OrderIntent packages for Nautilus validation",
1338
+ },
1339
+ "bracket_oco": {
1340
+ "status": "experimental",
1341
+ "endpoint": "QuantBTEndpoint.nautilus_bracket_orders(...)",
1342
+ "scope": "entry plus linked stop-loss/take-profit exits",
1343
+ "order_types": "bracket/OCO package",
1344
+ "notes": "Phase 5.2C; sibling cancellation is handled by the Nautilus package strategy",
1345
+ },
1346
+ "basket_pair": {
1347
+ "status": "experimental",
1348
+ "endpoint": "QuantBTEndpoint.basket(backend='nautilus', ...)",
1349
+ "scope": "multi-leg frozen hedge-ratio packages",
1350
+ "order_types": "per-leg explicit market/limit orders",
1351
+ "notes": "Phase 5.2D; compiles BasketSpec signals into Nautilus package orders",
1352
+ },
1353
+ "multi_symbol_portfolio": {
1354
+ "status": "experimental",
1355
+ "endpoint": "QuantBTEndpoint.portfolio(backend='nautilus', ...)",
1356
+ "scope": "position-matrix transitions across one Nautilus venue/account",
1357
+ "order_types": "per-symbol target delta orders",
1358
+ "notes": "Phase 5.2D; supports pre-scalable signal_notional/notional/unit modes",
1359
+ },
1360
+ "arbitrage_package_orders": {
1361
+ "status": "experimental",
1362
+ "endpoint": "QuantBTEndpoint.arbitrage(..., backend='nautilus')",
1363
+ "scope": "basis/stat-arb package validation",
1364
+ "order_types": "package market orders",
1365
+ "notes": "supported for selected arbitrage specs; not a general basket endpoint yet",
1366
+ },
1367
+ "parity_audit": {
1368
+ "status": "supported",
1369
+ "endpoint": "build_native_nautilus_parity_report(native, nautilus)",
1370
+ "scope": "native-vs-Nautilus order/fill/equity comparison",
1371
+ "order_types": "reporting helper",
1372
+ "notes": "row-level audit exists; summary artifacts live in report bundle and tests",
1373
+ },
1374
+ }
1375
+
1376
+ @classmethod
1377
+ def portfolio(cls, portfolio_mode: str = "longshort", backend: str = "native_portfolio", **kwargs) -> "QuantBTEndpoint":
1378
+ """
1379
+ Create a multi-symbol portfolio endpoint.
1380
+
1381
+ Use `backtest(positions=positions_df, data=data_dict)` where
1382
+ `positions_df.columns` are symbols and `data_dict[symbol]` is an OHLCV
1383
+ DataFrame. The endpoint wraps `PortfolioBacktestEngine`.
1384
+ """
1385
+ return cls(_config_from_kwargs(mode="portfolio", backend=backend, portfolio_mode=portfolio_mode, **kwargs))
1386
+
1387
+ @classmethod
1388
+ def nautilus_validation(cls, **kwargs) -> "QuantBTEndpoint":
1389
+ """
1390
+ Create a Nautilus validation endpoint.
1391
+
1392
+ This is for smaller high-fidelity validation runs. It currently supports
1393
+ single-symbol signal series using the optional NautilusTrader adapter.
1394
+ Nautilus must be installed in the active environment.
1395
+
1396
+ `use_pyramiding` is forwarded to the Nautilus strategy adapter. When it
1397
+ is false, fractional signals such as `1.4` are snapped to `1.0`; when it
1398
+ is true, the raw signal scale is preserved.
1399
+ """
1400
+ sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", "signal_notional"))
1401
+ return cls(_config_from_kwargs(mode="nautilus_validation", backend="nautilus", sizing=sizing, **kwargs))
1402
+
1403
+ @classmethod
1404
+ def walk_forward(
1405
+ cls,
1406
+ strategy_class,
1407
+ split_mode: Union[str, int, pd.Timestamp] = "walk_forward_2022",
1408
+ split_frequency: str = "quarterly",
1409
+ target_mode: str = "signal_notional",
1410
+ window_mode: str = "expanding",
1411
+ train_window: Optional[str] = None,
1412
+ optimization_mode: str = "none",
1413
+ optimization_config: Optional[Dict] = None,
1414
+ optuna_trials: int = 0,
1415
+ optuna_early_stopping: Optional[int] = None,
1416
+ random_seed: int = 42,
1417
+ **kwargs,
1418
+ ) -> "QuantBTEndpoint":
1419
+ """
1420
+ Create a walk-forward endpoint.
1421
+
1422
+ The strategy callable/class is invoked once per fold and must return OOS
1423
+ signal/position output indexed by timestamp. The stitched OOS output is
1424
+ then routed into an existing QuantBT backtest path, so boundary trades
1425
+ are charged by the normal engine instead of averaging fold equities.
1426
+ Supported optimization modes are `mode_1_decay`, `mode_2_sbb`,
1427
+ `mode_3_flat_minima`, `mode_4_is_only_robust`, and
1428
+ `mode_5_full_robust`.
1429
+ Fixed-parameter runs can leave
1430
+ `optimization_mode="none"` and pass `params=...` to `backtest()`.
1431
+ """
1432
+ optimization_config = dict(optimization_config or {})
1433
+ wf_config = kwargs.pop("walkforward_config", None)
1434
+ scoring_backend = str(
1435
+ optimization_config.get(
1436
+ "scoring_backend",
1437
+ _default_walkforward_scoring_backend(target_mode=target_mode, optimization_mode=optimization_mode),
1438
+ )
1439
+ )
1440
+ wf_metadata = dict(optimization_config.get("metadata", {}) or {})
1441
+ wf_metadata.setdefault("use_prepared_scoring_cache", bool(optimization_config.get("use_prepared_scoring_cache", True)))
1442
+ if wf_config is None:
1443
+ from .walkforward import WalkForwardConfig
1444
+
1445
+ wf_config = WalkForwardConfig(
1446
+ split_mode=split_mode,
1447
+ split_frequency=split_frequency,
1448
+ window_mode=window_mode,
1449
+ train_window=train_window,
1450
+ target_mode=target_mode,
1451
+ optimization_mode=optimization_mode,
1452
+ optuna_trials=optuna_trials,
1453
+ optuna_early_stopping=optuna_early_stopping,
1454
+ random_seed=random_seed,
1455
+ decay_lambda=float(optimization_config.get("decay_lambda", 0.5)),
1456
+ decay_gamma=float(optimization_config.get("decay_gamma", 0.5)),
1457
+ top_is_fraction=float(optimization_config.get("top_is_fraction", 0.10)),
1458
+ top_is_k=optimization_config.get("top_is_k"),
1459
+ candidate_selection_metric=str(
1460
+ optimization_config.get(
1461
+ "candidate_selection_metric",
1462
+ (
1463
+ "is_only_robust"
1464
+ if str(optimization_mode).lower().strip() == "mode_4_is_only_robust"
1465
+ else (
1466
+ "full_robust"
1467
+ if str(optimization_mode).lower().strip() == "mode_5_full_robust"
1468
+ else "robust_decay"
1469
+ )
1470
+ ),
1471
+ )
1472
+ ),
1473
+ candidate_decay_lambda=optimization_config.get("candidate_decay_lambda"),
1474
+ candidate_decay_gamma=optimization_config.get("candidate_decay_gamma"),
1475
+ sbb_samples=int(optimization_config.get("sbb_samples", 256)),
1476
+ sbb_block_length=int(optimization_config.get("sbb_block_length", 20)),
1477
+ sbb_decay_lambda=float(optimization_config.get("sbb_decay_lambda", 0.5)),
1478
+ sbb_std_penalty=float(optimization_config.get("sbb_std_penalty", 0.1)),
1479
+ sbb_simulation=str(optimization_config.get("sbb_simulation", "stationary")),
1480
+ regime_count=int(optimization_config.get("regime_count", 3)),
1481
+ regime_lookback=int(optimization_config.get("regime_lookback", 20)),
1482
+ regime_weights=optimization_config.get("regime_weights"),
1483
+ stress_vol_multiplier=float(optimization_config.get("stress_vol_multiplier", 1.0)),
1484
+ garch_p=int(optimization_config.get("garch_p", 1)),
1485
+ garch_q=int(optimization_config.get("garch_q", 1)),
1486
+ garch_dist=str(optimization_config.get("garch_dist", "t")),
1487
+ garch_vol_multiplier=float(optimization_config.get("garch_vol_multiplier", 1.0)),
1488
+ flat_top_fraction=float(optimization_config.get("flat_top_fraction", 0.1)),
1489
+ flat_eps=float(optimization_config.get("flat_eps", 0.15)),
1490
+ flat_min_samples=int(optimization_config.get("flat_min_samples", 3)),
1491
+ flat_selector=str(optimization_config.get("flat_selector", "medoid")),
1492
+ plateau_quantile=float(optimization_config.get("plateau_quantile", 0.25)),
1493
+ plateau_median_weight=float(optimization_config.get("plateau_median_weight", 0.25)),
1494
+ plateau_std_penalty=float(optimization_config.get("plateau_std_penalty", 0.50)),
1495
+ plateau_size_bonus=float(optimization_config.get("plateau_size_bonus", 0.01)),
1496
+ is_subperiods=int(optimization_config.get("is_subperiods", 6)),
1497
+ q25_weight=float(optimization_config.get("q25_weight", 0.30)),
1498
+ dispersion_penalty=float(optimization_config.get("dispersion_penalty", 0.50)),
1499
+ temporal_weight=float(optimization_config.get("temporal_weight", 0.65)),
1500
+ plateau_weight=float(optimization_config.get("plateau_weight", 0.35)),
1501
+ use_bootstrap_penalty=bool(optimization_config.get("use_bootstrap_penalty", False)),
1502
+ use_complexity_penalty=bool(optimization_config.get("use_complexity_penalty", False)),
1503
+ scoring_backend=scoring_backend,
1504
+ scoring_trading_days=int(optimization_config.get("scoring_trading_days", 365)),
1505
+ min_trades_per_year=optimization_config.get("min_trades_per_year"),
1506
+ trade_penalty_factor=optimization_config.get("trade_penalty_factor"),
1507
+ use_numba=bool(optimization_config.get("use_numba", True)),
1508
+ metadata=wf_metadata,
1509
+ )
1510
+ default_sizing = "signal_notional" if target_mode in {"portfolio", "basket", "arbitrage"} else target_mode
1511
+ sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", default_sizing))
1512
+ backend = kwargs.pop("backend", "auto")
1513
+ return cls(
1514
+ _config_from_kwargs(
1515
+ mode="walk_forward",
1516
+ backend=backend,
1517
+ sizing=sizing,
1518
+ strategy_class=strategy_class,
1519
+ walkforward_config=wf_config,
1520
+ walkforward_target_mode=target_mode,
1521
+ **kwargs,
1522
+ )
1523
+ )
1524
+
1525
+ @classmethod
1526
+ def train_test_split(
1527
+ cls,
1528
+ strategy_class,
1529
+ test_start: Union[str, int, pd.Timestamp],
1530
+ target_mode: str = "signal_notional",
1531
+ window_mode: str = "expanding",
1532
+ train_window: Optional[str] = None,
1533
+ optimization_mode: str = "none",
1534
+ optimization_config: Optional[Dict] = None,
1535
+ optuna_trials: int = 0,
1536
+ optuna_early_stopping: Optional[int] = None,
1537
+ random_seed: int = 42,
1538
+ **kwargs,
1539
+ ) -> "QuantBTEndpoint":
1540
+ """
1541
+ Create a single holdout train/test endpoint.
1542
+
1543
+ This is a convenience wrapper around `walk_forward(...)` with
1544
+ `split_frequency="single"`. The strategy is optimized on the train
1545
+ segment before `test_start`, emits OOS output on the holdout segment,
1546
+ then the stitched holdout signal is routed into the selected QuantBT
1547
+ target mode. `optimization_mode` accepts the same values as
1548
+ walk-forward: `none`, `mode_1_decay`, `mode_2_sbb`,
1549
+ `mode_3_flat_minima`, `mode_4_is_only_robust`, and
1550
+ `mode_5_full_robust`.
1551
+ """
1552
+ return cls.walk_forward(
1553
+ strategy_class=strategy_class,
1554
+ split_mode=test_start,
1555
+ split_frequency="single",
1556
+ target_mode=target_mode,
1557
+ window_mode=window_mode,
1558
+ train_window=train_window,
1559
+ optimization_mode=optimization_mode,
1560
+ optimization_config=optimization_config,
1561
+ optuna_trials=optuna_trials,
1562
+ optuna_early_stopping=optuna_early_stopping,
1563
+ random_seed=random_seed,
1564
+ **kwargs,
1565
+ )
1566
+
1567
+ def backtest(
1568
+ self,
1569
+ data=None,
1570
+ signal: Optional[pd.Series] = None,
1571
+ signal_col: Optional[str] = None,
1572
+ positions: Optional[Union[pd.DataFrame, SeriesMap]] = None,
1573
+ orders: Optional[Sequence[OrderIntent]] = None,
1574
+ order_commands: Optional[Sequence[OrderCommand]] = None,
1575
+ strategy=None,
1576
+ basket: Optional[BasketSpec] = None,
1577
+ closes: Optional[SeriesMap] = None,
1578
+ highs: Optional[SeriesMap] = None,
1579
+ lows: Optional[SeriesMap] = None,
1580
+ hedge_ratios: Optional[SeriesMap] = None,
1581
+ datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None,
1582
+ symbols: Optional[Sequence[str]] = None,
1583
+ params: Optional[Dict] = None,
1584
+ param_ranges: Optional[Dict] = None,
1585
+ chain: Optional[pd.DataFrame] = None,
1586
+ instruments: Optional[Union[OptionInstrumentRegistry, Sequence[OptionInstrumentSpec], Dict[str, OptionInstrumentSpec]]] = None,
1587
+ packages: Optional[Sequence[OptionPackageIntent]] = None,
1588
+ strategy_run: Optional[OptionStrategyRun] = None,
1589
+ intent: Optional[IntrabarIntentTape] = None,
1590
+ intent_cols: Optional[Dict[str, str]] = None,
1591
+ session_tape: Optional[IntrabarSessionTape] = None,
1592
+ funding_event_timestamps=None,
1593
+ funding_event_rates=None,
1594
+ fill_replay: Optional[Union[FillReplayTape, pd.DataFrame]] = None,
1595
+ underlying: Optional[Union[pd.DataFrame, pd.Series]] = None,
1596
+ hedge_policy: Optional[OptionHedgeConfig] = None,
1597
+ net_option_delta: Optional[pd.Series] = None,
1598
+ settlement_events: Optional[Sequence] = None,
1599
+ conversion_rates: Optional[Dict[str, float]] = None,
1600
+ prepared_cache: Optional[OptionPreparedRunCache] = None,
1601
+ ):
1602
+ """
1603
+ Run the configured backtest and store the result.
1604
+
1605
+ Parameters
1606
+ ----------
1607
+ data:
1608
+ For single-symbol modes, an OHLCV DataFrame. For portfolio/basket
1609
+ modes, either a `{symbol: DataFrame}` mapping or omitted when
1610
+ `closes/highs/lows` are supplied explicitly.
1611
+ signal:
1612
+ Single-symbol signal series, or basket entry/exit signal.
1613
+ signal_col:
1614
+ Column name to read from `data` when `signal` is omitted.
1615
+ positions:
1616
+ Portfolio positions as DataFrame or `{symbol: Series}` mapping.
1617
+ orders:
1618
+ Explicit `OrderIntent` sequence for order simulations.
1619
+ basket:
1620
+ Optional `BasketSpec` overriding the config basket for this run.
1621
+ closes/highs/lows:
1622
+ Explicit per-symbol price series maps.
1623
+ datetime_index:
1624
+ Optional common datetime index. Defaults to data/signal index.
1625
+ symbols:
1626
+ Optional symbol override for this run.
1627
+ """
1628
+ mode = self.config.mode.lower().strip()
1629
+ if mode == "options":
1630
+ return self._run_options(
1631
+ chain=chain if chain is not None else data,
1632
+ instruments=instruments,
1633
+ packages=packages,
1634
+ strategy_run=strategy_run,
1635
+ underlying=underlying,
1636
+ hedge_policy=hedge_policy,
1637
+ net_option_delta=net_option_delta,
1638
+ settlement_events=settlement_events,
1639
+ conversion_rates=conversion_rates,
1640
+ prepared_cache=prepared_cache,
1641
+ )
1642
+ if mode == "walk_forward":
1643
+ return self._run_walk_forward(
1644
+ data=data,
1645
+ signal=signal,
1646
+ signal_col=signal_col,
1647
+ positions=positions,
1648
+ closes=closes,
1649
+ highs=highs,
1650
+ lows=lows,
1651
+ hedge_ratios=hedge_ratios,
1652
+ datetime_index=datetime_index,
1653
+ symbols=symbols,
1654
+ params=params,
1655
+ param_ranges=param_ranges,
1656
+ )
1657
+ if mode == "arbitrage":
1658
+ return self._run_arbitrage(
1659
+ data=data,
1660
+ signal=signal,
1661
+ signal_col=signal_col,
1662
+ closes=closes,
1663
+ highs=highs,
1664
+ lows=lows,
1665
+ hedge_ratios=hedge_ratios,
1666
+ datetime_index=datetime_index,
1667
+ symbols=symbols,
1668
+ )
1669
+ if mode == "intrabar_bracket_reference":
1670
+ return self._run_intrabar_bracket_reference(
1671
+ data=data,
1672
+ signal=signal,
1673
+ signal_col=signal_col,
1674
+ datetime_index=datetime_index,
1675
+ symbols=symbols,
1676
+ intent=intent,
1677
+ intent_cols=intent_cols,
1678
+ session_tape=session_tape,
1679
+ funding_event_timestamps=funding_event_timestamps,
1680
+ funding_event_rates=funding_event_rates,
1681
+ )
1682
+ if mode == "intrabar_bracket":
1683
+ return self._run_intrabar_bracket_fast(
1684
+ data=data,
1685
+ signal=signal,
1686
+ signal_col=signal_col,
1687
+ datetime_index=datetime_index,
1688
+ symbols=symbols,
1689
+ intent=intent,
1690
+ intent_cols=intent_cols,
1691
+ session_tape=session_tape,
1692
+ funding_event_timestamps=funding_event_timestamps,
1693
+ funding_event_rates=funding_event_rates,
1694
+ )
1695
+ if mode == "fill_replay":
1696
+ return self._run_fill_replay(
1697
+ data=data,
1698
+ datetime_index=datetime_index,
1699
+ symbols=symbols,
1700
+ fill_replay=fill_replay,
1701
+ )
1702
+ if mode in ("single_signal", "pct_equity", "signal_notional", "dca_ladder", "nautilus_validation"):
1703
+ return self._run_single(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index, symbols=symbols)
1704
+ if mode == "orders":
1705
+ return self._run_orders(
1706
+ data=data,
1707
+ orders=orders,
1708
+ order_commands=order_commands,
1709
+ datetime_index=datetime_index,
1710
+ symbols=symbols,
1711
+ )
1712
+ if mode == "native_event_strategy":
1713
+ return self._run_native_event_strategy(
1714
+ data=data,
1715
+ strategy=strategy,
1716
+ datetime_index=datetime_index,
1717
+ symbols=symbols,
1718
+ )
1719
+ if mode in ("nautilus_dca_grid", "nautilus_bracket_orders", "native_event_dca_grid", "native_event_bracket_orders"):
1720
+ return self._run_structured_orders(data=data, datetime_index=datetime_index, symbols=symbols)
1721
+ if mode == "basket":
1722
+ return self._run_basket(
1723
+ data=data,
1724
+ signal=signal,
1725
+ signal_col=signal_col,
1726
+ basket=basket,
1727
+ closes=closes,
1728
+ highs=highs,
1729
+ lows=lows,
1730
+ datetime_index=datetime_index,
1731
+ symbols=symbols,
1732
+ )
1733
+ if mode == "portfolio":
1734
+ return self._run_portfolio(
1735
+ data=data,
1736
+ positions=positions,
1737
+ closes=closes,
1738
+ highs=highs,
1739
+ lows=lows,
1740
+ datetime_index=datetime_index,
1741
+ symbols=symbols,
1742
+ )
1743
+ raise ValueError(f"unsupported endpoint mode={self.config.mode!r}")
1744
+
1745
+ def simulate(
1746
+ self,
1747
+ *args,
1748
+ show_order_logs: bool = False,
1749
+ order_log_mode: str = "fills_only",
1750
+ order_log_limit: int = 500,
1751
+ **kwargs,
1752
+ ):
1753
+ """
1754
+ Alias for `backtest()` used by order, basket, and Nautilus workflows.
1755
+
1756
+ Services can call `simulate()` when the input is closer to an execution
1757
+ simulation than a pure signal backtest. The routing and return contract
1758
+ are identical to `backtest()`.
1759
+ """
1760
+ result = self.backtest(*args, **kwargs)
1761
+ if show_order_logs:
1762
+ _print_order_logs(result, mode=order_log_mode, limit=order_log_limit)
1763
+ return result
1764
+
1765
+ def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict:
1766
+ """
1767
+ Return the full QuantBT metrics dictionary for the latest result.
1768
+
1769
+ Parameters
1770
+ ----------
1771
+ trading_days:
1772
+ Annualization calendar. Use 365 for crypto and 252 for equities.
1773
+ scope:
1774
+ `auto` uses the natural reporting scope for the endpoint. For
1775
+ walk-forward and train/test split runs this means OOS/test bars
1776
+ only; other endpoints use the full result. Pass `full` to audit the
1777
+ complete stitched timeline, or `test`/`oos` to force OOS reporting.
1778
+
1779
+ Raises
1780
+ ------
1781
+ RuntimeError
1782
+ If no backtest has been run yet.
1783
+ """
1784
+ from .metrics import full_report as _full_report
1785
+
1786
+ return _full_report(self._result_for_report_scope(scope), trading_days=trading_days)
1787
+
1788
+ def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict:
1789
+ """
1790
+ Print key metrics and return the full metrics dictionary.
1791
+
1792
+ This intentionally mirrors the convenience style of legacy
1793
+ `BacktestEngine.analyze()` without forcing a plot.
1794
+ """
1795
+ rpt = self.full_report(trading_days=trading_days, scope=scope)
1796
+ print(format_metrics_report(rpt))
1797
+ return rpt
1798
+
1799
+ def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"):
1800
+ """
1801
+ Plot cumulative return and drawdown for the latest result.
1802
+ """
1803
+ from .viz import quick_plot as _quick_plot
1804
+
1805
+ return _quick_plot(self._require_result(), theme=theme, figsize=figsize, scope=scope)
1806
+
1807
+ def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"):
1808
+ """
1809
+ Render the full QuantBT tearsheet for the latest result.
1810
+ """
1811
+ from .viz import tearsheet as _tearsheet
1812
+
1813
+ return _tearsheet(self._require_result(), theme=theme, benchmark=benchmark, scope=scope)
1814
+
1815
+ def export_orders(self, path: Union[str, Path]) -> None:
1816
+ """
1817
+ Export latest event/Nautilus order report to CSV.
1818
+
1819
+ Native event runs store order diagnostics in
1820
+ `result.metadata["order_report"]`; Nautilus runs store raw
1821
+ `orders_report`.
1822
+ """
1823
+ result = self._require_result()
1824
+ report = result.metadata.get("order_report")
1825
+ if report is None:
1826
+ report = result.metadata.get("orders_report")
1827
+ if report is None:
1828
+ raise RuntimeError("latest result does not contain an order report")
1829
+ report.to_csv(path)
1830
+
1831
+ def export_fills(self, path: Union[str, Path]) -> None:
1832
+ """
1833
+ Export latest fills to CSV.
1834
+
1835
+ Native event fills are converted from `result.fills`; Nautilus fills use
1836
+ the raw `fills_report` when available.
1837
+ """
1838
+ result = self._require_result()
1839
+ report = result.metadata.get("fills_report")
1840
+ if report is None:
1841
+ rows = [getattr(fill, "__dict__", dict(fill=fill)) for fill in getattr(result, "fills", ())]
1842
+ report = pd.DataFrame(rows)
1843
+ if report.empty:
1844
+ raise RuntimeError("latest result does not contain fills")
1845
+ report.to_csv(path, index=False)
1846
+
1847
+ @property
1848
+ def metrics(self) -> Dict:
1849
+ """Return `full_report()` for the latest result."""
1850
+ return self.full_report()
1851
+
1852
+ @property
1853
+ def latest_orders(self):
1854
+ """Return latest explicit/generated orders, or an empty tuple."""
1855
+ return getattr(self._require_result(), "orders", ())
1856
+
1857
+ @property
1858
+ def fills(self):
1859
+ """Return latest fills, or an empty tuple for non-event results."""
1860
+ return getattr(self._require_result(), "fills", ())
1861
+
1862
+ @property
1863
+ def order_report(self) -> pd.DataFrame:
1864
+ """Return latest order report, or an empty DataFrame."""
1865
+ return self._require_result().metadata.get("order_report", pd.DataFrame())
1866
+
1867
+ @property
1868
+ def fills_report(self) -> pd.DataFrame:
1869
+ """Return latest fills report, or an empty DataFrame."""
1870
+ return self._require_result().metadata.get("fills_report", pd.DataFrame())
1871
+
1872
+ def nautilus_pct_equity_diagnostic(
1873
+ self,
1874
+ *,
1875
+ data,
1876
+ signal=None,
1877
+ signal_col: Optional[str] = None,
1878
+ native_fee_round_trip: Optional[float] = None,
1879
+ native_fee_one_way: Optional[float] = None,
1880
+ native_use_funding: Optional[bool] = None,
1881
+ native_slippage: Optional[float] = None,
1882
+ ) -> Dict:
1883
+ """
1884
+ Diagnose why a Nautilus `%_equity` validation run differs from native.
1885
+
1886
+ This helper reports signal transition count, Nautilus order/fill count,
1887
+ fee/slippage/funding semantic differences, and exchange lot-size
1888
+ constraints. It is diagnostic-only and does not mutate the result.
1889
+ """
1890
+ from .reporting import build_nautilus_pct_equity_diagnostic
1891
+
1892
+ frame, _, sig = _normalize_single_data(
1893
+ data=data,
1894
+ signal=signal,
1895
+ signal_col=signal_col,
1896
+ datetime_index=None,
1897
+ )
1898
+ return build_nautilus_pct_equity_diagnostic(
1899
+ self._require_result(),
1900
+ data=frame,
1901
+ signal=sig,
1902
+ native_fee_round_trip=native_fee_round_trip,
1903
+ native_fee_one_way=native_fee_one_way,
1904
+ native_use_funding=native_use_funding,
1905
+ native_slippage=native_slippage,
1906
+ )
1907
+
1908
+ def _run_options(
1909
+ self,
1910
+ chain,
1911
+ instruments,
1912
+ packages,
1913
+ strategy_run,
1914
+ underlying,
1915
+ hedge_policy,
1916
+ net_option_delta,
1917
+ settlement_events,
1918
+ conversion_rates,
1919
+ prepared_cache,
1920
+ ):
1921
+ if chain is None:
1922
+ raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe")
1923
+ if instruments is None:
1924
+ instruments = self.config.instruments
1925
+ if instruments is None:
1926
+ raise ValueError("options endpoint requires instruments=OptionInstrumentRegistry/list/mapping")
1927
+ config = self.config.option_config
1928
+ if config is None:
1929
+ config = NativeOptionConfig(
1930
+ account=self.config.account,
1931
+ execution=self.config.execution,
1932
+ option_execution=OptionExecutionConfig(fee_rate=self.config.v2_fee_rate),
1933
+ margin=OptionMarginConfig(),
1934
+ metadata=dict(self.config.metadata),
1935
+ )
1936
+ self.engine = OptionBacktestEngine(
1937
+ chain=chain,
1938
+ instruments=instruments,
1939
+ packages=packages or (),
1940
+ strategy_run=strategy_run,
1941
+ underlying=underlying,
1942
+ hedge_policy=hedge_policy,
1943
+ net_option_delta=net_option_delta,
1944
+ config=config,
1945
+ settlement_events=settlement_events or (),
1946
+ conversion_rates=conversion_rates,
1947
+ prepared_cache=prepared_cache,
1948
+ )
1949
+ self._store_result(self.engine.result)
1950
+ return self.result
1951
+
1952
+ def _run_intrabar_bracket_reference(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None):
1953
+ tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates)
1954
+ contract = _execution_contract_from_config(self.config)
1955
+ session_policy = _session_policy_from_config(self.config)
1956
+ if session_policy is not None and session_tape is None:
1957
+ raise ValueError("session_tape is required when session_policy is configured")
1958
+ oracle = run_intrabar_reference(
1959
+ tape=tape,
1960
+ intent=intent,
1961
+ account=self.config.account,
1962
+ contract=contract,
1963
+ fee_rate=self.config.v2_fee_rate,
1964
+ slippage_rate=float(self.config.execution.slippage_rate),
1965
+ contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
1966
+ session_policy=session_policy,
1967
+ session_tape=session_tape,
1968
+ **self._intrabar_execution_kwargs(symbol),
1969
+ )
1970
+ idx = oracle.equity.index
1971
+ returns = oracle.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
1972
+ diagnostics = pd.DataFrame(
1973
+ {
1974
+ "average_entry": oracle.average_entry,
1975
+ "active_stop": oracle.active_stop,
1976
+ "active_take_profit": oracle.active_take_profit,
1977
+ "event_flags": oracle.event_flags,
1978
+ "fees": oracle.fees,
1979
+ "funding": oracle.funding,
1980
+ },
1981
+ index=idx,
1982
+ )
1983
+ metadata = {
1984
+ **oracle.metadata,
1985
+ "backend": "intrabar_reference",
1986
+ "backend_alias": "intrabar_bracket_reference",
1987
+ "engine_id": "intrabar_reference_v1",
1988
+ "input_mode": "intrabar_intent",
1989
+ "symbol": symbol,
1990
+ "validation_certificate": asdict(tape.validation_certificate),
1991
+ "strict_market_tape": True,
1992
+ "phase": "31B_python_reference_oracle",
1993
+ "fills_report": _intrabar_fills_to_frame(oracle.fills),
1994
+ "positions_report": pd.DataFrame({f"Position_{symbol}": oracle.position}, index=idx),
1995
+ }
1996
+ result = BacktestResultV2(
1997
+ equity=oracle.equity,
1998
+ returns=returns,
1999
+ positions=pd.DataFrame({f"Position_{symbol}": oracle.position.to_numpy(dtype=float)}, index=idx),
2000
+ closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
2001
+ symbols=[symbol],
2002
+ initial_capital=float(self.config.account.initial_capital),
2003
+ leverage=float(self.config.account.leverage),
2004
+ fills=oracle.fills,
2005
+ fees=oracle.fees,
2006
+ funding=oracle.funding,
2007
+ diagnostics=diagnostics,
2008
+ metadata=metadata,
2009
+ )
2010
+ self.engine = oracle
2011
+ self._store_result(result)
2012
+ return self.result
2013
+
2014
+ def _run_intrabar_bracket_fast(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None):
2015
+ tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates)
2016
+ contract = _execution_contract_from_config(self.config)
2017
+ session_policy = _session_policy_from_config(self.config)
2018
+ if session_policy is not None and session_tape is None:
2019
+ raise ValueError("session_tape is required when session_policy is configured")
2020
+ if session_policy is None and session_tape is not None:
2021
+ raise ValueError("session_policy is required when session_tape is supplied")
2022
+ kwargs = {
2023
+ "tape": tape,
2024
+ "intent": intent,
2025
+ "account": self.config.account,
2026
+ "contract": contract,
2027
+ "fee_rate": self.config.v2_fee_rate,
2028
+ "slippage_rate": float(self.config.execution.slippage_rate),
2029
+ "contract_size": _scalar_for_symbol(self.config.contract_size, symbol),
2030
+ **self._intrabar_execution_kwargs(symbol),
2031
+ "report_level": self.config.report_level,
2032
+ }
2033
+ if session_policy is not None:
2034
+ kernel = run_intrabar_session_kernel(
2035
+ **kwargs,
2036
+ session_policy=session_policy,
2037
+ session_tape=session_tape,
2038
+ )
2039
+ else:
2040
+ kernel = run_intrabar_kernel(**kwargs)
2041
+ idx = kernel.equity.index
2042
+ returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
2043
+ diagnostics = pd.DataFrame(
2044
+ {
2045
+ "average_entry": kernel.average_entry,
2046
+ "active_stop": kernel.active_stop,
2047
+ "active_take_profit": kernel.active_take_profit,
2048
+ "event_flags": kernel.event_flags,
2049
+ "initial_margin": kernel.initial_margin,
2050
+ "maintenance_margin": kernel.maintenance_margin,
2051
+ "fees": kernel.fees,
2052
+ "funding": kernel.funding,
2053
+ },
2054
+ index=idx,
2055
+ )
2056
+ metadata = {
2057
+ **kernel.metadata,
2058
+ "input_mode": "intrabar_intent",
2059
+ "symbol": symbol,
2060
+ "phase": "31C_numba_intrabar_kernel",
2061
+ "fills_report": kernel.fills_report,
2062
+ "positions_report": pd.DataFrame({f"Position_{symbol}": kernel.position}, index=idx),
2063
+ }
2064
+ result = BacktestResultV2(
2065
+ equity=kernel.equity,
2066
+ returns=returns,
2067
+ positions=pd.DataFrame({f"Position_{symbol}": kernel.position.to_numpy(dtype=float)}, index=idx),
2068
+ closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
2069
+ symbols=[symbol],
2070
+ initial_capital=float(self.config.account.initial_capital),
2071
+ leverage=float(self.config.account.leverage),
2072
+ liquidated=bool(kernel.liquidated),
2073
+ liquidation_bar=int(kernel.liquidation_bar),
2074
+ fills=kernel.fills,
2075
+ fees=kernel.fees,
2076
+ funding=kernel.funding,
2077
+ margin=diagnostics[["initial_margin", "maintenance_margin"]],
2078
+ diagnostics=diagnostics,
2079
+ metadata=metadata,
2080
+ )
2081
+ self.engine = kernel
2082
+ self._store_result(result)
2083
+ return self.result
2084
+
2085
+ def _run_fill_replay(self, data, datetime_index, symbols, fill_replay):
2086
+ if fill_replay is None:
2087
+ raise ValueError("fill_replay endpoint requires fill_replay=FillReplayTape or DataFrame")
2088
+ symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
2089
+ if len(symbol_list) != 1:
2090
+ raise ValueError("fill_replay currently supports exactly one symbol")
2091
+ symbol = symbol_list[0]
2092
+ tape = prepare_market_tape(
2093
+ data=data,
2094
+ datetime_index=datetime_index,
2095
+ symbols=symbol_list,
2096
+ funding_rate=self.config.funding_rate,
2097
+ use_funding=False,
2098
+ validation_mode="strict",
2099
+ source_timezone=self.config.metadata.get("source_timezone"),
2100
+ bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
2101
+ )
2102
+ if isinstance(fill_replay, FillReplayTape):
2103
+ fill_tape = fill_replay
2104
+ elif isinstance(fill_replay, pd.DataFrame):
2105
+ fill_tape = FillReplayTape.from_frame(
2106
+ fill_replay,
2107
+ fee_rate=self.config.v2_fee_rate,
2108
+ contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
2109
+ )
2110
+ else:
2111
+ raise TypeError("fill_replay must be a FillReplayTape or pandas DataFrame")
2112
+ replay = run_fill_replay_kernel(
2113
+ tape=tape,
2114
+ fill_tape=fill_tape,
2115
+ account=self.config.account,
2116
+ contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
2117
+ )
2118
+ idx = replay.equity.index
2119
+ returns = replay.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
2120
+ metadata = {
2121
+ **replay.metadata,
2122
+ "symbol": symbol,
2123
+ "phase": "31C_fill_replay_kernel",
2124
+ "fills_report": fill_replay.copy() if isinstance(fill_replay, pd.DataFrame) else pd.DataFrame(),
2125
+ }
2126
+ result = BacktestResultV2(
2127
+ equity=replay.equity,
2128
+ returns=returns,
2129
+ positions=pd.DataFrame({f"Position_{symbol}": replay.position.to_numpy(dtype=float)}, index=idx),
2130
+ closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
2131
+ symbols=[symbol],
2132
+ initial_capital=float(self.config.account.initial_capital),
2133
+ leverage=float(self.config.account.leverage),
2134
+ fees=replay.fees,
2135
+ diagnostics=pd.DataFrame({"event_flags": replay.event_flags, "fees": replay.fees}, index=idx),
2136
+ metadata=metadata,
2137
+ )
2138
+ self.engine = replay
2139
+ self._store_result(result)
2140
+ return self.result
2141
+
2142
+ def _prepare_intrabar_run(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps=None, funding_event_rates=None):
2143
+ symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
2144
+ if len(symbol_list) != 1:
2145
+ raise ValueError(f"{self.config.mode} currently supports exactly one symbol")
2146
+ symbol = symbol_list[0]
2147
+ tape = prepare_market_tape(
2148
+ data=data,
2149
+ datetime_index=datetime_index,
2150
+ symbols=symbol_list,
2151
+ funding_rate=self.config.funding_rate,
2152
+ funding_event_timestamps=funding_event_timestamps,
2153
+ funding_event_rates=funding_event_rates,
2154
+ use_funding=self.config.use_funding,
2155
+ validation_mode="strict",
2156
+ missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")),
2157
+ source_timezone=self.config.metadata.get("source_timezone"),
2158
+ bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
2159
+ )
2160
+ lookup_frame = None if isinstance(data, PreparedMarketTape) else _strict_lookup_frame(data, datetime_index, source_timezone=self.config.metadata.get("source_timezone"))
2161
+ if intent is None:
2162
+ level_mode = IntrabarLevelMode(str(self.config.metadata.get("intrabar_level_mode", IntrabarLevelMode.PERCENT_DISTANCE.value)))
2163
+ intent = _intrabar_intent_from_endpoint_input(
2164
+ frame=lookup_frame,
2165
+ index=pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)),
2166
+ signal=signal,
2167
+ signal_col=signal_col,
2168
+ intent_cols=intent_cols or {},
2169
+ level_mode=level_mode,
2170
+ )
2171
+ return tape, intent, symbol
2172
+
2173
+ def _intrabar_execution_kwargs(self, symbol: str) -> Dict:
2174
+ constraints = build_quantity_constraints(
2175
+ [symbol],
2176
+ instruments=self.config.instruments,
2177
+ qty_step=self.config.qty_step,
2178
+ lot_size=self.config.lot_size,
2179
+ slot_size=self.config.slot_size,
2180
+ min_qty=self.config.min_qty,
2181
+ min_notional=self.config.min_notional,
2182
+ )
2183
+ sizing_mode = IntrabarSizingMode(str(self.config.metadata.get("intrabar_sizing_mode", IntrabarSizingMode.UNITS.value)))
2184
+ fixed_notional = float(self.config.metadata.get("fixed_notional", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0)))
2185
+ equity_fraction = float(self.config.metadata.get("equity_fraction", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0)))
2186
+ risk_fraction = float(self.config.metadata.get("risk_fraction", 0.0))
2187
+ return {
2188
+ "sizing_mode": sizing_mode,
2189
+ "fixed_notional": fixed_notional,
2190
+ "equity_fraction": equity_fraction,
2191
+ "risk_fraction": risk_fraction,
2192
+ "qty_step": float(constraints.qty_step[0]),
2193
+ "min_qty": float(constraints.min_qty[0]),
2194
+ "min_notional": float(constraints.min_notional[0]),
2195
+ "tick_size": _tick_size_for_symbol(self.config.instruments, symbol, self.config.metadata.get("tick_size", 0.0)),
2196
+ }
2197
+
2198
+ def _run_single(self, data, signal, signal_col, datetime_index, symbols):
2199
+ frame, idx, sig = _normalize_single_data(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index)
2200
+ backend = _resolve_backend(self.config)
2201
+ symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
2202
+ if backend == "legacy":
2203
+ self.engine = BacktestEngine(
2204
+ Datetime=idx,
2205
+ Position=sig,
2206
+ Close=frame["close"],
2207
+ High=frame.get("high"),
2208
+ Low=frame.get("low"),
2209
+ fee=self.config.fee,
2210
+ use_pyramiding=self.config.use_pyramiding,
2211
+ initial_capital=self.config.account.initial_capital,
2212
+ leverage=self.config.account.leverage,
2213
+ maintenance_ratio=self.config.account.maintenance_ratio,
2214
+ contract_size=self.config.contract_size,
2215
+ use_funding_rate=self.config.use_funding,
2216
+ funding_rate=self.config.funding_rate,
2217
+ alloc_per_trade=self.config.alloc_per_trade,
2218
+ hedge_type=self.config.sizing,
2219
+ slippage=self.config.slippage,
2220
+ symbols=None,
2221
+ instruments=self.config.instruments,
2222
+ qty_step=self.config.qty_step,
2223
+ lot_size=self.config.lot_size,
2224
+ slot_size=self.config.slot_size,
2225
+ min_qty=self.config.min_qty,
2226
+ min_notional=self.config.min_notional,
2227
+ **self.config.dca_kwargs,
2228
+ )
2229
+ self._store_result(self.engine.result)
2230
+ return self.result
2231
+
2232
+ self.engine = BacktestEngineV2(
2233
+ data=frame,
2234
+ signals=sig,
2235
+ symbols=symbol_list,
2236
+ backend=backend,
2237
+ native_backend=self.config.native_backend,
2238
+ account=self.config.account,
2239
+ execution=self.config.execution,
2240
+ fee_rate=self.config.v2_fee_rate,
2241
+ use_funding=self.config.use_funding,
2242
+ funding_rate=self.config.funding_rate,
2243
+ alloc_per_trade=self.config.alloc_per_trade,
2244
+ hedge_type=self.config.sizing,
2245
+ use_pyramiding=self.config.use_pyramiding,
2246
+ contract_size=self.config.contract_size,
2247
+ nautilus_config=self.config.nautilus_config,
2248
+ instruments=self.config.instruments,
2249
+ qty_step=self.config.qty_step,
2250
+ lot_size=self.config.lot_size,
2251
+ slot_size=self.config.slot_size,
2252
+ min_qty=self.config.min_qty,
2253
+ min_notional=self.config.min_notional,
2254
+ report_level=self.config.report_level,
2255
+ audit_sink=self.config.audit_sink,
2256
+ audit_sink_path=self.config.audit_sink_path,
2257
+ reactive_kernel_mode=self.config.reactive_kernel_mode,
2258
+ )
2259
+ markers = _intrabar_marker_columns(frame)
2260
+ if backend == "native_vectorized" and markers:
2261
+ warnings.warn(
2262
+ "native_vectorized is close_target_v2 and does not certify intrabar SL/TP/trailing columns "
2263
+ f"{markers}; use a future intrabar/fill-replay/event backend for those semantics.",
2264
+ RuntimeWarning,
2265
+ stacklevel=2,
2266
+ )
2267
+ self.engine.result.metadata["intrabar_misuse_markers"] = markers
2268
+ self.engine.result.metadata["certification_status"] = "uncertified_intrabar_columns_on_close_target"
2269
+ self._store_result(self.engine.result)
2270
+ return self.result
2271
+
2272
+ def _run_orders(self, data, orders, order_commands, datetime_index, symbols):
2273
+ if not orders and not order_commands:
2274
+ raise ValueError("orders endpoint requires orders=[OrderIntent(...)] or order_commands=[OrderCommand(...)]")
2275
+ frame, idx, _ = _normalize_single_data(data=data, signal=pd.Series(0.0, index=_infer_index(data, datetime_index)), signal_col=None, datetime_index=datetime_index)
2276
+ backend = _resolve_backend(self.config)
2277
+ event_version = str(self.config.event_engine_version).lower().strip()
2278
+ if order_commands is not None:
2279
+ event_version = "v2"
2280
+ self.engine = BacktestEngineV2(
2281
+ data=frame,
2282
+ symbols=list(symbols or self.config.symbols or ["asset"]),
2283
+ backend=backend,
2284
+ native_backend=self.config.native_backend,
2285
+ orders=orders,
2286
+ order_commands=order_commands,
2287
+ event_engine_version=event_version,
2288
+ account=self.config.account,
2289
+ execution=self.config.execution,
2290
+ fee_rate=self.config.v2_fee_rate,
2291
+ use_funding=self.config.use_funding,
2292
+ funding_rate=self.config.funding_rate,
2293
+ contract_size=self.config.contract_size,
2294
+ instruments=self.config.instruments,
2295
+ qty_step=self.config.qty_step,
2296
+ lot_size=self.config.lot_size,
2297
+ slot_size=self.config.slot_size,
2298
+ min_qty=self.config.min_qty,
2299
+ min_notional=self.config.min_notional,
2300
+ report_level=self.config.report_level,
2301
+ audit_sink=self.config.audit_sink,
2302
+ audit_sink_path=self.config.audit_sink_path,
2303
+ reactive_kernel_mode=self.config.reactive_kernel_mode,
2304
+ )
2305
+ self._store_result(self.engine.result)
2306
+ return self.result
2307
+
2308
+ def _run_native_event_strategy(self, data, strategy, datetime_index, symbols):
2309
+ if strategy is None:
2310
+ raise ValueError("native_event_strategy endpoint requires strategy=...")
2311
+ frame, idx, _ = _normalize_single_data(
2312
+ data=data,
2313
+ signal=pd.Series(0.0, index=_infer_index(data, datetime_index)),
2314
+ signal_col=None,
2315
+ datetime_index=datetime_index,
2316
+ )
2317
+ symbol_list = list(symbols or self.config.symbols or ["asset"])
2318
+ self.engine = BacktestEngineV2(
2319
+ data=frame,
2320
+ symbols=symbol_list,
2321
+ backend="native_event",
2322
+ native_backend=self.config.native_backend,
2323
+ strategy=strategy,
2324
+ event_engine_version="v2",
2325
+ reactive_execution_mode=self.config.reactive_execution_mode,
2326
+ account=self.config.account,
2327
+ execution=self.config.execution,
2328
+ fee_rate=self.config.v2_fee_rate,
2329
+ use_funding=self.config.use_funding,
2330
+ funding_rate=self.config.funding_rate,
2331
+ contract_size=self.config.contract_size,
2332
+ instruments=self.config.instruments,
2333
+ qty_step=self.config.qty_step,
2334
+ lot_size=self.config.lot_size,
2335
+ slot_size=self.config.slot_size,
2336
+ min_qty=self.config.min_qty,
2337
+ min_notional=self.config.min_notional,
2338
+ report_level=self.config.report_level,
2339
+ audit_sink=self.config.audit_sink,
2340
+ audit_sink_path=self.config.audit_sink_path,
2341
+ reactive_kernel_mode=self.config.reactive_kernel_mode,
2342
+ )
2343
+ self._store_result(self.engine.result)
2344
+ return self.result
2345
+
2346
+ def _run_structured_orders(self, data, datetime_index, symbols):
2347
+ spec = self.config.structured_order_spec
2348
+ if spec is None:
2349
+ raise ValueError(f"{self.config.mode} endpoint requires a structured order spec")
2350
+ frame = _standardize_frame(data, datetime_index=datetime_index)
2351
+ symbol_list = list(symbols or self.config.symbols or [spec.symbol])
2352
+ if spec.symbol not in symbol_list:
2353
+ symbol_list = [spec.symbol]
2354
+ if isinstance(spec, DcaGridSpec):
2355
+ plan = build_dca_grid_order_plan(spec, close=frame["close"])
2356
+ elif isinstance(spec, BracketOrderSpec):
2357
+ plan = build_bracket_order_plan(spec)
2358
+ else:
2359
+ raise TypeError(f"unsupported structured_order_spec={type(spec).__name__}")
2360
+
2361
+ params = {
2362
+ "input_mode": plan.package_type,
2363
+ "structured_order_plan": plan,
2364
+ "structured_order_table": plan.order_table,
2365
+ "package_id": plan.package_id,
2366
+ "package_type": plan.package_type,
2367
+ "package_metadata": plan.metadata,
2368
+ "order_count_input": len(plan.orders),
2369
+ }
2370
+ backend = _resolve_backend(self.config)
2371
+ if backend == "native_event":
2372
+ commands = order_intents_to_lifecycle_commands(plan.orders)
2373
+ self.engine = BacktestEngineV2(
2374
+ data=frame,
2375
+ symbols=[spec.symbol],
2376
+ backend="native_event",
2377
+ native_backend=self.config.native_backend,
2378
+ order_commands=commands,
2379
+ event_engine_version="v2",
2380
+ account=self.config.account,
2381
+ execution=self.config.execution,
2382
+ fee_rate=self.config.v2_fee_rate,
2383
+ use_funding=self.config.use_funding,
2384
+ funding_rate=self.config.funding_rate,
2385
+ contract_size=self.config.contract_size,
2386
+ instruments=self.config.instruments,
2387
+ qty_step=self.config.qty_step,
2388
+ lot_size=self.config.lot_size,
2389
+ slot_size=self.config.slot_size,
2390
+ min_qty=self.config.min_qty,
2391
+ min_notional=self.config.min_notional,
2392
+ report_level=self.config.report_level,
2393
+ audit_sink=self.config.audit_sink,
2394
+ audit_sink_path=self.config.audit_sink_path,
2395
+ )
2396
+ result = self.engine.result
2397
+ result.metadata.update(
2398
+ {
2399
+ **params,
2400
+ "engine": f"event_v2_{plan.package_type}",
2401
+ "lifecycle_command_count": len(commands),
2402
+ "lifecycle_commands": commands,
2403
+ }
2404
+ )
2405
+ elif backend == "nautilus":
2406
+ result = self._run_nautilus_package_orders(
2407
+ data={spec.symbol: frame},
2408
+ orders=plan.orders,
2409
+ symbols=[spec.symbol],
2410
+ params=params,
2411
+ )
2412
+ result.metadata["engine"] = f"nautilus_{plan.package_type}"
2413
+ else:
2414
+ raise ValueError(f"structured order endpoints require backend='native_event' or 'nautilus', got {backend!r}")
2415
+ self._store_result(result)
2416
+ return self.result
2417
+
2418
+ def _run_basket(self, data, signal, signal_col, basket, closes, highs, lows, datetime_index, symbols):
2419
+ spec = basket or self.config.basket
2420
+ if spec is None:
2421
+ raise ValueError("basket endpoint requires a BasketSpec")
2422
+ sig = signal if signal is not None else _signal_from_data(data, signal_col)
2423
+ if sig is None:
2424
+ raise ValueError("basket endpoint requires signal or signal_col")
2425
+ close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
2426
+ data=data,
2427
+ closes=closes,
2428
+ highs=highs,
2429
+ lows=lows,
2430
+ datetime_index=datetime_index,
2431
+ symbols=symbols,
2432
+ )
2433
+ backend = _resolve_backend(self.config)
2434
+ if backend == "nautilus":
2435
+ plan = build_frozen_basket_orders(
2436
+ datetime_index=idx,
2437
+ basket=spec,
2438
+ signal=sig,
2439
+ closes=close_map,
2440
+ order_type=OrderType.MARKET,
2441
+ tif=TimeInForce.IOC,
2442
+ )
2443
+ result = self._run_nautilus_package_orders(
2444
+ data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list),
2445
+ orders=plan.orders,
2446
+ symbols=symbol_list,
2447
+ params={
2448
+ "input_mode": "basket_package",
2449
+ "basket_id": spec.basket_id,
2450
+ "basket_plan": plan,
2451
+ "basket_target_units": plan.target_units,
2452
+ "basket_execution_policy": spec.execution_policy.value,
2453
+ "package_target_units": plan.target_units,
2454
+ "order_count_input": len(plan.orders),
2455
+ },
2456
+ )
2457
+ result.metadata["engine"] = "nautilus_basket_package"
2458
+ self._store_result(result)
2459
+ return self.result
2460
+
2461
+ self.engine = BacktestEngineV2(
2462
+ backend="native_event",
2463
+ native_backend=self.config.native_backend,
2464
+ basket=spec,
2465
+ signal=sig,
2466
+ closes=close_map,
2467
+ highs=high_map,
2468
+ lows=low_map,
2469
+ datetime_index=idx,
2470
+ symbols=symbol_list,
2471
+ account=self.config.account,
2472
+ execution=self.config.execution,
2473
+ fee_rate=self.config.v2_fee_rate,
2474
+ use_funding=self.config.use_funding,
2475
+ funding_rate=self.config.funding_rate,
2476
+ contract_size=self.config.contract_size,
2477
+ )
2478
+ self._store_result(self.engine.result)
2479
+ return self.result
2480
+
2481
+ def _run_arbitrage(self, data, signal, signal_col, closes, highs, lows, hedge_ratios, datetime_index, symbols):
2482
+ spec = self.config.arbitrage_spec
2483
+ if spec is None:
2484
+ raise ValueError("arbitrage endpoint requires an arbitrage spec")
2485
+ phase_g_package_specs = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec)
2486
+ schema_only_specs = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec)
2487
+ if isinstance(spec, schema_only_specs):
2488
+ if isinstance(spec, OptionsVolArbSpec):
2489
+ raise NotImplementedError(
2490
+ "OptionsVolArbSpec must route through QuantBTEndpoint.options(...), not generic arbitrage execution. "
2491
+ "The option route preserves package fills, multi-currency ledger, Greeks, settlement, and margin reports."
2492
+ )
2493
+ raise NotImplementedError(
2494
+ f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; "
2495
+ "do not route it through generic package execution"
2496
+ )
2497
+ if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec, *phase_g_package_specs)):
2498
+ raise NotImplementedError(
2499
+ "Arbitrage endpoint supports BasisArbitrageSpec, StatArbPairSpec, and package-style Phase G specs; "
2500
+ f"got {type(spec).__name__}"
2501
+ )
2502
+ backend = _resolve_backend(self.config)
2503
+ if backend not in ("native_event", "native_vectorized", "nautilus"):
2504
+ raise NotImplementedError("Phase F arbitrage endpoint supports backend='native_event', 'native_vectorized', or 'nautilus'")
2505
+ sig = signal if signal is not None else _signal_from_data(data, signal_col)
2506
+ if sig is None:
2507
+ raise ValueError("arbitrage endpoint requires signal or signal_col")
2508
+ spec_symbols = [leg.symbol for leg in spec.legs]
2509
+ close_map, high_map, low_map, idx, _ = _normalize_symbol_data(
2510
+ data=data,
2511
+ closes=closes,
2512
+ highs=highs,
2513
+ lows=lows,
2514
+ datetime_index=datetime_index,
2515
+ symbols=symbols or spec_symbols,
2516
+ )
2517
+ if backend == "nautilus":
2518
+ if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec)):
2519
+ raise NotImplementedError("Phase F Nautilus arbitrage supports BasisArbitrageSpec and StatArbPairSpec only")
2520
+ result = self._run_nautilus_arbitrage(
2521
+ spec=spec,
2522
+ signal=sig,
2523
+ close_map=close_map,
2524
+ high_map=high_map,
2525
+ low_map=low_map,
2526
+ idx=idx,
2527
+ hedge_ratios=hedge_ratios,
2528
+ )
2529
+ self._store_result(result)
2530
+ return self.result
2531
+
2532
+ if backend == "native_event":
2533
+ self.engine = NativeEventBackend(
2534
+ NativeEventConfig(
2535
+ account=self.config.account,
2536
+ execution=self.config.execution,
2537
+ fee_rate=self.config.v2_fee_rate,
2538
+ use_funding=self.config.use_funding,
2539
+ report_level=self.config.report_level,
2540
+ audit_sink=self.config.audit_sink,
2541
+ audit_sink_path=self.config.audit_sink_path,
2542
+ native_backend=self.config.native_backend,
2543
+ )
2544
+ )
2545
+ else:
2546
+ self.engine = NativeVectorizedBackend(
2547
+ NativeVectorizedConfig(
2548
+ account=self.config.account,
2549
+ execution=self.config.execution,
2550
+ fee_rate=self.config.v2_fee_rate,
2551
+ use_funding=self.config.use_funding,
2552
+ )
2553
+ )
2554
+ if isinstance(spec, BasisArbitrageSpec):
2555
+ result = self.engine.run_basis_arbitrage(
2556
+ datetime_index=idx,
2557
+ spec=spec,
2558
+ signal=sig,
2559
+ closes=close_map,
2560
+ highs=high_map,
2561
+ lows=low_map,
2562
+ funding_rate=self.config.funding_rate,
2563
+ contract_size=self.config.contract_size,
2564
+ leverage=self.config.account.leverage,
2565
+ hedge_ratios=hedge_ratios,
2566
+ )
2567
+ elif isinstance(spec, StatArbPairSpec):
2568
+ result = self.engine.run_stat_arb_pair_arbitrage(
2569
+ datetime_index=idx,
2570
+ spec=spec,
2571
+ signal=sig,
2572
+ closes=close_map,
2573
+ highs=high_map,
2574
+ lows=low_map,
2575
+ funding_rate=self.config.funding_rate,
2576
+ contract_size=self.config.contract_size,
2577
+ leverage=self.config.account.leverage,
2578
+ hedge_ratios=hedge_ratios,
2579
+ )
2580
+ else:
2581
+ result = self.engine.run_package_arbitrage(
2582
+ datetime_index=idx,
2583
+ spec=spec,
2584
+ signal=sig,
2585
+ closes=close_map,
2586
+ highs=high_map,
2587
+ lows=low_map,
2588
+ funding_rate=self.config.funding_rate,
2589
+ contract_size=self.config.contract_size,
2590
+ leverage=self.config.account.leverage,
2591
+ hedge_ratios=hedge_ratios,
2592
+ )
2593
+ self._store_result(result)
2594
+ return self.result
2595
+
2596
+ def _run_walk_forward(
2597
+ self,
2598
+ data,
2599
+ signal,
2600
+ signal_col,
2601
+ positions,
2602
+ closes,
2603
+ highs,
2604
+ lows,
2605
+ hedge_ratios,
2606
+ datetime_index,
2607
+ symbols,
2608
+ params,
2609
+ param_ranges,
2610
+ ):
2611
+ if self.config.strategy_class is None:
2612
+ raise ValueError("walk_forward endpoint requires strategy_class")
2613
+ from .walkforward import WalkForwardConfig, WalkForwardEngine
2614
+
2615
+ wf_config = self.config.walkforward_config or WalkForwardConfig(target_mode=self.config.walkforward_target_mode)
2616
+ target_mode = self.config.walkforward_target_mode.lower().strip()
2617
+ scorer = (
2618
+ _make_walkforward_endpoint_scorer(
2619
+ self.config,
2620
+ target_mode=target_mode,
2621
+ symbols=symbols,
2622
+ wf_config=wf_config,
2623
+ market_data=data,
2624
+ market_closes=closes,
2625
+ market_highs=highs,
2626
+ market_lows=lows,
2627
+ market_datetime_index=datetime_index,
2628
+ )
2629
+ if wf_config.scoring_backend == "endpoint"
2630
+ else None
2631
+ )
2632
+ engine = WalkForwardEngine(strategy=self.config.strategy_class, config=wf_config, scorer=scorer)
2633
+ wf_result = engine.run(
2634
+ data=data if data is not None else closes,
2635
+ params=params,
2636
+ param_ranges=param_ranges,
2637
+ datetime_index=datetime_index,
2638
+ )
2639
+ stitched = wf_result.oos_output
2640
+ if stitched is None:
2641
+ raise ValueError("walk-forward strategy produced no OOS output")
2642
+
2643
+ if target_mode == "portfolio":
2644
+ if isinstance(stitched, pd.Series):
2645
+ raise TypeError("portfolio walk_forward target_mode requires DataFrame or {symbol: Series} output")
2646
+ result = self._run_portfolio(
2647
+ data=data,
2648
+ positions=stitched,
2649
+ closes=closes,
2650
+ highs=highs,
2651
+ lows=lows,
2652
+ datetime_index=datetime_index,
2653
+ symbols=symbols,
2654
+ )
2655
+ elif target_mode == "arbitrage":
2656
+ if not isinstance(stitched, pd.Series):
2657
+ raise TypeError("arbitrage walk_forward target_mode requires a scalar signal Series output")
2658
+ result = self._run_arbitrage(
2659
+ data=data,
2660
+ signal=stitched,
2661
+ signal_col=None,
2662
+ closes=closes,
2663
+ highs=highs,
2664
+ lows=lows,
2665
+ hedge_ratios=hedge_ratios,
2666
+ datetime_index=datetime_index,
2667
+ symbols=symbols,
2668
+ )
2669
+ elif target_mode == "basket":
2670
+ if not isinstance(stitched, pd.Series):
2671
+ raise TypeError("basket walk_forward target_mode requires a scalar signal Series output")
2672
+ result = self._run_basket(
2673
+ data=data,
2674
+ signal=stitched,
2675
+ signal_col=None,
2676
+ basket=self.config.basket,
2677
+ closes=closes,
2678
+ highs=highs,
2679
+ lows=lows,
2680
+ datetime_index=datetime_index,
2681
+ symbols=symbols,
2682
+ )
2683
+ else:
2684
+ if not isinstance(stitched, pd.Series):
2685
+ raise TypeError(f"{target_mode} walk_forward target_mode requires a scalar signal Series output")
2686
+ result = self._run_single(
2687
+ data=data,
2688
+ signal=stitched,
2689
+ signal_col=None,
2690
+ datetime_index=datetime_index,
2691
+ symbols=symbols,
2692
+ )
2693
+
2694
+ wf_result.backtest_result = result
2695
+ result.metadata["walk_forward"] = {
2696
+ "engine": wf_result.metadata["engine"],
2697
+ "target_mode": target_mode,
2698
+ "n_folds": wf_result.metadata["n_folds"],
2699
+ "report_scope": "test" if wf_result.metadata.get("split_frequency") == "single" else "oos",
2700
+ "split_frequency": wf_result.metadata.get("split_frequency"),
2701
+ "window_mode": wf_result.metadata.get("window_mode"),
2702
+ "params": wf_result.params,
2703
+ "fold_table": wf_result.fold_table,
2704
+ "trial_table": wf_result.trial_table,
2705
+ "candidate_table": wf_result.candidate_table,
2706
+ "best_trial": wf_result.best_trial,
2707
+ "optimization_mode": wf_result.metadata.get("optimization_mode"),
2708
+ "validation_claim": wf_result.metadata.get("validation_claim"),
2709
+ "full_sample_used_for_selection": wf_result.metadata.get("full_sample_used_for_selection"),
2710
+ "oos_used_for_selection": wf_result.metadata.get("oos_used_for_selection"),
2711
+ "data_hash": wf_result.metadata.get("data_hash"),
2712
+ "config_hash": wf_result.metadata.get("config_hash"),
2713
+ "random_seed": wf_result.metadata.get("random_seed"),
2714
+ "top_is_fraction": wf_result.metadata.get("top_is_fraction"),
2715
+ "top_is_k": wf_result.metadata.get("top_is_k"),
2716
+ "candidate_selection_metric": wf_result.metadata.get("candidate_selection_metric"),
2717
+ "scoring_trading_days": wf_result.metadata.get("scoring_trading_days"),
2718
+ "min_trades_per_year": wf_result.metadata.get("min_trades_per_year"),
2719
+ "trade_penalty_factor": wf_result.metadata.get("trade_penalty_factor"),
2720
+ "sbb_simulation": wf_result.metadata.get("sbb_simulation"),
2721
+ "sbb_samples": wf_result.metadata.get("sbb_samples"),
2722
+ "sbb_block_length": wf_result.metadata.get("sbb_block_length"),
2723
+ "regime_count": wf_result.metadata.get("regime_count"),
2724
+ "regime_lookback": wf_result.metadata.get("regime_lookback"),
2725
+ "regime_weights": wf_result.metadata.get("regime_weights"),
2726
+ "stress_vol_multiplier": wf_result.metadata.get("stress_vol_multiplier"),
2727
+ "garch_p": wf_result.metadata.get("garch_p"),
2728
+ "garch_q": wf_result.metadata.get("garch_q"),
2729
+ "garch_dist": wf_result.metadata.get("garch_dist"),
2730
+ "garch_vol_multiplier": wf_result.metadata.get("garch_vol_multiplier"),
2731
+ "plateau_quantile": wf_result.metadata.get("plateau_quantile"),
2732
+ "plateau_median_weight": wf_result.metadata.get("plateau_median_weight"),
2733
+ "plateau_std_penalty": wf_result.metadata.get("plateau_std_penalty"),
2734
+ "plateau_size_bonus": wf_result.metadata.get("plateau_size_bonus"),
2735
+ "is_subperiods": wf_result.metadata.get("is_subperiods"),
2736
+ "q25_weight": wf_result.metadata.get("q25_weight"),
2737
+ "dispersion_penalty": wf_result.metadata.get("dispersion_penalty"),
2738
+ "temporal_weight": wf_result.metadata.get("temporal_weight"),
2739
+ "plateau_weight": wf_result.metadata.get("plateau_weight"),
2740
+ "use_bootstrap_penalty": wf_result.metadata.get("use_bootstrap_penalty"),
2741
+ "use_complexity_penalty": wf_result.metadata.get("use_complexity_penalty"),
2742
+ "scoring_backend": wf_result.metadata.get("scoring_backend"),
2743
+ "numba_enabled": wf_result.metadata.get("numba_enabled"),
2744
+ }
2745
+ if scorer is not None and hasattr(scorer, "prepared_cache_metadata"):
2746
+ result.metadata["walk_forward"]["prepared_scoring_cache"] = scorer.prepared_cache_metadata()
2747
+ result.metadata["walk_forward_result"] = wf_result
2748
+ self.engine = engine
2749
+ self.result = result
2750
+ return result
2751
+
2752
+ def _run_nautilus_arbitrage(self, spec, signal, close_map, high_map, low_map, idx, hedge_ratios):
2753
+ from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
2754
+
2755
+ symbols = [leg.symbol for leg in spec.legs]
2756
+ if isinstance(spec, BasisArbitrageSpec):
2757
+ plan = build_arbitrage_order_plan(
2758
+ datetime_index=idx,
2759
+ spec=spec,
2760
+ signal=signal,
2761
+ closes=close_map,
2762
+ hedge_ratios=hedge_ratios,
2763
+ )
2764
+ extra_metadata = {
2765
+ "spread_report": NativeVectorizedBackend(
2766
+ NativeVectorizedConfig(account=self.config.account, execution=self.config.execution)
2767
+ )._basis_spread_report(idx, spec, close_map, plan.target_units),
2768
+ "package_rejection_report": plan.rejection_report,
2769
+ }
2770
+ elif isinstance(spec, StatArbPairSpec):
2771
+ basket = BasketSpec(
2772
+ basket_id=spec.arb_id,
2773
+ legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs),
2774
+ gross_notional=float(spec.sizing_policy.notional),
2775
+ freeze_hedge=bool(spec.hedge_policy.freeze_on_entry),
2776
+ hedged_margin_offset=float(spec.margin_model.hedged_margin_offset),
2777
+ )
2778
+ rebalance_threshold = spec.hedge_policy.rebalance_threshold
2779
+ if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None:
2780
+ rebalance_threshold = 0.0
2781
+ plan = build_frozen_basket_orders(
2782
+ datetime_index=idx,
2783
+ basket=basket,
2784
+ signal=signal,
2785
+ closes=close_map,
2786
+ hedge_ratios=hedge_ratios,
2787
+ order_type=OrderType.MARKET,
2788
+ tif=TimeInForce.IOC,
2789
+ rebalance_threshold=rebalance_threshold,
2790
+ )
2791
+ extra_metadata = {
2792
+ "beta_drift_report": NativeVectorizedBackend(
2793
+ NativeVectorizedConfig(account=self.config.account, execution=self.config.execution)
2794
+ )._stat_arb_beta_drift_report(idx, spec, plan, rebalance_threshold),
2795
+ "rebalance_threshold": rebalance_threshold,
2796
+ }
2797
+ else:
2798
+ raise NotImplementedError(f"Nautilus arbitrage does not support {type(spec).__name__}")
2799
+
2800
+ data = _frames_from_symbol_maps(close_map, high_map, low_map, symbols)
2801
+ config = self.config.nautilus_config
2802
+ if config is None:
2803
+ config = NautilusBackendConfig(
2804
+ timeframe=str(self.config.metadata.get("timeframe", "1h")),
2805
+ starting_balance=self.config.account.initial_capital,
2806
+ trade_notional=0.0,
2807
+ sizing_mode="notional",
2808
+ )
2809
+ else:
2810
+ config = replace(
2811
+ config,
2812
+ starting_balance=self.config.account.initial_capital,
2813
+ trade_notional=0.0,
2814
+ sizing_mode="notional",
2815
+ )
2816
+ self.engine = NautilusBacktestEngine(config)
2817
+ result = self.engine.run_order_packages(
2818
+ data=data,
2819
+ orders=plan.orders,
2820
+ symbols=symbols,
2821
+ params={
2822
+ "arb_id": spec.arb_id,
2823
+ "arb_type": spec.arb_type.value,
2824
+ "arbitrage_plan": plan,
2825
+ "package_target_units": plan.target_units,
2826
+ **extra_metadata,
2827
+ },
2828
+ )
2829
+ result.metadata["engine"] = "nautilus_arbitrage_package"
2830
+ return result
2831
+
2832
+ def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, symbols):
2833
+ pos_map = _positions_to_map(positions)
2834
+ if not pos_map:
2835
+ raise ValueError("portfolio endpoint requires positions DataFrame or mapping")
2836
+ close_map, high_map, low_map, idx, _ = _normalize_symbol_data(
2837
+ data=data,
2838
+ closes=closes,
2839
+ highs=highs,
2840
+ lows=lows,
2841
+ datetime_index=datetime_index,
2842
+ symbols=symbols or list(pos_map.keys()),
2843
+ )
2844
+ if (
2845
+ self.config.mode.lower().strip() == "walk_forward"
2846
+ and self.config.walkforward_target_mode.lower().strip() == "portfolio"
2847
+ and self.config.backend.lower().strip() not in {"legacy_portfolio", "nautilus"}
2848
+ ):
2849
+ backend = "native_portfolio"
2850
+ else:
2851
+ backend = _resolve_backend(self.config)
2852
+ if backend == "nautilus":
2853
+ symbol_list = list(symbols or pos_map.keys())
2854
+ native_reference = PortfolioBacktestEngine(
2855
+ positions=pos_map,
2856
+ closes=close_map,
2857
+ highs=high_map,
2858
+ lows=low_map,
2859
+ datetime_index=idx,
2860
+ mode=self.config.portfolio_mode,
2861
+ backend="native_portfolio",
2862
+ account=self.config.account,
2863
+ execution=self.config.execution,
2864
+ fee_rate=self.config.canonical_one_way_fee_rate,
2865
+ alloc_per_trade=self.config.alloc_per_trade,
2866
+ contract_size=self.config.contract_size,
2867
+ hedge_type=self.config.sizing if self.config.sizing else "signal_notional",
2868
+ asset_type=self.config.asset_type,
2869
+ use_funding=self.config.use_funding,
2870
+ funding_rate=self.config.funding_rate,
2871
+ leverage=self.config.account.leverage,
2872
+ maintenance_ratio=self.config.account.maintenance_ratio,
2873
+ use_pyramiding=self.config.use_pyramiding,
2874
+ betas=self.config.betas,
2875
+ risk_lookback=self.config.risk_lookback,
2876
+ report_level=self.config.report_level,
2877
+ ).result
2878
+ target_units = native_reference.metadata["target_units_report"].reindex(columns=symbol_list)
2879
+ orders = _build_portfolio_orders_from_target_units_for_nautilus(
2880
+ target_units=target_units,
2881
+ symbols=symbol_list,
2882
+ tag=f"portfolio:{self.config.sizing if self.config.sizing else 'signal_notional'}",
2883
+ )
2884
+ result = self._run_nautilus_package_orders(
2885
+ data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list),
2886
+ orders=orders,
2887
+ symbols=symbol_list,
2888
+ params={
2889
+ "input_mode": "portfolio_matrix",
2890
+ "portfolio_mode": self.config.portfolio_mode,
2891
+ "portfolio_target_units": target_units,
2892
+ "package_target_units": target_units,
2893
+ "order_count_input": len(orders),
2894
+ },
2895
+ )
2896
+ result.metadata["engine"] = "nautilus_portfolio_matrix"
2897
+ result.metadata["native_portfolio_reference_final_equity"] = float(native_reference.equity.iloc[-1])
2898
+ from .reporting import build_portfolio_nautilus_validation_report
2899
+
2900
+ result.metadata["portfolio_nautilus_validation_report"] = build_portfolio_nautilus_validation_report(
2901
+ native_reference,
2902
+ result,
2903
+ equity_tolerance=float(self.config.metadata.get("portfolio_nautilus_equity_tolerance", 1e-6)),
2904
+ position_tolerance=float(self.config.metadata.get("portfolio_nautilus_position_tolerance", 1e-6)),
2905
+ )
2906
+ self._store_result(result)
2907
+ return self.result
2908
+
2909
+ self.engine = PortfolioBacktestEngine(
2910
+ positions=pos_map,
2911
+ closes=close_map,
2912
+ highs=high_map,
2913
+ lows=low_map,
2914
+ datetime_index=idx,
2915
+ mode=self.config.portfolio_mode,
2916
+ backend=backend,
2917
+ account=self.config.account,
2918
+ execution=self.config.execution,
2919
+ fee_rate=self.config.canonical_one_way_fee_rate,
2920
+ alloc_per_trade=self.config.alloc_per_trade,
2921
+ contract_size=self.config.contract_size,
2922
+ hedge_type=self.config.sizing if self.config.sizing else "notional",
2923
+ asset_type=self.config.asset_type,
2924
+ use_funding=self.config.use_funding,
2925
+ funding_rate=self.config.funding_rate,
2926
+ leverage=self.config.account.leverage,
2927
+ maintenance_ratio=self.config.account.maintenance_ratio,
2928
+ use_pyramiding=self.config.use_pyramiding,
2929
+ betas=self.config.betas,
2930
+ risk_lookback=self.config.risk_lookback,
2931
+ instruments=self.config.instruments,
2932
+ qty_step=self.config.qty_step,
2933
+ lot_size=self.config.lot_size,
2934
+ slot_size=self.config.slot_size,
2935
+ min_qty=self.config.min_qty,
2936
+ min_notional=self.config.min_notional,
2937
+ report_level=self.config.report_level,
2938
+ )
2939
+ self._store_result(self.engine.result)
2940
+ return self.result
2941
+
2942
+ def _run_nautilus_package_orders(self, data, orders, symbols, params):
2943
+ from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
2944
+
2945
+ run_params = dict(params or {})
2946
+ run_orders = _annotate_orders_for_depth(orders, run_params)
2947
+ depth_result = None
2948
+ if self.config.nautilus_depth_config is not None:
2949
+ depth_result = simulate_nautilus_order_package_depth(
2950
+ orders=run_orders,
2951
+ data=data,
2952
+ config=self.config.nautilus_depth_config,
2953
+ )
2954
+ run_orders = depth_result.orders
2955
+ run_params.update(
2956
+ {
2957
+ "nautilus_depth_enabled": True,
2958
+ "nautilus_depth_order_report": depth_result.order_report,
2959
+ "nautilus_depth_package_report": depth_result.package_report,
2960
+ "nautilus_depth_metadata": depth_result.metadata,
2961
+ "order_count_before_depth": len(orders),
2962
+ "order_count_after_depth": len(run_orders),
2963
+ }
2964
+ )
2965
+ else:
2966
+ run_params.setdefault("nautilus_depth_enabled", False)
2967
+
2968
+ config = self.config.nautilus_config
2969
+ if config is None:
2970
+ config = NautilusBackendConfig(
2971
+ instrument_id=symbols[0],
2972
+ timeframe=str(self.config.metadata.get("timeframe", "1h")),
2973
+ starting_balance=self.config.account.initial_capital,
2974
+ trade_notional=0.0,
2975
+ sizing_mode="notional",
2976
+ )
2977
+ else:
2978
+ config = replace(
2979
+ config,
2980
+ starting_balance=self.config.account.initial_capital,
2981
+ trade_notional=0.0,
2982
+ sizing_mode="notional",
2983
+ )
2984
+ self.engine = NautilusBacktestEngine(config)
2985
+ if not run_orders:
2986
+ return _empty_nautilus_preflight_result(
2987
+ data=data,
2988
+ symbols=symbols,
2989
+ account=self.config.account,
2990
+ metadata={
2991
+ "backend": "nautilus",
2992
+ "engine": "nautilus_package_orders_preflight_rejected",
2993
+ "input_mode": run_params.get("input_mode", "order_packages"),
2994
+ "orders_count": 0,
2995
+ "fills_count": 0,
2996
+ "positions_count": 0,
2997
+ **run_params,
2998
+ },
2999
+ )
3000
+ return self.engine.run_order_packages(
3001
+ data=data,
3002
+ orders=run_orders,
3003
+ symbols=symbols,
3004
+ params=run_params,
3005
+ )
3006
+
3007
+ def _store_result(self, result):
3008
+ _normalize_result_contract(result)
3009
+ _attach_endpoint_run_config(result, self.config)
3010
+ self.result = result
3011
+ return result
3012
+
3013
+ def _require_result(self):
3014
+ if self.result is None:
3015
+ raise RuntimeError("run backtest() or simulate() before requesting results")
3016
+ return self.result
3017
+
3018
+ def _result_for_report_scope(self, scope: str):
3019
+ from .core.scopes import scoped_result
3020
+
3021
+ return scoped_result(self._require_result(), scope=scope)
3022
+
3023
+
3024
+ def _normalize_result_contract(result) -> None:
3025
+ """
3026
+ Make common result artifacts safe to access across all endpoint backends.
3027
+
3028
+ Legacy and vectorized results do not naturally have fills/orders. Notebook
3029
+ integrations still benefit from stable empty artifacts instead of
3030
+ AttributeError/KeyError.
3031
+ """
3032
+ metadata = result.metadata
3033
+ if not hasattr(result, "orders"):
3034
+ setattr(result, "orders", ())
3035
+ if not hasattr(result, "fills"):
3036
+ setattr(result, "fills", ())
3037
+
3038
+ order_report = metadata.get("order_report")
3039
+ if order_report is None:
3040
+ order_report = metadata.get("orders_report")
3041
+ if order_report is None:
3042
+ order_report = pd.DataFrame()
3043
+ metadata["order_report"] = order_report
3044
+ if metadata.get("orders_report") is None:
3045
+ metadata["orders_report"] = order_report
3046
+
3047
+ if metadata.get("fills_report") is None:
3048
+ metadata["fills_report"] = _fills_to_frame(getattr(result, "fills", ()))
3049
+ if metadata.get("positions_report") is None:
3050
+ metadata["positions_report"] = pd.DataFrame()
3051
+ if "orders_count" not in metadata:
3052
+ metadata["orders_count"] = len(getattr(result, "orders", ()))
3053
+ if "fills_count" not in metadata:
3054
+ metadata["fills_count"] = len(getattr(result, "fills", ()))
3055
+ engine = str(metadata.get("engine", "unknown"))
3056
+ backend = str(metadata.get("backend", metadata.get("backend_alias", "unknown")))
3057
+ metadata.setdefault("backend_alias", backend)
3058
+ metadata.setdefault("engine_id", engine)
3059
+ metadata.setdefault("kernel_version", engine)
3060
+ metadata.setdefault(
3061
+ "execution_contract",
3062
+ {
3063
+ "engine_id": metadata["engine_id"],
3064
+ "signal_phase": metadata.get("signal_phase", "unspecified"),
3065
+ "fill_phase": metadata.get("fill_phase", "unspecified"),
3066
+ "intrabar_exit_model": metadata.get("intrabar_exit_model", "unspecified"),
3067
+ },
3068
+ )
3069
+
3070
+
3071
+ def _attach_endpoint_run_config(result, config: EndpointConfig) -> None:
3072
+ metadata = result.metadata
3073
+ payload = _endpoint_run_config_payload(config)
3074
+ _sync_applied_nautilus_config(payload, metadata)
3075
+ metadata["run_config"] = payload
3076
+ metadata.setdefault("initial_capital", payload["account"]["initial_capital"])
3077
+ metadata.setdefault("leverage", payload["account"]["leverage"])
3078
+ metadata.setdefault("maintenance_ratio", payload["account"]["maintenance_ratio"])
3079
+ metadata.setdefault("fee_rate", payload["fees"]["one_way_fee_rate"])
3080
+ metadata.setdefault("canonical_one_way_fee_rate", payload["fees"]["canonical_one_way_fee_rate"])
3081
+ metadata.setdefault("fee_round_trip", payload["fees"]["round_trip_fee"])
3082
+ metadata.setdefault("alloc_per_trade", payload["sizing"]["alloc_per_trade"])
3083
+ metadata.setdefault("slippage", payload["execution"]["legacy_slippage_rate"])
3084
+ metadata.setdefault("slippage_bps", payload["execution"]["slippage_bps"])
3085
+ metadata.setdefault("use_funding", payload["funding"]["use_funding"])
3086
+
3087
+
3088
+ def _sync_applied_nautilus_config(payload: Dict, metadata: Dict) -> None:
3089
+ """Keep report run_config aligned with the adapter config that actually ran."""
3090
+ if payload.get("backend") != "nautilus":
3091
+ return
3092
+ nautilus = dict(payload.get("nautilus") or {})
3093
+ if not nautilus:
3094
+ return
3095
+
3096
+ for source_key, target_key in (
3097
+ ("instrument_id", "instrument_id"),
3098
+ ("sizing_mode", "sizing_mode"),
3099
+ ("trade_notional", "trade_notional"),
3100
+ ("use_pyramiding", "use_pyramiding"),
3101
+ ("close_positions_on_stop", "close_positions_on_stop"),
3102
+ ):
3103
+ if metadata.get(source_key) is not None:
3104
+ nautilus[target_key] = _jsonable(metadata[source_key])
3105
+
3106
+ if metadata.get("timeframe") is not None:
3107
+ nautilus["timeframe"] = _jsonable(metadata["timeframe"])
3108
+ if metadata.get("initial_capital") is not None:
3109
+ nautilus["starting_balance"] = _jsonable(metadata["initial_capital"])
3110
+
3111
+ payload["nautilus"] = nautilus
3112
+
3113
+
3114
+ def _endpoint_run_config_payload(config: EndpointConfig) -> Dict:
3115
+ intrabar_mode = str(config.mode).lower().strip() in {"intrabar_bracket", "intrabar_bracket_reference", "fill_replay"}
3116
+ payload = {
3117
+ "mode": config.mode,
3118
+ "backend": config.backend,
3119
+ "portfolio_mode": config.portfolio_mode,
3120
+ "asset_type": config.asset_type,
3121
+ "account": _jsonable(asdict(config.account)),
3122
+ "execution": {
3123
+ **_jsonable(asdict(config.execution)),
3124
+ "legacy_slippage_rate": None if intrabar_mode else float(config.slippage),
3125
+ "slippage_bps": float(config.execution.slippage_bps),
3126
+ },
3127
+ "fees": {
3128
+ "round_trip_fee": float(config.fee),
3129
+ "one_way_fee_rate": float(config.canonical_one_way_fee_rate),
3130
+ "canonical_one_way_fee_rate": float(config.canonical_one_way_fee_rate),
3131
+ "explicit_fee_rate": None if config.fee_rate is None else float(config.fee_rate),
3132
+ "legacy_fee_converted": config.fee_rate is None,
3133
+ "applied_fee_source": "fee_rate" if config.fee_rate is not None else "legacy_fee",
3134
+ },
3135
+ "sizing": {
3136
+ "hedge_type": config.sizing,
3137
+ "alloc_per_trade": _jsonable(config.alloc_per_trade),
3138
+ "use_pyramiding": bool(config.use_pyramiding),
3139
+ "contract_size": _jsonable(config.contract_size),
3140
+ },
3141
+ "funding": {
3142
+ "use_funding": bool(config.use_funding),
3143
+ "funding_rate": _jsonable(config.funding_rate),
3144
+ },
3145
+ "dca_kwargs": _jsonable(config.dca_kwargs),
3146
+ "structured_order_spec": _jsonable(config.structured_order_spec),
3147
+ "symbols": _jsonable(config.symbols),
3148
+ "metadata": _jsonable(config.metadata),
3149
+ "report_level": config.report_level,
3150
+ "audit_sink": config.audit_sink,
3151
+ "audit_sink_path": config.audit_sink_path,
3152
+ }
3153
+ if config.nautilus_config is not None:
3154
+ payload["nautilus"] = _jsonable(
3155
+ asdict(config.nautilus_config) if is_dataclass(config.nautilus_config) else vars(config.nautilus_config)
3156
+ )
3157
+ return payload
3158
+
3159
+
3160
+ def _jsonable(value):
3161
+ if is_dataclass(value):
3162
+ return _jsonable(asdict(value))
3163
+ if isinstance(value, dict):
3164
+ return {str(k): _jsonable(v) for k, v in value.items()}
3165
+ if isinstance(value, (list, tuple)):
3166
+ return [_jsonable(v) for v in value]
3167
+ if isinstance(value, pd.Series):
3168
+ return {
3169
+ "type": "Series",
3170
+ "name": value.name,
3171
+ "rows": int(len(value)),
3172
+ "start": str(value.index[0]) if len(value) else None,
3173
+ "end": str(value.index[-1]) if len(value) else None,
3174
+ }
3175
+ if isinstance(value, pd.DataFrame):
3176
+ return {
3177
+ "type": "DataFrame",
3178
+ "rows": int(len(value)),
3179
+ "columns": list(value.columns),
3180
+ "start": str(value.index[0]) if len(value) else None,
3181
+ "end": str(value.index[-1]) if len(value) else None,
3182
+ }
3183
+ if hasattr(value, "value"):
3184
+ return value.value
3185
+ return value
3186
+
3187
+
3188
+ def _fills_to_frame(fills) -> pd.DataFrame:
3189
+ rows = []
3190
+ for fill in fills:
3191
+ row = getattr(fill, "__dict__", None)
3192
+ rows.append(dict(row) if row is not None else {"fill": fill})
3193
+ return pd.DataFrame(rows)
3194
+
3195
+
3196
+ def format_metrics_report(report: Dict) -> str:
3197
+ """
3198
+ Format a metrics dictionary as a legacy-style text report.
3199
+
3200
+ The returned string is intentionally plain monospaced text so notebooks,
3201
+ terminals, logs, and services all render the same high-signal report.
3202
+ """
3203
+ lines = [
3204
+ ("Initial Capital", _fmt_money(report.get("initial_capital"), decimals=0)),
3205
+ ("Final Equity", _fmt_money(report.get("final_equity"), decimals=2)),
3206
+ ("Total Return", _fmt_pct(report.get("total_return_pct"), signed=True, decimals=2)),
3207
+ ("CAGR", _fmt_pct(report.get("cagr_pct"), signed=True, decimals=2)),
3208
+ ("Sharpe Ratio", _fmt_float(report.get("sharpe"), decimals=3)),
3209
+ ("Sortino Ratio", _fmt_float(report.get("sortino"), decimals=3)),
3210
+ ("Calmar Ratio", _fmt_float(report.get("calmar"), decimals=3)),
3211
+ ("Omega Ratio", _fmt_float(report.get("omega"), decimals=3)),
3212
+ ("Max Drawdown", _fmt_pct(report.get("max_drawdown_pct"), signed=False, decimals=2)),
3213
+ ("Avg Drawdown", _fmt_pct(report.get("avg_drawdown_pct"), signed=False, decimals=2)),
3214
+ ("Max DD Duration", _fmt_days(report.get("max_dd_duration_days"))),
3215
+ ("Profit Factor", _fmt_float(report.get("profit_factor"), decimals=3)),
3216
+ ("Long Hit Rate", _fmt_pct(report.get("long_hitrate_pct"), signed=False, decimals=2)),
3217
+ ("Short Hit Rate", _fmt_pct(report.get("short_hitrate_pct"), signed=False, decimals=2)),
3218
+ ("Avg Win", _fmt_pct(report.get("avg_win_pct"), signed=True, decimals=3)),
3219
+ ("Avg Loss", _fmt_pct(report.get("avg_loss_pct"), signed=True, decimals=3)),
3220
+ ("Expectancy", _fmt_pct(report.get("expectancy_pct"), signed=True, decimals=3)),
3221
+ ("Number of Trades", _fmt_int(report.get("num_trades"))),
3222
+ ("Liquidated", f"{'Yes' if report.get('liquidated') else 'No':>14}"),
3223
+ ]
3224
+ col_width = max(len(key) for key, _ in lines)
3225
+ body = "\n".join(f" {key:<{col_width}} {value}" for key, value in lines)
3226
+ return f"\n{body}\n"
3227
+
3228
+
3229
+ def _fmt_money(value, decimals: int) -> str:
3230
+ if value is None or pd.isna(value):
3231
+ return f"{'n/a':>15}"
3232
+ return f"$ {float(value):>13,.{decimals}f}"
3233
+
3234
+
3235
+ def _fmt_pct(value, signed: bool, decimals: int) -> str:
3236
+ if value is None or pd.isna(value):
3237
+ return f"{'n/a':>15}"
3238
+ sign = "+" if signed else ""
3239
+ return f"{float(value):>{sign}13.{decimals}f}%"
3240
+
3241
+
3242
+ def _fmt_float(value, decimals: int) -> str:
3243
+ if value is None or pd.isna(value):
3244
+ return f"{'n/a':>14}"
3245
+ return f"{float(value):>14.{decimals}f}"
3246
+
3247
+
3248
+ def _fmt_days(value) -> str:
3249
+ if value is None or pd.isna(value):
3250
+ return f"{'n/a':>16}"
3251
+ return f"{int(value):>11d} days"
3252
+
3253
+
3254
+ def _fmt_int(value) -> str:
3255
+ if value is None or pd.isna(value):
3256
+ return f"{'n/a':>14}"
3257
+ return f"{int(value):>14,d}"
3258
+
3259
+
3260
+ def _config_from_kwargs(**kwargs) -> EndpointConfig:
3261
+ mode_name = str(kwargs.get("mode", "")).lower().strip()
3262
+ metadata = dict(kwargs.pop("metadata", {}) or {})
3263
+ if "tick_size" in kwargs:
3264
+ metadata.setdefault("tick_size", kwargs.pop("tick_size"))
3265
+ if "source_timezone" in kwargs:
3266
+ metadata.setdefault("source_timezone", kwargs.pop("source_timezone"))
3267
+ if "missing_funding_policy" in kwargs:
3268
+ metadata.setdefault("missing_funding_policy", kwargs.pop("missing_funding_policy"))
3269
+ if "bar_timestamp_semantics" in kwargs:
3270
+ metadata.setdefault("bar_timestamp_semantics", kwargs.pop("bar_timestamp_semantics"))
3271
+ hedge_type_alias = kwargs.pop("hedge_type", None)
3272
+ if hedge_type_alias is not None and "sizing" not in kwargs:
3273
+ kwargs["sizing"] = hedge_type_alias
3274
+
3275
+ initial_capital = kwargs.pop("initial_capital", None)
3276
+ leverage = kwargs.pop("leverage", None)
3277
+ maintenance_ratio = kwargs.pop("maintenance_ratio", None)
3278
+ account = kwargs.pop("account", None)
3279
+ if account is None:
3280
+ account = AccountConfig(
3281
+ initial_capital=100_000.0 if initial_capital is None else float(initial_capital),
3282
+ leverage=1.0 if leverage is None else float(leverage),
3283
+ maintenance_ratio=0.005 if maintenance_ratio is None else float(maintenance_ratio),
3284
+ )
3285
+
3286
+ legacy_slippage_supplied = "slippage" in kwargs
3287
+ legacy_slippage_value = kwargs.get("slippage")
3288
+ slippage_bps = kwargs.pop("slippage_bps", None)
3289
+ execution = kwargs.pop("execution", None)
3290
+ if slippage_bps is not None and execution is not None:
3291
+ raise ValueError("pass either execution=ExecutionConfig(...) or slippage_bps=..., not both")
3292
+ if slippage_bps is not None and legacy_slippage_supplied:
3293
+ raise ValueError("pass either slippage_bps or legacy slippage, not both")
3294
+ if execution is None:
3295
+ if slippage_bps is not None:
3296
+ execution = ExecutionConfig(slippage_bps=float(slippage_bps))
3297
+ elif mode_name in {"intrabar_bracket", "intrabar_bracket_reference", "portfolio"} and legacy_slippage_supplied:
3298
+ warnings.warn(
3299
+ "QuantBT native endpoints use slippage_bps as the source of truth; "
3300
+ "legacy slippage was converted to slippage_bps for compatibility.",
3301
+ DeprecationWarning,
3302
+ stacklevel=3,
3303
+ )
3304
+ execution = ExecutionConfig(slippage_bps=float(legacy_slippage_value) * 10_000.0)
3305
+ else:
3306
+ execution = ExecutionConfig(slippage_bps=0.0)
3307
+
3308
+ dca_kwargs = kwargs.pop("dca_kwargs", {})
3309
+ for key in (
3310
+ "dca_base_notional",
3311
+ "dca_safety_notional",
3312
+ "dca_step_pct",
3313
+ "dca_step_scale",
3314
+ "dca_volume_scale",
3315
+ "dca_max_safety_orders",
3316
+ "dca_take_profit_pct",
3317
+ "dca_allow_same_bar_exit",
3318
+ ):
3319
+ if key in kwargs:
3320
+ dca_kwargs[key] = kwargs.pop(key)
3321
+
3322
+ return EndpointConfig(account=account, execution=execution, dca_kwargs=dca_kwargs, metadata=metadata, **kwargs)
3323
+
3324
+
3325
+ def _pop_dataclass_kwargs(kwargs: Dict, dataclass_type) -> Dict:
3326
+ fields = getattr(dataclass_type, "__dataclass_fields__", {})
3327
+ out = {}
3328
+ for key in list(fields):
3329
+ if key == "metadata":
3330
+ continue
3331
+ if key in kwargs:
3332
+ out[key] = kwargs.pop(key)
3333
+ return out
3334
+
3335
+
3336
+ def _resolve_backend(config: EndpointConfig) -> str:
3337
+ backend = config.backend.lower().strip()
3338
+ if backend != "auto":
3339
+ if backend == "legacy_portfolio":
3340
+ return backend
3341
+ if backend not in {"legacy", "native_vectorized", "native_event", "native_portfolio", "native_option", "nautilus"}:
3342
+ raise ValueError(f"unsupported backend={config.backend!r}")
3343
+ return backend
3344
+ mode = config.mode.lower().strip()
3345
+ sizing = config.sizing.lower().strip()
3346
+ if mode == "portfolio":
3347
+ return "native_portfolio"
3348
+ if mode == "options":
3349
+ return "native_option"
3350
+ if mode in ("pct_equity", "dca_ladder") or sizing in ("%_equity", "pct_equity", "dca_ladder", "dca"):
3351
+ return "legacy"
3352
+ if mode == "nautilus_validation":
3353
+ return "nautilus"
3354
+ if mode in ("orders", "basket", "arbitrage"):
3355
+ return "native_event"
3356
+ return "native_vectorized"
3357
+
3358
+
3359
+ def _default_walkforward_scoring_backend(target_mode: str, optimization_mode: str) -> str:
3360
+ mode = str(target_mode).lower().strip()
3361
+ opt_mode = str(optimization_mode).lower().strip()
3362
+ if opt_mode == "mode_2_sbb":
3363
+ return "proxy"
3364
+ if mode in {"pct_equity", "%_equity", "signal_notional", "single_signal", "dca_ladder"}:
3365
+ return "endpoint"
3366
+ return "proxy"
3367
+
3368
+
3369
+ def _make_walkforward_endpoint_scorer(
3370
+ config: EndpointConfig,
3371
+ target_mode: str,
3372
+ symbols=None,
3373
+ wf_config: Optional[WalkForwardConfig] = None,
3374
+ market_data=None,
3375
+ market_closes=None,
3376
+ market_highs=None,
3377
+ market_lows=None,
3378
+ market_datetime_index=None,
3379
+ ):
3380
+ return _WalkForwardEndpointScorer(
3381
+ config=config,
3382
+ target_mode=target_mode,
3383
+ symbols=symbols,
3384
+ wf_config=wf_config,
3385
+ market_data=market_data,
3386
+ market_closes=market_closes,
3387
+ market_highs=market_highs,
3388
+ market_lows=market_lows,
3389
+ market_datetime_index=market_datetime_index,
3390
+ )
3391
+
3392
+
3393
+ @dataclass
3394
+ class QuantBTPreparedContext:
3395
+ """
3396
+ Run-local prepared market context for repeated endpoint replays.
3397
+
3398
+ The context stores copied prepared market arrays and validates datetime /
3399
+ symbol signatures inside the backend on every replay. It is intentionally
3400
+ caller-owned and never a mutable global cache.
3401
+ """
3402
+
3403
+ endpoint: QuantBTEndpoint
3404
+ mode: str
3405
+ idx: pd.DatetimeIndex
3406
+ symbols: list
3407
+ close_map: SeriesMap
3408
+ high_map: SeriesMap
3409
+ low_map: SeriesMap
3410
+ market_arrays: object
3411
+ backend: object
3412
+ frame: Optional[pd.DataFrame] = None
3413
+ runs: int = 0
3414
+
3415
+ @classmethod
3416
+ def from_endpoint(
3417
+ cls,
3418
+ endpoint: QuantBTEndpoint,
3419
+ *,
3420
+ data=None,
3421
+ closes=None,
3422
+ highs=None,
3423
+ lows=None,
3424
+ datetime_index=None,
3425
+ symbols=None,
3426
+ ) -> "QuantBTPreparedContext":
3427
+ config = endpoint.config
3428
+ backend_name = _resolve_backend(config)
3429
+ mode = config.mode.lower().strip()
3430
+ sizing = config.sizing.lower().strip()
3431
+
3432
+ if mode in {"single_signal", "signal_notional"} and backend_name == "native_vectorized" and sizing in {"signal_notional", "signal"}:
3433
+ frame = _standardize_frame(data, datetime_index=datetime_index)
3434
+ symbol_list = list(symbols or config.symbols or ["DEFAULT"])
3435
+ if len(symbol_list) != 1:
3436
+ raise ValueError("single-symbol prepared context requires exactly one symbol")
3437
+ symbol = symbol_list[0]
3438
+ close_map = {symbol: frame["close"]}
3439
+ high_map = {symbol: frame.get("high", frame["close"])}
3440
+ low_map = {symbol: frame.get("low", frame["close"])}
3441
+ backend = NativeVectorizedBackend(
3442
+ NativeVectorizedConfig(
3443
+ account=config.account,
3444
+ execution=config.execution,
3445
+ fee_rate=config.v2_fee_rate,
3446
+ use_funding=bool(config.use_funding),
3447
+ )
3448
+ )
3449
+ market = backend.prepare_market_arrays(
3450
+ datetime_index=frame.index,
3451
+ closes=close_map,
3452
+ highs=high_map,
3453
+ lows=low_map,
3454
+ funding_rate=config.funding_rate,
3455
+ symbols=symbol_list,
3456
+ )
3457
+ return cls(
3458
+ endpoint=endpoint,
3459
+ mode="single_signal_notional",
3460
+ idx=frame.index,
3461
+ symbols=symbol_list,
3462
+ close_map=close_map,
3463
+ high_map=high_map,
3464
+ low_map=low_map,
3465
+ market_arrays=market,
3466
+ backend=backend,
3467
+ frame=frame,
3468
+ )
3469
+
3470
+ if mode == "portfolio" and backend_name == "native_portfolio":
3471
+ close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
3472
+ data=data,
3473
+ closes=closes,
3474
+ highs=highs,
3475
+ lows=lows,
3476
+ datetime_index=datetime_index,
3477
+ symbols=symbols or config.symbols,
3478
+ )
3479
+ fee_oneway = config.canonical_one_way_fee_rate
3480
+ backend = NativePortfolioBackend(
3481
+ NativePortfolioConfig(
3482
+ account=config.account,
3483
+ execution=config.execution,
3484
+ fee_rate=fee_oneway,
3485
+ use_funding=bool(config.use_funding),
3486
+ report_level=config.report_level,
3487
+ )
3488
+ )
3489
+ market = backend.prepare_market_arrays(
3490
+ datetime_index=idx,
3491
+ closes=close_map,
3492
+ highs=high_map,
3493
+ lows=low_map,
3494
+ funding_rate=config.funding_rate,
3495
+ symbols=symbol_list,
3496
+ )
3497
+ return cls(
3498
+ endpoint=endpoint,
3499
+ mode="portfolio",
3500
+ idx=idx,
3501
+ symbols=list(symbol_list),
3502
+ close_map=close_map,
3503
+ high_map=high_map,
3504
+ low_map=low_map,
3505
+ market_arrays=market,
3506
+ backend=backend,
3507
+ )
3508
+
3509
+ raise NotImplementedError(
3510
+ "prepared service context currently supports native_vectorized signal_notional "
3511
+ "and native_portfolio only; use normal backtest(...) for this endpoint"
3512
+ )
3513
+
3514
+ @property
3515
+ def metadata(self) -> Dict[str, object]:
3516
+ return {
3517
+ "mode": self.mode,
3518
+ "symbols": tuple(self.symbols),
3519
+ "bars": int(len(self.idx)),
3520
+ "runs": int(self.runs),
3521
+ "market_signature": self.market_arrays.signature,
3522
+ }
3523
+
3524
+ def backtest(self, *, signal=None, signal_col: Optional[str] = None, positions=None):
3525
+ """Replay a new signal or position matrix on the prepared market tape."""
3526
+ if self.mode == "single_signal_notional":
3527
+ result = self._run_single(signal=signal, signal_col=signal_col)
3528
+ elif self.mode == "portfolio":
3529
+ result = self._run_portfolio(positions=positions)
3530
+ else: # pragma: no cover - guarded by constructor
3531
+ raise NotImplementedError(f"unsupported prepared context mode={self.mode!r}")
3532
+ self.runs += 1
3533
+ result.metadata.setdefault("prepared_service_context", self.metadata)
3534
+ self.endpoint._store_result(result)
3535
+ return result
3536
+
3537
+ simulate = backtest
3538
+
3539
+ def _run_single(self, *, signal=None, signal_col: Optional[str] = None):
3540
+ config = self.endpoint.config
3541
+ if signal is None:
3542
+ signal = _signal_from_data(self.frame, signal_col)
3543
+ if signal is None:
3544
+ raise ValueError("prepared single-symbol context requires signal or signal_col")
3545
+ raw = _series_to_raw_matrix(signal, self.idx)
3546
+ symbol = self.symbols[0]
3547
+ return self.backend.run_signals(
3548
+ datetime_index=self.idx,
3549
+ positions={symbol: pd.Series(0.0, index=self.idx)},
3550
+ closes=self.close_map,
3551
+ highs=self.high_map,
3552
+ lows=self.low_map,
3553
+ funding_rate=config.funding_rate,
3554
+ contract_size=config.contract_size,
3555
+ leverage=config.account.leverage,
3556
+ alloc_per_trade=config.alloc_per_trade,
3557
+ hedge_type=config.sizing,
3558
+ use_pyramiding=config.use_pyramiding,
3559
+ symbols=self.symbols,
3560
+ market_arrays=self.market_arrays,
3561
+ raw_signal_matrix=raw,
3562
+ instruments=config.instruments,
3563
+ qty_step=config.qty_step,
3564
+ lot_size=config.lot_size,
3565
+ slot_size=config.slot_size,
3566
+ min_qty=config.min_qty,
3567
+ min_notional=config.min_notional,
3568
+ )
3569
+
3570
+ def _run_portfolio(self, *, positions=None):
3571
+ if positions is None:
3572
+ raise ValueError("prepared portfolio context requires positions")
3573
+ config = self.endpoint.config
3574
+ raw = _positions_to_raw_matrix(positions, self.idx, self.symbols)
3575
+ return self.backend.run_signals(
3576
+ positions=None,
3577
+ closes=self.close_map,
3578
+ highs=self.high_map,
3579
+ lows=self.low_map,
3580
+ datetime_index=self.idx,
3581
+ mode=config.portfolio_mode,
3582
+ alloc_per_trade=config.alloc_per_trade,
3583
+ contract_size=config.contract_size,
3584
+ hedge_type=config.sizing if config.sizing else "notional",
3585
+ funding_rate=config.funding_rate,
3586
+ leverage=config.account.leverage,
3587
+ maintenance_ratio=config.account.maintenance_ratio,
3588
+ asset_type=config.asset_type,
3589
+ use_pyramiding=config.use_pyramiding,
3590
+ betas=config.betas,
3591
+ risk_lookback=config.risk_lookback,
3592
+ market_arrays=self.market_arrays,
3593
+ raw_signal_matrix=raw,
3594
+ instruments=config.instruments,
3595
+ qty_step=config.qty_step,
3596
+ lot_size=config.lot_size,
3597
+ slot_size=config.slot_size,
3598
+ min_qty=config.min_qty,
3599
+ min_notional=config.min_notional,
3600
+ report_level=config.report_level,
3601
+ )
3602
+
3603
+
3604
+ class _WalkForwardEndpointScorer:
3605
+ """
3606
+ Endpoint-backed WFO scorer with run-local prepared market array reuse.
3607
+
3608
+ The cache is intentionally scoped to one scorer instance, which is created
3609
+ for one `QuantBTEndpoint.backtest(...)` call. It never caches by pandas
3610
+ object identity and every prepared reuse is validated by backend signatures.
3611
+ """
3612
+
3613
+ def __init__(
3614
+ self,
3615
+ config: EndpointConfig,
3616
+ target_mode: str,
3617
+ symbols=None,
3618
+ wf_config: Optional[WalkForwardConfig] = None,
3619
+ market_data=None,
3620
+ market_closes=None,
3621
+ market_highs=None,
3622
+ market_lows=None,
3623
+ market_datetime_index=None,
3624
+ ):
3625
+ self.config = config
3626
+ self.target_mode = str(target_mode).lower().strip()
3627
+ self.score_config = _walkforward_scoring_config(config, self.target_mode)
3628
+ self.symbols = None if symbols is None and config.symbols is None else list(symbols or config.symbols or [])
3629
+ self.wf_config = wf_config
3630
+ self.market_data = market_data
3631
+ self.market_closes = market_closes
3632
+ self.market_highs = market_highs
3633
+ self.market_lows = market_lows
3634
+ self.market_datetime_index = market_datetime_index
3635
+ self.use_prepared_cache = bool((wf_config.metadata if wf_config is not None else {}).get("use_prepared_scoring_cache", True))
3636
+ self.prepared_scoring_report_level = str(
3637
+ (wf_config.metadata if wf_config is not None else {}).get("prepared_scoring_report_level", "minimal")
3638
+ )
3639
+ self._single_backend = None
3640
+ self._single_market_maps = {}
3641
+ self._single_market_cache = {}
3642
+ self._portfolio_backend = None
3643
+ self._portfolio_market_maps = {}
3644
+ self._portfolio_market_cache = {}
3645
+ self._stats = {
3646
+ "enabled": bool(self.use_prepared_cache),
3647
+ "target_mode": self.target_mode,
3648
+ "backend": self.score_config.backend,
3649
+ "market_cache_hits": 0,
3650
+ "market_cache_misses": 0,
3651
+ "market_cache_entries": 0,
3652
+ "prepared_runs": 0,
3653
+ "fallback_runs": 0,
3654
+ }
3655
+
3656
+ def __call__(self, data, output, index, fold, params, context: str, trading_days: int) -> Dict[str, float]:
3657
+ try:
3658
+ if self._can_score_single_vectorized_prepared(output):
3659
+ result = self._score_single_vectorized_prepared(output=output, index=index)
3660
+ elif self._can_score_portfolio_prepared(output):
3661
+ result = self._score_portfolio_prepared(output=output, index=index)
3662
+ else:
3663
+ result = self._score_fallback(data=data, output=output, index=index)
3664
+ report = result.full_report(trading_days=trading_days, scope="full")
3665
+ except Exception as exc:
3666
+ raise RuntimeError(
3667
+ "walk-forward endpoint scoring failed during "
3668
+ f"{context} for fold_id={fold.fold_id}; target_mode={self.target_mode!r}; params={params}"
3669
+ ) from exc
3670
+ return {
3671
+ "sharpe": float(report.get("sharpe", 0.0)),
3672
+ "turnover": float(report.get("num_trades", 0.0)),
3673
+ "trade_count": float(report.get("num_trades", 0.0)),
3674
+ "mean_return": float(report.get("total_return_pct", 0.0)) / 100.0,
3675
+ "volatility": 0.0,
3676
+ "max_drawdown_pct": float(report.get("max_drawdown_pct", 0.0)),
3677
+ "profit_factor": float(report.get("profit_factor", 0.0)),
3678
+ }
3679
+
3680
+ def prepared_cache_metadata(self) -> Dict[str, object]:
3681
+ meta = dict(self._stats)
3682
+ meta["market_cache_entries"] = len(self._portfolio_market_cache) + len(self._single_market_cache)
3683
+ meta["prepared_scoring_report_level"] = self.prepared_scoring_report_level
3684
+ meta["available"] = (
3685
+ self._prepared_single_available()
3686
+ or (self.target_mode == "portfolio" and self.score_config.backend == "native_portfolio")
3687
+ )
3688
+ return meta
3689
+
3690
+ def _prepared_single_available(self) -> bool:
3691
+ return (
3692
+ self.score_config.mode == "signal_notional"
3693
+ and _resolve_backend(self.score_config) == "native_vectorized"
3694
+ )
3695
+
3696
+ def _can_score_single_vectorized_prepared(self, output) -> bool:
3697
+ return (
3698
+ self.use_prepared_cache
3699
+ and self._prepared_single_available()
3700
+ and isinstance(output, pd.Series)
3701
+ )
3702
+
3703
+ def _can_score_portfolio_prepared(self, output) -> bool:
3704
+ return (
3705
+ self.use_prepared_cache
3706
+ and self.score_config.mode == "portfolio"
3707
+ and self.score_config.backend == "native_portfolio"
3708
+ and isinstance(output, (pd.DataFrame, dict))
3709
+ )
3710
+
3711
+ def _score_fallback(self, data, output, index):
3712
+ self._stats["fallback_runs"] += 1
3713
+ temp = QuantBTEndpoint(self.score_config)
3714
+ sliced_data = _slice_wf_data_to_index(data, index)
3715
+ symbol_list = self._symbol_list(output)
3716
+ if self.score_config.mode == "portfolio":
3717
+ return temp.backtest(data=sliced_data, positions=output, symbols=symbol_list)
3718
+ return temp.backtest(data=sliced_data, signal=output, symbols=symbol_list)
3719
+
3720
+ def _score_single_vectorized_prepared(self, output: pd.Series, index):
3721
+ idx = _ensure_utc_index(index)
3722
+ symbol_list = self._symbol_list(output)
3723
+ close_map, high_map, low_map = self._single_maps(symbol_list)
3724
+ backend = self._single_backend_instance()
3725
+ cache_key = self._market_cache_key(idx, symbol_list)
3726
+ market = self._single_market_cache.get(cache_key)
3727
+ if market is None:
3728
+ market = backend.prepare_market_arrays(
3729
+ datetime_index=idx,
3730
+ closes=close_map,
3731
+ highs=high_map,
3732
+ lows=low_map,
3733
+ funding_rate=self.score_config.funding_rate,
3734
+ symbols=symbol_list,
3735
+ )
3736
+ self._single_market_cache[cache_key] = market
3737
+ self._stats["market_cache_misses"] += 1
3738
+ else:
3739
+ self._stats["market_cache_hits"] += 1
3740
+
3741
+ self._stats["prepared_runs"] += 1
3742
+ return backend.run_signals(
3743
+ positions={symbol_list[0]: output},
3744
+ closes=close_map,
3745
+ highs=high_map,
3746
+ lows=low_map,
3747
+ datetime_index=idx,
3748
+ funding_rate=self.score_config.funding_rate,
3749
+ contract_size=self.score_config.contract_size,
3750
+ leverage=self.score_config.account.leverage,
3751
+ alloc_per_trade=self.score_config.alloc_per_trade,
3752
+ hedge_type=self.score_config.sizing,
3753
+ use_pyramiding=self.score_config.use_pyramiding,
3754
+ symbols=symbol_list,
3755
+ market_arrays=market,
3756
+ instruments=self.score_config.instruments,
3757
+ qty_step=self.score_config.qty_step,
3758
+ lot_size=self.score_config.lot_size,
3759
+ slot_size=self.score_config.slot_size,
3760
+ min_qty=self.score_config.min_qty,
3761
+ min_notional=self.score_config.min_notional,
3762
+ )
3763
+
3764
+ def _score_portfolio_prepared(self, output, index):
3765
+ idx = _ensure_utc_index(index)
3766
+ symbol_list = self._symbol_list(output)
3767
+ close_map, high_map, low_map = self._portfolio_maps(symbol_list)
3768
+ backend = self._portfolio_backend_instance()
3769
+ cache_key = self._market_cache_key(idx, symbol_list)
3770
+ market = self._portfolio_market_cache.get(cache_key)
3771
+ if market is None:
3772
+ market = backend.prepare_market_arrays(
3773
+ datetime_index=idx,
3774
+ closes=close_map,
3775
+ highs=high_map,
3776
+ lows=low_map,
3777
+ funding_rate=self.score_config.funding_rate,
3778
+ symbols=symbol_list,
3779
+ )
3780
+ self._portfolio_market_cache[cache_key] = market
3781
+ self._stats["market_cache_misses"] += 1
3782
+ else:
3783
+ self._stats["market_cache_hits"] += 1
3784
+
3785
+ pos_map = _positions_to_map(output)
3786
+ raw_signals = NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list)
3787
+ self._stats["prepared_runs"] += 1
3788
+ return backend.run_signals(
3789
+ positions=None,
3790
+ closes=close_map,
3791
+ highs=high_map,
3792
+ lows=low_map,
3793
+ datetime_index=idx,
3794
+ mode=self.score_config.portfolio_mode,
3795
+ alloc_per_trade=self.score_config.alloc_per_trade,
3796
+ contract_size=self.score_config.contract_size,
3797
+ hedge_type=self.score_config.sizing if self.score_config.sizing else "notional",
3798
+ funding_rate=self.score_config.funding_rate,
3799
+ leverage=self.score_config.account.leverage,
3800
+ maintenance_ratio=self.score_config.account.maintenance_ratio,
3801
+ asset_type=self.score_config.asset_type,
3802
+ use_pyramiding=self.score_config.use_pyramiding,
3803
+ betas=self.score_config.betas,
3804
+ risk_lookback=self.score_config.risk_lookback,
3805
+ market_arrays=market,
3806
+ raw_signal_matrix=raw_signals,
3807
+ instruments=self.score_config.instruments,
3808
+ qty_step=self.score_config.qty_step,
3809
+ lot_size=self.score_config.lot_size,
3810
+ slot_size=self.score_config.slot_size,
3811
+ min_qty=self.score_config.min_qty,
3812
+ min_notional=self.score_config.min_notional,
3813
+ report_level=self.prepared_scoring_report_level,
3814
+ )
3815
+
3816
+ def _single_backend_instance(self) -> NativeVectorizedBackend:
3817
+ if self._single_backend is None:
3818
+ self._single_backend = NativeVectorizedBackend(
3819
+ NativeVectorizedConfig(
3820
+ account=self.score_config.account,
3821
+ execution=self.score_config.execution,
3822
+ fee_rate=self.score_config.v2_fee_rate,
3823
+ use_funding=bool(self.score_config.use_funding),
3824
+ )
3825
+ )
3826
+ return self._single_backend
3827
+
3828
+ def _portfolio_backend_instance(self) -> NativePortfolioBackend:
3829
+ if self._portfolio_backend is None:
3830
+ fee_oneway = self.score_config.canonical_one_way_fee_rate
3831
+ self._portfolio_backend = NativePortfolioBackend(
3832
+ NativePortfolioConfig(
3833
+ account=self.score_config.account,
3834
+ execution=self.score_config.execution,
3835
+ fee_rate=fee_oneway,
3836
+ use_funding=bool(self.score_config.use_funding),
3837
+ report_level=self.prepared_scoring_report_level,
3838
+ )
3839
+ )
3840
+ return self._portfolio_backend
3841
+
3842
+ def _single_maps(self, symbol_list):
3843
+ key = tuple(symbol_list)
3844
+ if key not in self._single_market_maps:
3845
+ if self.market_closes is not None or isinstance(self.market_data, dict):
3846
+ close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data(
3847
+ data=self.market_data,
3848
+ closes=self.market_closes,
3849
+ highs=self.market_highs,
3850
+ lows=self.market_lows,
3851
+ datetime_index=self.market_datetime_index,
3852
+ symbols=symbol_list,
3853
+ )
3854
+ else:
3855
+ frame = _standardize_frame(self.market_data, datetime_index=self.market_datetime_index)
3856
+ symbol = symbol_list[0]
3857
+ close_map = {symbol: frame["close"]}
3858
+ high_map = {symbol: frame.get("high", frame["close"])}
3859
+ low_map = {symbol: frame.get("low", frame["close"])}
3860
+ self._single_market_maps[key] = (close_map, high_map, low_map)
3861
+ return self._single_market_maps[key]
3862
+
3863
+ def _portfolio_maps(self, symbol_list):
3864
+ key = tuple(symbol_list)
3865
+ if key not in self._portfolio_market_maps:
3866
+ close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data(
3867
+ data=self.market_data,
3868
+ closes=self.market_closes,
3869
+ highs=self.market_highs,
3870
+ lows=self.market_lows,
3871
+ datetime_index=self.market_datetime_index,
3872
+ symbols=symbol_list,
3873
+ )
3874
+ self._portfolio_market_maps[key] = (close_map, high_map, low_map)
3875
+ return self._portfolio_market_maps[key]
3876
+
3877
+ def _symbol_list(self, output) -> list:
3878
+ if self.symbols:
3879
+ return list(self.symbols)
3880
+ if isinstance(output, pd.DataFrame):
3881
+ return list(output.columns)
3882
+ if isinstance(output, dict):
3883
+ return list(output.keys())
3884
+ return ["DEFAULT"]
3885
+
3886
+ @staticmethod
3887
+ def _market_cache_key(index: pd.DatetimeIndex, symbols: Sequence[str]):
3888
+ idx = _ensure_utc_index(index)
3889
+ first = None if len(idx) == 0 else int(idx.asi8[0])
3890
+ last = None if len(idx) == 0 else int(idx.asi8[-1])
3891
+ return (tuple(symbols), int(len(idx)), first, last)
3892
+
3893
+
3894
+ def _walkforward_scoring_config(config: EndpointConfig, target_mode: str) -> EndpointConfig:
3895
+ mode = str(target_mode).lower().strip()
3896
+ if mode in {"pct_equity", "%_equity"}:
3897
+ return replace(config, mode="pct_equity", backend="legacy", sizing="%_equity")
3898
+ if mode == "dca_ladder":
3899
+ return replace(config, mode="dca_ladder", backend="legacy", sizing="dca_ladder")
3900
+ if mode in {"signal_notional", "single_signal"}:
3901
+ return replace(config, mode="signal_notional", backend=config.backend, sizing="signal_notional")
3902
+ if mode == "portfolio":
3903
+ return replace(config, mode="portfolio", backend="native_portfolio")
3904
+ raise NotImplementedError(f"endpoint scoring is not implemented for walk-forward target_mode={target_mode!r}")
3905
+
3906
+
3907
+ def _strict_lookup_frame(data, datetime_index=None, *, source_timezone: Optional[str] = None) -> pd.DataFrame:
3908
+ if not isinstance(data, pd.DataFrame):
3909
+ raise ValueError("intrabar endpoint requires a DataFrame when intent is not supplied explicitly")
3910
+ frame = data.copy().rename(
3911
+ columns={
3912
+ "Datetime": "timestamp",
3913
+ "Date": "timestamp",
3914
+ "Timestamp": "timestamp",
3915
+ "Open": "open",
3916
+ "High": "high",
3917
+ "Low": "low",
3918
+ "Close": "close",
3919
+ "Volume": "volume",
3920
+ }
3921
+ )
3922
+ if datetime_index is not None:
3923
+ frame.index = _endpoint_strict_index(datetime_index, source_timezone=source_timezone)
3924
+ elif "timestamp" in frame.columns:
3925
+ frame = frame.set_index(_endpoint_strict_index(frame["timestamp"], source_timezone=source_timezone))
3926
+ else:
3927
+ frame.index = _endpoint_strict_index(frame.index, source_timezone=source_timezone)
3928
+ return frame
3929
+
3930
+
3931
+ def _endpoint_strict_index(value, *, source_timezone: Optional[str] = None) -> pd.DatetimeIndex:
3932
+ raw = pd.DatetimeIndex(pd.to_datetime(value, errors="raise"))
3933
+ if raw.tz is None:
3934
+ if source_timezone is None:
3935
+ raise ValueError("intrabar endpoint received timezone-naive data; pass metadata={'source_timezone': ...}")
3936
+ raw = raw.tz_localize(source_timezone)
3937
+ return raw.tz_convert("UTC")
3938
+
3939
+
3940
+ def _execution_contract_from_config(config: EndpointConfig) -> ExecutionContract:
3941
+ meta = config.metadata.get("execution_contract")
3942
+ if meta:
3943
+ return ExecutionContract.from_metadata(meta)
3944
+ contract_id = str(config.metadata.get("execution_contract_id", "intrabar_bracket_v1"))
3945
+ if contract_id == "intrabar_bracket_v1":
3946
+ return ExecutionContract.intrabar_bracket()
3947
+ return ExecutionContract.from_metadata({"engine_id": contract_id})
3948
+
3949
+
3950
+ def _session_policy_from_config(config: EndpointConfig) -> Optional[SessionExecutionPolicy]:
3951
+ return SessionExecutionPolicy.from_metadata(config.metadata.get("session_policy"))
3952
+
3953
+
3954
+ def _tick_size_for_symbol(instruments, symbol: str, default: float = 0.0) -> float:
3955
+ if isinstance(default, dict):
3956
+ fallback = float(default.get(symbol, 0.0))
3957
+ else:
3958
+ fallback = float(default or 0.0)
3959
+ if instruments is None:
3960
+ return fallback
3961
+ if isinstance(instruments, dict):
3962
+ inst = instruments.get(symbol)
3963
+ return fallback if inst is None else float(getattr(inst, "tick_size", fallback))
3964
+ for inst in instruments:
3965
+ if getattr(inst, "symbol", None) == symbol:
3966
+ return float(getattr(inst, "tick_size", fallback))
3967
+ return fallback
3968
+
3969
+
3970
+ def _prepared_profile_signature(data_signature: str, profile: Dict) -> str:
3971
+ payload = dict(profile)
3972
+ payload["data_signature"] = data_signature
3973
+ raw = json.dumps(_jsonable(payload), sort_keys=True, default=str).encode("utf-8")
3974
+ return hashlib.sha256(raw).hexdigest()
3975
+
3976
+
3977
+ def _intrabar_intent_from_endpoint_input(
3978
+ *,
3979
+ frame: Optional[pd.DataFrame],
3980
+ index: pd.DatetimeIndex,
3981
+ signal,
3982
+ signal_col: Optional[str],
3983
+ intent_cols: Dict[str, str],
3984
+ level_mode: IntrabarLevelMode,
3985
+ ) -> IntrabarIntentTape:
3986
+ signed_signal = None
3987
+ if signal is not None:
3988
+ signed_signal = _strict_series_values(signal, index, name="signal")
3989
+ elif signal_col is not None:
3990
+ signed_signal = _strict_frame_col_values(frame, index, signal_col, dtype=float)
3991
+ elif "entry_signal" in intent_cols:
3992
+ signed_signal = _strict_frame_col_values(frame, index, intent_cols["entry_signal"], dtype=float)
3993
+ elif "signal" in intent_cols:
3994
+ signed_signal = _strict_frame_col_values(frame, index, intent_cols["signal"], dtype=float)
3995
+
3996
+ if "entry_side" in intent_cols:
3997
+ entry_side = np.sign(_strict_frame_col_values(frame, index, intent_cols["entry_side"], dtype=float)).astype(np.int8)
3998
+ elif signed_signal is not None:
3999
+ entry_side = np.sign(signed_signal).astype(np.int8)
4000
+ else:
4001
+ raise ValueError("intrabar endpoint requires signal/signal_col or intent_cols['entry_side']")
4002
+
4003
+ if "entry_size" in intent_cols:
4004
+ entry_size = np.abs(_strict_frame_col_values(frame, index, intent_cols["entry_size"], dtype=float))
4005
+ elif signed_signal is not None:
4006
+ entry_size = np.abs(signed_signal)
4007
+ else:
4008
+ raise ValueError("intrabar endpoint requires intent_cols['entry_size'] when no signed signal is supplied")
4009
+
4010
+ return IntrabarIntentTape.from_arrays(
4011
+ entry_side=entry_side,
4012
+ entry_size=entry_size,
4013
+ stop_value=_optional_intent_col(frame, index, intent_cols, "stop_value"),
4014
+ take_profit_value=_optional_intent_col(frame, index, intent_cols, "take_profit_value"),
4015
+ trailing_value=_optional_intent_col(frame, index, intent_cols, "trailing_value"),
4016
+ technical_exit=_optional_intent_col(frame, index, intent_cols, "technical_exit", dtype=bool),
4017
+ exit_long=_optional_intent_col(frame, index, intent_cols, "exit_long", dtype=bool),
4018
+ exit_short=_optional_intent_col(frame, index, intent_cols, "exit_short", dtype=bool),
4019
+ level_mode=level_mode,
4020
+ )
4021
+
4022
+
4023
+ def _strict_series_values(series, index: pd.DatetimeIndex, *, name: str) -> np.ndarray:
4024
+ if not isinstance(series, pd.Series):
4025
+ series = pd.Series(series, index=index)
4026
+ s = series.copy()
4027
+ s.index = pd.DatetimeIndex(pd.to_datetime(s.index, errors="raise", utc=True))
4028
+ if not s.index.equals(index):
4029
+ raise ValueError(f"{name} index must exactly match the strict market tape index")
4030
+ return pd.to_numeric(s, errors="raise").to_numpy(dtype=np.float64)
4031
+
4032
+
4033
+ def _strict_frame_col_values(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, col: str, *, dtype=float) -> np.ndarray:
4034
+ if frame is None:
4035
+ raise ValueError(f"intent column {col!r} requires DataFrame data")
4036
+ if col not in frame.columns:
4037
+ raise ValueError(f"intent column {col!r} not found in data")
4038
+ if not pd.DatetimeIndex(frame.index).equals(index):
4039
+ raise ValueError(f"intent column {col!r} index must exactly match the strict market tape index")
4040
+ if dtype is bool:
4041
+ return frame[col].fillna(False).astype(bool).to_numpy(dtype=np.bool_)
4042
+ return pd.to_numeric(frame[col], errors="raise").to_numpy(dtype=np.float64)
4043
+
4044
+
4045
+ def _optional_intent_col(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, cols: Dict[str, str], key: str, *, dtype=float):
4046
+ col = cols.get(key)
4047
+ if col is None:
4048
+ return None
4049
+ return _strict_frame_col_values(frame, index, col, dtype=dtype)
4050
+
4051
+
4052
+ def _scalar_for_symbol(value, symbol: str, default: float = 1.0) -> float:
4053
+ if isinstance(value, dict):
4054
+ return float(value.get(symbol, default))
4055
+ return float(default if value is None else value)
4056
+
4057
+
4058
+ def _intrabar_fills_to_frame(fills) -> pd.DataFrame:
4059
+ rows = []
4060
+ for fill in fills:
4061
+ rows.append(
4062
+ {
4063
+ "bar_index": int(fill.bar_index),
4064
+ "sequence": int(fill.sequence),
4065
+ "timestamp": pd.Timestamp(fill.timestamp),
4066
+ "side": int(fill.side),
4067
+ "qty": float(fill.qty),
4068
+ "price": float(fill.price),
4069
+ "fee": float(fill.fee),
4070
+ "reason": fill.reason.value if hasattr(fill.reason, "value") else str(fill.reason),
4071
+ }
4072
+ )
4073
+ return pd.DataFrame(rows)
4074
+
4075
+
4076
+ def _slice_wf_data_to_index(data, index: pd.DatetimeIndex):
4077
+ if isinstance(data, pd.DataFrame):
4078
+ return data.reindex(index).copy()
4079
+ if isinstance(data, pd.Series):
4080
+ return data.reindex(index).copy()
4081
+ if isinstance(data, dict):
4082
+ out = {}
4083
+ for key, value in data.items():
4084
+ if isinstance(value, (pd.DataFrame, pd.Series)):
4085
+ out[key] = value.reindex(index).copy()
4086
+ else:
4087
+ out[key] = value
4088
+ return out
4089
+ return data
4090
+
4091
+
4092
+ def _normalize_single_data(data, signal, signal_col, datetime_index):
4093
+ if data is None:
4094
+ raise ValueError("single-symbol endpoint requires data DataFrame")
4095
+ frame = _standardize_frame(data, datetime_index)
4096
+ sig = signal if signal is not None else _signal_from_data(frame, signal_col)
4097
+ if sig is None:
4098
+ raise ValueError("single-symbol endpoint requires signal or signal_col")
4099
+ sig = sig.copy()
4100
+ if isinstance(sig.index, pd.DatetimeIndex):
4101
+ sig.index = sig.index.tz_localize("UTC") if sig.index.tz is None else sig.index.tz_convert("UTC")
4102
+ sig = sig[~sig.index.duplicated(keep="first")].reindex(frame.index, method="ffill").fillna(0.0)
4103
+ return frame, frame.index, sig
4104
+
4105
+
4106
+ def _intrabar_marker_columns(frame: pd.DataFrame) -> list[str]:
4107
+ markers = {
4108
+ "exit_price",
4109
+ "exit_type",
4110
+ "stop_loss",
4111
+ "stoploss",
4112
+ "sl",
4113
+ "take_profit",
4114
+ "takeprofit",
4115
+ "tp",
4116
+ "trailing",
4117
+ "trailing_stop",
4118
+ "use_sl",
4119
+ "use_tp",
4120
+ "slpercent",
4121
+ "tppercent",
4122
+ }
4123
+ found = []
4124
+ for col in frame.columns:
4125
+ key = str(col).lower()
4126
+ if key in markers or "trailing" in key or "stop_loss" in key or "take_profit" in key:
4127
+ found.append(str(col))
4128
+ return found
4129
+
4130
+
4131
+ def _normalize_symbol_data(data, closes, highs, lows, datetime_index, symbols):
4132
+ if closes is not None:
4133
+ symbol_list = list(symbols or closes.keys())
4134
+ idx = pd.DatetimeIndex(datetime_index if datetime_index is not None else closes[symbol_list[0]].index)
4135
+ idx = _ensure_utc_index(idx)
4136
+ close_map = {s: _align_series(closes[s], idx) for s in symbol_list}
4137
+ high_map = {s: _align_series((highs or closes)[s], idx) for s in symbol_list}
4138
+ low_map = {s: _align_series((lows or closes)[s], idx) for s in symbol_list}
4139
+ return close_map, high_map, low_map, idx, symbol_list
4140
+ if not isinstance(data, dict):
4141
+ raise ValueError("multi-symbol endpoint requires data dict or explicit closes")
4142
+ symbol_list = list(symbols or data.keys())
4143
+ frames = {s: _standardize_frame(data[s], datetime_index=None) for s in symbol_list}
4144
+ idx = _ensure_utc_index(datetime_index if datetime_index is not None else frames[symbol_list[0]].index)
4145
+ close_map = {s: _align_series(frames[s]["close"], idx) for s in symbol_list}
4146
+ high_map = {s: _align_series(frames[s].get("high", frames[s]["close"]), idx) for s in symbol_list}
4147
+ low_map = {s: _align_series(frames[s].get("low", frames[s]["close"]), idx) for s in symbol_list}
4148
+ return close_map, high_map, low_map, idx, symbol_list
4149
+
4150
+
4151
+ def _frames_from_symbol_maps(close_map, high_map, low_map, symbols) -> FrameMap:
4152
+ frames = {}
4153
+ for symbol in symbols:
4154
+ close = close_map[symbol]
4155
+ frames[symbol] = pd.DataFrame(
4156
+ {
4157
+ "open": close,
4158
+ "high": high_map.get(symbol, close),
4159
+ "low": low_map.get(symbol, close),
4160
+ "close": close,
4161
+ "volume": 0.0,
4162
+ },
4163
+ index=close.index,
4164
+ )
4165
+ return frames
4166
+
4167
+
4168
+ def _prepared_native_event_open_volume_arrays(data, idx: pd.DatetimeIndex, symbols, close_map) -> tuple[np.ndarray, np.ndarray]:
4169
+ open_cols = []
4170
+ volume_cols = []
4171
+ for symbol in symbols:
4172
+ close = close_map[symbol]
4173
+ if isinstance(data, dict) and symbol in data and isinstance(data[symbol], pd.DataFrame):
4174
+ frame = _standardize_frame(data[symbol], datetime_index=None)
4175
+ open_cols.append(_align_series(frame.get("open", frame["close"]), idx).to_numpy(dtype=np.float64))
4176
+ volume_cols.append(_align_series(frame.get("volume", pd.Series(0.0, index=frame.index)), idx).to_numpy(dtype=np.float64))
4177
+ else:
4178
+ open_cols.append(close.to_numpy(dtype=np.float64))
4179
+ volume_cols.append(np.zeros(len(idx), dtype=np.float64))
4180
+ return (
4181
+ np.ascontiguousarray(np.column_stack(open_cols), dtype=np.float64),
4182
+ np.ascontiguousarray(np.column_stack(volume_cols), dtype=np.float64),
4183
+ )
4184
+
4185
+
4186
+ def _empty_nautilus_preflight_result(data, symbols, account: AccountConfig, metadata: Dict) -> BacktestResultV2:
4187
+ symbol_list = list(symbols)
4188
+ if not symbol_list:
4189
+ raise ValueError("symbols are required for empty Nautilus preflight result")
4190
+ first = data[symbol_list[0]]
4191
+ idx = _ensure_utc_index(first.index)
4192
+ equity = pd.Series(float(account.initial_capital), index=idx, name="equity")
4193
+ returns = pd.Series(0.0, index=idx, name="returns")
4194
+ positions = pd.DataFrame({f"Position_{symbol}": 0.0 for symbol in symbol_list}, index=idx)
4195
+ closes = {}
4196
+ for symbol in symbol_list:
4197
+ frame = data[symbol]
4198
+ close = frame["close"] if "close" in frame else frame["Close"]
4199
+ close = close.copy()
4200
+ close.index = _ensure_utc_index(close.index)
4201
+ closes[f"Close_{symbol}"] = close.reindex(idx).ffill().bfill().astype(float)
4202
+ return BacktestResultV2(
4203
+ equity=equity,
4204
+ returns=returns,
4205
+ positions=positions,
4206
+ closes=pd.DataFrame(closes, index=idx),
4207
+ symbols=symbol_list,
4208
+ initial_capital=float(account.initial_capital),
4209
+ leverage=float(account.leverage),
4210
+ metadata=dict(metadata),
4211
+ )
4212
+
4213
+
4214
+ def _annotate_orders_for_depth(orders: Sequence[OrderIntent], params: Dict) -> tuple[OrderIntent, ...]:
4215
+ package_type = params.get("package_type") or params.get("input_mode")
4216
+ package_id = params.get("package_id") or params.get("basket_id") or params.get("arb_id")
4217
+ if package_type is None and package_id is None:
4218
+ return tuple(orders)
4219
+ out = []
4220
+ for order in orders:
4221
+ metadata = dict(order.metadata)
4222
+ if package_type is not None:
4223
+ metadata.setdefault("package_type", str(package_type))
4224
+ if package_id is not None:
4225
+ metadata.setdefault("package_id", str(package_id))
4226
+ out.append(replace(order, metadata=metadata))
4227
+ return tuple(out)
4228
+
4229
+
4230
+ def _standardize_frame(data, datetime_index=None) -> pd.DataFrame:
4231
+ if not isinstance(data, pd.DataFrame):
4232
+ raise ValueError("data must be a pandas DataFrame")
4233
+ frame = data.copy()
4234
+ frame = frame.rename(
4235
+ columns={
4236
+ "Datetime": "timestamp",
4237
+ "Date": "timestamp",
4238
+ "Timestamp": "timestamp",
4239
+ "Open": "open",
4240
+ "High": "high",
4241
+ "Low": "low",
4242
+ "Close": "close",
4243
+ "Volume": "volume",
4244
+ }
4245
+ )
4246
+ if datetime_index is not None:
4247
+ frame.index = _ensure_utc_index(datetime_index)
4248
+ elif "timestamp" in frame.columns:
4249
+ frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="coerce")
4250
+ frame = frame.dropna(subset=["timestamp"]).set_index("timestamp")
4251
+ else:
4252
+ frame.index = _ensure_utc_index(frame.index)
4253
+ frame = frame[~frame.index.duplicated(keep="first")].sort_index()
4254
+ if "close" not in frame.columns:
4255
+ raise ValueError("data must contain close/Close")
4256
+ for col in ("high", "low"):
4257
+ if col not in frame.columns:
4258
+ frame[col] = frame["close"]
4259
+ if "open" not in frame.columns:
4260
+ frame["open"] = frame["close"]
4261
+ if "volume" not in frame.columns:
4262
+ frame["volume"] = 0.0
4263
+ return frame
4264
+
4265
+
4266
+ def _signal_from_data(data, signal_col):
4267
+ if signal_col is None:
4268
+ return None
4269
+ if data is None or signal_col not in data:
4270
+ raise ValueError(f"signal_col={signal_col!r} not found in data")
4271
+ return data[signal_col]
4272
+
4273
+
4274
+ def _positions_to_map(positions) -> Dict[str, pd.Series]:
4275
+ if positions is None:
4276
+ return {}
4277
+ if isinstance(positions, pd.DataFrame):
4278
+ return {str(col): positions[col] for col in positions.columns}
4279
+ return dict(positions)
4280
+
4281
+
4282
+ def _series_to_raw_matrix(signal, idx: pd.DatetimeIndex) -> np.ndarray:
4283
+ if isinstance(signal, pd.Series):
4284
+ ser = signal
4285
+ else:
4286
+ ser = pd.Series(signal, index=idx)
4287
+ if _series_index_matches(ser, idx):
4288
+ values = ser.to_numpy(dtype=np.float64, copy=True)
4289
+ else:
4290
+ values = _align_series(ser, idx).fillna(0.0).to_numpy(dtype=np.float64, copy=True)
4291
+ return np.ascontiguousarray(values.reshape(-1, 1), dtype=np.float64)
4292
+
4293
+
4294
+ def _positions_to_raw_matrix(positions, idx: pd.DatetimeIndex, symbols: Sequence[str]) -> np.ndarray:
4295
+ symbol_list = list(symbols)
4296
+ if isinstance(positions, pd.DataFrame) and all(symbol in positions.columns for symbol in symbol_list):
4297
+ frame = positions.loc[:, symbol_list]
4298
+ if _frame_index_matches(frame, idx):
4299
+ return np.ascontiguousarray(frame.to_numpy(dtype=np.float64, copy=True), dtype=np.float64)
4300
+ elif isinstance(positions, dict):
4301
+ exact = True
4302
+ cols = []
4303
+ for symbol in symbol_list:
4304
+ series = positions.get(symbol)
4305
+ if not isinstance(series, pd.Series) or not _series_index_matches(series, idx):
4306
+ exact = False
4307
+ break
4308
+ cols.append(series.to_numpy(dtype=np.float64, copy=True))
4309
+ if exact:
4310
+ return np.ascontiguousarray(np.column_stack(cols), dtype=np.float64)
4311
+
4312
+ pos_map = _positions_to_map(positions)
4313
+ return NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list)
4314
+
4315
+
4316
+ def _series_index_matches(series: pd.Series, idx: pd.DatetimeIndex) -> bool:
4317
+ if not isinstance(series.index, pd.DatetimeIndex) or len(series.index) != len(idx):
4318
+ return False
4319
+ return bool(np.array_equal(_ensure_utc_index(series.index).asi8, idx.asi8))
4320
+
4321
+
4322
+ def _frame_index_matches(frame: pd.DataFrame, idx: pd.DatetimeIndex) -> bool:
4323
+ if not isinstance(frame.index, pd.DatetimeIndex) or len(frame.index) != len(idx):
4324
+ return False
4325
+ return bool(np.array_equal(_ensure_utc_index(frame.index).asi8, idx.asi8))
4326
+
4327
+
4328
+ def _build_portfolio_orders_for_nautilus(
4329
+ datetime_index,
4330
+ positions: Dict[str, pd.Series],
4331
+ closes: Dict[str, pd.Series],
4332
+ alloc_per_trade,
4333
+ hedge_type: str,
4334
+ use_pyramiding: bool,
4335
+ symbols,
4336
+ ) -> tuple[tuple[OrderIntent, ...], pd.DataFrame]:
4337
+ ht = str(hedge_type).lower().strip()
4338
+ if ht in {"%_equity", "pct_equity", "dca_ladder", "dca"}:
4339
+ raise NotImplementedError(
4340
+ "Nautilus portfolio validation currently supports pre-scalable modes "
4341
+ "('signal_notional', 'notional', 'unit'). Use native portfolio for "
4342
+ f"hedge_type={hedge_type!r}."
4343
+ )
4344
+ idx = _ensure_utc_index(datetime_index)
4345
+ alloc = _alloc_map(alloc_per_trade, symbols)
4346
+ orders = []
4347
+ target_cols = {}
4348
+ for symbol in symbols:
4349
+ signal = _align_series(positions[symbol], idx)
4350
+ close = _align_series(closes[symbol], idx)
4351
+ target = compute_target_units(
4352
+ hedge_type=hedge_type,
4353
+ signal=signal,
4354
+ close=close,
4355
+ alloc=alloc[symbol],
4356
+ use_pyramiding=use_pyramiding,
4357
+ ).fillna(0.0)
4358
+ target_cols[symbol] = target
4359
+ prev = 0.0
4360
+ for ts, value in target.items():
4361
+ current = float(value)
4362
+ delta = current - prev
4363
+ if abs(delta) > 1e-12:
4364
+ orders.append(
4365
+ OrderIntent(
4366
+ timestamp=ts,
4367
+ symbol=symbol,
4368
+ side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
4369
+ order_type=OrderType.MARKET,
4370
+ qty=abs(delta),
4371
+ tif=TimeInForce.IOC,
4372
+ tag=f"portfolio:{ht}",
4373
+ metadata={
4374
+ "portfolio_mode": "matrix",
4375
+ "target_units": current,
4376
+ "previous_units": prev,
4377
+ },
4378
+ )
4379
+ )
4380
+ prev = current
4381
+ out = pd.DataFrame({symbol: target_cols[symbol] for symbol in symbols}, index=idx)
4382
+ return tuple(sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value)), out
4383
+
4384
+
4385
+ def _build_portfolio_orders_from_target_units_for_nautilus(
4386
+ target_units: pd.DataFrame,
4387
+ symbols,
4388
+ tag: str,
4389
+ ) -> tuple[OrderIntent, ...]:
4390
+ idx = _ensure_utc_index(target_units.index)
4391
+ target = target_units.copy()
4392
+ target.index = idx
4393
+ orders = []
4394
+ for symbol in symbols:
4395
+ if symbol not in target:
4396
+ raise ValueError(f"target_units missing symbol {symbol!r}")
4397
+ prev = 0.0
4398
+ for ts, value in target[symbol].fillna(0.0).items():
4399
+ current = float(value)
4400
+ delta = current - prev
4401
+ if abs(delta) > 1e-12:
4402
+ orders.append(
4403
+ OrderIntent(
4404
+ timestamp=ts,
4405
+ symbol=symbol,
4406
+ side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
4407
+ order_type=OrderType.MARKET,
4408
+ qty=abs(delta),
4409
+ tif=TimeInForce.IOC,
4410
+ tag=tag,
4411
+ metadata={
4412
+ "portfolio_mode": "matrix",
4413
+ "target_units": current,
4414
+ "previous_units": prev,
4415
+ },
4416
+ )
4417
+ )
4418
+ prev = current
4419
+ return tuple(sorted(orders, key=lambda order: (pd.Timestamp(order.timestamp).value, str(order.symbol))))
4420
+
4421
+
4422
+ def _alloc_map(value, symbols) -> Dict[str, float]:
4423
+ if isinstance(value, dict):
4424
+ return {symbol: float(value.get(symbol, 100_000.0)) for symbol in symbols}
4425
+ return {symbol: float(value) for symbol in symbols}
4426
+
4427
+
4428
+ def _print_order_logs(result, mode: str = "fills_only", limit: int = 500) -> None:
4429
+ try:
4430
+ from .reporting.nautilus_bundle import format_nautilus_event_log
4431
+
4432
+ orders_report = result.metadata.get("orders_report")
4433
+ if orders_report is None:
4434
+ orders_report = result.metadata.get("order_report")
4435
+ lines = format_nautilus_event_log(
4436
+ fills_report=result.metadata.get("fills_report"),
4437
+ orders_report=orders_report,
4438
+ positions=getattr(result, "positions", None),
4439
+ mode=mode,
4440
+ limit=int(limit),
4441
+ )
4442
+ except Exception as exc:
4443
+ print(f"Order log unavailable: {type(exc).__name__}: {exc}")
4444
+ return
4445
+ for line in lines:
4446
+ print(line)
4447
+
4448
+
4449
+ def _infer_index(data, datetime_index):
4450
+ if datetime_index is not None:
4451
+ return _ensure_utc_index(datetime_index)
4452
+ if isinstance(data, pd.DataFrame):
4453
+ return _standardize_frame(data).index
4454
+ raise ValueError("could not infer datetime index")
4455
+
4456
+
4457
+ def _ensure_utc_index(index) -> pd.DatetimeIndex:
4458
+ return pd.DatetimeIndex(pd.to_datetime(index, utc=True))
4459
+
4460
+
4461
+ def _align_series(series: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
4462
+ ser = series.copy()
4463
+ if isinstance(ser.index, pd.DatetimeIndex):
4464
+ ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC")
4465
+ return ser[~ser.index.duplicated(keep="first")].reindex(idx, method="ffill")