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,226 @@
1
+ """Nautilus certification artifact helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, dataclass
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Optional, Sequence
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ from ..core.results import BacktestResultV2
14
+ from .parity import build_native_nautilus_parity_report, summarize_native_nautilus_parity_report
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class NautilusToleranceProfile:
19
+ """Tolerance contract for native-vs-Nautilus certification artifacts."""
20
+
21
+ fill_price_tolerance: float = 1e-9
22
+ fee_tolerance: float = 1e-9
23
+ position_tolerance: float = 1e-9
24
+ equity_tolerance: float = 1e-6
25
+ quantity_tolerance: float = 1e-9
26
+ slippage_tolerance: float = 1e-9
27
+
28
+
29
+ def build_nautilus_certification_profile(
30
+ native_result: Optional[BacktestResultV2],
31
+ nautilus_result: BacktestResultV2,
32
+ *,
33
+ tolerance: NautilusToleranceProfile | Dict[str, float] | None = None,
34
+ workflow: str = "nautilus",
35
+ ) -> Dict[str, Any]:
36
+ """
37
+ Build a compact tolerance profile for a native-vs-Nautilus run.
38
+
39
+ The profile is deliberately report-layer only. It never changes engine
40
+ accounting and is suitable for saved stakeholder bundles.
41
+ """
42
+ tol = _coerce_tolerance(tolerance)
43
+ if native_result is None:
44
+ return {
45
+ "workflow": workflow,
46
+ "status": "reference_missing",
47
+ "passed": False,
48
+ "reason": "native reference result is required for tolerance certification",
49
+ "tolerance": asdict(tol),
50
+ "checks": {},
51
+ }
52
+
53
+ parity = build_native_nautilus_parity_report(native_result, nautilus_result)
54
+ summary = summarize_native_nautilus_parity_report(
55
+ parity,
56
+ fill_price_tolerance=tol.fill_price_tolerance,
57
+ fee_tolerance=tol.fee_tolerance,
58
+ position_tolerance=tol.position_tolerance,
59
+ equity_tolerance=tol.equity_tolerance,
60
+ )
61
+ quantity_diff = _max_abs_quantity_diff(native_result, nautilus_result)
62
+ final_equity_diff = _final_equity_diff(native_result, nautilus_result)
63
+ max_equity_diff = max(float(summary.get("max_abs_equity_diff", 0.0)), final_equity_diff)
64
+ slippage_diff = float(summary.get("max_abs_fill_price_diff", 0.0))
65
+ checks = {
66
+ "fill_price_within_tolerance": float(summary["max_abs_fill_price_diff"]) <= tol.fill_price_tolerance,
67
+ "fee_within_tolerance": float(summary["max_abs_fee_diff"]) <= tol.fee_tolerance,
68
+ "position_within_tolerance": float(summary["max_abs_position_diff"]) <= tol.position_tolerance,
69
+ "equity_within_tolerance": max_equity_diff <= tol.equity_tolerance,
70
+ "quantity_within_tolerance": quantity_diff <= tol.quantity_tolerance,
71
+ "slippage_within_tolerance": slippage_diff <= tol.slippage_tolerance,
72
+ }
73
+ passed = bool(all(checks.values()))
74
+ status = "pass" if passed else _profile_status(checks)
75
+ return {
76
+ "workflow": workflow,
77
+ "status": status,
78
+ "passed": passed,
79
+ "tolerance": asdict(tol),
80
+ "checks": checks,
81
+ "summary": summary,
82
+ "max_abs_quantity_diff": float(quantity_diff),
83
+ "max_abs_final_equity_diff": float(final_equity_diff),
84
+ "max_abs_equity_diff_including_final": float(max_equity_diff),
85
+ "max_abs_slippage_proxy_diff": float(slippage_diff),
86
+ "rows": int(len(parity)),
87
+ }
88
+
89
+
90
+ def write_nautilus_certification_artifacts(
91
+ *,
92
+ native_result: Optional[BacktestResultV2],
93
+ nautilus_result: BacktestResultV2,
94
+ report_dir: str | Path,
95
+ workflow: str,
96
+ tolerance: NautilusToleranceProfile | Dict[str, float] | None = None,
97
+ known_differences: Optional[Sequence[str]] = None,
98
+ ) -> Dict[str, Any]:
99
+ """
100
+ Write parity, tolerance, known-difference, and summary files into a bundle.
101
+ """
102
+ path = Path(report_dir)
103
+ path.mkdir(parents=True, exist_ok=True)
104
+ tol = _coerce_tolerance(tolerance)
105
+ parity = (
106
+ build_native_nautilus_parity_report(native_result, nautilus_result)
107
+ if native_result is not None
108
+ else pd.DataFrame()
109
+ )
110
+ profile = build_nautilus_certification_profile(
111
+ native_result=native_result,
112
+ nautilus_result=nautilus_result,
113
+ tolerance=tol,
114
+ workflow=workflow,
115
+ )
116
+ differences = list(known_differences or ())
117
+ parity_path = path / "native_vs_nautilus_parity.csv"
118
+ profile_path = path / "tolerance_profile.json"
119
+ known_path = path / "known_differences.md"
120
+ summary_path = path / "certification_summary.json"
121
+
122
+ parity.to_csv(parity_path, index=False)
123
+ _write_json(profile_path, profile)
124
+ known_path.write_text(_known_differences_markdown(workflow, differences), encoding="utf-8")
125
+ summary = {
126
+ "workflow": workflow,
127
+ "status": profile["status"],
128
+ "passed": profile["passed"],
129
+ "report_dir": str(path),
130
+ "files": {
131
+ "native_vs_nautilus_parity": parity_path.name,
132
+ "tolerance_profile": profile_path.name,
133
+ "known_differences": known_path.name,
134
+ },
135
+ "known_differences_count": len(differences),
136
+ }
137
+ _write_json(summary_path, summary)
138
+ return {
139
+ **summary,
140
+ "tolerance_profile": profile,
141
+ "artifact_files": [parity_path.name, profile_path.name, known_path.name, summary_path.name],
142
+ }
143
+
144
+
145
+ def _coerce_tolerance(value: NautilusToleranceProfile | Dict[str, float] | None) -> NautilusToleranceProfile:
146
+ if value is None:
147
+ return NautilusToleranceProfile()
148
+ if isinstance(value, NautilusToleranceProfile):
149
+ return value
150
+ return NautilusToleranceProfile(**{key: float(val) for key, val in dict(value).items()})
151
+
152
+
153
+ def _profile_status(checks: Dict[str, bool]) -> str:
154
+ if not checks.get("fill_price_within_tolerance", True) or not checks.get("quantity_within_tolerance", True):
155
+ return "execution_diff"
156
+ if not checks.get("position_within_tolerance", True):
157
+ return "position_diff"
158
+ if not checks.get("fee_within_tolerance", True) or not checks.get("equity_within_tolerance", True):
159
+ return "accounting_diff"
160
+ return "diff"
161
+
162
+
163
+ def _max_abs_quantity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float:
164
+ native_qty = _fill_quantities(native_result)
165
+ nautilus_qty = _fill_quantities(nautilus_result)
166
+ n = max(len(native_qty), len(nautilus_qty))
167
+ if n == 0:
168
+ return 0.0
169
+ left = np.zeros(n, dtype=float)
170
+ right = np.zeros(n, dtype=float)
171
+ left[: len(native_qty)] = native_qty
172
+ right[: len(nautilus_qty)] = nautilus_qty
173
+ return float(np.max(np.abs(left - right)))
174
+
175
+
176
+ def _final_equity_diff(native_result: BacktestResultV2, nautilus_result: BacktestResultV2) -> float:
177
+ if len(native_result.equity) == 0 or len(nautilus_result.equity) == 0:
178
+ return 0.0
179
+ return float(abs(float(native_result.equity.iloc[-1]) - float(nautilus_result.equity.iloc[-1])))
180
+
181
+
182
+ def _fill_quantities(result: BacktestResultV2) -> np.ndarray:
183
+ report = _frame((result.metadata or {}).get("fills_report"))
184
+ if report.empty:
185
+ report = _frame((result.metadata or {}).get("orders_report"))
186
+ if not report.empty:
187
+ if "status" in report:
188
+ report = report[report["status"].astype(str).str.upper().eq("FILLED")]
189
+ for col in ("filled_qty", "quantity", "qty"):
190
+ if col in report:
191
+ return pd.to_numeric(report[col], errors="coerce").fillna(0.0).to_numpy(dtype=float)
192
+ fills = getattr(result, "fills", ())
193
+ if fills:
194
+ return np.asarray([float(getattr(fill, "qty", 0.0)) for fill in fills], dtype=float)
195
+ return np.asarray([], dtype=float)
196
+
197
+
198
+ def _frame(value: Any) -> pd.DataFrame:
199
+ if isinstance(value, pd.DataFrame):
200
+ return value.copy()
201
+ if value is None:
202
+ return pd.DataFrame()
203
+ try:
204
+ return pd.DataFrame(value).copy()
205
+ except Exception:
206
+ return pd.DataFrame()
207
+
208
+
209
+ def _known_differences_markdown(workflow: str, differences: Sequence[str]) -> str:
210
+ lines = [f"# Known Differences - {workflow}", ""]
211
+ if differences:
212
+ for item in differences:
213
+ lines.append(f"- {item}")
214
+ else:
215
+ lines.append("- None recorded for this certification run.")
216
+ lines.extend(
217
+ [
218
+ "",
219
+ "These notes describe known adapter or venue-model differences. They do not override tolerance failures.",
220
+ ]
221
+ )
222
+ return "\n".join(lines) + "\n"
223
+
224
+
225
+ def _write_json(path: Path, payload: Dict[str, Any]) -> None:
226
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True, default=str), encoding="utf-8")
@@ -0,0 +1,225 @@
1
+ """Nautilus validation diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List, Optional
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from ..adapters.nautilus.instruments import SUPPORTED_BINANCE_PERP_SPECS, normalize_binance_perp_symbol
11
+ from ..core.results import BacktestResultV2
12
+
13
+
14
+ def build_nautilus_pct_equity_diagnostic(
15
+ result: BacktestResultV2,
16
+ *,
17
+ data: pd.DataFrame,
18
+ signal: pd.Series,
19
+ native_fee_round_trip: Optional[float] = None,
20
+ native_fee_one_way: Optional[float] = None,
21
+ native_use_funding: Optional[bool] = None,
22
+ native_slippage: Optional[float] = None,
23
+ ) -> Dict:
24
+ """
25
+ Compare a Nautilus `%_equity` validation run against native expectations.
26
+
27
+ The helper is intentionally diagnostic-only: it does not claim that
28
+ Nautilus and native legacy should match. It highlights the most common
29
+ sources of divergence: fee convention/application, funding, slippage,
30
+ signal transitions, and Binance lot-size constraints.
31
+ """
32
+ if not isinstance(result, BacktestResultV2):
33
+ raise TypeError("build_nautilus_pct_equity_diagnostic requires BacktestResultV2")
34
+ if "close" not in data:
35
+ raise ValueError("data must contain a close column")
36
+
37
+ metadata = result.metadata or {}
38
+ idx = _utc_index(data.index)
39
+ sig = _align_signal(signal, idx)
40
+ use_pyramiding = bool(metadata.get("use_pyramiding", _nested(metadata, "run_config", "sizing", "use_pyramiding", default=True)))
41
+ effective_signal = sig.astype(float) if use_pyramiding else np.sign(sig.astype(float))
42
+ transitions = effective_signal.ne(effective_signal.shift(1).fillna(0.0))
43
+ transition_report = pd.DataFrame(
44
+ {
45
+ "timestamp": idx[transitions.to_numpy()],
46
+ "raw_signal": sig.loc[transitions].to_numpy(dtype=float),
47
+ "effective_signal": effective_signal.loc[transitions].to_numpy(dtype=float),
48
+ "close": data.reindex(idx)["close"].loc[transitions].to_numpy(dtype=float),
49
+ }
50
+ )
51
+
52
+ orders_count = int(metadata.get("orders_count", len(_frame(metadata.get("orders_report", metadata.get("order_report"))))))
53
+ fills_count = int(metadata.get("fills_count", len(_frame(metadata.get("fills_report")))))
54
+ sizing_mode = str(metadata.get("sizing_mode", _nested(metadata, "run_config", "sizing", "hedge_type", default=""))).lower()
55
+ requested_fee = _safe_float(metadata.get("fee_rate"))
56
+ requested_slippage = _safe_float(metadata.get("slippage"))
57
+ expected_fee = native_fee_one_way
58
+ if expected_fee is None and native_fee_round_trip is not None:
59
+ expected_fee = float(native_fee_round_trip) / 2.0
60
+
61
+ constraints = _instrument_constraints(metadata)
62
+ lot_report = _lot_size_risk_report(
63
+ transition_report=transition_report,
64
+ initial_capital=float(result.initial_capital),
65
+ alloc_per_trade=float(metadata.get("trade_notional", metadata.get("alloc_per_trade", 0.0)) or 0.0),
66
+ constraints=constraints,
67
+ )
68
+
69
+ checks = {
70
+ "sizing_mode_is_pct_equity": sizing_mode in {"%_equity", "pct_equity"},
71
+ "orders_not_more_than_signal_transitions": orders_count <= int(len(transition_report)),
72
+ "fills_not_more_than_orders": fills_count <= orders_count,
73
+ "fee_convention_matches_native": True if expected_fee is None or requested_fee is None else abs(float(expected_fee) - float(requested_fee)) <= 1e-15,
74
+ "custom_fee_rate_applied_to_nautilus": False,
75
+ "funding_matches_native": True if native_use_funding is None else bool(native_use_funding) is False,
76
+ "slippage_matches_native": True if native_slippage is None else abs(float(native_slippage)) <= 1e-15,
77
+ "custom_slippage_applied_to_nautilus": False,
78
+ "has_lot_size_constraints": constraints.get("qty_step") is not None,
79
+ }
80
+ recommendations = _recommendations(checks, native_use_funding=native_use_funding, native_slippage=native_slippage)
81
+ status = "ok" if all(checks.values()) else "diff"
82
+ return {
83
+ "status": status,
84
+ "checks": checks,
85
+ "signal": {
86
+ "rows": int(len(idx)),
87
+ "raw_transition_count": int(sig.ne(sig.shift(1).fillna(0.0)).sum()),
88
+ "effective_transition_count": int(len(transition_report)),
89
+ "use_pyramiding": use_pyramiding,
90
+ "signal_index_matches_data_index": bool(_utc_index(signal.index).equals(idx)),
91
+ "transition_report": transition_report,
92
+ },
93
+ "orders": {
94
+ "orders_count": orders_count,
95
+ "fills_count": fills_count,
96
+ "positions_count": int(metadata.get("positions_count", 0) or 0),
97
+ "missing_order_events_vs_transitions": max(0, int(len(transition_report)) - orders_count),
98
+ },
99
+ "execution_semantics": {
100
+ "requested_fee_rate": requested_fee,
101
+ "expected_native_one_way_fee_rate": expected_fee,
102
+ "custom_fee_rate_applied_to_nautilus": False,
103
+ "requested_slippage": requested_slippage,
104
+ "native_slippage": native_slippage,
105
+ "custom_slippage_applied_to_nautilus": False,
106
+ "native_use_funding": native_use_funding,
107
+ "nautilus_signal_funding_supported": False,
108
+ "adapter_fill_model": "NautilusTrader bar market execution with instrument maker/taker fee model",
109
+ },
110
+ "instrument_constraints": constraints,
111
+ "lot_size_risk": lot_report,
112
+ "recommendations": recommendations,
113
+ }
114
+
115
+
116
+ def _instrument_constraints(metadata: Dict) -> Dict:
117
+ instrument_id = metadata.get("instrument_id") or _nested(metadata, "run_config", "nautilus", "instrument_id")
118
+ out = {
119
+ "instrument_id": instrument_id,
120
+ "qty_step": metadata.get("qty_step") or metadata.get("lot_size") or metadata.get("size_increment"),
121
+ "lot_size": metadata.get("lot_size") or metadata.get("qty_step") or metadata.get("size_increment"),
122
+ "min_qty": metadata.get("min_qty") or metadata.get("min_quantity"),
123
+ "min_notional": metadata.get("min_notional"),
124
+ "contract_size_note": "contract_size is a multiplier; lot_size/qty_step controls fractional crypto order acceptance",
125
+ }
126
+ if instrument_id:
127
+ try:
128
+ spec = SUPPORTED_BINANCE_PERP_SPECS[normalize_binance_perp_symbol(str(instrument_id))]
129
+ out.update(
130
+ {
131
+ "qty_step": out["qty_step"] or spec.size_increment,
132
+ "lot_size": out["lot_size"] or spec.size_increment,
133
+ "min_qty": out["min_qty"] or spec.min_quantity,
134
+ "min_notional": out["min_notional"] or "10.0",
135
+ "price_increment": spec.price_increment,
136
+ "quantity_precision": spec.size_precision,
137
+ }
138
+ )
139
+ except ValueError:
140
+ pass
141
+ return out
142
+
143
+
144
+ def _lot_size_risk_report(
145
+ *,
146
+ transition_report: pd.DataFrame,
147
+ initial_capital: float,
148
+ alloc_per_trade: float,
149
+ constraints: Dict,
150
+ ) -> Dict:
151
+ qty_step = _safe_float(constraints.get("qty_step"))
152
+ min_qty = _safe_float(constraints.get("min_qty"))
153
+ if transition_report.empty or qty_step is None:
154
+ return {"status": "unknown", "potential_small_delta_count": 0}
155
+ alloc = alloc_per_trade / 100.0 if alloc_per_trade > 1.0 else alloc_per_trade
156
+ close = pd.to_numeric(transition_report["close"], errors="coerce").replace(0.0, np.nan)
157
+ signal = pd.to_numeric(transition_report["effective_signal"], errors="coerce").abs()
158
+ approx_qty = (initial_capital * alloc * signal / close).fillna(0.0)
159
+ threshold = max(qty_step, min_qty or 0.0)
160
+ small = approx_qty < threshold
161
+ return {
162
+ "status": "ok" if not bool(small.any()) else "risk",
163
+ "potential_small_delta_count": int(small.sum()),
164
+ "qty_step": float(qty_step),
165
+ "min_qty": None if min_qty is None else float(min_qty),
166
+ "min_transition_approx_qty": float(approx_qty.min()) if len(approx_qty) else 0.0,
167
+ "note": "Approximation uses initial capital only; live equity and current position can create smaller deltas later.",
168
+ }
169
+
170
+
171
+ def _recommendations(checks: Dict[str, bool], *, native_use_funding, native_slippage) -> List[str]:
172
+ out: List[str] = []
173
+ if not checks["fee_convention_matches_native"]:
174
+ out.append("Align fee convention: legacy `fee` is round-trip; Nautilus `fee_rate` is metadata one-way today.")
175
+ if not checks["custom_fee_rate_applied_to_nautilus"]:
176
+ out.append("Current Nautilus signal adapter uses Nautilus instrument maker/taker fees, not endpoint custom fee_rate.")
177
+ if native_use_funding:
178
+ out.append("Disable native funding for apples-to-apples, or implement Nautilus funding/carry adapter.")
179
+ if native_slippage and abs(float(native_slippage)) > 0.0:
180
+ out.append("Disable native slippage for apples-to-apples, or implement Nautilus slippage model.")
181
+ if not checks["orders_not_more_than_signal_transitions"]:
182
+ out.append("Inspect signal timestamp alignment and Nautilus order reports; order count exceeds transition count.")
183
+ return out
184
+
185
+
186
+ def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
187
+ sig = signal.copy()
188
+ if not isinstance(sig.index, pd.DatetimeIndex):
189
+ sig.index = pd.to_datetime(sig.index, utc=True)
190
+ if sig.index.tz is None:
191
+ sig.index = sig.index.tz_localize("UTC")
192
+ else:
193
+ sig.index = sig.index.tz_convert("UTC")
194
+ return sig.reindex(idx, method="ffill").fillna(0.0)
195
+
196
+
197
+ def _utc_index(index) -> pd.DatetimeIndex:
198
+ idx = pd.DatetimeIndex(pd.to_datetime(index, utc=True))
199
+ if idx.tz is None:
200
+ idx = idx.tz_localize("UTC")
201
+ else:
202
+ idx = idx.tz_convert("UTC")
203
+ return idx
204
+
205
+
206
+ def _nested(mapping: Dict, *keys, default=None):
207
+ cur = mapping
208
+ for key in keys:
209
+ if not isinstance(cur, dict) or key not in cur:
210
+ return default
211
+ cur = cur[key]
212
+ return cur
213
+
214
+
215
+ def _frame(value) -> pd.DataFrame:
216
+ return value if isinstance(value, pd.DataFrame) else pd.DataFrame()
217
+
218
+
219
+ def _safe_float(value):
220
+ try:
221
+ if value is None:
222
+ return None
223
+ return float(value)
224
+ except (TypeError, ValueError):
225
+ return None