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,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
|
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))
|
quantbt/sizing/modes.py
ADDED
|
@@ -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
|
+
)
|
quantbt/viz/__init__.py
ADDED