quantbt-engine 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,392 @@
1
+ """
2
+ Portfolio domain contracts for the native portfolio upgrade.
3
+
4
+ This module does not execute a backtest. It defines the institutional-grade
5
+ contract that the future native portfolio engine must satisfy while legacy
6
+ portfolio behavior remains the compatibility oracle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Dict, Iterable, Optional, Set
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from ..reporting.portfolio_audit import build_portfolio_domain_audit
19
+
20
+
21
+ class PortfolioMode(str, Enum):
22
+ LONGSHORT = "longshort"
23
+ MARKET_NEUTRAL = "market_neutral"
24
+ DIRECTIONAL = "directional"
25
+ EQUAL_WEIGHT = "equal_weight"
26
+ RISK_PARITY = "risk_parity"
27
+ BETA_NEUTRAL = "beta_neutral"
28
+
29
+
30
+ class PortfolioSizingMode(str, Enum):
31
+ SIGNAL_NOTIONAL = "signal_notional"
32
+ SIGNAL = "signal"
33
+ NOTIONAL = "notional"
34
+ UNIT = "unit"
35
+ PCT_EQUITY = "%_equity"
36
+ TARGET_WEIGHT = "target_weight"
37
+ TARGET_NOTIONAL = "target_notional"
38
+ TARGET_UNITS = "target_units"
39
+ FIXED_NOTIONAL = "fixed_notional"
40
+ GROSS_EXPOSURE = "gross_exposure"
41
+ NET_EXPOSURE = "net_exposure"
42
+ DCA_LADDER = "dca_ladder"
43
+
44
+
45
+ class PortfolioRebalancePolicy(str, Enum):
46
+ ON_SIGNAL_CHANGE = "on_signal_change"
47
+ EVERY_BAR = "every_bar"
48
+ THRESHOLD = "threshold"
49
+ SCHEDULED = "scheduled"
50
+
51
+
52
+ LEGACY_PORTFOLIO_MODES: Set[str] = {mode.value for mode in PortfolioMode}
53
+ LEGACY_COMPATIBLE_PORTFOLIO_MODES: Set[str] = {
54
+ PortfolioMode.LONGSHORT.value,
55
+ PortfolioMode.MARKET_NEUTRAL.value,
56
+ PortfolioMode.DIRECTIONAL.value,
57
+ PortfolioMode.EQUAL_WEIGHT.value,
58
+ }
59
+ LEGACY_PORTFOLIO_SIZING_MODES: Set[str] = {
60
+ PortfolioSizingMode.SIGNAL_NOTIONAL.value,
61
+ PortfolioSizingMode.SIGNAL.value,
62
+ PortfolioSizingMode.NOTIONAL.value,
63
+ PortfolioSizingMode.UNIT.value,
64
+ }
65
+ NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES: Set[str] = {mode.value for mode in PortfolioSizingMode}
66
+ NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES: Set[str] = {
67
+ *LEGACY_PORTFOLIO_SIZING_MODES,
68
+ PortfolioSizingMode.PCT_EQUITY.value,
69
+ PortfolioSizingMode.TARGET_WEIGHT.value,
70
+ PortfolioSizingMode.TARGET_NOTIONAL.value,
71
+ PortfolioSizingMode.TARGET_UNITS.value,
72
+ PortfolioSizingMode.FIXED_NOTIONAL.value,
73
+ PortfolioSizingMode.GROSS_EXPOSURE.value,
74
+ PortfolioSizingMode.NET_EXPOSURE.value,
75
+ }
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class PortfolioDomainSpec:
80
+ """
81
+ Declarative contract for a portfolio backtest.
82
+
83
+ Phase 11 uses this as a validation layer around legacy portfolio results.
84
+ Phase 11/native portfolio should use the same spec as its input contract.
85
+ """
86
+
87
+ mode: str = PortfolioMode.LONGSHORT.value
88
+ sizing_mode: str = PortfolioSizingMode.SIGNAL_NOTIONAL.value
89
+ rebalance_policy: str = PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value
90
+ allow_short: bool = True
91
+ require_gross_net_reports: bool = True
92
+ require_symbol_pnl_report: bool = True
93
+ require_margin_report: bool = True
94
+ target_gross_exposure: Optional[float] = None
95
+ target_net_exposure: Optional[float] = None
96
+ max_gross_leverage: Optional[float] = None
97
+ max_net_exposure_abs: Optional[float] = None
98
+ metadata: Dict = field(default_factory=dict)
99
+
100
+ def __post_init__(self) -> None:
101
+ mode = normalize_portfolio_mode(self.mode)
102
+ sizing = normalize_portfolio_sizing_mode(self.sizing_mode)
103
+ rebalance = normalize_rebalance_policy(self.rebalance_policy)
104
+ object.__setattr__(self, "mode", mode)
105
+ object.__setattr__(self, "sizing_mode", sizing)
106
+ object.__setattr__(self, "rebalance_policy", rebalance)
107
+
108
+ if self.target_gross_exposure is not None and self.target_gross_exposure < 0.0:
109
+ raise ValueError("target_gross_exposure must be >= 0")
110
+ if self.max_gross_leverage is not None and self.max_gross_leverage < 0.0:
111
+ raise ValueError("max_gross_leverage must be >= 0")
112
+ if self.max_net_exposure_abs is not None and self.max_net_exposure_abs < 0.0:
113
+ raise ValueError("max_net_exposure_abs must be >= 0")
114
+
115
+ @property
116
+ def legacy_compatible(self) -> bool:
117
+ return self.mode in LEGACY_COMPATIBLE_PORTFOLIO_MODES and self.sizing_mode in LEGACY_PORTFOLIO_SIZING_MODES
118
+
119
+ @property
120
+ def native_planned(self) -> bool:
121
+ return self.mode in LEGACY_PORTFOLIO_MODES and self.sizing_mode in NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES
122
+
123
+
124
+ def normalize_portfolio_mode(mode: str) -> str:
125
+ value = str(mode).lower().strip()
126
+ aliases = {
127
+ "long_short": PortfolioMode.LONGSHORT.value,
128
+ "long/short": PortfolioMode.LONGSHORT.value,
129
+ "dollar_neutral": PortfolioMode.MARKET_NEUTRAL.value,
130
+ "marketneutral": PortfolioMode.MARKET_NEUTRAL.value,
131
+ "equal": PortfolioMode.EQUAL_WEIGHT.value,
132
+ "equalweight": PortfolioMode.EQUAL_WEIGHT.value,
133
+ "riskparity": PortfolioMode.RISK_PARITY.value,
134
+ "inverse_vol": PortfolioMode.RISK_PARITY.value,
135
+ "inverse_volatility": PortfolioMode.RISK_PARITY.value,
136
+ "betaneutral": PortfolioMode.BETA_NEUTRAL.value,
137
+ "beta_neutral_basic": PortfolioMode.BETA_NEUTRAL.value,
138
+ }
139
+ value = aliases.get(value, value)
140
+ if value not in LEGACY_PORTFOLIO_MODES:
141
+ raise ValueError(f"unsupported portfolio mode: {mode!r}")
142
+ return value
143
+
144
+
145
+ def normalize_portfolio_sizing_mode(mode: str) -> str:
146
+ value = str(mode).lower().strip()
147
+ aliases = {
148
+ "pct_equity": PortfolioSizingMode.PCT_EQUITY.value,
149
+ "percent_equity": PortfolioSizingMode.PCT_EQUITY.value,
150
+ "signal_notional": PortfolioSizingMode.SIGNAL_NOTIONAL.value,
151
+ "signal": PortfolioSizingMode.SIGNAL.value,
152
+ "units": PortfolioSizingMode.TARGET_UNITS.value,
153
+ "target_unit": PortfolioSizingMode.TARGET_UNITS.value,
154
+ "dollar": PortfolioSizingMode.NOTIONAL.value,
155
+ "portfolio_target_weight": PortfolioSizingMode.TARGET_WEIGHT.value,
156
+ "portfolio_target_notional": PortfolioSizingMode.TARGET_NOTIONAL.value,
157
+ "portfolio_target_units": PortfolioSizingMode.TARGET_UNITS.value,
158
+ "target_weights": PortfolioSizingMode.TARGET_WEIGHT.value,
159
+ "target_notionals": PortfolioSizingMode.TARGET_NOTIONAL.value,
160
+ "target_unit": PortfolioSizingMode.TARGET_UNITS.value,
161
+ "gross": PortfolioSizingMode.GROSS_EXPOSURE.value,
162
+ "net": PortfolioSizingMode.NET_EXPOSURE.value,
163
+ }
164
+ value = aliases.get(value, value)
165
+ if value not in NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES:
166
+ raise ValueError(f"unsupported portfolio sizing mode: {mode!r}")
167
+ return value
168
+
169
+
170
+ def normalize_rebalance_policy(policy: str) -> str:
171
+ value = str(policy).lower().strip()
172
+ aliases = {
173
+ "on_transition": PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value,
174
+ "signal_change": PortfolioRebalancePolicy.ON_SIGNAL_CHANGE.value,
175
+ "bar": PortfolioRebalancePolicy.EVERY_BAR.value,
176
+ }
177
+ value = aliases.get(value, value)
178
+ valid = {item.value for item in PortfolioRebalancePolicy}
179
+ if value not in valid:
180
+ raise ValueError(f"unsupported portfolio rebalance policy: {policy!r}")
181
+ return value
182
+
183
+
184
+ def portfolio_capability_matrix() -> pd.DataFrame:
185
+ rows = []
186
+ for mode in sorted(LEGACY_PORTFOLIO_MODES):
187
+ for sizing in sorted(NATIVE_PORTFOLIO_ROADMAP_SIZING_MODES):
188
+ rows.append(
189
+ {
190
+ "mode": mode,
191
+ "sizing_mode": sizing,
192
+ "legacy_supported": mode in LEGACY_COMPATIBLE_PORTFOLIO_MODES
193
+ and sizing in LEGACY_PORTFOLIO_SIZING_MODES,
194
+ "native_supported": sizing in NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES,
195
+ "native_roadmap": True,
196
+ "nautilus_validation_phase": "phase_4",
197
+ }
198
+ )
199
+ return pd.DataFrame(rows)
200
+
201
+
202
+ def validate_portfolio_result_contract(
203
+ result,
204
+ spec: PortfolioDomainSpec,
205
+ *,
206
+ tolerance: float = 1e-8,
207
+ raise_on_fail: bool = False,
208
+ ) -> Dict:
209
+ """
210
+ Validate a completed portfolio result against the Phase 11 domain contract.
211
+
212
+ The report combines accounting reconciliation from
213
+ `build_portfolio_domain_audit` with mode-specific exposure invariants.
214
+ """
215
+ metadata = getattr(result, "metadata", {}) or {}
216
+ exposure = _frame(metadata.get("exposure_report"))
217
+ accepted_notional = _frame(metadata.get("accepted_notional_report"))
218
+ accepted_units = _frame(metadata.get("accepted_units_report"))
219
+ symbol_pnl = _frame(metadata.get("symbol_pnl_report"))
220
+ base_audit = build_portfolio_domain_audit(result, tolerance=tolerance, raise_on_fail=False)
221
+
222
+ checks = {
223
+ "base_accounting_audit": bool(base_audit.get("passed")),
224
+ "mode_matches_spec": metadata.get("mode") == spec.mode,
225
+ "sizing_matches_spec": metadata.get("hedge_type") == spec.sizing_mode,
226
+ "has_exposure_report": not exposure.empty if spec.require_gross_net_reports else True,
227
+ "has_symbol_pnl_report": not symbol_pnl.empty if spec.require_symbol_pnl_report else True,
228
+ "has_margin_columns": _has_columns(exposure, {"initial_margin", "maintenance_margin"}) if spec.require_margin_report else True,
229
+ "short_policy_respected": _short_policy_respected(accepted_units, spec.allow_short),
230
+ "gross_leverage_limit_respected": _max_column(exposure, "gross_leverage") <= spec.max_gross_leverage + tolerance
231
+ if spec.max_gross_leverage is not None and not exposure.empty
232
+ else True,
233
+ "net_exposure_limit_respected": _max_abs_column(exposure, "net_exposure_pct") <= spec.max_net_exposure_abs + tolerance
234
+ if spec.max_net_exposure_abs is not None and not exposure.empty
235
+ else True,
236
+ }
237
+ checks.update(_mode_specific_checks(spec.mode, exposure, accepted_notional, tolerance))
238
+
239
+ passed = all(bool(v) for v in checks.values())
240
+ report = {
241
+ "status": "pass" if passed else "fail",
242
+ "passed": passed,
243
+ "spec": {
244
+ "mode": spec.mode,
245
+ "sizing_mode": spec.sizing_mode,
246
+ "rebalance_policy": spec.rebalance_policy,
247
+ "legacy_compatible": spec.legacy_compatible,
248
+ "native_planned": spec.native_planned,
249
+ },
250
+ "checks": checks,
251
+ "base_audit": base_audit,
252
+ }
253
+ if raise_on_fail and not passed:
254
+ raise AssertionError(f"portfolio contract validation failed: {report}")
255
+ return report
256
+
257
+
258
+ def _mode_specific_checks(mode: str, exposure: pd.DataFrame, accepted_notional: pd.DataFrame, tolerance: float) -> Dict[str, bool]:
259
+ if exposure.empty:
260
+ return {
261
+ "market_neutral_balanced": mode != PortfolioMode.MARKET_NEUTRAL.value,
262
+ "directional_single_active": mode != PortfolioMode.DIRECTIONAL.value,
263
+ "equal_weight_balanced": mode != PortfolioMode.EQUAL_WEIGHT.value,
264
+ "risk_parity_balanced": mode != PortfolioMode.RISK_PARITY.value,
265
+ "beta_neutral_balanced": mode != PortfolioMode.BETA_NEUTRAL.value,
266
+ }
267
+
268
+ if mode == PortfolioMode.MARKET_NEUTRAL.value:
269
+ active = exposure["gross_notional"].abs() > tolerance
270
+ residual = (exposure.loc[active, "long_notional"] - exposure.loc[active, "short_notional"]).abs()
271
+ return {
272
+ "market_neutral_balanced": residual.empty or bool(residual.max() <= tolerance),
273
+ "directional_single_active": True,
274
+ "equal_weight_balanced": True,
275
+ "risk_parity_balanced": True,
276
+ "beta_neutral_balanced": True,
277
+ }
278
+
279
+ if mode == PortfolioMode.DIRECTIONAL.value:
280
+ if accepted_notional.empty:
281
+ single_active = False
282
+ else:
283
+ active_counts = (accepted_notional.abs() > tolerance).sum(axis=1)
284
+ single_active = bool((active_counts <= 1).all())
285
+ return {
286
+ "market_neutral_balanced": True,
287
+ "directional_single_active": single_active,
288
+ "equal_weight_balanced": True,
289
+ "risk_parity_balanced": True,
290
+ "beta_neutral_balanced": True,
291
+ }
292
+
293
+ if mode == PortfolioMode.EQUAL_WEIGHT.value:
294
+ balanced = _equal_weight_balanced(accepted_notional, tolerance)
295
+ return {
296
+ "market_neutral_balanced": True,
297
+ "directional_single_active": True,
298
+ "equal_weight_balanced": balanced,
299
+ "risk_parity_balanced": True,
300
+ "beta_neutral_balanced": True,
301
+ }
302
+
303
+ if mode == PortfolioMode.RISK_PARITY.value:
304
+ risk_ok = _risk_parity_balanced(_frame_from_exposure_attr(exposure, "risk_contribution_report"), tolerance)
305
+ return {
306
+ "market_neutral_balanced": True,
307
+ "directional_single_active": True,
308
+ "equal_weight_balanced": True,
309
+ "risk_parity_balanced": risk_ok,
310
+ "beta_neutral_balanced": True,
311
+ }
312
+
313
+ if mode == PortfolioMode.BETA_NEUTRAL.value:
314
+ if "beta_exposure_notional" in exposure:
315
+ active = exposure["gross_notional"].abs() > tolerance
316
+ beta_abs = exposure.loc[active, "beta_exposure_notional"].abs()
317
+ beta_ok = beta_abs.empty or bool(beta_abs.max() <= tolerance)
318
+ else:
319
+ beta_ok = False
320
+ return {
321
+ "market_neutral_balanced": True,
322
+ "directional_single_active": True,
323
+ "equal_weight_balanced": True,
324
+ "risk_parity_balanced": True,
325
+ "beta_neutral_balanced": beta_ok,
326
+ }
327
+
328
+ return {
329
+ "market_neutral_balanced": True,
330
+ "directional_single_active": True,
331
+ "equal_weight_balanced": True,
332
+ "risk_parity_balanced": True,
333
+ "beta_neutral_balanced": True,
334
+ }
335
+
336
+
337
+ def _equal_weight_balanced(accepted_notional: pd.DataFrame, tolerance: float) -> bool:
338
+ if accepted_notional.empty:
339
+ return False
340
+ abs_notional = accepted_notional.abs()
341
+ for _, row in abs_notional.iterrows():
342
+ active = row[row > tolerance]
343
+ if len(active) <= 1:
344
+ continue
345
+ if float(active.max() - active.min()) > tolerance:
346
+ return False
347
+ return True
348
+
349
+
350
+ def _short_policy_respected(accepted_units: pd.DataFrame, allow_short: bool) -> bool:
351
+ if allow_short or accepted_units.empty:
352
+ return True
353
+ return bool((accepted_units >= -1e-12).all().all())
354
+
355
+
356
+ def _has_columns(frame: pd.DataFrame, columns: Iterable[str]) -> bool:
357
+ return set(columns).issubset(frame.columns)
358
+
359
+
360
+ def _max_column(frame: pd.DataFrame, column: str) -> float:
361
+ if column not in frame:
362
+ return 0.0
363
+ values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna()
364
+ return float(values.max()) if not values.empty else 0.0
365
+
366
+
367
+ def _max_abs_column(frame: pd.DataFrame, column: str) -> float:
368
+ if column not in frame:
369
+ return 0.0
370
+ values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna().abs()
371
+ return float(values.max()) if not values.empty else 0.0
372
+
373
+
374
+ def _frame(value) -> pd.DataFrame:
375
+ return value if isinstance(value, pd.DataFrame) else pd.DataFrame()
376
+
377
+
378
+ def _frame_from_exposure_attr(exposure: pd.DataFrame, attr_name: str) -> pd.DataFrame:
379
+ value = exposure.attrs.get(attr_name) if isinstance(exposure, pd.DataFrame) else None
380
+ return value if isinstance(value, pd.DataFrame) else pd.DataFrame()
381
+
382
+
383
+ def _risk_parity_balanced(risk_contribution: pd.DataFrame, tolerance: float) -> bool:
384
+ if risk_contribution.empty:
385
+ return False
386
+ for _, row in risk_contribution.iterrows():
387
+ active = row[row > tolerance]
388
+ if len(active) <= 1:
389
+ continue
390
+ if float(active.max() - active.min()) > max(tolerance, 1e-6):
391
+ return False
392
+ return True
@@ -0,0 +1,256 @@
1
+ """
2
+ quantbt.core.preprocessor
3
+ --------------------------
4
+ Data alignment and numpy array assembly for the simulation kernels.
5
+ Keeps BacktestEngine clean; all pandas wrangling lives here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Dict, Optional, Union
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class MarketDataSignature:
19
+ length: int
20
+ first_timestamp_ns: Optional[int]
21
+ last_timestamp_ns: Optional[int]
22
+ symbols: tuple
23
+ shape: tuple
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class PreparedMarketArrays:
28
+ idx: pd.DatetimeIndex
29
+ symbols: tuple
30
+ closes: np.ndarray
31
+ highs: np.ndarray
32
+ lows: np.ndarray
33
+ funding: np.ndarray
34
+ is_funding_bar: np.ndarray
35
+ signature: MarketDataSignature
36
+
37
+
38
+ def validate_datetime(dt_input) -> pd.DatetimeIndex:
39
+ """Return a sorted, unique, UTC DatetimeIndex from any sensible input."""
40
+ if isinstance(dt_input, pd.DatetimeIndex):
41
+ idx = dt_input
42
+ else:
43
+ idx = pd.to_datetime(pd.Series(dt_input), errors="coerce", utc=True)
44
+ idx = pd.DatetimeIndex(idx).drop_duplicates().sort_values()
45
+ if idx.tz is None:
46
+ idx = idx.tz_localize("UTC")
47
+ else:
48
+ idx = idx.tz_convert("UTC")
49
+ return idx
50
+
51
+
52
+ def align_series(
53
+ data: Union[pd.Series, Dict[str, pd.Series]],
54
+ symbols: list,
55
+ idx: pd.DatetimeIndex,
56
+ fill_val: float = np.nan,
57
+ fallback: Optional[Dict[str, pd.Series]] = None,
58
+ ) -> Dict[str, pd.Series]:
59
+ """
60
+ Reindex each symbol's series to idx using forward-fill.
61
+ If data is a bare Series (single-symbol case), map it to symbols[0].
62
+ """
63
+ is_single = (len(symbols) == 1 and symbols[0] == "DEFAULT")
64
+ out: Dict[str, pd.Series] = {}
65
+
66
+ for sym in symbols:
67
+ if isinstance(data, dict):
68
+ s = data.get(sym)
69
+ elif is_single:
70
+ s = data
71
+ else:
72
+ s = None
73
+
74
+ if s is None:
75
+ if fallback is not None:
76
+ out[sym] = fallback[sym]
77
+ else:
78
+ out[sym] = pd.Series(fill_val, index=idx)
79
+ continue
80
+
81
+ if not isinstance(s, pd.Series):
82
+ s = pd.Series(s, index=idx)
83
+ else:
84
+ # ensure UTC
85
+ if isinstance(s.index, pd.DatetimeIndex):
86
+ if s.index.tz is None:
87
+ s.index = s.index.tz_localize("UTC")
88
+ else:
89
+ s.index = s.index.tz_convert("UTC")
90
+ s = s[~s.index.duplicated(keep="first")]
91
+ s = s.reindex(idx, method="ffill")
92
+
93
+ out[sym] = s
94
+
95
+ return out
96
+
97
+
98
+ def prepare_funding(
99
+ fr_input: Union[float, int, pd.Series, Dict],
100
+ symbols: list,
101
+ idx: pd.DatetimeIndex,
102
+ ) -> Dict[str, pd.Series]:
103
+ """Build per-symbol funding-rate series aligned to idx."""
104
+ out: Dict[str, pd.Series] = {}
105
+ for sym in symbols:
106
+ if isinstance(fr_input, dict):
107
+ if sym not in fr_input:
108
+ raise KeyError(
109
+ f"funding_rate dict is missing symbol {sym!r}; pass 0.0 explicitly "
110
+ "or set use_funding=False to avoid synthetic funding defaults"
111
+ )
112
+ val = fr_input[sym]
113
+ elif isinstance(fr_input, pd.Series):
114
+ val = fr_input
115
+ else:
116
+ val = fr_input
117
+
118
+ if isinstance(val, (float, int)):
119
+ out[sym] = pd.Series(float(val), index=idx)
120
+ else:
121
+ if isinstance(val.index, pd.DatetimeIndex):
122
+ if val.index.tz is None:
123
+ val.index = val.index.tz_localize("UTC")
124
+ else:
125
+ val.index = val.index.tz_convert("UTC")
126
+ out[sym] = val.reindex(idx, method="ffill").fillna(0.0)
127
+
128
+ return out
129
+
130
+
131
+ def make_funding_mask(idx: pd.DatetimeIndex) -> np.ndarray:
132
+ """
133
+ Boolean mask: True on the FIRST bar that enters each funding window.
134
+ Windows are [00:00, 08:00, 16:00) UTC. Works for any bar frequency.
135
+
136
+ Compared to np.isin(hour, [0,8,16]) this fires exactly once per window
137
+ instead of once per bar within the hour.
138
+ """
139
+ hours = idx.hour.to_numpy()
140
+ mask = np.zeros(len(idx), dtype=np.bool_)
141
+ funding_hours = {0, 8, 16}
142
+ for i in range(1, len(idx)):
143
+ if hours[i] in funding_hours and hours[i] != hours[i - 1]:
144
+ mask[i] = True
145
+ return mask
146
+
147
+
148
+ def build_arrays(
149
+ symbols: list,
150
+ idx: pd.DatetimeIndex,
151
+ closes_dict: Dict[str, pd.Series],
152
+ highs_dict: Dict[str, pd.Series],
153
+ lows_dict: Dict[str, pd.Series],
154
+ signals_dict: Dict[str, pd.Series],
155
+ funding_dict: Dict[str, pd.Series],
156
+ ) -> tuple:
157
+ """
158
+ Pack all per-symbol Series into contiguous float64 numpy arrays
159
+ ready for the numba kernels.
160
+
161
+ Returns
162
+ -------
163
+ closes, highs, lows, signals, funding each shape (n_bars, n_syms)
164
+ is_funding_bar shape (n_bars,) bool
165
+ """
166
+ market = build_market_arrays(
167
+ symbols=symbols,
168
+ idx=idx,
169
+ closes_dict=closes_dict,
170
+ highs_dict=highs_dict,
171
+ lows_dict=lows_dict,
172
+ funding_dict=funding_dict,
173
+ )
174
+ signals = build_signal_matrix(symbols=symbols, idx=idx, signals_dict=signals_dict)
175
+ return market.closes, market.highs, market.lows, signals, market.funding, market.is_funding_bar
176
+
177
+
178
+ def build_market_arrays(
179
+ symbols: list,
180
+ idx: pd.DatetimeIndex,
181
+ closes_dict: Dict[str, pd.Series],
182
+ highs_dict: Dict[str, pd.Series],
183
+ lows_dict: Dict[str, pd.Series],
184
+ funding_dict: Dict[str, pd.Series],
185
+ ) -> PreparedMarketArrays:
186
+ """
187
+ Pack immutable market arrays without allocating a dummy signal matrix.
188
+
189
+ This is the safe prepared-data object used by event-driven runs and future
190
+ optimizer caches. It stores arrays plus an explicit signature; it does not
191
+ cache results or infer validity from mutable pandas object identity.
192
+ """
193
+ n = len(idx)
194
+ s = len(symbols)
195
+ closes = np.zeros((n, s), dtype=np.float64)
196
+ highs = np.zeros((n, s), dtype=np.float64)
197
+ lows = np.zeros((n, s), dtype=np.float64)
198
+ funding = np.zeros((n, s), dtype=np.float64)
199
+
200
+ for k, sym in enumerate(symbols):
201
+ c_ser = closes_dict[sym].fillna(0)
202
+ c = c_ser.values
203
+ closes[:, k] = c
204
+ # fillna with close series (same index), then extract values
205
+ highs[:, k] = highs_dict[sym].fillna(c_ser).values
206
+ lows[:, k] = lows_dict[sym].fillna(c_ser).values
207
+ funding[:, k] = funding_dict[sym].fillna(0).values
208
+
209
+ is_funding_bar = make_funding_mask(idx)
210
+ closes = np.ascontiguousarray(closes, dtype=np.float64)
211
+ highs = np.ascontiguousarray(highs, dtype=np.float64)
212
+ lows = np.ascontiguousarray(lows, dtype=np.float64)
213
+ funding = np.ascontiguousarray(funding, dtype=np.float64)
214
+ is_funding_bar = np.ascontiguousarray(is_funding_bar, dtype=np.bool_)
215
+ for arr in (closes, highs, lows, funding, is_funding_bar):
216
+ arr.setflags(write=False)
217
+ return PreparedMarketArrays(
218
+ idx=idx,
219
+ symbols=tuple(symbols),
220
+ closes=closes,
221
+ highs=highs,
222
+ lows=lows,
223
+ funding=funding,
224
+ is_funding_bar=is_funding_bar,
225
+ signature=market_data_signature(idx, symbols),
226
+ )
227
+
228
+
229
+ def build_signal_matrix(
230
+ symbols: list,
231
+ idx: pd.DatetimeIndex,
232
+ signals_dict: Dict[str, pd.Series],
233
+ ) -> np.ndarray:
234
+ n = len(idx)
235
+ s = len(symbols)
236
+ signals = np.zeros((n, s), dtype=np.float64)
237
+ for k, sym in enumerate(symbols):
238
+ signals[:, k] = signals_dict[sym].fillna(0).values
239
+ return np.ascontiguousarray(signals, dtype=np.float64)
240
+
241
+
242
+ def market_data_signature(idx: pd.DatetimeIndex, symbols: list) -> MarketDataSignature:
243
+ if len(idx) == 0:
244
+ first = None
245
+ last = None
246
+ else:
247
+ values = idx.view("int64")
248
+ first = int(values[0])
249
+ last = int(values[-1])
250
+ return MarketDataSignature(
251
+ length=int(len(idx)),
252
+ first_timestamp_ns=first,
253
+ last_timestamp_ns=last,
254
+ symbols=tuple(symbols),
255
+ shape=(int(len(idx)), int(len(symbols))),
256
+ )