quantbt-engine 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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())