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,758 @@
1
+ """
2
+ Nautilus trustee report bundle exporter.
3
+
4
+ This module is intentionally outside the backtest engines. It consumes a
5
+ completed Nautilus-backed `BacktestResultV2` and writes human/audit artifacts:
6
+ raw Nautilus reports, normalized trade logs, a run manifest, and optional
7
+ QuantStats HTML.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import datetime, timezone
13
+ import hashlib
14
+ import json
15
+ from pathlib import Path
16
+ import platform
17
+ import subprocess
18
+ from typing import Any, Dict, Iterable, List, Optional, Tuple
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from ..core.results import BacktestResultV2
24
+
25
+
26
+ REPORT_FILES = (
27
+ "equity_curve.csv",
28
+ "returns.csv",
29
+ "account_report.csv",
30
+ "orders_report.csv",
31
+ "fills_report.csv",
32
+ "positions_report.csv",
33
+ "trade_log.csv",
34
+ "fill_log.txt",
35
+ "metrics_summary.json",
36
+ "run_manifest.json",
37
+ "config.json",
38
+ )
39
+
40
+
41
+ def export_nautilus_report_bundle(
42
+ result: BacktestResultV2,
43
+ output_dir: str | Path,
44
+ strategy_id: str,
45
+ config: Optional[Dict[str, Any]] = None,
46
+ benchmark_returns: Optional[pd.Series] = None,
47
+ make_quantstats: bool = True,
48
+ quantstats_frequency: str = "1D",
49
+ quantstats_periods_per_year: int = 365,
50
+ print_fills: bool = False,
51
+ fill_log_limit: int = 500,
52
+ fill_log_mode: str = "fills_only",
53
+ ) -> Path:
54
+ """
55
+ Export a professional evidence bundle for a Nautilus-backed backtest.
56
+
57
+ Parameters
58
+ ----------
59
+ result:
60
+ Completed `BacktestResultV2`, normally returned by
61
+ `QuantBTEndpoint.nautilus_validation(...).simulate(...)`.
62
+ output_dir:
63
+ Parent folder where `report_{strategy_id}_{timestamp}` is created.
64
+ strategy_id:
65
+ Stable identifier for the run/report.
66
+ config:
67
+ Optional JSON-serializable run config to save as `config.json`.
68
+ benchmark_returns:
69
+ Optional benchmark returns passed through to QuantStats when supported.
70
+ make_quantstats:
71
+ Generate `quantstats_daily.html` when quantstats is installed.
72
+ quantstats_frequency:
73
+ Resampling frequency for QuantStats input. Default is daily.
74
+ quantstats_periods_per_year:
75
+ Annualization factor passed to QuantStats. Default is 365 for crypto.
76
+ print_fills:
77
+ Print bounded fill/order event lines to stdout while exporting.
78
+ fill_log_limit:
79
+ Maximum number of human-readable event lines to write/print.
80
+ fill_log_mode:
81
+ `fills_only`, `order_events`, or `bars_debug`. `bars_debug` is bounded
82
+ by `fill_log_limit` and uses position-change rows, not every no-op bar.
83
+
84
+ Returns
85
+ -------
86
+ Path
87
+ The created report directory.
88
+ """
89
+ if not isinstance(result, BacktestResultV2):
90
+ raise TypeError("export_nautilus_report_bundle requires BacktestResultV2")
91
+ if not strategy_id:
92
+ raise ValueError("strategy_id is required")
93
+ if fill_log_limit < 0:
94
+ raise ValueError("fill_log_limit must be >= 0")
95
+
96
+ run_id = _run_id(strategy_id)
97
+ report_dir = Path(output_dir) / f"report_{_slug(strategy_id)}_{run_id}"
98
+ report_dir.mkdir(parents=True, exist_ok=True)
99
+
100
+ metadata = dict(result.metadata or {})
101
+ config_payload = _config_payload_from_result(result=result, metadata=metadata)
102
+ if config:
103
+ config_payload["annotations"] = {**dict(config_payload.get("annotations", {})), **dict(config)}
104
+
105
+ account_report = _report_frame(metadata.get("account_report"))
106
+ orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report")))
107
+ fills_report = _report_frame(metadata.get("fills_report"))
108
+ positions_report = _report_frame(metadata.get("positions_report"))
109
+
110
+ equity_curve = _equity_frame(result)
111
+ returns_frame = pd.DataFrame({"timestamp": result.returns.index, "returns": result.returns.to_numpy(dtype=float)})
112
+ trade_log = build_nautilus_trade_log(positions_report, fills_report, strategy_id=strategy_id)
113
+ fill_lines = format_nautilus_event_log(
114
+ fills_report=fills_report,
115
+ orders_report=orders_report,
116
+ positions=result.positions,
117
+ mode=fill_log_mode,
118
+ limit=fill_log_limit,
119
+ )
120
+
121
+ _write_frame(equity_curve, report_dir / "equity_curve.csv")
122
+ _write_frame(returns_frame, report_dir / "returns.csv")
123
+ _write_frame(account_report, report_dir / "account_report.csv")
124
+ _write_frame(orders_report, report_dir / "orders_report.csv")
125
+ _write_frame(fills_report, report_dir / "fills_report.csv")
126
+ _write_frame(positions_report, report_dir / "positions_report.csv")
127
+ _write_frame(trade_log, report_dir / "trade_log.csv")
128
+ (report_dir / "fill_log.txt").write_text("\n".join(fill_lines) + ("\n" if fill_lines else ""), encoding="utf-8")
129
+
130
+ if print_fills:
131
+ for line in fill_lines:
132
+ print(line)
133
+
134
+ metrics_summary = _metrics_summary(result=result, trade_log=trade_log, metadata=metadata)
135
+ manifest = _run_manifest(
136
+ result=result,
137
+ strategy_id=strategy_id,
138
+ run_id=run_id,
139
+ metadata=metadata,
140
+ config=config_payload,
141
+ account_report=account_report,
142
+ orders_report=orders_report,
143
+ fills_report=fills_report,
144
+ positions_report=positions_report,
145
+ )
146
+
147
+ if make_quantstats:
148
+ _try_write_quantstats_html(
149
+ result=result,
150
+ output_path=report_dir / "quantstats_daily.html",
151
+ frequency=quantstats_frequency,
152
+ periods_per_year=int(quantstats_periods_per_year),
153
+ benchmark_returns=benchmark_returns,
154
+ manifest=manifest,
155
+ )
156
+
157
+ _write_json(report_dir / "metrics_summary.json", metrics_summary)
158
+ _write_json(report_dir / "run_manifest.json", manifest)
159
+ _write_json(report_dir / "config.json", config_payload)
160
+
161
+ return report_dir
162
+
163
+
164
+ def build_nautilus_trade_log(
165
+ positions_report: Optional[pd.DataFrame],
166
+ fills_report: Optional[pd.DataFrame],
167
+ strategy_id: str,
168
+ ) -> pd.DataFrame:
169
+ """Build a stable closed-trade table from Nautilus positions/fills reports."""
170
+ columns = [
171
+ "strategy_id",
172
+ "symbol",
173
+ "exchange",
174
+ "instrument_id",
175
+ "position_type",
176
+ "open_datetime",
177
+ "close_datetime",
178
+ "entry_price",
179
+ "exit_price",
180
+ "quantity",
181
+ "realized_pnl",
182
+ "fees",
183
+ "duration_seconds",
184
+ "return_pct",
185
+ "order_ids",
186
+ ]
187
+ positions = _report_frame(positions_report)
188
+ if positions.empty:
189
+ return pd.DataFrame(columns=columns)
190
+
191
+ fills = _report_frame(fills_report)
192
+ rows: List[Dict[str, Any]] = []
193
+ for _, pos in positions.iterrows():
194
+ close_dt = _coerce_timestamp_scalar(pos.get("ts_closed", pos.get("closed_time")))
195
+ if pd.isna(close_dt):
196
+ continue
197
+ instrument_id = str(pos.get("instrument_id", ""))
198
+ symbol, exchange = _instrument_parts(instrument_id)
199
+ open_dt = _coerce_timestamp_scalar(pos.get("ts_opened", pos.get("opened_time")))
200
+ entry_price = _coerce_float(pos.get("avg_px_open", pos.get("entry_price", np.nan)))
201
+ exit_price = _coerce_float(pos.get("avg_px_close", pos.get("exit_price", np.nan)))
202
+ quantity = _coerce_float(pos.get("quantity", pos.get("signed_qty", pos.get("qty", np.nan))))
203
+ realized_pnl = _coerce_money(pos.get("realized_pnl", 0.0))
204
+ fees = _fees_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt)
205
+ side = _position_type(pos)
206
+ duration = _duration_seconds(open_dt, close_dt)
207
+ return_pct = _position_return_pct(side=side, entry_price=entry_price, exit_price=exit_price)
208
+
209
+ rows.append(
210
+ {
211
+ "strategy_id": strategy_id,
212
+ "symbol": symbol,
213
+ "exchange": exchange,
214
+ "instrument_id": instrument_id,
215
+ "position_type": side,
216
+ "open_datetime": open_dt,
217
+ "close_datetime": close_dt,
218
+ "entry_price": entry_price,
219
+ "exit_price": exit_price,
220
+ "quantity": quantity,
221
+ "realized_pnl": realized_pnl,
222
+ "fees": fees,
223
+ "duration_seconds": duration,
224
+ "return_pct": return_pct,
225
+ "order_ids": _order_ids_for_position(fills=fills, instrument_id=instrument_id, open_dt=open_dt, close_dt=close_dt),
226
+ }
227
+ )
228
+
229
+ out = pd.DataFrame(rows, columns=columns)
230
+ if not out.empty:
231
+ out = out.sort_values(["close_datetime", "open_datetime"], kind="stable").reset_index(drop=True)
232
+ return out
233
+
234
+
235
+ def format_nautilus_event_log(
236
+ fills_report: Optional[pd.DataFrame] = None,
237
+ orders_report: Optional[pd.DataFrame] = None,
238
+ positions: Optional[pd.DataFrame] = None,
239
+ mode: str = "fills_only",
240
+ limit: int = 500,
241
+ ) -> List[str]:
242
+ """Return bounded human-readable event lines for console/file logging."""
243
+ mode = str(mode).lower().strip()
244
+ if mode not in {"fills_only", "order_events", "bars_debug"}:
245
+ raise ValueError("fill_log_mode must be fills_only, order_events, or bars_debug")
246
+ if limit <= 0:
247
+ return []
248
+
249
+ if mode == "bars_debug":
250
+ return _position_change_lines(positions, limit=limit)
251
+
252
+ report = _report_frame(fills_report)
253
+ source = "FILL"
254
+ if (report.empty or mode == "order_events") and orders_report is not None:
255
+ order_report = _report_frame(orders_report)
256
+ if not order_report.empty:
257
+ report = order_report
258
+ source = "ORDER"
259
+ if report.empty:
260
+ return []
261
+
262
+ lines: List[str] = []
263
+ for _, row in report.head(limit).iterrows():
264
+ ts = _event_timestamp(row)
265
+ side = _event_side(row)
266
+ qty = _event_qty(row)
267
+ instrument = str(row.get("instrument_id", row.get("instrument", "")))
268
+ price = _event_price(row)
269
+ fee = _event_fee(row)
270
+ lines.append(f"{ts} {source} {side} {qty:g} {instrument} @ {price:g} fee={fee:g}")
271
+ return lines
272
+
273
+
274
+ def _try_write_quantstats_html(
275
+ result: BacktestResultV2,
276
+ output_path: Path,
277
+ frequency: str,
278
+ periods_per_year: int,
279
+ benchmark_returns: Optional[pd.Series],
280
+ manifest: Dict[str, Any],
281
+ ) -> None:
282
+ manifest["quantstats_frequency"] = frequency
283
+ manifest["quantstats_periods_per_year"] = int(periods_per_year)
284
+ try:
285
+ import quantstats as qs
286
+ except Exception as exc:
287
+ manifest["quantstats_status"] = f"skipped: {type(exc).__name__}: {exc}"
288
+ return
289
+
290
+ returns = _resampled_returns_for_quantstats(result.equity, frequency=frequency)
291
+ if returns.empty:
292
+ manifest["quantstats_status"] = "skipped: empty returns"
293
+ return
294
+ try:
295
+ qs.reports.html(
296
+ returns,
297
+ benchmark=benchmark_returns,
298
+ output=str(output_path),
299
+ title=f"QuantBT Nautilus Report - {manifest.get('strategy_id', '')}",
300
+ periods_per_year=periods_per_year,
301
+ )
302
+ manifest["quantstats_status"] = "written"
303
+ manifest["quantstats_file"] = output_path.name
304
+ except Exception as exc:
305
+ manifest["quantstats_status"] = f"failed: {type(exc).__name__}: {exc}"
306
+
307
+
308
+ def _resampled_returns_for_quantstats(equity: pd.Series, frequency: str = "1D") -> pd.Series:
309
+ eq = equity.copy()
310
+ eq.index = pd.to_datetime(eq.index, utc=True)
311
+ eq = pd.to_numeric(eq, errors="coerce").dropna()
312
+ if eq.empty:
313
+ return pd.Series(dtype=float)
314
+ sampled = eq.resample(frequency).last().dropna()
315
+ return sampled.pct_change().replace([np.inf, -np.inf], np.nan).dropna()
316
+
317
+
318
+ def _equity_frame(result: BacktestResultV2) -> pd.DataFrame:
319
+ equity = pd.to_numeric(result.equity, errors="coerce")
320
+ returns = pd.to_numeric(result.returns.reindex(result.equity.index), errors="coerce").fillna(0.0)
321
+ peak = equity.cummax()
322
+ drawdown = (equity / peak.replace(0.0, np.nan) - 1.0).fillna(0.0)
323
+ return pd.DataFrame(
324
+ {
325
+ "timestamp": result.equity.index,
326
+ "equity": equity.to_numpy(dtype=float),
327
+ "returns": returns.to_numpy(dtype=float),
328
+ "drawdown": drawdown.to_numpy(dtype=float),
329
+ }
330
+ )
331
+
332
+
333
+ def _metrics_summary(result: BacktestResultV2, trade_log: pd.DataFrame, metadata: Dict[str, Any]) -> Dict[str, Any]:
334
+ equity = pd.to_numeric(result.equity, errors="coerce").dropna()
335
+ final_equity = float(equity.iloc[-1]) if not equity.empty else float("nan")
336
+ total_return_pct = (final_equity / float(result.initial_capital) - 1.0) * 100.0 if result.initial_capital else float("nan")
337
+ drawdown = result.drawdown if len(result.equity) else pd.Series(dtype=float)
338
+ orders_report = _report_frame(metadata.get("orders_report", metadata.get("order_report")))
339
+ status_counts = _order_status_counts(orders_report)
340
+ return {
341
+ "initial_capital": float(result.initial_capital),
342
+ "final_equity": final_equity,
343
+ "total_return_pct": float(total_return_pct),
344
+ "max_drawdown_pct": float(drawdown.max() * 100.0) if not drawdown.empty else 0.0,
345
+ "input_mode": metadata.get("input_mode"),
346
+ "order_count_input": _safe_int(metadata.get("order_count_input")),
347
+ "orders_count": int(metadata.get("orders_count", 0) or 0),
348
+ "fills_count": int(metadata.get("fills_count", 0) or 0),
349
+ "positions_count": int(metadata.get("positions_count", 0) or 0),
350
+ "cancelled_count": status_counts["cancelled"],
351
+ "rejected_count": status_counts["rejected"],
352
+ "trade_log_count": int(len(trade_log)),
353
+ "liquidated": bool(result.liquidated),
354
+ }
355
+
356
+
357
+ def _run_manifest(
358
+ result: BacktestResultV2,
359
+ strategy_id: str,
360
+ run_id: str,
361
+ metadata: Dict[str, Any],
362
+ config: Dict[str, Any],
363
+ account_report: pd.DataFrame,
364
+ orders_report: pd.DataFrame,
365
+ fills_report: pd.DataFrame,
366
+ positions_report: pd.DataFrame,
367
+ ) -> Dict[str, Any]:
368
+ idx = result.equity.index
369
+ status_counts = _order_status_counts(orders_report)
370
+ return {
371
+ "strategy_id": strategy_id,
372
+ "run_id": run_id,
373
+ "created_at": datetime.now(timezone.utc).isoformat(),
374
+ "backend": metadata.get("backend", "nautilus"),
375
+ "engine": metadata.get("engine", "NautilusTrader BacktestEngine"),
376
+ "execution_model": "event-driven bar execution",
377
+ "instrument_id": metadata.get("instrument_id") or _first_symbol(result),
378
+ "timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")),
379
+ "data_start": str(idx[0]) if len(idx) else None,
380
+ "data_end": str(idx[-1]) if len(idx) else None,
381
+ "bar_count": int(len(idx)),
382
+ "signal_count": metadata.get("signal_count"),
383
+ "signal_changes": metadata.get("signal_changes"),
384
+ "input_mode": metadata.get("input_mode"),
385
+ "order_count_input": _safe_int(metadata.get("order_count_input")),
386
+ "initial_capital": float(result.initial_capital),
387
+ "leverage": float(result.leverage),
388
+ "alloc_per_trade": metadata.get("trade_notional", metadata.get("alloc_per_trade")),
389
+ "sizing_mode": metadata.get("sizing_mode"),
390
+ "fee_rate": metadata.get("fee_rate"),
391
+ "use_funding": metadata.get("use_funding"),
392
+ "orders_count": int(metadata.get("orders_count", len(orders_report)) or 0),
393
+ "fills_count": int(metadata.get("fills_count", len(fills_report)) or 0),
394
+ "positions_count": int(metadata.get("positions_count", len(positions_report)) or 0),
395
+ "cancelled_count": status_counts["cancelled"],
396
+ "rejected_count": status_counts["rejected"],
397
+ "account_report_rows": int(len(account_report)),
398
+ "account_final_equity": _safe_float(metadata.get("account_final_equity")),
399
+ "reconstructed_final_equity": _safe_float(metadata.get("reconstructed_final_equity")),
400
+ "account_reconstructed_diff": _safe_float(metadata.get("account_reconstructed_diff")),
401
+ "quantbt_git_commit": _git_commit(),
402
+ "nautilus_version": _package_version("nautilus_trader"),
403
+ "python_version": platform.python_version(),
404
+ "data_hash": _hash_frame(result.closes),
405
+ "signal_hash": metadata.get("signal_hash"),
406
+ "config_keys": sorted(config.keys()),
407
+ }
408
+
409
+
410
+ def _config_payload_from_result(result: BacktestResultV2, metadata: Dict[str, Any]) -> Dict[str, Any]:
411
+ run_config = dict(metadata.get("run_config") or {})
412
+ account = dict(run_config.get("account") or {})
413
+ execution = dict(run_config.get("execution") or {})
414
+ fees = dict(run_config.get("fees") or {})
415
+ sizing = dict(run_config.get("sizing") or {})
416
+ funding = dict(run_config.get("funding") or {})
417
+ nautilus = dict(run_config.get("nautilus") or {})
418
+
419
+ requested_fee_rate = _safe_float(metadata.get("fee_rate"))
420
+ if requested_fee_rate is None:
421
+ requested_fee_rate = _safe_float(fees.get("one_way_fee_rate"))
422
+ requested_slippage = _safe_float(metadata.get("slippage"))
423
+ if requested_slippage is None:
424
+ requested_slippage = _safe_float(execution.get("legacy_slippage_rate"))
425
+ requested_slippage_bps = _safe_float(metadata.get("slippage_bps"))
426
+ if requested_slippage_bps is None:
427
+ requested_slippage_bps = _safe_float(execution.get("slippage_bps"))
428
+
429
+ return {
430
+ "schema_version": 2,
431
+ "backend": metadata.get("backend", "nautilus"),
432
+ "engine": metadata.get("engine", "NautilusTrader BacktestEngine"),
433
+ "instrument": {
434
+ "instrument_id": metadata.get("instrument_id") or _first_symbol(result),
435
+ "bar_type": metadata.get("bar_type"),
436
+ "timeframe": metadata.get("timeframe") or _bar_timeframe(metadata.get("bar_type")),
437
+ },
438
+ "effective_account": {
439
+ "initial_capital": _safe_float(metadata.get("initial_capital")) or float(result.initial_capital),
440
+ "leverage": _safe_float(metadata.get("leverage")) or float(result.leverage),
441
+ "maintenance_ratio": _safe_float(metadata.get("maintenance_ratio")),
442
+ "margin_mode": account.get("margin_mode"),
443
+ "oms_mode": account.get("oms_mode"),
444
+ "base_currency": account.get("base_currency"),
445
+ },
446
+ "effective_sizing": {
447
+ "sizing_mode": metadata.get("sizing_mode") or sizing.get("hedge_type"),
448
+ "hedge_type": sizing.get("hedge_type"),
449
+ "trade_notional": metadata.get("trade_notional"),
450
+ "alloc_per_trade": metadata.get("alloc_per_trade", sizing.get("alloc_per_trade")),
451
+ "use_pyramiding": metadata.get("use_pyramiding", sizing.get("use_pyramiding")),
452
+ "contract_size": sizing.get("contract_size"),
453
+ "contract_size_note": "contract_size is a multiplier for notional/PnL, not the exchange lot size",
454
+ "quantity_constraints": {
455
+ "qty_step": metadata.get("qty_step"),
456
+ "lot_size": metadata.get("lot_size", metadata.get("qty_step")),
457
+ "min_qty": metadata.get("min_qty"),
458
+ "min_notional": metadata.get("min_notional"),
459
+ "price_increment": metadata.get("price_increment"),
460
+ "note": "Use qty_step/lot_size/min_qty/min_notional for Binance-style fractional order constraints.",
461
+ },
462
+ },
463
+ "effective_fees": {
464
+ "requested_fee_rate": requested_fee_rate,
465
+ "requested_fee_convention": "one_way",
466
+ "requested_fee_source": "endpoint.fee_rate",
467
+ "legacy_fee_round_trip_ignored": fees.get("round_trip_fee"),
468
+ "applied_by": "NautilusTrader MakerTakerFeeModel",
469
+ "custom_fee_rate_applied_to_nautilus": False,
470
+ },
471
+ "effective_execution": {
472
+ "requested_slippage_rate": requested_slippage,
473
+ "requested_slippage_bps": requested_slippage_bps,
474
+ "requested_slippage_source": "endpoint.slippage",
475
+ "applied_by": "NautilusTrader bar market execution",
476
+ "custom_slippage_applied_to_nautilus": False,
477
+ "fill_price_policy": execution.get("fill_price_policy"),
478
+ "same_bar_policy": execution.get("same_bar_policy"),
479
+ "allow_partial_fill": execution.get("allow_partial_fill"),
480
+ "reject_on_insufficient_margin": execution.get("reject_on_insufficient_margin"),
481
+ },
482
+ "funding": {
483
+ "use_funding": metadata.get("use_funding", funding.get("use_funding")),
484
+ "funding_rate": metadata.get("funding_rate", funding.get("funding_rate")),
485
+ },
486
+ "nautilus": nautilus,
487
+ "diagnostics": {
488
+ "orders_count": int(metadata.get("orders_count", 0) or 0),
489
+ "fills_count": int(metadata.get("fills_count", 0) or 0),
490
+ "positions_count": int(metadata.get("positions_count", 0) or 0),
491
+ },
492
+ "annotations": {},
493
+ }
494
+
495
+
496
+ def _report_frame(value: Any) -> pd.DataFrame:
497
+ if isinstance(value, pd.DataFrame):
498
+ return value.copy()
499
+ if value is None:
500
+ return pd.DataFrame()
501
+ try:
502
+ return pd.DataFrame(value).copy()
503
+ except Exception:
504
+ return pd.DataFrame()
505
+
506
+
507
+ def _order_status_counts(orders_report: pd.DataFrame) -> Dict[str, int]:
508
+ if orders_report.empty or "status" not in orders_report:
509
+ return {"cancelled": 0, "rejected": 0}
510
+ status = orders_report["status"].astype(str).str.upper()
511
+ return {
512
+ "cancelled": int(status.isin({"CANCELED", "CANCELLED"}).sum()),
513
+ "rejected": int(status.eq("REJECTED").sum()),
514
+ }
515
+
516
+
517
+ def _safe_int(value: Any) -> Optional[int]:
518
+ try:
519
+ if value is None or pd.isna(value):
520
+ return None
521
+ return int(value)
522
+ except (TypeError, ValueError):
523
+ return None
524
+
525
+
526
+ def _write_frame(frame: pd.DataFrame, path: Path) -> None:
527
+ frame.copy().to_csv(path, index=False)
528
+
529
+
530
+ def _write_json(path: Path, payload: Dict[str, Any]) -> None:
531
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8")
532
+
533
+
534
+ def _run_id(strategy_id: str) -> str:
535
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
536
+ return f"{ts}_{_hash_text(strategy_id + ts)[:8]}"
537
+
538
+
539
+ def _slug(value: str) -> str:
540
+ cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "_" for ch in str(value).strip())
541
+ return cleaned.strip("_") or "strategy"
542
+
543
+
544
+ def _hash_text(value: str) -> str:
545
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
546
+
547
+
548
+ def _hash_frame(frame: pd.DataFrame) -> str:
549
+ try:
550
+ payload = {
551
+ "rows": int(len(frame)),
552
+ "columns": list(frame.columns),
553
+ "start": str(frame.index[0]) if len(frame) else None,
554
+ "end": str(frame.index[-1]) if len(frame) else None,
555
+ }
556
+ return _hash_text(json.dumps(payload, sort_keys=True, default=str))
557
+ except Exception:
558
+ return "unavailable"
559
+
560
+
561
+ def _git_commit() -> str:
562
+ try:
563
+ root = Path(__file__).resolve().parents[1]
564
+ completed = subprocess.run(
565
+ ["git", "rev-parse", "HEAD"],
566
+ cwd=str(root),
567
+ check=True,
568
+ capture_output=True,
569
+ text=True,
570
+ )
571
+ return completed.stdout.strip()
572
+ except Exception:
573
+ return "unavailable"
574
+
575
+
576
+ def _package_version(package: str) -> str:
577
+ try:
578
+ import importlib.metadata as metadata
579
+
580
+ return metadata.version(package)
581
+ except Exception:
582
+ return "unavailable"
583
+
584
+
585
+ def _first_symbol(result: BacktestResultV2) -> Optional[str]:
586
+ return result.symbols[0] if result.symbols else None
587
+
588
+
589
+ def _bar_timeframe(bar_type: Any) -> Optional[str]:
590
+ if not bar_type:
591
+ return None
592
+ parts = str(bar_type).split("-")
593
+ if len(parts) >= 4:
594
+ return "-".join(parts[-4:-2])
595
+ return None
596
+
597
+
598
+ def _instrument_parts(instrument_id: str) -> Tuple[Optional[str], Optional[str]]:
599
+ if not instrument_id:
600
+ return None, None
601
+ if "." in instrument_id:
602
+ main, exchange = instrument_id.split(".", 1)
603
+ else:
604
+ main, exchange = instrument_id, None
605
+ symbol = main.split("-")[0] if "-" in main else main
606
+ return symbol, exchange
607
+
608
+
609
+ def _position_type(row: pd.Series) -> Optional[str]:
610
+ entry = str(row.get("entry", row.get("side", ""))).upper()
611
+ if entry in {"BUY", "LONG"}:
612
+ return "LONG"
613
+ if entry in {"SELL", "SHORT"}:
614
+ return "SHORT"
615
+ side = str(row.get("position_side", "")).upper()
616
+ if side in {"LONG", "SHORT"}:
617
+ return side
618
+ return None
619
+
620
+
621
+ def _coerce_timestamp_scalar(value: Any) -> pd.Timestamp:
622
+ return pd.to_datetime(value, utc=True, errors="coerce")
623
+
624
+
625
+ def _coerce_float(value: Any) -> float:
626
+ try:
627
+ return float(value)
628
+ except Exception:
629
+ return _coerce_money(value)
630
+
631
+
632
+ def _safe_float(value: Any) -> Optional[float]:
633
+ try:
634
+ if value is None:
635
+ return None
636
+ out = float(value)
637
+ if np.isnan(out):
638
+ return None
639
+ return out
640
+ except Exception:
641
+ return None
642
+
643
+
644
+ def _coerce_money(value: Any) -> float:
645
+ if value is None:
646
+ return 0.0
647
+ try:
648
+ return float(value)
649
+ except Exception:
650
+ text = str(value)
651
+ if text.startswith("[") and text.endswith("]"):
652
+ text = text.strip("[]").strip().strip("'").replace("'", "")
653
+ for token in text.replace(",", "").split():
654
+ try:
655
+ return float(token)
656
+ except Exception:
657
+ continue
658
+ return 0.0
659
+
660
+
661
+ def _duration_seconds(open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> Optional[float]:
662
+ if pd.isna(open_dt) or pd.isna(close_dt):
663
+ return None
664
+ return float((close_dt - open_dt).total_seconds())
665
+
666
+
667
+ def _position_return_pct(side: Optional[str], entry_price: float, exit_price: float) -> Optional[float]:
668
+ if not np.isfinite(entry_price) or not np.isfinite(exit_price) or entry_price == 0.0:
669
+ return None
670
+ raw = (exit_price / entry_price - 1.0) * 100.0
671
+ return float(raw if side != "SHORT" else -raw)
672
+
673
+
674
+ def _fees_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> float:
675
+ subset = _fills_for_position(fills, instrument_id, open_dt, close_dt)
676
+ if subset.empty:
677
+ return 0.0
678
+ fee_cols = [col for col in ("commissions", "commission", "fee") if col in subset.columns]
679
+ if not fee_cols:
680
+ return 0.0
681
+ return float(subset[fee_cols[0]].apply(_coerce_money).sum())
682
+
683
+
684
+ def _order_ids_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> str:
685
+ subset = _fills_for_position(fills, instrument_id, open_dt, close_dt)
686
+ for col in ("client_order_id", "order_id", "venue_order_id"):
687
+ if col in subset.columns:
688
+ return ",".join(str(x) for x in subset[col].dropna().unique())
689
+ return ""
690
+
691
+
692
+ def _fills_for_position(fills: pd.DataFrame, instrument_id: str, open_dt: pd.Timestamp, close_dt: pd.Timestamp) -> pd.DataFrame:
693
+ if fills.empty or pd.isna(open_dt) or pd.isna(close_dt):
694
+ return pd.DataFrame()
695
+ out = fills.copy()
696
+ if "instrument_id" in out.columns:
697
+ out = out[out["instrument_id"].astype(str) == instrument_id]
698
+ ts_col = _timestamp_column(out)
699
+ if ts_col is None:
700
+ return pd.DataFrame()
701
+ out["_ts"] = pd.to_datetime(out[ts_col], utc=True, errors="coerce")
702
+ return out[(out["_ts"] >= open_dt) & (out["_ts"] <= close_dt)]
703
+
704
+
705
+ def _timestamp_column(frame: pd.DataFrame) -> Optional[str]:
706
+ for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"):
707
+ if col in frame.columns:
708
+ return col
709
+ return None
710
+
711
+
712
+ def _event_timestamp(row: pd.Series) -> str:
713
+ for col in ("ts_last", "ts_event", "ts_init", "timestamp", "time"):
714
+ if col in row.index:
715
+ ts = _coerce_timestamp_scalar(row.get(col))
716
+ if not pd.isna(ts):
717
+ return str(ts)
718
+ return "NaT"
719
+
720
+
721
+ def _event_side(row: pd.Series) -> str:
722
+ for col in ("side", "order_side", "entry"):
723
+ if col in row.index and str(row.get(col, "")).strip():
724
+ return str(row.get(col)).upper()
725
+ return "UNKNOWN"
726
+
727
+
728
+ def _event_qty(row: pd.Series) -> float:
729
+ for col in ("filled_qty", "last_qty", "quantity", "qty"):
730
+ if col in row.index:
731
+ return abs(_coerce_float(row.get(col)))
732
+ return 0.0
733
+
734
+
735
+ def _event_price(row: pd.Series) -> float:
736
+ for col in ("avg_px", "last_px", "price", "avg_px_open"):
737
+ if col in row.index:
738
+ return _coerce_float(row.get(col))
739
+ return 0.0
740
+
741
+
742
+ def _event_fee(row: pd.Series) -> float:
743
+ for col in ("commissions", "commission", "fee"):
744
+ if col in row.index:
745
+ return _coerce_money(row.get(col))
746
+ return 0.0
747
+
748
+
749
+ def _position_change_lines(positions: Optional[pd.DataFrame], limit: int) -> List[str]:
750
+ if positions is None or positions.empty:
751
+ return []
752
+ pos = positions.copy().fillna(0.0)
753
+ changed = pos.diff().abs().sum(axis=1).fillna(pos.abs().sum(axis=1)) > 0.0
754
+ lines: List[str] = []
755
+ for ts, row in pos.loc[changed].head(limit).iterrows():
756
+ state = ", ".join(f"{col}={float(value):g}" for col, value in row.items())
757
+ lines.append(f"{pd.Timestamp(ts)} POSITION {state}")
758
+ return lines