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,928 @@
1
+ """
2
+ quantbt.backends.native_portfolio
3
+ ---------------------------------
4
+ Native portfolio backend.
5
+
6
+ Phase 11B keeps the proven `_engine_portfolio` accounting kernel as the
7
+ compatibility oracle path, but moves portfolio preparation, mode transforms, and
8
+ report construction behind an explicit backend. This lets the native portfolio
9
+ route evolve independently from `MultiSymbolPortfolio` without changing legacy
10
+ endpoint defaults.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Dict, List, Optional, Sequence, Union
17
+
18
+ import numpy as np
19
+ import pandas as pd
20
+
21
+ from ..core.engine import _engine_portfolio, _engine_portfolio_equity_sizing
22
+ from ..core.constraints import build_quantity_constraints, quantize_target_units_matrix
23
+ from ..core.portfolio import (
24
+ NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES,
25
+ PortfolioDomainSpec,
26
+ normalize_portfolio_mode,
27
+ normalize_portfolio_sizing_mode,
28
+ validate_portfolio_result_contract,
29
+ )
30
+ from ..core.preprocessor import (
31
+ PreparedMarketArrays,
32
+ align_series,
33
+ build_market_arrays,
34
+ build_signal_matrix,
35
+ market_data_signature,
36
+ prepare_funding,
37
+ validate_datetime,
38
+ )
39
+ from ..core.results import BacktestResultV2
40
+ from ..core.schema import AccountConfig
41
+ from ..core.schema import ExecutionConfig, InstrumentSpec
42
+ from ..sizing.fast import scale_signal_notional_matrix
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class NativePortfolioConfig:
47
+ account: AccountConfig
48
+ execution: ExecutionConfig = field(default_factory=ExecutionConfig)
49
+ fee_rate: float = 0.0
50
+ use_funding: bool = True
51
+ report_level: str = "full"
52
+
53
+ def __post_init__(self) -> None:
54
+ if float(self.fee_rate) < 0.0:
55
+ raise ValueError("fee_rate must be >= 0")
56
+ object.__setattr__(self, "report_level", _normalize_report_level(self.report_level))
57
+
58
+
59
+ class NativePortfolioBackend:
60
+ """
61
+ Explicit native portfolio backend for multi-symbol position matrices.
62
+
63
+ `fee_rate` is interpreted as a canonical one-way rate inside this backend.
64
+ Legacy round-trip `fee` compatibility is handled only at facade boundaries.
65
+ """
66
+
67
+ def __init__(self, config: NativePortfolioConfig):
68
+ self.config = config
69
+
70
+ def run_signals(
71
+ self,
72
+ positions: Optional[Dict[str, pd.Series]],
73
+ closes: Dict[str, pd.Series],
74
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
75
+ *,
76
+ mode: str = "longshort",
77
+ alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
78
+ contract_size: Union[float, Dict[str, float], None] = 1.0,
79
+ hedge_type: str = "signal_notional",
80
+ funding_rate: Union[float, Dict[str, float], pd.Series, None] = 0.0,
81
+ leverage: Optional[Union[float, Dict[str, float]]] = None,
82
+ maintenance_ratio: Optional[float] = None,
83
+ highs: Optional[Dict[str, pd.Series]] = None,
84
+ lows: Optional[Dict[str, pd.Series]] = None,
85
+ symbols: Optional[Sequence[str]] = None,
86
+ use_pyramiding: bool = True,
87
+ asset_type: str = "crypto",
88
+ betas: Optional[Union[float, Dict[str, float]]] = None,
89
+ risk_lookback: int = 60,
90
+ market_arrays: Optional[PreparedMarketArrays] = None,
91
+ raw_signal_matrix: Optional[np.ndarray] = None,
92
+ instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
93
+ qty_step: Optional[Union[float, Dict[str, float]]] = None,
94
+ lot_size: Optional[Union[float, Dict[str, float]]] = None,
95
+ slot_size: Optional[Union[float, Dict[str, float]]] = None,
96
+ min_qty: Optional[Union[float, Dict[str, float]]] = None,
97
+ min_notional: Optional[Union[float, Dict[str, float]]] = None,
98
+ report_level: Optional[str] = None,
99
+ ) -> BacktestResultV2:
100
+ idx = validate_datetime(datetime_index)
101
+ if positions is None and raw_signal_matrix is None:
102
+ raise ValueError("positions or raw_signal_matrix is required")
103
+ position_keys = set(positions.keys()) if positions is not None else set()
104
+ symbol_list = list(symbols) if symbols is not None else list(positions.keys() if positions is not None else closes.keys())
105
+ if positions is not None and set(symbol_list) != position_keys:
106
+ raise ValueError("symbols and positions must contain the same keys")
107
+ if market_arrays is None and set(symbol_list) != set(closes.keys()):
108
+ raise ValueError("symbols and closes must contain the same keys")
109
+
110
+ portfolio_mode = normalize_portfolio_mode(mode)
111
+ sizing_mode = normalize_portfolio_sizing_mode(hedge_type)
112
+ if sizing_mode not in NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES:
113
+ raise NotImplementedError(
114
+ f"native_portfolio does not yet support equity-dependent sizing mode {hedge_type!r}"
115
+ )
116
+
117
+ if market_arrays is None:
118
+ market = self.prepare_market_arrays(
119
+ datetime_index=idx,
120
+ closes=closes,
121
+ highs=highs,
122
+ lows=lows,
123
+ funding_rate=funding_rate,
124
+ symbols=symbol_list,
125
+ )
126
+ elif market_arrays.signature != market_data_signature(idx, symbol_list):
127
+ raise ValueError("prepared market arrays do not match datetime_index/symbols")
128
+ else:
129
+ market = market_arrays
130
+
131
+ if raw_signal_matrix is None:
132
+ pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0)
133
+ raw_signals = build_signal_matrix(symbol_list, idx, pos_dict)
134
+ else:
135
+ raw_signals = np.ascontiguousarray(raw_signal_matrix, dtype=np.float64)
136
+ if raw_signals.shape != market.closes.shape:
137
+ raise ValueError("raw_signal_matrix shape does not match prepared market arrays")
138
+
139
+ cs_arr = self._per_symbol_array(contract_size, symbol_list, default=1.0)
140
+ constraints = build_quantity_constraints(
141
+ symbol_list,
142
+ instruments=instruments,
143
+ qty_step=qty_step,
144
+ lot_size=lot_size,
145
+ slot_size=slot_size,
146
+ min_qty=min_qty,
147
+ min_notional=min_notional,
148
+ )
149
+ lev_arr = self._per_symbol_array(
150
+ self.config.account.leverage if leverage is None else leverage,
151
+ symbol_list,
152
+ default=self.config.account.leverage,
153
+ )
154
+ alloc_arr = self._per_symbol_array(alloc_per_trade, symbol_list, default=100_000.0)
155
+ maint_ratio = self.config.account.maintenance_ratio if maintenance_ratio is None else float(maintenance_ratio)
156
+
157
+ beta_arr = self._per_symbol_array(betas, symbol_list, default=1.0)
158
+ tradable_mask = self._tradable_matrix(
159
+ closes=closes,
160
+ idx=idx,
161
+ symbols=symbol_list,
162
+ market=market,
163
+ max_stale_bars=int(self.config.account.metadata.get("portfolio_max_stale_bars", 0)),
164
+ )
165
+ risk_vol = self._risk_volatility_matrix(market.closes, lookback=int(risk_lookback))
166
+ inv_vol = np.divide(1.0, risk_vol, out=np.zeros_like(risk_vol), where=risk_vol > 0.0)
167
+ equity_aware = sizing_mode in {"%_equity", "target_weight", "gross_exposure", "net_exposure"}
168
+ slippage_rate = float(self.config.execution.slippage_rate)
169
+
170
+ if equity_aware:
171
+ (
172
+ equity_arr,
173
+ target_units,
174
+ pos_arr,
175
+ sym_pnl_arr,
176
+ fee_arr,
177
+ slippage_arr,
178
+ turnover_arr,
179
+ liq_flag,
180
+ liq_idx,
181
+ ) = _engine_portfolio_equity_sizing(
182
+ n_bars=len(idx),
183
+ n_syms=len(symbol_list),
184
+ highs=market.highs,
185
+ lows=market.lows,
186
+ closes=market.closes,
187
+ raw_signals=raw_signals,
188
+ funding_rates=market.funding,
189
+ is_funding_bar=market.is_funding_bar,
190
+ init_capital=self.config.account.initial_capital,
191
+ leverages=lev_arr,
192
+ maint_ratio=maint_ratio,
193
+ fee_rate=float(self.config.fee_rate),
194
+ slippage_rate=slippage_rate,
195
+ contract_sizes=cs_arr,
196
+ use_funding=bool(self.config.use_funding),
197
+ allocs=alloc_arr,
198
+ sizing_mode_id=self._sizing_mode_id(sizing_mode),
199
+ portfolio_mode_id=self._portfolio_mode_id(portfolio_mode),
200
+ use_pyramiding=bool(use_pyramiding),
201
+ exposure_scalar=float(np.mean(alloc_arr)) if len(alloc_arr) else 1.0,
202
+ beta=beta_arr,
203
+ inv_vol=inv_vol,
204
+ qty_steps=constraints.qty_step,
205
+ min_qtys=constraints.min_qty,
206
+ min_notionals=constraints.min_notional,
207
+ tradable=tradable_mask,
208
+ )
209
+ else:
210
+ target_units = self._scale_target_units(
211
+ sizing_mode=sizing_mode,
212
+ raw_signals=raw_signals,
213
+ closes=market.closes,
214
+ alloc_arr=alloc_arr,
215
+ contract_sizes=cs_arr,
216
+ use_pyramiding=use_pyramiding,
217
+ )
218
+ target_units = self._apply_mode(
219
+ mode=portfolio_mode,
220
+ target_units=target_units,
221
+ closes=market.closes,
222
+ contract_sizes=cs_arr,
223
+ betas=beta_arr,
224
+ risk_vol=risk_vol,
225
+ )
226
+ target_units = quantize_target_units_matrix(target_units, market.closes, cs_arr, constraints)
227
+
228
+ (
229
+ equity_arr,
230
+ pos_arr,
231
+ sym_pnl_arr,
232
+ fee_arr,
233
+ slippage_arr,
234
+ turnover_arr,
235
+ liq_flag,
236
+ liq_idx,
237
+ ) = _engine_portfolio(
238
+ n_bars=len(idx),
239
+ n_syms=len(symbol_list),
240
+ highs=market.highs,
241
+ lows=market.lows,
242
+ closes=market.closes,
243
+ target_pos=target_units,
244
+ funding_rates=market.funding,
245
+ is_funding_bar=market.is_funding_bar,
246
+ init_capital=self.config.account.initial_capital,
247
+ leverages=lev_arr,
248
+ maint_ratio=maint_ratio,
249
+ fee_rate=float(self.config.fee_rate),
250
+ slippage_rate=slippage_rate,
251
+ contract_sizes=cs_arr,
252
+ use_funding=bool(self.config.use_funding),
253
+ tradable=tradable_mask,
254
+ )
255
+
256
+ result = self._build_result(
257
+ idx=idx,
258
+ symbol_list=symbol_list,
259
+ closes_m=market.closes,
260
+ target_m=target_units,
261
+ pos_arr=pos_arr,
262
+ sym_pnl_arr=sym_pnl_arr,
263
+ funding_m=market.funding,
264
+ is_funding_bar=market.is_funding_bar,
265
+ equity_arr=equity_arr,
266
+ fee_arr=fee_arr,
267
+ slippage_arr=slippage_arr,
268
+ turnover_arr=turnover_arr,
269
+ contract_sizes=cs_arr,
270
+ leverages=lev_arr,
271
+ betas=beta_arr,
272
+ risk_vol=risk_vol,
273
+ mode=portfolio_mode,
274
+ hedge_type=sizing_mode,
275
+ asset_type=asset_type,
276
+ maintenance_ratio=maint_ratio,
277
+ liquidated=bool(liq_flag),
278
+ liquidation_bar=int(liq_idx),
279
+ quantity_constraints=constraints.as_dict(),
280
+ tradable_mask=tradable_mask,
281
+ report_level=self.config.report_level if report_level is None else report_level,
282
+ )
283
+ spec = PortfolioDomainSpec(mode=portfolio_mode, sizing_mode=sizing_mode)
284
+ if result.metadata.get("report_level") == "minimal":
285
+ result.metadata["portfolio_contract_report"] = {
286
+ "status": "skipped",
287
+ "passed": None,
288
+ "reason": "report_level='minimal' omits heavy audit reports; rerun with report_level='full' for contract validation",
289
+ "spec": {"mode": portfolio_mode, "sizing_mode": sizing_mode},
290
+ }
291
+ else:
292
+ result.metadata["portfolio_contract_report"] = validate_portfolio_result_contract(result, spec, tolerance=1e-8)
293
+ return result
294
+
295
+ def prepare_market_arrays(
296
+ self,
297
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
298
+ closes: Dict[str, pd.Series],
299
+ highs: Optional[Dict[str, pd.Series]] = None,
300
+ lows: Optional[Dict[str, pd.Series]] = None,
301
+ funding_rate: Union[float, Dict[str, float], pd.Series, None] = 0.0,
302
+ symbols: Optional[Sequence[str]] = None,
303
+ ) -> PreparedMarketArrays:
304
+ """
305
+ Normalize portfolio market data once for WFO/service loops.
306
+
307
+ The returned object is immutable ndarray-backed market state with a
308
+ datetime/symbol signature. `run_signals` rejects stale reuse against a
309
+ different index or symbol order, avoiding identity-cache bugs.
310
+ """
311
+ idx = validate_datetime(datetime_index)
312
+ symbol_list = list(symbols) if symbols is not None else list(closes.keys())
313
+ close_dict = align_series(closes, symbol_list, idx)
314
+ high_dict = align_series(highs, symbol_list, idx, fallback=close_dict)
315
+ low_dict = align_series(lows, symbol_list, idx, fallback=close_dict)
316
+ funding_dict = prepare_funding(funding_rate if self.config.use_funding else 0.0, symbol_list, idx)
317
+ return build_market_arrays(symbol_list, idx, close_dict, high_dict, low_dict, funding_dict)
318
+
319
+ @staticmethod
320
+ def prepare_signal_matrix(
321
+ positions: Dict[str, pd.Series],
322
+ datetime_index: Union[pd.DatetimeIndex, pd.Series],
323
+ symbols: Sequence[str],
324
+ ) -> np.ndarray:
325
+ """
326
+ Normalize a portfolio signal matrix once when replaying prepared data.
327
+ """
328
+ idx = validate_datetime(datetime_index)
329
+ symbol_list = list(symbols)
330
+ if set(symbol_list) != set(positions.keys()):
331
+ raise ValueError("symbols and positions must contain the same keys")
332
+ pos_dict = align_series(positions, symbol_list, idx, fill_val=0.0)
333
+ return build_signal_matrix(symbol_list, idx, pos_dict)
334
+
335
+ @staticmethod
336
+ def _scale_target_units(
337
+ *,
338
+ sizing_mode: str,
339
+ raw_signals: np.ndarray,
340
+ closes: np.ndarray,
341
+ alloc_arr: np.ndarray,
342
+ contract_sizes: np.ndarray,
343
+ use_pyramiding: bool,
344
+ ) -> np.ndarray:
345
+ if sizing_mode in ("signal_notional", "signal"):
346
+ return scale_signal_notional_matrix(raw_signals, closes, alloc_arr, use_pyramiding=use_pyramiding)
347
+
348
+ sig = raw_signals if use_pyramiding else np.sign(raw_signals)
349
+ denom = closes * contract_sizes.reshape(1, -1)
350
+
351
+ if sizing_mode == "notional":
352
+ notionals = sig * alloc_arr.reshape(1, -1)
353
+ return np.ascontiguousarray(
354
+ np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0),
355
+ dtype=np.float64,
356
+ )
357
+
358
+ if sizing_mode == "unit":
359
+ first_denom = denom[0:1, :]
360
+ scale = np.divide(
361
+ alloc_arr.reshape(1, -1),
362
+ first_denom,
363
+ out=np.zeros((1, raw_signals.shape[1]), dtype=np.float64),
364
+ where=first_denom != 0.0,
365
+ )
366
+ return np.ascontiguousarray(sig * scale, dtype=np.float64)
367
+
368
+ if sizing_mode == "target_units":
369
+ return np.ascontiguousarray(raw_signals, dtype=np.float64)
370
+
371
+ if sizing_mode == "target_notional":
372
+ return np.ascontiguousarray(
373
+ np.divide(raw_signals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0),
374
+ dtype=np.float64,
375
+ )
376
+
377
+ if sizing_mode == "fixed_notional":
378
+ notionals = sig * alloc_arr.reshape(1, -1)
379
+ return np.ascontiguousarray(
380
+ np.divide(notionals, denom, out=np.zeros_like(raw_signals, dtype=np.float64), where=denom != 0.0),
381
+ dtype=np.float64,
382
+ )
383
+
384
+ raise NotImplementedError(f"native_portfolio sizing mode {sizing_mode!r} is not vectorized")
385
+
386
+ @staticmethod
387
+ def _apply_mode(
388
+ *,
389
+ mode: str,
390
+ target_units: np.ndarray,
391
+ closes: np.ndarray,
392
+ contract_sizes: np.ndarray,
393
+ betas: np.ndarray,
394
+ risk_vol: np.ndarray,
395
+ ) -> np.ndarray:
396
+ out = np.array(target_units, dtype=np.float64, copy=True, order="C")
397
+ notional = out * closes * contract_sizes.reshape(1, -1)
398
+
399
+ if mode == "market_neutral":
400
+ long_sum = np.where(notional > 0.0, notional, 0.0).sum(axis=1)
401
+ short_sum = np.where(notional < 0.0, -notional, 0.0).sum(axis=1)
402
+ target = (long_sum + short_sum) / 2.0
403
+ valid = (long_sum > 0.0) & (short_sum > 0.0)
404
+ long_scale = np.divide(target, long_sum, out=np.zeros_like(target), where=valid)
405
+ short_scale = np.divide(target, short_sum, out=np.zeros_like(target), where=valid)
406
+ out = np.where(
407
+ notional > 0.0,
408
+ out * long_scale.reshape(-1, 1),
409
+ np.where(notional < 0.0, out * short_scale.reshape(-1, 1), 0.0),
410
+ )
411
+ elif mode == "directional":
412
+ dominant = np.abs(notional).argmax(axis=1)
413
+ mask = np.zeros_like(out, dtype=bool)
414
+ mask[np.arange(out.shape[0]), dominant] = True
415
+ out = np.where(mask, out, 0.0)
416
+ out = np.where(np.abs(notional).sum(axis=1).reshape(-1, 1) > 0.0, out, 0.0)
417
+ elif mode == "equal_weight":
418
+ active = (notional != 0.0).sum(axis=1)
419
+ gross = np.abs(notional).sum(axis=1)
420
+ target_abs = np.divide(gross, active, out=np.zeros_like(gross), where=active != 0)
421
+ denom = closes * contract_sizes.reshape(1, -1)
422
+ out = np.sign(notional) * np.divide(
423
+ target_abs.reshape(-1, 1),
424
+ denom,
425
+ out=np.zeros_like(out),
426
+ where=denom != 0.0,
427
+ )
428
+ elif mode == "risk_parity":
429
+ gross = np.abs(notional).sum(axis=1)
430
+ inv_vol = np.divide(1.0, risk_vol, out=np.zeros_like(risk_vol), where=risk_vol > 0.0)
431
+ active_inv = np.where(notional != 0.0, inv_vol, 0.0)
432
+ denom_inv = active_inv.sum(axis=1)
433
+ target_abs = np.divide(gross.reshape(-1, 1) * active_inv, denom_inv.reshape(-1, 1), out=np.zeros_like(out), where=denom_inv.reshape(-1, 1) != 0.0)
434
+ denom = closes * contract_sizes.reshape(1, -1)
435
+ out = np.sign(notional) * np.divide(target_abs, denom, out=np.zeros_like(out), where=denom != 0.0)
436
+ elif mode == "beta_neutral":
437
+ beta_notional = notional * betas.reshape(1, -1)
438
+ long_beta = np.where(beta_notional > 0.0, beta_notional, 0.0).sum(axis=1)
439
+ short_beta = np.where(beta_notional < 0.0, -beta_notional, 0.0).sum(axis=1)
440
+ target = (long_beta + short_beta) / 2.0
441
+ long_scale = np.divide(target, long_beta, out=np.zeros_like(target), where=long_beta != 0.0)
442
+ short_scale = np.divide(target, short_beta, out=np.zeros_like(target), where=short_beta != 0.0)
443
+ out = np.where(
444
+ beta_notional > 0.0,
445
+ out * long_scale.reshape(-1, 1),
446
+ np.where(beta_notional < 0.0, out * short_scale.reshape(-1, 1), 0.0),
447
+ )
448
+
449
+ return np.ascontiguousarray(out, dtype=np.float64)
450
+
451
+ def _build_result(
452
+ self,
453
+ *,
454
+ idx: pd.DatetimeIndex,
455
+ symbol_list: List[str],
456
+ closes_m: np.ndarray,
457
+ target_m: np.ndarray,
458
+ pos_arr: np.ndarray,
459
+ sym_pnl_arr: np.ndarray,
460
+ funding_m: np.ndarray,
461
+ is_funding_bar: np.ndarray,
462
+ equity_arr: np.ndarray,
463
+ fee_arr: np.ndarray,
464
+ slippage_arr: np.ndarray,
465
+ turnover_arr: np.ndarray,
466
+ contract_sizes: np.ndarray,
467
+ leverages: np.ndarray,
468
+ betas: np.ndarray,
469
+ risk_vol: np.ndarray,
470
+ mode: str,
471
+ hedge_type: str,
472
+ asset_type: str,
473
+ maintenance_ratio: float,
474
+ liquidated: bool,
475
+ liquidation_bar: int,
476
+ quantity_constraints: Dict[str, Dict[str, float]],
477
+ tradable_mask: np.ndarray,
478
+ report_level: str,
479
+ ) -> BacktestResultV2:
480
+ level = _normalize_report_level(report_level)
481
+ equity = pd.Series(equity_arr, index=idx, name="equity")
482
+ close_report = pd.DataFrame(closes_m, index=idx, columns=symbol_list, copy=False)
483
+ target_units_report = pd.DataFrame(target_m, index=idx, columns=symbol_list, copy=False)
484
+ accepted_units_report = pd.DataFrame(pos_arr, index=idx, columns=symbol_list, copy=False)
485
+ cs = pd.Series({s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)})
486
+ lev = pd.Series({s: float(leverages[j]) for j, s in enumerate(symbol_list)})
487
+ beta_s = pd.Series({s: float(betas[j]) for j, s in enumerate(symbol_list)})
488
+ cs_row = contract_sizes.reshape(1, -1)
489
+ target_notional_arr = target_m * closes_m * cs_row
490
+ accepted_notional_arr = pos_arr * closes_m * cs_row
491
+ target_notional = pd.DataFrame(target_notional_arr, index=idx, columns=symbol_list, copy=False)
492
+ accepted_notional = pd.DataFrame(accepted_notional_arr, index=idx, columns=symbol_list, copy=False)
493
+
494
+ positions = pd.DataFrame(pos_arr, index=idx, columns=[f"Position_{s}" for s in symbol_list], copy=False)
495
+ closes = pd.DataFrame(closes_m, index=idx, columns=[f"Close_{s}" for s in symbol_list], copy=False)
496
+ fees = pd.Series(fee_arr, index=idx, name="fees")
497
+ slippage = pd.Series(slippage_arr, index=idx, name="slippage")
498
+ turnover = pd.Series(turnover_arr, index=idx, name="turnover")
499
+ prev_units = np.vstack([np.zeros((1, len(symbol_list)), dtype=np.float64), pos_arr[:-1]])
500
+ funding_cost_arr = prev_units * closes_m * cs_row * funding_m
501
+ funding_cost_arr = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost_arr, 0.0).sum(axis=1)
502
+ abs_accepted = np.abs(accepted_notional_arr)
503
+ margin = pd.DataFrame(
504
+ {
505
+ "initial_margin": (abs_accepted / leverages.reshape(1, -1)).sum(axis=1),
506
+ "maintenance_margin": abs_accepted.sum(axis=1) * float(maintenance_ratio),
507
+ },
508
+ index=idx,
509
+ )
510
+ diagnostics = pd.DataFrame(
511
+ {
512
+ "turnover": turnover_arr,
513
+ "slippage": slippage_arr,
514
+ "rejected_rebalances": np.abs(target_m - pos_arr).sum(axis=1) > 1e-10,
515
+ },
516
+ index=idx,
517
+ )
518
+ returns_arr = np.zeros_like(equity_arr, dtype=np.float64)
519
+ if len(equity_arr) > 1:
520
+ returns_arr[1:] = np.divide(
521
+ equity_arr[1:] - equity_arr[:-1],
522
+ equity_arr[:-1],
523
+ out=np.zeros(len(equity_arr) - 1, dtype=np.float64),
524
+ where=equity_arr[:-1] != 0.0,
525
+ )
526
+
527
+ metadata = {
528
+ "backend": "native_portfolio",
529
+ "mode": mode,
530
+ "asset_type": asset_type,
531
+ "hedge_type": hedge_type,
532
+ "engine": "native_portfolio_v1",
533
+ "report_level": level,
534
+ "initial_buying_power": self.config.account.initial_capital * float(np.mean(leverages)),
535
+ "funding_rate_unit": "per_event",
536
+ "target_units_report": target_units_report,
537
+ "accepted_units_report": accepted_units_report,
538
+ "beta": {s: float(betas[j]) for j, s in enumerate(symbol_list)},
539
+ "fee_series": fees,
540
+ "turnover_series": turnover,
541
+ "slippage_series": slippage,
542
+ "fee_total": float(np.sum(fee_arr)),
543
+ "slippage_total": float(np.sum(slippage_arr)),
544
+ "turnover_total": float(np.sum(turnover_arr)),
545
+ "fee_rate_oneway": float(self.config.fee_rate),
546
+ "canonical_one_way_fee_rate": float(self.config.fee_rate),
547
+ "slippage_bps": float(self.config.execution.slippage_bps),
548
+ "contract_size": {s: float(contract_sizes[j]) for j, s in enumerate(symbol_list)},
549
+ "quantity_constraints": quantity_constraints,
550
+ }
551
+ omitted = []
552
+ if level in {"full", "standard"}:
553
+ funding_rates = pd.DataFrame(funding_m, index=idx, columns=symbol_list, copy=False)
554
+ exposure_report = self._build_exposure_report(
555
+ accepted_notional_arr=accepted_notional_arr,
556
+ target_notional_arr=target_notional_arr,
557
+ equity_arr=equity_arr,
558
+ idx=idx,
559
+ leverages=leverages,
560
+ maintenance_ratio=maintenance_ratio,
561
+ betas=betas,
562
+ )
563
+ symbol_pnl_report = self._build_symbol_pnl_report(
564
+ idx=idx,
565
+ symbols=symbol_list,
566
+ accepted_units_arr=pos_arr,
567
+ closes_arr=closes_m,
568
+ funding_rates_arr=funding_m,
569
+ is_funding_bar=is_funding_bar,
570
+ contract_sizes=contract_sizes,
571
+ fee_arr=fee_arr,
572
+ slippage_arr=slippage_arr,
573
+ )
574
+ metadata.update(
575
+ {
576
+ "target_notional_report": target_notional,
577
+ "accepted_notional_report": accepted_notional,
578
+ "exposure_report": exposure_report,
579
+ "funding_rates_report": funding_rates,
580
+ "symbol_pnl_report": symbol_pnl_report,
581
+ }
582
+ )
583
+ if level == "full":
584
+ risk_vol_report = pd.DataFrame(risk_vol, index=idx, columns=symbol_list, copy=False)
585
+ risk_contribution_report = pd.DataFrame(np.abs(accepted_notional_arr) * risk_vol, index=idx, columns=symbol_list, copy=False)
586
+ exposure_report.attrs["risk_contribution_report"] = risk_contribution_report
587
+ rebalance_report = self._build_rebalance_report(
588
+ idx=idx,
589
+ symbols=symbol_list,
590
+ target_units_arr=target_m,
591
+ accepted_units_arr=pos_arr,
592
+ closes_arr=closes_m,
593
+ contract_sizes=contract_sizes,
594
+ tradable_mask=tradable_mask,
595
+ quantity_constraints=quantity_constraints,
596
+ )
597
+ reconciliation_report = self._build_reconciliation_report(
598
+ initial_capital=float(self.config.account.initial_capital),
599
+ equity_arr=equity_arr,
600
+ fee_arr=fee_arr,
601
+ slippage_arr=slippage_arr,
602
+ turnover_arr=turnover_arr,
603
+ positions=positions,
604
+ target_units_report=target_units_report,
605
+ accepted_units_report=accepted_units_report,
606
+ symbol_pnl_report=symbol_pnl_report,
607
+ )
608
+ metadata.update(
609
+ {
610
+ "risk_volatility_report": risk_vol_report,
611
+ "risk_contribution_report": risk_contribution_report,
612
+ "kernel_symbol_pnl": pd.DataFrame(sym_pnl_arr, index=idx, columns=symbol_list, copy=False),
613
+ "rebalance_report": rebalance_report,
614
+ "portfolio_reconciliation_report": reconciliation_report,
615
+ }
616
+ )
617
+ else:
618
+ omitted.extend(["risk_volatility_report", "risk_contribution_report", "kernel_symbol_pnl", "rebalance_report", "portfolio_reconciliation_report"])
619
+ else:
620
+ omitted.extend(
621
+ [
622
+ "target_notional_report",
623
+ "accepted_notional_report",
624
+ "exposure_report",
625
+ "funding_rates_report",
626
+ "risk_volatility_report",
627
+ "risk_contribution_report",
628
+ "symbol_pnl_report",
629
+ "kernel_symbol_pnl",
630
+ "rebalance_report",
631
+ "portfolio_reconciliation_report",
632
+ ]
633
+ )
634
+ metadata["reports_omitted"] = tuple(omitted)
635
+
636
+ return BacktestResultV2(
637
+ equity=equity,
638
+ returns=pd.Series(returns_arr, index=idx, name="returns"),
639
+ positions=positions,
640
+ closes=closes,
641
+ symbols=symbol_list,
642
+ initial_capital=self.config.account.initial_capital,
643
+ leverage=float(np.mean(leverages)),
644
+ liquidated=liquidated,
645
+ liquidation_bar=liquidation_bar,
646
+ fees=fees,
647
+ funding=pd.Series(funding_cost_arr, index=idx, name="funding"),
648
+ margin=margin,
649
+ diagnostics=diagnostics,
650
+ metadata=metadata,
651
+ )
652
+
653
+ @staticmethod
654
+ def _build_symbol_pnl_report(
655
+ *,
656
+ idx: pd.DatetimeIndex,
657
+ symbols: List[str],
658
+ accepted_units_arr: np.ndarray,
659
+ closes_arr: np.ndarray,
660
+ funding_rates_arr: np.ndarray,
661
+ is_funding_bar: np.ndarray,
662
+ contract_sizes: np.ndarray,
663
+ fee_arr: np.ndarray,
664
+ slippage_arr: np.ndarray,
665
+ ) -> pd.DataFrame:
666
+ n_bars, n_syms = accepted_units_arr.shape
667
+ if n_bars == 0 or n_syms == 0:
668
+ return pd.DataFrame()
669
+ prev_units = np.vstack([np.zeros((1, n_syms), dtype=np.float64), accepted_units_arr[:-1]])
670
+ prev_close = np.vstack([closes_arr[0:1], closes_arr[:-1]])
671
+ cs = contract_sizes.reshape(1, -1)
672
+ mark_pnl = prev_units * (closes_arr - prev_close) * cs
673
+ funding_cost = prev_units * closes_arr * cs * funding_rates_arr
674
+ funding_cost = np.where(is_funding_bar.reshape(-1, 1).astype(bool), funding_cost, 0.0)
675
+ trade_delta = np.abs(accepted_units_arr - prev_units)
676
+ trade_notional = trade_delta * closes_arr * cs
677
+ total_trade_notional = trade_notional.sum(axis=1, keepdims=True)
678
+ share = np.divide(
679
+ trade_notional,
680
+ total_trade_notional,
681
+ out=np.zeros_like(trade_notional),
682
+ where=total_trade_notional != 0.0,
683
+ )
684
+ fee = fee_arr.reshape(-1, 1) * share
685
+ slippage = slippage_arr.reshape(-1, 1) * share
686
+ total_pnl = mark_pnl - funding_cost - fee - slippage
687
+
688
+ return pd.DataFrame(
689
+ {
690
+ "timestamp": np.tile(np.asarray(idx, dtype=object), n_syms),
691
+ "symbol": np.repeat(np.asarray(symbols, dtype=object), n_bars),
692
+ "position_units": accepted_units_arr.T.reshape(-1),
693
+ "close": closes_arr.T.reshape(-1),
694
+ "mark_pnl": mark_pnl.T.reshape(-1),
695
+ "funding_cost": funding_cost.T.reshape(-1),
696
+ "funding_pnl": (-funding_cost).T.reshape(-1),
697
+ "fee": fee.T.reshape(-1),
698
+ "fee_pnl": (-fee).T.reshape(-1),
699
+ "slippage_cost": slippage.T.reshape(-1),
700
+ "slippage_pnl": (-slippage).T.reshape(-1),
701
+ "total_pnl": total_pnl.T.reshape(-1),
702
+ }
703
+ )
704
+
705
+ @staticmethod
706
+ def _build_exposure_report(
707
+ *,
708
+ accepted_notional_arr: np.ndarray,
709
+ target_notional_arr: np.ndarray,
710
+ equity_arr: np.ndarray,
711
+ idx: pd.DatetimeIndex,
712
+ leverages: np.ndarray,
713
+ maintenance_ratio: float,
714
+ betas: np.ndarray,
715
+ ) -> pd.DataFrame:
716
+ abs_accepted = np.abs(accepted_notional_arr)
717
+ gross = abs_accepted.sum(axis=1)
718
+ net = accepted_notional_arr.sum(axis=1)
719
+ initial_margin = (abs_accepted / leverages.reshape(1, -1)).sum(axis=1)
720
+ maintenance_margin = gross * float(maintenance_ratio)
721
+ beta_exposure = (accepted_notional_arr * betas.reshape(1, -1)).sum(axis=1)
722
+ target_gross = np.abs(target_notional_arr).sum(axis=1)
723
+ target_beta_exposure = (target_notional_arr * betas.reshape(1, -1)).sum(axis=1)
724
+ mean_leverage = float(np.mean(leverages))
725
+ gross_leverage = np.divide(gross, equity_arr, out=np.zeros_like(gross), where=equity_arr != 0.0)
726
+ net_exposure_pct = np.divide(net, equity_arr, out=np.zeros_like(net), where=equity_arr != 0.0)
727
+ return pd.DataFrame(
728
+ {
729
+ "long_notional": np.where(accepted_notional_arr > 0.0, accepted_notional_arr, 0.0).sum(axis=1),
730
+ "short_notional": np.where(accepted_notional_arr < 0.0, -accepted_notional_arr, 0.0).sum(axis=1),
731
+ "gross_notional": gross,
732
+ "net_notional": net,
733
+ "beta_exposure_notional": beta_exposure,
734
+ "target_gross_notional": target_gross,
735
+ "target_beta_exposure_notional": target_beta_exposure,
736
+ "initial_margin": initial_margin,
737
+ "maintenance_margin": maintenance_margin,
738
+ "equity": equity_arr,
739
+ "available_equity_after_im": equity_arr - initial_margin,
740
+ "buying_power": equity_arr * mean_leverage,
741
+ "gross_leverage": gross_leverage,
742
+ "net_exposure_pct": net_exposure_pct,
743
+ },
744
+ index=idx,
745
+ )
746
+
747
+ @staticmethod
748
+ def _build_rebalance_report(
749
+ *,
750
+ idx: pd.DatetimeIndex,
751
+ symbols: List[str],
752
+ target_units_arr: np.ndarray,
753
+ accepted_units_arr: np.ndarray,
754
+ closes_arr: np.ndarray,
755
+ contract_sizes: np.ndarray,
756
+ tradable_mask: np.ndarray,
757
+ quantity_constraints: Dict[str, Dict[str, float]],
758
+ ) -> pd.DataFrame:
759
+ diff = target_units_arr - accepted_units_arr
760
+ row_idx, col_idx = np.nonzero(np.abs(diff) > 1e-10)
761
+ if len(row_idx) == 0:
762
+ return pd.DataFrame(
763
+ columns=["timestamp", "symbol", "target_units", "accepted_units", "unit_diff", "notional_diff", "reason"]
764
+ )
765
+ unit_diff = diff[row_idx, col_idx]
766
+ notional_diff = unit_diff * closes_arr[row_idx, col_idx] * contract_sizes[col_idx]
767
+ symbol_arr = np.asarray(symbols, dtype=object)
768
+ reasons = []
769
+ for r, c in zip(row_idx, col_idx):
770
+ symbol = symbols[int(c)]
771
+ target = float(target_units_arr[r, c])
772
+ close = float(closes_arr[r, c])
773
+ cs = float(contract_sizes[c])
774
+ constraints = quantity_constraints.get(symbol, {})
775
+ min_qty = float(constraints.get("min_qty", 0.0) or 0.0)
776
+ min_notional = float(constraints.get("min_notional", 0.0) or 0.0)
777
+ abs_target = abs(target)
778
+ notional = abs_target * close * cs if np.isfinite(close) else np.nan
779
+ if not np.isfinite(target):
780
+ reasons.append("INVALID_TARGET")
781
+ elif not np.isfinite(close) or close <= 0.0:
782
+ reasons.append("NON_TRADABLE")
783
+ elif not bool(tradable_mask[r, c]):
784
+ reasons.append("STALE_PRICE")
785
+ elif min_qty > 0.0 and 0.0 < abs_target < min_qty:
786
+ reasons.append("MIN_QTY")
787
+ elif min_notional > 0.0 and np.isfinite(notional) and 0.0 < notional < min_notional:
788
+ reasons.append("MIN_NOTIONAL")
789
+ else:
790
+ reasons.append("POST_COST_MARGIN")
791
+ return pd.DataFrame(
792
+ {
793
+ "timestamp": idx.take(row_idx),
794
+ "symbol": symbol_arr[col_idx],
795
+ "target_units": target_units_arr[row_idx, col_idx],
796
+ "accepted_units": accepted_units_arr[row_idx, col_idx],
797
+ "unit_diff": unit_diff,
798
+ "notional_diff": notional_diff,
799
+ "reason": reasons,
800
+ }
801
+ )
802
+
803
+ @staticmethod
804
+ def _build_reconciliation_report(
805
+ *,
806
+ initial_capital: float,
807
+ equity_arr: np.ndarray,
808
+ fee_arr: np.ndarray,
809
+ slippage_arr: np.ndarray,
810
+ turnover_arr: np.ndarray,
811
+ positions: pd.DataFrame,
812
+ target_units_report: pd.DataFrame,
813
+ accepted_units_report: pd.DataFrame,
814
+ symbol_pnl_report: pd.DataFrame,
815
+ ) -> Dict[str, float]:
816
+ if symbol_pnl_report is None or symbol_pnl_report.empty:
817
+ symbol_fee = 0.0
818
+ symbol_slippage = 0.0
819
+ symbol_pnl = 0.0
820
+ else:
821
+ symbol_fee = float(symbol_pnl_report["fee"].sum())
822
+ symbol_slippage = float(symbol_pnl_report["slippage_cost"].sum())
823
+ symbol_pnl = float(symbol_pnl_report["total_pnl"].sum())
824
+ positions_values = positions.to_numpy(dtype=np.float64, copy=False)
825
+ accepted_values = accepted_units_report.to_numpy(dtype=np.float64, copy=False)
826
+ return {
827
+ "fee_total": float(np.sum(fee_arr)),
828
+ "symbol_fee_total": symbol_fee,
829
+ "fee_diff": float(np.sum(fee_arr) - symbol_fee),
830
+ "slippage_total": float(np.sum(slippage_arr)),
831
+ "symbol_slippage_total": symbol_slippage,
832
+ "slippage_diff": float(np.sum(slippage_arr) - symbol_slippage),
833
+ "turnover_total": float(np.sum(turnover_arr)),
834
+ "symbol_total_pnl": symbol_pnl,
835
+ "equity_pnl": float(equity_arr[-1] - initial_capital) if len(equity_arr) else 0.0,
836
+ "equity_symbol_pnl_diff": float((equity_arr[-1] - initial_capital) - symbol_pnl) if len(equity_arr) else 0.0,
837
+ "max_result_position_diff": float(np.nanmax(np.abs(positions_values - accepted_values))) if positions_values.size else 0.0,
838
+ "max_target_accepted_diff": float(np.nanmax(np.abs(target_units_report.to_numpy(dtype=np.float64, copy=False) - accepted_values))) if accepted_values.size else 0.0,
839
+ }
840
+
841
+ @staticmethod
842
+ def _per_symbol_array(value, symbols: List[str], default: float) -> np.ndarray:
843
+ if value is None:
844
+ return np.full(len(symbols), float(default), dtype=np.float64)
845
+ if isinstance(value, dict):
846
+ return np.array([float(value.get(symbol, default)) for symbol in symbols], dtype=np.float64)
847
+ return np.full(len(symbols), float(value), dtype=np.float64)
848
+
849
+ @staticmethod
850
+ def _risk_volatility_matrix(closes: np.ndarray, lookback: int) -> np.ndarray:
851
+ frame = pd.DataFrame(closes).where(lambda x: x > 0.0)
852
+ returns = np.log(frame).diff()
853
+ window = max(2, int(lookback))
854
+ vol = returns.rolling(window, min_periods=window).std()
855
+ arr = vol.to_numpy(dtype=np.float64)
856
+ arr[~np.isfinite(arr)] = 0.0
857
+ arr[arr <= 0.0] = 0.0
858
+ return np.ascontiguousarray(arr, dtype=np.float64)
859
+
860
+ @staticmethod
861
+ def _tradable_matrix(
862
+ *,
863
+ closes: Dict[str, pd.Series],
864
+ idx: pd.DatetimeIndex,
865
+ symbols: Sequence[str],
866
+ market: PreparedMarketArrays,
867
+ max_stale_bars: int = 0,
868
+ ) -> np.ndarray:
869
+ out = np.isfinite(market.closes) & (market.closes > 0.0)
870
+ if closes is None:
871
+ return np.ascontiguousarray(out, dtype=np.bool_)
872
+ max_stale = max(0, int(max_stale_bars))
873
+ for j, symbol in enumerate(symbols):
874
+ raw = closes[symbol]
875
+ if not isinstance(raw, pd.Series):
876
+ raw = pd.Series(raw, index=idx)
877
+ raw_idx = raw.index
878
+ if isinstance(raw_idx, pd.DatetimeIndex):
879
+ if raw_idx.tz is None:
880
+ raw = raw.copy()
881
+ raw.index = raw.index.tz_localize("UTC")
882
+ else:
883
+ raw = raw.copy()
884
+ raw.index = raw.index.tz_convert("UTC")
885
+ observed = raw[~raw.index.duplicated(keep="first")].reindex(idx)
886
+ values = observed.to_numpy(dtype=np.float64)
887
+ stale = max_stale + 1
888
+ for i in range(len(idx)):
889
+ if np.isfinite(values[i]) and values[i] > 0.0:
890
+ stale = 0
891
+ else:
892
+ stale += 1
893
+ out[i, j] = bool(out[i, j] and stale <= max_stale)
894
+ return np.ascontiguousarray(out, dtype=np.bool_)
895
+
896
+ @staticmethod
897
+ def _sizing_mode_id(sizing_mode: str) -> int:
898
+ mapping = {"%_equity": 0, "target_weight": 1, "gross_exposure": 2, "net_exposure": 3}
899
+ return mapping[sizing_mode]
900
+
901
+ @staticmethod
902
+ def _portfolio_mode_id(mode: str) -> int:
903
+ mapping = {
904
+ "longshort": 0,
905
+ "market_neutral": 1,
906
+ "directional": 2,
907
+ "equal_weight": 3,
908
+ "risk_parity": 4,
909
+ "beta_neutral": 5,
910
+ }
911
+ return mapping[mode]
912
+
913
+
914
+ def _normalize_report_level(report_level: str) -> str:
915
+ level = str(report_level or "full").lower().strip()
916
+ aliases = {
917
+ "audit": "full",
918
+ "complete": "full",
919
+ "default": "full",
920
+ "lite": "standard",
921
+ "light": "minimal",
922
+ "optimizer": "minimal",
923
+ "scoring": "minimal",
924
+ }
925
+ level = aliases.get(level, level)
926
+ if level not in {"full", "standard", "minimal"}:
927
+ raise ValueError("report_level must be one of 'full', 'standard', or 'minimal'")
928
+ return level