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.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|