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,537 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Run a real-data-ready parity audit for the Phase 11 native portfolio backend.
|
|
4
|
+
|
|
5
|
+
The script accepts an optional directory of OHLCV CSV/parquet files. When no
|
|
6
|
+
market directory is supplied it falls back to deterministic correlated OHLCV so
|
|
7
|
+
the audit remains reproducible in CI and in clean workspaces.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
PACKAGE_DIR = Path(__file__).resolve().parents[1]
|
|
22
|
+
PROJECT_DIR = PACKAGE_DIR.parent
|
|
23
|
+
if str(PROJECT_DIR) not in sys.path:
|
|
24
|
+
sys.path.insert(0, str(PROJECT_DIR))
|
|
25
|
+
|
|
26
|
+
from quantbt import ( # noqa: E402
|
|
27
|
+
AccountConfig,
|
|
28
|
+
LEGACY_PORTFOLIO_SIZING_MODES,
|
|
29
|
+
NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES,
|
|
30
|
+
PortfolioBacktestEngine,
|
|
31
|
+
PortfolioDomainSpec,
|
|
32
|
+
validate_portfolio_result_contract,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
LEGACY_PARITY_MODES = ("longshort", "market_neutral", "directional", "equal_weight")
|
|
37
|
+
LEGACY_PARITY_SIZING = ("signal_notional", "signal", "notional", "unit")
|
|
38
|
+
NATIVE_ONLY_SIZING = ("target_units", "target_notional", "fixed_notional")
|
|
39
|
+
NATIVE_EQUITY_SIZING = ("%_equity", "target_weight", "gross_exposure", "net_exposure")
|
|
40
|
+
NATIVE_ONLY_MODES = ("risk_parity", "beta_neutral")
|
|
41
|
+
UNSUPPORTED_NATIVE_SIZING = ("dca_ladder",)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_market_data(
|
|
45
|
+
data_dir: Optional[Path],
|
|
46
|
+
*,
|
|
47
|
+
bars: int,
|
|
48
|
+
symbols: Iterable[str],
|
|
49
|
+
seed: int,
|
|
50
|
+
) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series], str]:
|
|
51
|
+
if data_dir is not None:
|
|
52
|
+
loaded = _load_ohlcv_directory(data_dir, bars=bars)
|
|
53
|
+
if loaded is not None:
|
|
54
|
+
return (*loaded, "data_dir")
|
|
55
|
+
generated = _generate_realistic_ohlcv(bars=bars, symbols=tuple(symbols), seed=seed)
|
|
56
|
+
return (*generated, "deterministic_mock_real")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_position_signals(closes: Mapping[str, pd.Series]) -> Dict[str, pd.Series]:
|
|
60
|
+
close_frame = pd.DataFrame(closes).astype(float)
|
|
61
|
+
common_close = close_frame.mean(axis=1)
|
|
62
|
+
common_ret = np.log(common_close).diff()
|
|
63
|
+
common_fast = common_ret.rolling(12, min_periods=12).mean()
|
|
64
|
+
common_slow = common_ret.rolling(72, min_periods=72).mean()
|
|
65
|
+
common_vol = common_ret.rolling(72, min_periods=72).std().replace(0.0, np.nan)
|
|
66
|
+
common_z = ((common_fast - common_slow) / common_vol).shift(1).fillna(0.0)
|
|
67
|
+
common_raw = np.where(common_z > 0.10, 1.0, np.where(common_z < -0.10, -1.0, 0.0))
|
|
68
|
+
|
|
69
|
+
out: Dict[str, pd.Series] = {}
|
|
70
|
+
for i, (symbol, close) in enumerate(closes.items()):
|
|
71
|
+
scale = 1.0 + 0.25 * (i % 3)
|
|
72
|
+
direction = -1.0 if i % 2 else 1.0
|
|
73
|
+
out[symbol] = pd.Series(common_raw * scale * direction, index=close.index, name=symbol)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run_suite(
|
|
78
|
+
*,
|
|
79
|
+
data_dir: Optional[Path] = None,
|
|
80
|
+
bars: int = 2_000,
|
|
81
|
+
symbols: Iterable[str] = ("BTC", "ETH", "SOL", "BNB"),
|
|
82
|
+
seed: int = 42,
|
|
83
|
+
initial_capital: float = 250_000.0,
|
|
84
|
+
leverage: float = 5.0,
|
|
85
|
+
fee_rate: float = 0.0004,
|
|
86
|
+
tolerance: float = 1e-8,
|
|
87
|
+
) -> Dict:
|
|
88
|
+
idx, closes, highs, lows, data_source = load_market_data(data_dir, bars=bars, symbols=symbols, seed=seed)
|
|
89
|
+
positions = build_position_signals(closes)
|
|
90
|
+
symbol_list = list(closes.keys())
|
|
91
|
+
alloc = {symbol: 10_000.0 * (1.0 + 0.25 * (i % 4)) for i, symbol in enumerate(symbol_list)}
|
|
92
|
+
account = AccountConfig(initial_capital=initial_capital, leverage=leverage, maintenance_ratio=0.005)
|
|
93
|
+
|
|
94
|
+
parity_records = []
|
|
95
|
+
for mode in LEGACY_PARITY_MODES:
|
|
96
|
+
for sizing in LEGACY_PARITY_SIZING:
|
|
97
|
+
legacy = _run_portfolio(
|
|
98
|
+
positions,
|
|
99
|
+
closes,
|
|
100
|
+
highs,
|
|
101
|
+
lows,
|
|
102
|
+
idx,
|
|
103
|
+
mode=mode,
|
|
104
|
+
backend="legacy_portfolio",
|
|
105
|
+
hedge_type=sizing,
|
|
106
|
+
account=account,
|
|
107
|
+
alloc_per_trade=alloc,
|
|
108
|
+
fee_rate=fee_rate,
|
|
109
|
+
)
|
|
110
|
+
native = _run_portfolio(
|
|
111
|
+
positions,
|
|
112
|
+
closes,
|
|
113
|
+
highs,
|
|
114
|
+
lows,
|
|
115
|
+
idx,
|
|
116
|
+
mode=mode,
|
|
117
|
+
backend="native_portfolio",
|
|
118
|
+
hedge_type=sizing,
|
|
119
|
+
account=account,
|
|
120
|
+
alloc_per_trade=alloc,
|
|
121
|
+
fee_rate=fee_rate,
|
|
122
|
+
)
|
|
123
|
+
contract = validate_portfolio_result_contract(
|
|
124
|
+
native,
|
|
125
|
+
PortfolioDomainSpec(mode=mode, sizing_mode=sizing),
|
|
126
|
+
tolerance=tolerance,
|
|
127
|
+
raise_on_fail=False,
|
|
128
|
+
)
|
|
129
|
+
record = {
|
|
130
|
+
"mode": mode,
|
|
131
|
+
"sizing_mode": sizing,
|
|
132
|
+
"legacy_final_equity": float(legacy.equity.iloc[-1]),
|
|
133
|
+
"native_final_equity": float(native.equity.iloc[-1]),
|
|
134
|
+
"max_abs_equity_diff": _max_abs_series_diff(native.equity, legacy.equity),
|
|
135
|
+
"max_abs_position_diff": _max_abs_frame_diff(native.positions, legacy.positions),
|
|
136
|
+
"max_abs_target_units_diff": _max_abs_metadata_frame_diff(native, legacy, "target_units_report"),
|
|
137
|
+
"max_abs_accepted_units_diff": _max_abs_metadata_frame_diff(native, legacy, "accepted_units_report"),
|
|
138
|
+
"max_abs_accepted_notional_diff": _max_abs_metadata_frame_diff(
|
|
139
|
+
native, legacy, "accepted_notional_report"
|
|
140
|
+
),
|
|
141
|
+
"contract_passed": bool(contract["passed"]),
|
|
142
|
+
}
|
|
143
|
+
record["passed"] = (
|
|
144
|
+
record["max_abs_equity_diff"] <= tolerance
|
|
145
|
+
and record["max_abs_position_diff"] <= tolerance
|
|
146
|
+
and record["max_abs_target_units_diff"] <= tolerance
|
|
147
|
+
and record["max_abs_accepted_units_diff"] <= tolerance
|
|
148
|
+
and record["max_abs_accepted_notional_diff"] <= tolerance
|
|
149
|
+
and record["contract_passed"]
|
|
150
|
+
)
|
|
151
|
+
parity_records.append(record)
|
|
152
|
+
|
|
153
|
+
native_only_records = []
|
|
154
|
+
native_only_positions = {
|
|
155
|
+
"target_units": positions,
|
|
156
|
+
"target_notional": {symbol: series * alloc[symbol] for symbol, series in positions.items()},
|
|
157
|
+
"fixed_notional": positions,
|
|
158
|
+
"%_equity": positions,
|
|
159
|
+
"target_weight": positions,
|
|
160
|
+
"gross_exposure": positions,
|
|
161
|
+
"net_exposure": {symbol: series.abs() for symbol, series in positions.items()},
|
|
162
|
+
}
|
|
163
|
+
for sizing in (*NATIVE_ONLY_SIZING, *NATIVE_EQUITY_SIZING):
|
|
164
|
+
case_alloc = 1.0 if sizing in {"gross_exposure", "net_exposure"} else 0.5 if sizing == "%_equity" else alloc
|
|
165
|
+
result = _run_portfolio(
|
|
166
|
+
native_only_positions[sizing],
|
|
167
|
+
closes,
|
|
168
|
+
highs,
|
|
169
|
+
lows,
|
|
170
|
+
idx,
|
|
171
|
+
mode="longshort",
|
|
172
|
+
backend="native_portfolio",
|
|
173
|
+
hedge_type=sizing,
|
|
174
|
+
account=account,
|
|
175
|
+
alloc_per_trade=case_alloc,
|
|
176
|
+
fee_rate=fee_rate,
|
|
177
|
+
)
|
|
178
|
+
contract = validate_portfolio_result_contract(
|
|
179
|
+
result,
|
|
180
|
+
PortfolioDomainSpec(mode="longshort", sizing_mode=sizing),
|
|
181
|
+
tolerance=tolerance,
|
|
182
|
+
raise_on_fail=False,
|
|
183
|
+
)
|
|
184
|
+
native_only_records.append(
|
|
185
|
+
{
|
|
186
|
+
"mode": "longshort",
|
|
187
|
+
"sizing_mode": sizing,
|
|
188
|
+
"final_equity": float(result.equity.iloc[-1]),
|
|
189
|
+
"max_gross_leverage": _safe_max(result.metadata["exposure_report"]["gross_leverage"]),
|
|
190
|
+
"fee_total": float(result.metadata["fee_total"]),
|
|
191
|
+
"turnover_total": float(result.metadata["turnover_total"]),
|
|
192
|
+
"contract_passed": bool(contract["passed"]),
|
|
193
|
+
"passed": bool(contract["passed"]),
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
for mode in NATIVE_ONLY_MODES:
|
|
198
|
+
result = _run_portfolio(
|
|
199
|
+
positions,
|
|
200
|
+
closes,
|
|
201
|
+
highs,
|
|
202
|
+
lows,
|
|
203
|
+
idx,
|
|
204
|
+
mode=mode,
|
|
205
|
+
backend="native_portfolio",
|
|
206
|
+
hedge_type="gross_exposure",
|
|
207
|
+
account=account,
|
|
208
|
+
alloc_per_trade=1.0,
|
|
209
|
+
fee_rate=fee_rate,
|
|
210
|
+
)
|
|
211
|
+
contract = validate_portfolio_result_contract(
|
|
212
|
+
result,
|
|
213
|
+
PortfolioDomainSpec(mode=mode, sizing_mode="gross_exposure"),
|
|
214
|
+
tolerance=tolerance,
|
|
215
|
+
raise_on_fail=False,
|
|
216
|
+
)
|
|
217
|
+
native_only_records.append(
|
|
218
|
+
{
|
|
219
|
+
"mode": mode,
|
|
220
|
+
"sizing_mode": "gross_exposure",
|
|
221
|
+
"final_equity": float(result.equity.iloc[-1]),
|
|
222
|
+
"max_gross_leverage": _safe_max(result.metadata["exposure_report"]["gross_leverage"]),
|
|
223
|
+
"fee_total": float(result.metadata["fee_total"]),
|
|
224
|
+
"turnover_total": float(result.metadata["turnover_total"]),
|
|
225
|
+
"contract_passed": bool(contract["passed"]),
|
|
226
|
+
"passed": bool(contract["passed"]),
|
|
227
|
+
}
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
unsupported_records = []
|
|
231
|
+
for sizing in UNSUPPORTED_NATIVE_SIZING:
|
|
232
|
+
unsupported_records.append(_probe_unsupported_sizing(positions, closes, highs, lows, idx, sizing, account, alloc, fee_rate))
|
|
233
|
+
|
|
234
|
+
parity_passed = all(item["passed"] for item in parity_records)
|
|
235
|
+
native_only_passed = all(item["passed"] for item in native_only_records)
|
|
236
|
+
unsupported_passed = all(item["rejected"] for item in unsupported_records)
|
|
237
|
+
return {
|
|
238
|
+
"status": "pass" if parity_passed and native_only_passed and unsupported_passed else "fail",
|
|
239
|
+
"data_source": data_source,
|
|
240
|
+
"bars": int(len(idx)),
|
|
241
|
+
"symbols": symbol_list,
|
|
242
|
+
"initial_capital": float(initial_capital),
|
|
243
|
+
"leverage": float(leverage),
|
|
244
|
+
"fee_rate_round_trip": float(fee_rate),
|
|
245
|
+
"native_supported_modes": list((*LEGACY_PARITY_MODES, *NATIVE_ONLY_MODES)),
|
|
246
|
+
"native_supported_sizing_modes": sorted(NATIVE_PORTFOLIO_SUPPORTED_SIZING_MODES),
|
|
247
|
+
"legacy_compatible_sizing_modes": sorted(LEGACY_PORTFOLIO_SIZING_MODES),
|
|
248
|
+
"native_unsupported_sizing_modes": list(UNSUPPORTED_NATIVE_SIZING),
|
|
249
|
+
"parity_records": parity_records,
|
|
250
|
+
"native_only_records": native_only_records,
|
|
251
|
+
"unsupported_records": unsupported_records,
|
|
252
|
+
"summary": {
|
|
253
|
+
"legacy_parity_cases": len(parity_records),
|
|
254
|
+
"legacy_parity_passed": parity_passed,
|
|
255
|
+
"native_only_cases": len(native_only_records),
|
|
256
|
+
"native_only_passed": native_only_passed,
|
|
257
|
+
"unsupported_cases": len(unsupported_records),
|
|
258
|
+
"unsupported_rejected": unsupported_passed,
|
|
259
|
+
"max_abs_equity_diff": max(item["max_abs_equity_diff"] for item in parity_records),
|
|
260
|
+
"max_abs_position_diff": max(item["max_abs_position_diff"] for item in parity_records),
|
|
261
|
+
"max_abs_target_units_diff": max(item["max_abs_target_units_diff"] for item in parity_records),
|
|
262
|
+
"max_abs_accepted_notional_diff": max(item["max_abs_accepted_notional_diff"] for item in parity_records),
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def make_markdown_report(report: Dict) -> str:
|
|
268
|
+
summary = report["summary"]
|
|
269
|
+
lines = [
|
|
270
|
+
"# Native Portfolio Real-Parity Audit",
|
|
271
|
+
"",
|
|
272
|
+
f"Status: **{report['status']}**",
|
|
273
|
+
f"Data source: `{report['data_source']}`",
|
|
274
|
+
f"Shape: `{report['bars']}` bars x `{len(report['symbols'])}` symbols",
|
|
275
|
+
f"Symbols: `{', '.join(report['symbols'])}`",
|
|
276
|
+
"",
|
|
277
|
+
"## Summary",
|
|
278
|
+
"",
|
|
279
|
+
f"- Legacy-compatible parity cases: `{summary['legacy_parity_cases']}`",
|
|
280
|
+
f"- Legacy parity passed: `{summary['legacy_parity_passed']}`",
|
|
281
|
+
f"- Native-only domain cases: `{summary['native_only_cases']}`",
|
|
282
|
+
f"- Native-only contract passed: `{summary['native_only_passed']}`",
|
|
283
|
+
f"- Unsupported sizing rejected: `{summary['unsupported_rejected']}`",
|
|
284
|
+
f"- Max abs equity diff: `{summary['max_abs_equity_diff']:.12g}`",
|
|
285
|
+
f"- Max abs position diff: `{summary['max_abs_position_diff']:.12g}`",
|
|
286
|
+
f"- Max abs target units diff: `{summary['max_abs_target_units_diff']:.12g}`",
|
|
287
|
+
f"- Max abs accepted notional diff: `{summary['max_abs_accepted_notional_diff']:.12g}`",
|
|
288
|
+
"",
|
|
289
|
+
"## Supported Surface",
|
|
290
|
+
"",
|
|
291
|
+
f"- Modes: `{', '.join(report['native_supported_modes'])}`",
|
|
292
|
+
f"- Sizing: `{', '.join(report['native_supported_sizing_modes'])}`",
|
|
293
|
+
f"- Explicitly rejected: `{', '.join(report['native_unsupported_sizing_modes'])}`",
|
|
294
|
+
"",
|
|
295
|
+
"## Legacy-Compatible Parity",
|
|
296
|
+
"",
|
|
297
|
+
"| mode | sizing | legacy equity | native equity | max equity diff | max position diff | pass |",
|
|
298
|
+
"|---|---:|---:|---:|---:|---:|---:|",
|
|
299
|
+
]
|
|
300
|
+
for item in report["parity_records"]:
|
|
301
|
+
lines.append(
|
|
302
|
+
"| {mode} | {sizing_mode} | {legacy_final_equity:.6f} | {native_final_equity:.6f} | "
|
|
303
|
+
"{max_abs_equity_diff:.3g} | {max_abs_position_diff:.3g} | {passed} |".format(**item)
|
|
304
|
+
)
|
|
305
|
+
lines.extend(
|
|
306
|
+
[
|
|
307
|
+
"",
|
|
308
|
+
"## Native-Only Contract Checks",
|
|
309
|
+
"",
|
|
310
|
+
"| mode | sizing | final equity | max gross leverage | fee total | turnover total | pass |",
|
|
311
|
+
"|---|---:|---:|---:|---:|---:|---:|",
|
|
312
|
+
]
|
|
313
|
+
)
|
|
314
|
+
for item in report["native_only_records"]:
|
|
315
|
+
lines.append(
|
|
316
|
+
"| {mode} | {sizing_mode} | {final_equity:.6f} | {max_gross_leverage:.6f} | "
|
|
317
|
+
"{fee_total:.6f} | {turnover_total:.6f} | {passed} |".format(**item)
|
|
318
|
+
)
|
|
319
|
+
return "\n".join(lines) + "\n"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _run_portfolio(
|
|
323
|
+
positions: Mapping[str, pd.Series],
|
|
324
|
+
closes: Mapping[str, pd.Series],
|
|
325
|
+
highs: Mapping[str, pd.Series],
|
|
326
|
+
lows: Mapping[str, pd.Series],
|
|
327
|
+
idx: pd.DatetimeIndex,
|
|
328
|
+
*,
|
|
329
|
+
mode: str,
|
|
330
|
+
backend: str,
|
|
331
|
+
hedge_type: str,
|
|
332
|
+
account: AccountConfig,
|
|
333
|
+
alloc_per_trade: Mapping[str, float],
|
|
334
|
+
fee_rate: float,
|
|
335
|
+
):
|
|
336
|
+
engine = PortfolioBacktestEngine(
|
|
337
|
+
positions=dict(positions),
|
|
338
|
+
closes=dict(closes),
|
|
339
|
+
highs=dict(highs),
|
|
340
|
+
lows=dict(lows),
|
|
341
|
+
datetime_index=idx,
|
|
342
|
+
mode=mode,
|
|
343
|
+
backend=backend,
|
|
344
|
+
account=account,
|
|
345
|
+
fee_rate=fee_rate,
|
|
346
|
+
alloc_per_trade=dict(alloc_per_trade) if isinstance(alloc_per_trade, Mapping) else float(alloc_per_trade),
|
|
347
|
+
contract_size=1.0,
|
|
348
|
+
hedge_type=hedge_type,
|
|
349
|
+
asset_type="crypto",
|
|
350
|
+
use_funding=False,
|
|
351
|
+
)
|
|
352
|
+
return engine.result
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _probe_unsupported_sizing(
|
|
356
|
+
positions: Mapping[str, pd.Series],
|
|
357
|
+
closes: Mapping[str, pd.Series],
|
|
358
|
+
highs: Mapping[str, pd.Series],
|
|
359
|
+
lows: Mapping[str, pd.Series],
|
|
360
|
+
idx: pd.DatetimeIndex,
|
|
361
|
+
sizing: str,
|
|
362
|
+
account: AccountConfig,
|
|
363
|
+
alloc_per_trade: Mapping[str, float],
|
|
364
|
+
fee_rate: float,
|
|
365
|
+
) -> Dict:
|
|
366
|
+
try:
|
|
367
|
+
_run_portfolio(
|
|
368
|
+
positions,
|
|
369
|
+
closes,
|
|
370
|
+
highs,
|
|
371
|
+
lows,
|
|
372
|
+
idx,
|
|
373
|
+
mode="longshort",
|
|
374
|
+
backend="native_portfolio",
|
|
375
|
+
hedge_type=sizing,
|
|
376
|
+
account=account,
|
|
377
|
+
alloc_per_trade=alloc_per_trade,
|
|
378
|
+
fee_rate=fee_rate,
|
|
379
|
+
)
|
|
380
|
+
except (NotImplementedError, ValueError) as exc:
|
|
381
|
+
return {"sizing_mode": sizing, "rejected": True, "error": type(exc).__name__, "message": str(exc)}
|
|
382
|
+
return {"sizing_mode": sizing, "rejected": False, "error": None, "message": "unexpectedly accepted"}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _generate_realistic_ohlcv(
|
|
386
|
+
*,
|
|
387
|
+
bars: int,
|
|
388
|
+
symbols: Tuple[str, ...],
|
|
389
|
+
seed: int,
|
|
390
|
+
) -> Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series]]:
|
|
391
|
+
rng = np.random.default_rng(seed)
|
|
392
|
+
idx = pd.date_range("2021-01-01", periods=bars, freq="1h", tz="UTC")
|
|
393
|
+
market = rng.normal(0.00005, 0.010, size=bars)
|
|
394
|
+
closes: Dict[str, pd.Series] = {}
|
|
395
|
+
highs: Dict[str, pd.Series] = {}
|
|
396
|
+
lows: Dict[str, pd.Series] = {}
|
|
397
|
+
start_prices = np.linspace(32_000.0, 250.0, num=len(symbols))
|
|
398
|
+
for i, symbol in enumerate(symbols):
|
|
399
|
+
idio = rng.normal(0.0, 0.006 + 0.001 * i, size=bars)
|
|
400
|
+
seasonal = 0.0002 * np.sin(np.linspace(0.0, 8.0 * np.pi, bars) + i)
|
|
401
|
+
log_ret = 0.65 * market + 0.35 * idio + seasonal
|
|
402
|
+
price = start_prices[i] * np.exp(np.cumsum(log_ret))
|
|
403
|
+
spread = np.abs(rng.normal(0.0015, 0.0005, size=bars))
|
|
404
|
+
close = pd.Series(price, index=idx, name=symbol)
|
|
405
|
+
closes[symbol] = close
|
|
406
|
+
highs[symbol] = pd.Series(price * (1.0 + spread), index=idx, name=symbol)
|
|
407
|
+
lows[symbol] = pd.Series(price * (1.0 - spread), index=idx, name=symbol)
|
|
408
|
+
return idx, closes, highs, lows
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _load_ohlcv_directory(
|
|
412
|
+
data_dir: Path,
|
|
413
|
+
*,
|
|
414
|
+
bars: int,
|
|
415
|
+
) -> Optional[Tuple[pd.DatetimeIndex, Dict[str, pd.Series], Dict[str, pd.Series], Dict[str, pd.Series]]]:
|
|
416
|
+
if not data_dir.exists():
|
|
417
|
+
return None
|
|
418
|
+
frames = {}
|
|
419
|
+
for path in sorted([*data_dir.glob("*.csv"), *data_dir.glob("*.parquet"), *data_dir.glob("*.feather")]):
|
|
420
|
+
frame = _read_ohlcv_file(path)
|
|
421
|
+
if frame is None:
|
|
422
|
+
continue
|
|
423
|
+
frames[path.stem.upper()] = frame
|
|
424
|
+
if len(frames) < 2:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
common_index = None
|
|
428
|
+
for frame in frames.values():
|
|
429
|
+
common_index = frame.index if common_index is None else common_index.intersection(frame.index)
|
|
430
|
+
if common_index is None or len(common_index) < 50:
|
|
431
|
+
return None
|
|
432
|
+
common_index = common_index.sort_values()[-bars:]
|
|
433
|
+
closes = {symbol: frame.reindex(common_index)["close"].ffill().dropna() for symbol, frame in frames.items()}
|
|
434
|
+
valid_index = common_index
|
|
435
|
+
for series in closes.values():
|
|
436
|
+
valid_index = valid_index.intersection(series.index)
|
|
437
|
+
valid_index = valid_index.sort_values()
|
|
438
|
+
closes = {symbol: frame.reindex(valid_index)["close"].ffill() for symbol, frame in frames.items()}
|
|
439
|
+
highs = {symbol: frame.reindex(valid_index)["high"].ffill() for symbol, frame in frames.items()}
|
|
440
|
+
lows = {symbol: frame.reindex(valid_index)["low"].ffill() for symbol, frame in frames.items()}
|
|
441
|
+
return valid_index, closes, highs, lows
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _read_ohlcv_file(path: Path) -> Optional[pd.DataFrame]:
|
|
445
|
+
try:
|
|
446
|
+
if path.suffix == ".parquet":
|
|
447
|
+
frame = pd.read_parquet(path)
|
|
448
|
+
elif path.suffix == ".feather":
|
|
449
|
+
frame = pd.read_feather(path)
|
|
450
|
+
else:
|
|
451
|
+
frame = pd.read_csv(path)
|
|
452
|
+
except Exception:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
frame = frame.copy()
|
|
456
|
+
frame.columns = [str(col).lower() for col in frame.columns]
|
|
457
|
+
if "close" not in frame.columns:
|
|
458
|
+
return None
|
|
459
|
+
if "high" not in frame.columns:
|
|
460
|
+
frame["high"] = frame["close"]
|
|
461
|
+
if "low" not in frame.columns:
|
|
462
|
+
frame["low"] = frame["close"]
|
|
463
|
+
|
|
464
|
+
dt_col = next((col for col in ("datetime", "timestamp", "date", "time") if col in frame.columns), None)
|
|
465
|
+
if dt_col is not None:
|
|
466
|
+
idx = pd.to_datetime(frame[dt_col], utc=True, errors="coerce")
|
|
467
|
+
else:
|
|
468
|
+
idx = pd.to_datetime(frame.index, utc=True, errors="coerce")
|
|
469
|
+
frame.index = idx
|
|
470
|
+
frame = frame.loc[frame.index.notna(), ["close", "high", "low"]].astype(float).sort_index()
|
|
471
|
+
frame = frame[~frame.index.duplicated(keep="last")]
|
|
472
|
+
return frame.dropna()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _max_abs_series_diff(left: pd.Series, right: pd.Series) -> float:
|
|
476
|
+
a, b = left.align(right, join="inner")
|
|
477
|
+
if len(a) == 0:
|
|
478
|
+
return float("inf")
|
|
479
|
+
return float(np.max(np.abs(a.to_numpy(dtype=float) - b.to_numpy(dtype=float))))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _max_abs_frame_diff(left: pd.DataFrame, right: pd.DataFrame) -> float:
|
|
483
|
+
a, b = left.align(right, join="inner", axis=None)
|
|
484
|
+
if a.empty and b.empty:
|
|
485
|
+
return 0.0
|
|
486
|
+
if a.empty or b.empty:
|
|
487
|
+
return float("inf")
|
|
488
|
+
return float(np.max(np.abs(a.to_numpy(dtype=float) - b.to_numpy(dtype=float))))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _max_abs_metadata_frame_diff(left, right, key: str) -> float:
|
|
492
|
+
return _max_abs_frame_diff(left.metadata[key], right.metadata[key])
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _safe_max(series: pd.Series) -> float:
|
|
496
|
+
return float(series.max()) if len(series) else 0.0
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _json_default(value):
|
|
500
|
+
if isinstance(value, (np.bool_,)):
|
|
501
|
+
return bool(value)
|
|
502
|
+
if isinstance(value, (np.integer,)):
|
|
503
|
+
return int(value)
|
|
504
|
+
if isinstance(value, (np.floating,)):
|
|
505
|
+
return float(value)
|
|
506
|
+
if isinstance(value, (pd.Timestamp,)):
|
|
507
|
+
return value.isoformat()
|
|
508
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
512
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
513
|
+
parser.add_argument("--data-dir", type=Path, default=None, help="Directory containing OHLCV CSV/parquet files.")
|
|
514
|
+
parser.add_argument("--bars", type=int, default=2_000)
|
|
515
|
+
parser.add_argument("--symbols", default="BTC,ETH,SOL,BNB")
|
|
516
|
+
parser.add_argument("--seed", type=int, default=42)
|
|
517
|
+
parser.add_argument("--json-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "portfolio_real_parity_report.json")
|
|
518
|
+
parser.add_argument("--md-out", type=Path, default=PACKAGE_DIR / "benchmarks" / "portfolio_real_parity_report.md")
|
|
519
|
+
args = parser.parse_args(argv)
|
|
520
|
+
|
|
521
|
+
report = run_suite(
|
|
522
|
+
data_dir=args.data_dir,
|
|
523
|
+
bars=args.bars,
|
|
524
|
+
symbols=tuple(item.strip() for item in args.symbols.split(",") if item.strip()),
|
|
525
|
+
seed=args.seed,
|
|
526
|
+
)
|
|
527
|
+
markdown = make_markdown_report(report)
|
|
528
|
+
args.json_out.parent.mkdir(parents=True, exist_ok=True)
|
|
529
|
+
args.md_out.parent.mkdir(parents=True, exist_ok=True)
|
|
530
|
+
args.json_out.write_text(json.dumps(report, indent=2, default=_json_default) + "\n", encoding="utf-8")
|
|
531
|
+
args.md_out.write_text(markdown, encoding="utf-8")
|
|
532
|
+
print(markdown)
|
|
533
|
+
return 0 if report["status"] == "pass" else 1
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
if __name__ == "__main__":
|
|
537
|
+
raise SystemExit(main())
|