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,206 @@
1
+ """
2
+ Portfolio domain audit helpers.
3
+
4
+ These functions validate accounting and exposure invariants on completed
5
+ multi-symbol portfolio results. They are report-level checks only; they do not
6
+ change execution semantics.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, Optional
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+
17
+ def build_portfolio_domain_audit(
18
+ result,
19
+ *,
20
+ tolerance: float = 1e-9,
21
+ raise_on_fail: bool = False,
22
+ ) -> Dict:
23
+ """
24
+ Return a compact audit summary for a multi-symbol portfolio result.
25
+
26
+ The audit checks that accepted-position attribution reconciles to the equity
27
+ curve, fees reconcile to the per-bar fee series, accepted notionals match
28
+ units times closes times contract size, and exposure-report identities hold.
29
+ Rebalance rows are informational: a non-empty report means the requested
30
+ target matrix differed from accepted positions, usually because a
31
+ portfolio/margin gate rejected a rebalance.
32
+ """
33
+ metadata = getattr(result, "metadata", {}) or {}
34
+ missing = [
35
+ name
36
+ for name in (
37
+ "target_units_report",
38
+ "accepted_units_report",
39
+ "accepted_notional_report",
40
+ "exposure_report",
41
+ "symbol_pnl_report",
42
+ )
43
+ if not isinstance(metadata.get(name), pd.DataFrame)
44
+ ]
45
+
46
+ accepted_units = _frame(metadata.get("accepted_units_report"))
47
+ accepted_notional = _frame(metadata.get("accepted_notional_report"))
48
+ exposure_report = _frame(metadata.get("exposure_report"))
49
+ symbol_pnl_report = _frame(metadata.get("symbol_pnl_report"))
50
+ rebalance_report = _frame(metadata.get("rebalance_report"))
51
+ fee_series = _series(metadata.get("fee_series"))
52
+
53
+ equity_residual = np.nan
54
+ if not symbol_pnl_report.empty:
55
+ pnl_sum = (
56
+ symbol_pnl_report.assign(
57
+ timestamp=pd.to_datetime(symbol_pnl_report["timestamp"], utc=True),
58
+ total_pnl=pd.to_numeric(symbol_pnl_report["total_pnl"], errors="coerce").fillna(0.0),
59
+ )
60
+ .groupby("timestamp", sort=False)["total_pnl"]
61
+ .sum()
62
+ )
63
+ equity_delta = result.equity.diff().fillna(0.0)
64
+ if getattr(result, "liquidated", False):
65
+ liq_idx = int(getattr(result, "liquidation_bar", -1))
66
+ if liq_idx >= 0:
67
+ equity_delta = equity_delta.iloc[:liq_idx]
68
+ pnl_sum = pnl_sum.reindex(equity_delta.index, fill_value=0.0)
69
+ else:
70
+ pnl_sum = pnl_sum.reindex(equity_delta.index, fill_value=0.0)
71
+ equity_residual = _max_abs((pnl_sum - equity_delta).to_numpy(dtype=float))
72
+
73
+ fee_residual = np.nan
74
+ if not symbol_pnl_report.empty and "fee" in symbol_pnl_report:
75
+ pnl_fee = float(pd.to_numeric(symbol_pnl_report["fee"], errors="coerce").fillna(0.0).sum())
76
+ metadata_fee = float(metadata.get("fee_total", fee_series.sum() if not fee_series.empty else 0.0))
77
+ fee_residual = abs(pnl_fee - metadata_fee)
78
+
79
+ notional_residual = _accepted_notional_residual(result, accepted_units, accepted_notional)
80
+ exposure_residual = _exposure_identity_residual(exposure_report)
81
+
82
+ rebalance_abs_notional = 0.0
83
+ if not rebalance_report.empty and "notional_diff" in rebalance_report:
84
+ rebalance_abs_notional = float(
85
+ pd.to_numeric(rebalance_report["notional_diff"], errors="coerce").fillna(0.0).abs().sum()
86
+ )
87
+
88
+ checks = {
89
+ "has_required_reports": not missing,
90
+ "pnl_reconciles_to_equity": _ok(equity_residual, tolerance),
91
+ "fees_reconcile": _ok(fee_residual, tolerance),
92
+ "accepted_notional_reconciles": _ok(notional_residual, tolerance),
93
+ "exposure_identities_reconcile": _ok(exposure_residual, tolerance),
94
+ }
95
+ passed = all(checks.values())
96
+ audit = {
97
+ "status": "pass" if passed else "fail",
98
+ "passed": passed,
99
+ "tolerance": float(tolerance),
100
+ "engine": metadata.get("engine"),
101
+ "backend": metadata.get("backend"),
102
+ "mode": metadata.get("mode"),
103
+ "asset_type": metadata.get("asset_type"),
104
+ "hedge_type": metadata.get("hedge_type"),
105
+ "missing_reports": missing,
106
+ "checks": checks,
107
+ "max_abs_pnl_equity_residual": _float_or_none(equity_residual),
108
+ "max_abs_fee_residual": _float_or_none(fee_residual),
109
+ "max_abs_accepted_notional_residual": _float_or_none(notional_residual),
110
+ "max_abs_exposure_identity_residual": _float_or_none(exposure_residual),
111
+ "rebalance_count": int(len(rebalance_report)),
112
+ "rebalance_abs_notional": float(rebalance_abs_notional),
113
+ "liquidated": bool(getattr(result, "liquidated", False)),
114
+ "liquidation_bar": int(getattr(result, "liquidation_bar", -1)),
115
+ "symbols": list(map(str, getattr(result, "symbols", ()))),
116
+ }
117
+ if raise_on_fail and not passed:
118
+ raise AssertionError(f"portfolio domain audit failed: {audit}")
119
+ return audit
120
+
121
+
122
+ def _accepted_notional_residual(result, accepted_units: pd.DataFrame, accepted_notional: pd.DataFrame) -> float:
123
+ if accepted_units.empty or accepted_notional.empty:
124
+ return np.nan
125
+ closes = getattr(result, "closes", pd.DataFrame()).copy()
126
+ if closes.empty:
127
+ return np.nan
128
+ closes = closes.rename(columns={col: str(col).replace("Close_", "", 1) for col in closes.columns})
129
+ closes = closes.reindex(columns=accepted_units.columns)
130
+ contract_sizes = _contract_sizes_from_metadata(result, accepted_units.columns)
131
+ expected = accepted_units.mul(closes, axis=0).mul(contract_sizes, axis=1)
132
+ expected = expected.reindex_like(accepted_notional)
133
+ return _max_abs((expected - accepted_notional).to_numpy(dtype=float))
134
+
135
+
136
+ def _contract_sizes_from_metadata(result, columns) -> pd.Series:
137
+ metadata = getattr(result, "metadata", {}) or {}
138
+ target = _frame(metadata.get("accepted_notional_report"))
139
+ units = _frame(metadata.get("accepted_units_report"))
140
+ closes = getattr(result, "closes", pd.DataFrame()).copy()
141
+ closes = closes.rename(columns={col: str(col).replace("Close_", "", 1) for col in closes.columns})
142
+ values = {}
143
+ for symbol in columns:
144
+ values[symbol] = 1.0
145
+ if target.empty or units.empty or closes.empty or symbol not in target or symbol not in units or symbol not in closes:
146
+ continue
147
+ denom = units[symbol] * closes[symbol]
148
+ mask = denom.abs() > 1e-12
149
+ if mask.any():
150
+ inferred = (target.loc[mask, symbol] / denom.loc[mask]).replace([np.inf, -np.inf], np.nan).dropna()
151
+ if not inferred.empty:
152
+ values[symbol] = float(inferred.iloc[0])
153
+ return pd.Series(values)
154
+
155
+
156
+ def _exposure_identity_residual(exposure_report: pd.DataFrame) -> float:
157
+ if exposure_report.empty:
158
+ return np.nan
159
+ required = {"long_notional", "short_notional", "gross_notional", "net_notional"}
160
+ if not required.issubset(exposure_report.columns):
161
+ return np.nan
162
+ long_notional = pd.to_numeric(exposure_report["long_notional"], errors="coerce").fillna(0.0)
163
+ short_notional = pd.to_numeric(exposure_report["short_notional"], errors="coerce").fillna(0.0)
164
+ gross_notional = pd.to_numeric(exposure_report["gross_notional"], errors="coerce").fillna(0.0)
165
+ net_notional = pd.to_numeric(exposure_report["net_notional"], errors="coerce").fillna(0.0)
166
+ gross_residual = _max_abs((long_notional + short_notional - gross_notional).to_numpy(dtype=float))
167
+ net_residual = _max_abs((long_notional - short_notional - net_notional).to_numpy(dtype=float))
168
+ return max(gross_residual, net_residual)
169
+
170
+
171
+ def _frame(value) -> pd.DataFrame:
172
+ return value if isinstance(value, pd.DataFrame) else pd.DataFrame()
173
+
174
+
175
+ def _series(value) -> pd.Series:
176
+ return value if isinstance(value, pd.Series) else pd.Series(dtype=float)
177
+
178
+
179
+ def _max_abs(value) -> float:
180
+ if value is None:
181
+ return np.nan
182
+ arr = np.asarray(value, dtype=np.float64)
183
+ arr = arr[np.isfinite(arr)]
184
+ if arr.size == 0:
185
+ return np.nan
186
+ return float(np.max(np.abs(arr)))
187
+
188
+
189
+ def _ok(value: Optional[float], tolerance: float) -> bool:
190
+ if value is None:
191
+ return False
192
+ try:
193
+ numeric = float(value)
194
+ except (TypeError, ValueError):
195
+ return False
196
+ return bool(np.isfinite(numeric) and numeric <= float(tolerance))
197
+
198
+
199
+ def _float_or_none(value):
200
+ try:
201
+ numeric = float(value)
202
+ except (TypeError, ValueError):
203
+ return None
204
+ if not np.isfinite(numeric):
205
+ return None
206
+ return numeric
@@ -0,0 +1,140 @@
1
+ """Portfolio native-vs-Nautilus validation helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from ..core.results import BacktestResultV2
11
+
12
+
13
+ def build_portfolio_nautilus_position_report(
14
+ native_result: BacktestResultV2,
15
+ nautilus_result: BacktestResultV2,
16
+ ) -> pd.DataFrame:
17
+ """Return timestamp/symbol position differences between native and Nautilus."""
18
+ native_pos = _position_frame(native_result)
19
+ nautilus_pos = _position_frame(nautilus_result)
20
+ symbols = sorted(set(native_pos.columns) | set(nautilus_pos.columns))
21
+ idx = native_result.equity.index.union(nautilus_result.equity.index).sort_values()
22
+ native_pos = native_pos.reindex(idx).ffill().fillna(0.0).reindex(columns=symbols, fill_value=0.0)
23
+ nautilus_pos = nautilus_pos.reindex(idx).ffill().fillna(0.0).reindex(columns=symbols, fill_value=0.0)
24
+
25
+ rows: List[Dict[str, Any]] = []
26
+ for timestamp in idx:
27
+ for symbol in symbols:
28
+ native_value = float(native_pos.loc[timestamp, symbol])
29
+ nautilus_value = float(nautilus_pos.loc[timestamp, symbol])
30
+ rows.append(
31
+ {
32
+ "timestamp": timestamp,
33
+ "symbol": symbol,
34
+ "native_position": native_value,
35
+ "nautilus_position": nautilus_value,
36
+ "position_diff": native_value - nautilus_value,
37
+ }
38
+ )
39
+ return pd.DataFrame(rows)
40
+
41
+
42
+ def build_portfolio_nautilus_validation_report(
43
+ native_result: BacktestResultV2,
44
+ nautilus_result: BacktestResultV2,
45
+ *,
46
+ target_tolerance: float = 1e-9,
47
+ position_tolerance: float = 1e-6,
48
+ equity_tolerance: float = 1e-6,
49
+ ) -> Dict[str, Any]:
50
+ """
51
+ Summarize portfolio package validation between native and Nautilus results.
52
+
53
+ This is an institutional audit summary, not a claim that Nautilus is the
54
+ optimizer path. It checks that the submitted Nautilus package matches the
55
+ native target-unit matrix and, when reports are available, compares
56
+ positions and equity.
57
+ """
58
+ native_target = _frame((native_result.metadata or {}).get("target_units_report"))
59
+ nautilus_target = _frame((nautilus_result.metadata or {}).get("portfolio_target_units"))
60
+ package_order_map = _frame((nautilus_result.metadata or {}).get("package_order_map"))
61
+ orders_report = _frame((nautilus_result.metadata or {}).get("orders_report", (nautilus_result.metadata or {}).get("order_report")))
62
+ fills_report = _frame((nautilus_result.metadata or {}).get("fills_report"))
63
+
64
+ expected_orders = _expected_order_count(native_target)
65
+ nautilus_orders = int((nautilus_result.metadata or {}).get("orders_count", len(orders_report)))
66
+ if nautilus_orders == 0:
67
+ nautilus_orders = int((nautilus_result.metadata or {}).get("order_count_input", len(package_order_map)))
68
+ nautilus_fills = int((nautilus_result.metadata or {}).get("fills_count", len(fills_report)))
69
+ if nautilus_fills == 0 and nautilus_orders > 0 and len(fills_report) == 0:
70
+ nautilus_fills = int((nautilus_result.metadata or {}).get("order_count_input", 0))
71
+
72
+ target_diff = _target_units_diff(native_target, nautilus_target)
73
+ position_report = build_portfolio_nautilus_position_report(native_result, nautilus_result)
74
+ max_position_diff = _max_abs(position_report, "position_diff")
75
+ final_equity_diff = float(native_result.equity.iloc[-1] - nautilus_result.equity.iloc[-1])
76
+
77
+ checks = {
78
+ "input_mode_is_portfolio_matrix": (nautilus_result.metadata or {}).get("input_mode") == "portfolio_matrix",
79
+ "target_units_match": target_diff <= float(target_tolerance),
80
+ "order_count_matches_target_transitions": nautilus_orders == expected_orders,
81
+ "fills_do_not_exceed_orders": nautilus_fills <= nautilus_orders,
82
+ "positions_within_tolerance": max_position_diff <= float(position_tolerance),
83
+ "final_equity_within_tolerance": abs(final_equity_diff) <= float(equity_tolerance),
84
+ }
85
+ passed = all(bool(value) for value in checks.values())
86
+ return {
87
+ "status": "pass" if passed else "diff",
88
+ "passed": bool(passed),
89
+ "checks": checks,
90
+ "expected_order_count": int(expected_orders),
91
+ "nautilus_orders": int(nautilus_orders),
92
+ "nautilus_fills": int(nautilus_fills),
93
+ "max_abs_target_units_diff": float(target_diff),
94
+ "max_abs_position_diff": float(max_position_diff),
95
+ "final_equity_diff": float(final_equity_diff),
96
+ "target_tolerance": float(target_tolerance),
97
+ "position_tolerance": float(position_tolerance),
98
+ "equity_tolerance": float(equity_tolerance),
99
+ "native_backend": (native_result.metadata or {}).get("backend"),
100
+ "nautilus_backend": (nautilus_result.metadata or {}).get("backend"),
101
+ "engine": (nautilus_result.metadata or {}).get("engine"),
102
+ }
103
+
104
+
105
+ def _expected_order_count(target_units: pd.DataFrame) -> int:
106
+ if target_units.empty:
107
+ return 0
108
+ prev = target_units.shift(1).fillna(0.0)
109
+ delta = (target_units - prev).abs()
110
+ return int((delta > 1e-12).sum().sum())
111
+
112
+
113
+ def _target_units_diff(native_target: pd.DataFrame, nautilus_target: pd.DataFrame) -> float:
114
+ if native_target.empty or nautilus_target.empty:
115
+ return np.inf
116
+ common_cols = sorted(set(native_target.columns) & set(nautilus_target.columns))
117
+ if not common_cols:
118
+ return np.inf
119
+ idx = native_target.index.union(nautilus_target.index).sort_values()
120
+ left = native_target.reindex(idx).ffill().fillna(0.0).reindex(columns=common_cols)
121
+ right = nautilus_target.reindex(idx).ffill().fillna(0.0).reindex(columns=common_cols)
122
+ arr = (left - right).to_numpy(dtype=float)
123
+ return float(np.nanmax(np.abs(arr))) if arr.size else 0.0
124
+
125
+
126
+ def _position_frame(result: BacktestResultV2) -> pd.DataFrame:
127
+ frame = result.positions.copy()
128
+ frame = frame.rename(columns={col: str(col).replace("Position_", "", 1) for col in frame.columns})
129
+ return frame
130
+
131
+
132
+ def _frame(value) -> pd.DataFrame:
133
+ return value if isinstance(value, pd.DataFrame) else pd.DataFrame()
134
+
135
+
136
+ def _max_abs(frame: pd.DataFrame, column: str) -> float:
137
+ if frame.empty or column not in frame:
138
+ return np.inf
139
+ values = pd.to_numeric(frame[column], errors="coerce").replace([np.inf, -np.inf], np.nan).dropna().abs()
140
+ return float(values.max()) if not values.empty else 0.0
@@ -0,0 +1,3 @@
1
+ from .modes import compute_target_units
2
+
3
+ __all__ = ["compute_target_units"]
quantbt/sizing/fast.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Fast ndarray sizing helpers.
3
+
4
+ These helpers are internal optimization paths. They must match the public
5
+ Series-based sizing functions in `quantbt.sizing.modes`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ from numba import njit
12
+
13
+
14
+ @njit(cache=True)
15
+ def _signal_notional_matrix_numba(
16
+ signals: np.ndarray,
17
+ closes: np.ndarray,
18
+ allocs: np.ndarray,
19
+ use_pyramiding: bool,
20
+ ) -> np.ndarray:
21
+ n_bars, n_syms = signals.shape
22
+ out = np.zeros((n_bars, n_syms), dtype=np.float64)
23
+ for j in range(n_syms):
24
+ current_scale = 0.0
25
+ prev_sig = 0.0
26
+ for i in range(n_bars):
27
+ sig = signals[i, j]
28
+ if not use_pyramiding:
29
+ if sig > 0.0:
30
+ sig = 1.0
31
+ elif sig < 0.0:
32
+ sig = -1.0
33
+ else:
34
+ sig = 0.0
35
+ if i == 0 or sig != prev_sig:
36
+ if sig != 0.0:
37
+ current_scale = allocs[j] / closes[i, j]
38
+ else:
39
+ current_scale = 0.0
40
+ out[i, j] = sig * current_scale
41
+ prev_sig = sig
42
+ return out
43
+
44
+
45
+ def scale_signal_notional_matrix(
46
+ signals: np.ndarray,
47
+ closes: np.ndarray,
48
+ allocs: np.ndarray,
49
+ use_pyramiding: bool = True,
50
+ ) -> np.ndarray:
51
+ """
52
+ Return target-unit matrix for signal_notional sizing.
53
+
54
+ The behavior is intentionally identical to `scale_signal_notional` applied
55
+ per symbol: anchor units on signal transition and keep them frozen between
56
+ transitions.
57
+ """
58
+ sig = np.ascontiguousarray(signals, dtype=np.float64)
59
+ cls = np.ascontiguousarray(closes, dtype=np.float64)
60
+ alc = np.ascontiguousarray(allocs, dtype=np.float64)
61
+ if sig.shape != cls.shape:
62
+ raise ValueError("signals and closes must have the same shape")
63
+ if sig.ndim != 2:
64
+ raise ValueError("signals and closes must be 2D arrays")
65
+ if len(alc) != sig.shape[1]:
66
+ raise ValueError("allocs length must match number of symbols")
67
+ return _signal_notional_matrix_numba(sig, cls, alc, bool(use_pyramiding))
@@ -0,0 +1,162 @@
1
+ """
2
+ quantbt.sizing.modes
3
+ --------------------
4
+ Position scaling: converts raw signal weights into target *units* (contracts)
5
+ that the numba engine can consume directly.
6
+
7
+ Five modes
8
+ ~~~~~~~~~~
9
+ notional
10
+ target_units[i] = signal[i] × (alloc / close[i])
11
+ Units recomputed every bar → constant notional exposure.
12
+ Generates a trade whenever signal OR price changes. High turnover on
13
+ intraday data; intended for EOD / multi-day bars.
14
+
15
+ unit
16
+ target_units[i] = signal[i] × (alloc / close[0])
17
+ Scale fixed at the *first* bar's price. Units stable as price moves;
18
+ notional drifts with the market.
19
+
20
+ signal_notional ← recommended for systematic strategies
21
+ Units are re-anchored to current price ONLY when the signal weight
22
+ changes. Between signal changes the unit count is frozen → no
23
+ spurious micro-trades due to price drift.
24
+ target_units[i] = signal[i] × (alloc / close[change_bar])
25
+
26
+ pct_equity
27
+ Raw weight is passed straight through to the %_equity numba kernel,
28
+ which sizes units from live equity at execution time.
29
+ Returns the raw signal unchanged; no pre-scaling needed.
30
+
31
+ dca_ladder
32
+ Raw signed structural level is passed straight through to the DCA ladder
33
+ execution kernel. The kernel turns High/Low limit touches into actual
34
+ filled units at each grid trigger price.
35
+
36
+ Parameters
37
+ ----------
38
+ signal : pd.Series raw weight (float), e.g. 1.0 / -0.5 / 0.3
39
+ close : pd.Series closing price, same index as signal
40
+ alloc : float notional allocation per full signal unit (USD)
41
+ use_pyramiding : bool if False, signal is clipped to {-1, 0, 1}
42
+
43
+ Returns
44
+ -------
45
+ pd.Series target units (float), same index as signal
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import numpy as np
51
+ import pandas as pd
52
+
53
+
54
+ def scale_notional(
55
+ signal: pd.Series,
56
+ close: pd.Series,
57
+ alloc: float,
58
+ use_pyramiding: bool = True,
59
+ ) -> pd.Series:
60
+ sig = signal if use_pyramiding else np.sign(signal)
61
+ return sig * (alloc / close)
62
+
63
+
64
+ def scale_unit(
65
+ signal: pd.Series,
66
+ close: pd.Series,
67
+ alloc: float,
68
+ use_pyramiding: bool = True,
69
+ ) -> pd.Series:
70
+ sig = signal if use_pyramiding else np.sign(signal)
71
+ scale = alloc / close.iloc[0]
72
+ return sig * scale
73
+
74
+
75
+ def scale_signal_notional(
76
+ signal: pd.Series,
77
+ close: pd.Series,
78
+ alloc: float,
79
+ use_pyramiding: bool = True,
80
+ ) -> pd.Series:
81
+ """
82
+ Anchor-on-change scaling.
83
+
84
+ Units are computed once per signal transition using the prevailing price
85
+ at that bar, then held constant until the next transition. This is the
86
+ standard approach in institutional systematic desks to avoid phantom
87
+ rebalancing trades.
88
+ """
89
+ sig_vals = signal.values if use_pyramiding else np.sign(signal.values)
90
+ pr_vals = close.values
91
+ n = len(sig_vals)
92
+ target = np.zeros(n, dtype=np.float64)
93
+
94
+ current_scale = 0.0
95
+ for i in range(n):
96
+ if i == 0 or sig_vals[i] != sig_vals[i - 1]:
97
+ current_scale = (alloc / pr_vals[i]) if sig_vals[i] != 0 else 0.0
98
+ target[i] = sig_vals[i] * current_scale
99
+
100
+ return pd.Series(target, index=signal.index)
101
+
102
+
103
+ def scale_pct_equity(
104
+ signal: pd.Series,
105
+ use_pyramiding: bool = True,
106
+ ) -> pd.Series:
107
+ """
108
+ No pre-scaling. Pass raw weight directly; the numba kernel sizes from
109
+ live equity at execution time.
110
+ """
111
+ return signal if use_pyramiding else np.sign(signal).astype(float)
112
+
113
+
114
+ def scale_dca_ladder(signal: pd.Series) -> pd.Series:
115
+ """
116
+ No pre-scaling. Pass signed structural levels directly:
117
+ +1..+N for long ladders, -1..-N for short ladders, 0 for flat.
118
+ """
119
+ return signal.astype(float)
120
+
121
+
122
+ # ── dispatcher ──────────────────────────────────────────────────────────────
123
+
124
+ def compute_target_units(
125
+ hedge_type: str,
126
+ signal: pd.Series,
127
+ close: pd.Series,
128
+ alloc: float,
129
+ use_pyramiding: bool = True,
130
+ ) -> pd.Series:
131
+ """
132
+ Central dispatcher. Returns target-unit series for any supported mode.
133
+
134
+ Parameters
135
+ ----------
136
+ hedge_type : {'notional', 'unit', 'signal_notional', '%_equity', 'dca_ladder'}
137
+ signal : raw weight series
138
+ close : close price series
139
+ alloc : notional per full unit of signal
140
+ use_pyramiding : allow fractional weights; if False snaps to {-1,0,1}
141
+ """
142
+ ht = hedge_type.lower().strip()
143
+
144
+ if ht == "notional":
145
+ return scale_notional(signal, close, alloc, use_pyramiding)
146
+
147
+ if ht == "unit":
148
+ return scale_unit(signal, close, alloc, use_pyramiding)
149
+
150
+ if ht in ("signal_notional", "signal"):
151
+ return scale_signal_notional(signal, close, alloc, use_pyramiding)
152
+
153
+ if ht in ("%_equity", "pct_equity"):
154
+ return scale_pct_equity(signal, use_pyramiding)
155
+
156
+ if ht in ("dca_ladder", "dca"):
157
+ return scale_dca_ladder(signal)
158
+
159
+ raise ValueError(
160
+ f"Unknown hedge_type '{hedge_type}'. "
161
+ "Choose from: 'notional', 'unit', 'signal_notional', '%_equity', 'dca_ladder'."
162
+ )
@@ -0,0 +1,4 @@
1
+ from .plots import quick_plot, tearsheet
2
+ from .themes import apply_theme, PALETTE
3
+
4
+ __all__ = ["quick_plot", "tearsheet", "apply_theme", "PALETTE"]