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,2168 @@
1
+ """Optional PyO3 adapter for the certified native-event Rust slices.
2
+
3
+ Python remains the full-featured reactive implementation. Rust is explicit and
4
+ capability-gated for the certified single-symbol batched tape contract; audit
5
+ buffers are adapted back to the common Python result surface outside the score
6
+ hot path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, replace
12
+ import importlib
13
+ import os
14
+ from types import ModuleType
15
+ from typing import Callable, Mapping, Optional, Sequence
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ from ..core.event import ORDER_STATUS_PENDING
21
+ from ..core.constraints import quantize_signed_quantity
22
+ from ..core.order_compiler import CompiledOrderCommandArrays, command_tape_fingerprint
23
+ from ..core.orders import OrderAction, OrderActivationPolicy, OrderCommand
24
+ from ..core.reactive import NativeActiveOrderSnapshot, NativeFillEvent, NativeOrderEvent, NativeStrategyContext
25
+ from ..core.schema import OrderSide, OrderType, TimeInForce
26
+ from ..core.native_event_capabilities import normalize_native_event_capabilities
27
+
28
+
29
+ RUST_NATIVE_API_VERSION = "0.4"
30
+ _VALID_BACKENDS = frozenset({"auto", "python", "rust", "replay_certified"})
31
+ _R1_ACTION_PLACE = 0
32
+ _R1_ACTION_CANCEL = 1
33
+ _R2_ACTION_AMEND = 2
34
+ _R2_ACTION_REPLACE = 3
35
+ _R1_ORDER_MARKET = 0
36
+ _R1_ORDER_LIMIT = 1
37
+ _R2_ORDER_STOP_MARKET = 2
38
+ _R2_ORDER_STOP_LIMIT = 3
39
+ _R1_CODE_WIDTH = 8
40
+ _R1_VALUE_WIDTH = 3
41
+ _R2_FLAG_REDUCE_ONLY = 1
42
+ _R2_MUTATE_QTY = 1
43
+ _R2_MUTATE_PRICE = 2
44
+ _R2_MUTATE_TRIGGER = 4
45
+ _FULL_CODE_WIDTH = 16
46
+ _FULL_VALUE_WIDTH = 3
47
+ _FULL_OUTPUT_POSITIONS = 1
48
+ _FULL_OUTPUT_FILLS = 2
49
+ _FULL_OUTPUT_EVENTS = 4
50
+ _FULL_OUTPUT_ACTIVE_ORDERS = 8
51
+
52
+
53
+ def _step_value(payload, key: str, default=None):
54
+ """Read a legacy dict or the API 0.4 typed Rust step result."""
55
+
56
+ if isinstance(payload, Mapping):
57
+ return payload.get(key, default)
58
+ return getattr(payload, key, default)
59
+
60
+
61
+ def _step_has(payload, key: str) -> bool:
62
+ if isinstance(payload, Mapping):
63
+ return key in payload
64
+ return hasattr(payload, key)
65
+
66
+
67
+ class NativeEventRustBackendError(RuntimeError):
68
+ """Raised when an explicitly requested Rust backend cannot be used."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class NativeEventRustExtensionStatus:
73
+ """Import and compatibility state of the optional ``_quantbt_native`` wheel."""
74
+
75
+ available: bool
76
+ compatible: bool
77
+ executable: bool
78
+ version: Optional[str]
79
+ api_version: Optional[str]
80
+ capabilities: Mapping[str, bool]
81
+ reason: Optional[str] = None
82
+ canonical_capabilities: Mapping[str, bool] = field(default_factory=dict)
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class NativeEventBackendSelection:
87
+ """Internal backend decision without changing the public endpoint API."""
88
+
89
+ requested: str
90
+ resolved: str
91
+ extension: NativeEventRustExtensionStatus
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class RustCommandBatch:
96
+ """Contiguous R1 command buffers plus the Python-side identity table."""
97
+
98
+ codes: np.ndarray
99
+ values: np.ndarray
100
+ expiry: np.ndarray
101
+ commands: tuple[OrderCommand, ...]
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class RustBatchedScoreResult:
106
+ """Scalar result returned by one Rust full-tape call."""
107
+
108
+ final_equity: float
109
+ final_position: float
110
+ total_fee: float
111
+ total_turnover: float
112
+ fill_count: int
113
+ event_count: int
114
+ rejected_count: int
115
+ canceled_count: int
116
+ max_initial_margin: float
117
+ max_maintenance_margin: float
118
+ bars: int
119
+ metadata: Mapping[str, object] = field(default_factory=dict)
120
+
121
+
122
+ @dataclass(frozen=True, slots=True)
123
+ class RustBatchedAuditResult:
124
+ """Contiguous SoA audit buffers returned by one Rust full-tape call."""
125
+
126
+ equity: np.ndarray
127
+ positions: np.ndarray
128
+ fees: np.ndarray
129
+ turnover: np.ndarray
130
+ initial_margin: np.ndarray
131
+ maintenance_margin: np.ndarray
132
+ fill_bar: np.ndarray
133
+ fill_order_id: np.ndarray
134
+ fill_side: np.ndarray
135
+ fill_qty: np.ndarray
136
+ fill_price: np.ndarray
137
+ fill_fee: np.ndarray
138
+ event_bar: np.ndarray
139
+ event_kind: np.ndarray
140
+ event_status: np.ndarray
141
+ event_order_id: np.ndarray
142
+ event_target_id: np.ndarray
143
+ total_fee: float
144
+ total_turnover: float
145
+ fill_count: int
146
+ event_count: int
147
+ rejected_count: int
148
+ canceled_count: int
149
+ max_initial_margin: float
150
+ max_maintenance_margin: float
151
+ metadata: Mapping[str, object] = field(default_factory=dict)
152
+ id_values: tuple[str, ...] = ()
153
+
154
+ @property
155
+ def final_equity(self) -> float:
156
+ """Final equity without materializing a second result object."""
157
+
158
+ return float(self.equity[-1]) if len(self.equity) else 0.0
159
+
160
+ @property
161
+ def final_position(self) -> float:
162
+ """Final single-symbol position from the audit path."""
163
+
164
+ return float(self.positions[-1]) if len(self.positions) else 0.0
165
+
166
+ def to_backtest_result(
167
+ self,
168
+ *,
169
+ datetime_index: pd.DatetimeIndex,
170
+ closes: pd.Series | pd.DataFrame,
171
+ symbol: str,
172
+ initial_capital: float,
173
+ leverage: float = 1.0,
174
+ metadata: Optional[Mapping[str, object]] = None,
175
+ include_fills: bool = True,
176
+ ):
177
+ """Adapt a Rust audit into the common :class:`BacktestResultV2`.
178
+
179
+ The Rust boundary intentionally returns typed scalar/SoA data rather
180
+ than Python domain objects. This adapter is the single report
181
+ boundary: it creates the same equity, position, fee, margin,
182
+ ``fills_report`` and ``order_report`` surfaces used by native-event
183
+ Python results. It is an audit/report operation, not part of the
184
+ batched score hot path.
185
+ """
186
+
187
+ from ..core.results import BacktestResultV2
188
+ from ..core.orders import Fill
189
+ from ..core.schema import OrderSide
190
+
191
+ idx = pd.DatetimeIndex(datetime_index)
192
+ if len(idx) != len(self.equity):
193
+ raise ValueError("datetime_index length must match Rust audit equity path")
194
+ if isinstance(closes, pd.DataFrame):
195
+ if symbol in closes.columns:
196
+ close_series = closes[symbol]
197
+ elif f"Close_{symbol}" in closes.columns:
198
+ close_series = closes[f"Close_{symbol}"]
199
+ elif len(closes.columns) == 1:
200
+ close_series = closes.iloc[:, 0]
201
+ else:
202
+ raise KeyError(f"close data does not contain symbol={symbol!r}")
203
+ else:
204
+ close_series = closes
205
+ close_series = pd.Series(close_series, index=idx, dtype=float)
206
+ equity = pd.Series(np.asarray(self.equity, dtype=np.float64), index=idx, name="equity")
207
+ positions = pd.DataFrame(
208
+ {f"Position_{symbol}": np.asarray(self.positions, dtype=np.float64)},
209
+ index=idx,
210
+ )
211
+ fees = pd.Series(np.asarray(self.fees, dtype=np.float64), index=idx, name="fees")
212
+ funding = pd.Series(0.0, index=idx, name="funding")
213
+ margin = pd.DataFrame(
214
+ {
215
+ "initial_margin": np.asarray(self.initial_margin, dtype=np.float64),
216
+ "maintenance_margin": np.asarray(self.maintenance_margin, dtype=np.float64),
217
+ },
218
+ index=idx,
219
+ )
220
+ diagnostics = pd.DataFrame(
221
+ {
222
+ "turnover": np.asarray(self.turnover, dtype=np.float64),
223
+ "rejected_orders": np.bincount(
224
+ np.asarray(self.event_bar, dtype=np.int64)[
225
+ np.asarray(self.event_kind, dtype=np.int64) == 3
226
+ ],
227
+ minlength=len(idx),
228
+ ),
229
+ "canceled_orders": np.bincount(
230
+ np.asarray(self.event_bar, dtype=np.int64)[
231
+ np.asarray(self.event_kind, dtype=np.int64) == 1
232
+ ],
233
+ minlength=len(idx),
234
+ ),
235
+ },
236
+ index=idx,
237
+ )
238
+
239
+ id_values = tuple(self.id_values or self.metadata.get("id_values", ()))
240
+
241
+ def order_id(code: int) -> Optional[str]:
242
+ return id_values[int(code)] if 0 <= int(code) < len(id_values) else None
243
+
244
+ fills_report = pd.DataFrame(
245
+ {
246
+ "bar": np.asarray(self.fill_bar, dtype=np.int64),
247
+ "timestamp": [idx[int(bar)] for bar in self.fill_bar],
248
+ "order_id": [order_id(code) for code in self.fill_order_id],
249
+ "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side],
250
+ "qty": np.asarray(self.fill_qty, dtype=np.float64),
251
+ "price": np.asarray(self.fill_price, dtype=np.float64),
252
+ "fee": np.asarray(self.fill_fee, dtype=np.float64),
253
+ "symbol": symbol,
254
+ }
255
+ )
256
+ order_report = pd.DataFrame(
257
+ {
258
+ "bar": np.asarray(self.event_bar, dtype=np.int64),
259
+ "timestamp": [idx[int(bar)] for bar in self.event_bar],
260
+ "event_kind": np.asarray(self.event_kind, dtype=np.int64),
261
+ "event_status": np.asarray(self.event_status, dtype=np.int64),
262
+ "order_id": [order_id(code) for code in self.event_order_id],
263
+ "target_order_id": [order_id(code) for code in self.event_target_id],
264
+ "symbol": symbol,
265
+ }
266
+ )
267
+ fill_objects = ()
268
+ if include_fills:
269
+ fill_objects = tuple(
270
+ Fill(
271
+ timestamp=idx[int(bar)],
272
+ symbol=symbol,
273
+ side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL,
274
+ qty=float(qty),
275
+ price=float(price),
276
+ fee=float(fee),
277
+ order_id=order_id(order_code),
278
+ metadata={"backend": "rust_batched", "bar": int(bar)},
279
+ )
280
+ for bar, order_code, side, qty, price, fee in zip(
281
+ self.fill_bar,
282
+ self.fill_order_id,
283
+ self.fill_side,
284
+ self.fill_qty,
285
+ self.fill_price,
286
+ self.fill_fee,
287
+ )
288
+ )
289
+ result_metadata = {
290
+ "backend": "native_event",
291
+ "engine": "event_v2_rust_batched_audit",
292
+ "report_level": "audit",
293
+ "native_event_backend_requested": "rust",
294
+ "native_event_backend_resolved": "rust",
295
+ "fills_report": fills_report,
296
+ "order_report": order_report,
297
+ "command_report": order_report,
298
+ "id_values": id_values,
299
+ "lifecycle_counters": {
300
+ "fill_count": int(self.fill_count),
301
+ "event_count": int(self.event_count),
302
+ "rejected_count": int(self.rejected_count),
303
+ "canceled_count": int(self.canceled_count),
304
+ },
305
+ "rust_audit_adapter": "RustBatchedAuditResult.to_backtest_result",
306
+ }
307
+ if metadata:
308
+ result_metadata.update(dict(metadata))
309
+ return BacktestResultV2(
310
+ equity=equity,
311
+ returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0),
312
+ positions=positions,
313
+ closes=pd.DataFrame({f"Close_{symbol}": close_series.to_numpy()}, index=idx),
314
+ symbols=[symbol],
315
+ initial_capital=float(initial_capital),
316
+ leverage=float(leverage),
317
+ liquidated=False,
318
+ orders=(),
319
+ fills=fill_objects,
320
+ fees=fees,
321
+ funding=funding,
322
+ margin=margin,
323
+ diagnostics=diagnostics,
324
+ metadata=result_metadata,
325
+ )
326
+
327
+
328
+ @dataclass(frozen=True, slots=True)
329
+ class RustFullAuditResult:
330
+ """Full-contract Rust SoA result, including multi-symbol/funding state."""
331
+
332
+ equity: np.ndarray
333
+ positions: np.ndarray
334
+ fees: np.ndarray
335
+ turnover: np.ndarray
336
+ funding: np.ndarray
337
+ initial_margin: np.ndarray
338
+ maintenance_margin: np.ndarray
339
+ fill_bar: np.ndarray
340
+ fill_order_id: np.ndarray
341
+ fill_symbol: np.ndarray
342
+ fill_side: np.ndarray
343
+ fill_qty: np.ndarray
344
+ fill_price: np.ndarray
345
+ fill_fee: np.ndarray
346
+ event_bar: np.ndarray
347
+ event_kind: np.ndarray
348
+ event_status: np.ndarray
349
+ event_order_id: np.ndarray
350
+ event_target_id: np.ndarray
351
+ event_symbol: np.ndarray
352
+ event_reject_code: np.ndarray
353
+ total_fee: float
354
+ total_turnover: float
355
+ total_funding: float
356
+ fill_count: int
357
+ event_count: int
358
+ rejected_count: int
359
+ canceled_count: int
360
+ max_initial_margin: float
361
+ max_maintenance_margin: float
362
+ liquidated: bool
363
+ liquidation_bar: int
364
+ liquidation_reason: int
365
+ id_values: tuple[str, ...] = ()
366
+ command_report: Optional[pd.DataFrame] = None
367
+ command_metadata: Mapping[str, Mapping[str, object]] = field(default_factory=dict)
368
+
369
+ @property
370
+ def final_equity(self) -> float:
371
+ return float(self.equity[-1]) if len(self.equity) else 0.0
372
+
373
+ def to_backtest_result(
374
+ self,
375
+ *,
376
+ datetime_index: pd.DatetimeIndex,
377
+ closes: pd.DataFrame,
378
+ symbols: Sequence[str],
379
+ initial_capital: float,
380
+ leverage: float,
381
+ metadata: Optional[Mapping[str, object]] = None,
382
+ ):
383
+ """Materialize the common result surface outside the Rust hot path."""
384
+ from ..core.results import BacktestResultV2
385
+ from ..core.orders import Fill
386
+ from ..core.schema import OrderSide
387
+
388
+ idx = pd.DatetimeIndex(datetime_index)
389
+ equity = pd.Series(self.equity, index=idx, name="equity")
390
+ positions = pd.DataFrame(
391
+ {f"Position_{symbol}": self.positions[:, col] for col, symbol in enumerate(symbols)},
392
+ index=idx,
393
+ )
394
+ close_frame = pd.DataFrame(
395
+ {f"Close_{symbol}": closes[symbol].to_numpy(dtype=np.float64) for symbol in symbols},
396
+ index=idx,
397
+ )
398
+
399
+ def order_id(code: int) -> Optional[str]:
400
+ return self.id_values[int(code)] if 0 <= int(code) < len(self.id_values) else None
401
+
402
+ fill_meta = [
403
+ self.command_metadata.get(order_id(code) or "", {}) for code in self.fill_order_id
404
+ ]
405
+ fills_report = pd.DataFrame({
406
+ "bar": self.fill_bar,
407
+ "timestamp": [idx[int(bar)] for bar in self.fill_bar],
408
+ "order_id": [order_id(code) for code in self.fill_order_id],
409
+ "symbol": [symbols[int(code)] for code in self.fill_symbol],
410
+ "side": ["BUY" if int(side) > 0 else "SELL" for side in self.fill_side],
411
+ "qty": self.fill_qty,
412
+ "price": self.fill_price,
413
+ "fee": self.fill_fee,
414
+ "tag": [meta.get("tag") for meta in fill_meta],
415
+ "campaign_id": [meta.get("campaign_id") for meta in fill_meta],
416
+ "cycle_id": [meta.get("cycle_id") for meta in fill_meta],
417
+ "level_id": [meta.get("level_id") for meta in fill_meta],
418
+ })
419
+ order_report = pd.DataFrame({
420
+ "bar": self.event_bar,
421
+ "timestamp": [idx[int(bar)] for bar in self.event_bar],
422
+ "event_kind": self.event_kind,
423
+ "event_status": self.event_status,
424
+ "order_id": [order_id(code) for code in self.event_order_id],
425
+ "target_order_id": [order_id(code) for code in self.event_target_id],
426
+ "symbol": [None if int(code) < 0 else symbols[int(code)] for code in self.event_symbol],
427
+ "reject_code": self.event_reject_code,
428
+ })
429
+ fills = tuple(
430
+ Fill(
431
+ timestamp=idx[int(bar)], symbol=symbols[int(symbol)],
432
+ side=OrderSide.BUY if int(side) > 0 else OrderSide.SELL,
433
+ qty=float(qty), price=float(price), fee=float(fee), order_id=order_id(order_code),
434
+ metadata={"backend": "rust_full_contract", "bar": int(bar)},
435
+ )
436
+ for bar, order_code, symbol, side, qty, price, fee in zip(
437
+ self.fill_bar, self.fill_order_id, self.fill_symbol, self.fill_side,
438
+ self.fill_qty, self.fill_price, self.fill_fee,
439
+ )
440
+ )
441
+ diagnostics = pd.DataFrame({
442
+ "turnover": self.turnover,
443
+ "rejected_orders": np.bincount(self.event_bar[self.event_kind == 7], minlength=len(idx)),
444
+ "canceled_orders": np.bincount(self.event_bar[self.event_kind == 1], minlength=len(idx)),
445
+ }, index=idx)
446
+ result_metadata = {
447
+ "backend": "native_event",
448
+ "engine": "event_v2_rust_full_contract",
449
+ "report_level": "audit",
450
+ "native_event_backend_requested": "rust",
451
+ "native_event_backend_resolved": "rust",
452
+ "fills_report": fills_report,
453
+ "order_report": order_report,
454
+ "command_report": (
455
+ self.command_report.copy(deep=False)
456
+ if self.command_report is not None
457
+ else pd.DataFrame()
458
+ ),
459
+ "id_values": self.id_values,
460
+ "liquidation_reason": int(self.liquidation_reason),
461
+ "lifecycle_counters": {
462
+ "fill_count": int(self.fill_count), "event_count": int(self.event_count),
463
+ "rejected_count": int(self.rejected_count), "canceled_count": int(self.canceled_count),
464
+ },
465
+ "rust_contract": "native_event_v2_full_contract",
466
+ }
467
+ if metadata:
468
+ result_metadata.update(dict(metadata))
469
+ return BacktestResultV2(
470
+ equity=equity,
471
+ returns=equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0),
472
+ positions=positions,
473
+ closes=close_frame,
474
+ symbols=list(symbols),
475
+ initial_capital=float(initial_capital),
476
+ leverage=float(leverage),
477
+ liquidated=bool(self.liquidated),
478
+ liquidation_bar=int(self.liquidation_bar),
479
+ orders=(), fills=fills,
480
+ fees=pd.Series(self.fees, index=idx, name="fees"),
481
+ funding=pd.Series(self.funding, index=idx, name="funding"),
482
+ margin=pd.DataFrame({"initial_margin": self.initial_margin, "maintenance_margin": self.maintenance_margin}, index=idx),
483
+ diagnostics=diagnostics,
484
+ metadata=result_metadata,
485
+ )
486
+
487
+
488
+ @dataclass(frozen=True, slots=True)
489
+ class RustBatchedChunkResult:
490
+ """Sparse result for one stateful ``run_until`` continuation chunk.
491
+
492
+ The arrays contain only fills/order events observed in the chunk. No
493
+ dense equity or position path is materialized; the caller can request a
494
+ full audit separately when it needs bar-by-bar diagnostics.
495
+ """
496
+
497
+ start_bar: int
498
+ stop_bar: int
499
+ final_equity: float
500
+ final_position: float
501
+ total_fee: float
502
+ total_turnover: float
503
+ fill_count: int
504
+ event_count: int
505
+ rejected_count: int
506
+ canceled_count: int
507
+ max_initial_margin: float
508
+ max_maintenance_margin: float
509
+ liquidation_seen: bool
510
+ wake_bar: np.ndarray
511
+ wake_kind: np.ndarray
512
+ fill_bar: np.ndarray
513
+ fill_order_id: np.ndarray
514
+ fill_side: np.ndarray
515
+ fill_qty: np.ndarray
516
+ fill_price: np.ndarray
517
+ fill_fee: np.ndarray
518
+ event_bar: np.ndarray
519
+ event_kind: np.ndarray
520
+ event_status: np.ndarray
521
+ event_order_id: np.ndarray
522
+ event_target_id: np.ndarray
523
+ metadata: Mapping[str, object] = field(default_factory=dict)
524
+
525
+
526
+ @dataclass
527
+ class RustCommandBuffer:
528
+ """Capacity-managed primitive buffers reused across Rust callback bars."""
529
+
530
+ codes: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_CODE_WIDTH), dtype=np.int64))
531
+ values: np.ndarray = field(default_factory=lambda: np.empty((0, _R1_VALUE_WIDTH), dtype=np.float64))
532
+ expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64))
533
+
534
+ def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
535
+ if size > len(self.codes):
536
+ capacity = max(int(size), max(8, len(self.codes) * 2))
537
+ self.codes = np.empty((capacity, _R1_CODE_WIDTH), dtype=np.int64)
538
+ self.values = np.empty((capacity, _R1_VALUE_WIDTH), dtype=np.float64)
539
+ self.expiry = np.empty(capacity, dtype=np.int64)
540
+ return self.codes[:size], self.values[:size], self.expiry[:size]
541
+
542
+
543
+ @dataclass
544
+ class RustFullCommandBuffer:
545
+ """Capacity-managed buffers for the API 0.4 full command ABI.
546
+
547
+ The public compiler remains the source of truth for command meaning and
548
+ ordering. This object only owns reusable contiguous storage so repeated
549
+ static or reactive runs do not allocate a new ``(n, 16)``/``(n, 3)`` pair
550
+ for every call.
551
+ """
552
+
553
+ codes: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64))
554
+ values: np.ndarray = field(default_factory=lambda: np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64))
555
+ expiry: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.int64))
556
+ growth_count: int = 0
557
+ commands_compiled: int = 0
558
+
559
+ @property
560
+ def capacity(self) -> int:
561
+ """Number of command rows currently reserved."""
562
+
563
+ return int(len(self.codes))
564
+
565
+ def reserve(self, size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
566
+ size = int(size)
567
+ if size < 0:
568
+ raise ValueError("command buffer size must be >= 0")
569
+ if size > self.capacity:
570
+ capacity = max(size, max(8, self.capacity * 2))
571
+ self.codes = np.empty((capacity, _FULL_CODE_WIDTH), dtype=np.int64)
572
+ self.values = np.empty((capacity, _FULL_VALUE_WIDTH), dtype=np.float64)
573
+ self.expiry = np.empty(capacity, dtype=np.int64)
574
+ self.growth_count += 1
575
+ self.commands_compiled += size
576
+ codes = self.codes[:size]
577
+ values = self.values[:size]
578
+ expiry = self.expiry[:size]
579
+ codes.fill(-1)
580
+ values.fill(0.0)
581
+ expiry.fill(-1)
582
+ return codes, values, expiry
583
+
584
+ def clear(self) -> None:
585
+ """Release storage and reset counters for explicit cache cleanup."""
586
+
587
+ self.codes = np.empty((0, _FULL_CODE_WIDTH), dtype=np.int64)
588
+ self.values = np.empty((0, _FULL_VALUE_WIDTH), dtype=np.float64)
589
+ self.expiry = np.empty(0, dtype=np.int64)
590
+ self.growth_count = 0
591
+ self.commands_compiled = 0
592
+
593
+
594
+ @dataclass(frozen=True)
595
+ class _RustPendingOrder:
596
+ order_id: Optional[str]
597
+ side: OrderSide
598
+ order_type: OrderType
599
+ qty: float
600
+ price: float
601
+ trigger_price: float
602
+ reduce_only: bool
603
+
604
+
605
+ def _empty_status(reason: str) -> NativeEventRustExtensionStatus:
606
+ return NativeEventRustExtensionStatus(
607
+ available=False,
608
+ compatible=False,
609
+ executable=False,
610
+ version=None,
611
+ api_version=None,
612
+ capabilities={},
613
+ reason=reason,
614
+ canonical_capabilities={},
615
+ )
616
+
617
+
618
+ def _load_extension() -> Optional[ModuleType]:
619
+ return importlib.import_module("_quantbt_native")
620
+
621
+
622
+ def _read_native_value(module: ModuleType, name: str) -> Optional[object]:
623
+ value = getattr(module, name, None)
624
+ return value() if callable(value) else value
625
+
626
+
627
+ def probe_native_event_rust_extension(
628
+ module: Optional[ModuleType] = None,
629
+ *,
630
+ module_loader: Optional[Callable[[], Optional[ModuleType]]] = None,
631
+ ) -> NativeEventRustExtensionStatus:
632
+ """Return extension compatibility without enabling a Rust execution path.
633
+
634
+ ``module`` and ``module_loader`` are test seams. Runtime callers should
635
+ leave both unset so the optional extension is imported normally.
636
+ """
637
+ if module is None:
638
+ loader = _load_extension if module_loader is None else module_loader
639
+ try:
640
+ module = loader()
641
+ except (ImportError, OSError) as exc:
642
+ return _empty_status(f"unable to import _quantbt_native: {exc}")
643
+ if module is None:
644
+ return _empty_status("quantbt-native is not installed; install a compatible native wheel first")
645
+
646
+ try:
647
+ version_value = _read_native_value(module, "version")
648
+ version = str(version_value if version_value is not None else getattr(module, "__version__", "")) or None
649
+ api_value = _read_native_value(module, "api_version")
650
+ api_version = str(api_value) if api_value is not None else None
651
+ raw_capabilities = _read_native_value(module, "capabilities")
652
+ except Exception as exc: # pragma: no cover - protects optional binary imports.
653
+ return _empty_status(f"failed to query _quantbt_native metadata: {exc}")
654
+
655
+ if not isinstance(raw_capabilities, Mapping):
656
+ raw_capabilities = {}
657
+ capabilities = {str(name): bool(enabled) for name, enabled in raw_capabilities.items()}
658
+ canonical_capabilities = normalize_native_event_capabilities(capabilities)
659
+ # 0.3 remains readable for the legacy R1/R2 classes. Full V2 capability
660
+ # is gated independently by the explicit 0.4 capability keys below.
661
+ compatible = api_version in {"0.3", RUST_NATIVE_API_VERSION}
662
+ if not compatible:
663
+ return NativeEventRustExtensionStatus(
664
+ available=True,
665
+ compatible=False,
666
+ executable=False,
667
+ version=version,
668
+ api_version=api_version,
669
+ capabilities=capabilities,
670
+ reason=(
671
+ "_quantbt_native API version mismatch: "
672
+ f"expected {RUST_NATIVE_API_VERSION!r}, received {api_version!r}"
673
+ ),
674
+ canonical_capabilities=canonical_capabilities,
675
+ )
676
+
677
+ executable = bool(capabilities.get("reactive_session", False))
678
+ reason = None if executable else "_quantbt_native does not advertise the required R1 reactive_session capability"
679
+ return NativeEventRustExtensionStatus(
680
+ available=True,
681
+ compatible=True,
682
+ executable=executable,
683
+ version=version,
684
+ api_version=api_version,
685
+ capabilities=capabilities,
686
+ reason=reason,
687
+ canonical_capabilities=canonical_capabilities,
688
+ )
689
+
690
+
691
+ def resolve_native_event_backend(
692
+ requested: Optional[str] = None,
693
+ *,
694
+ extension_status: Optional[NativeEventRustExtensionStatus] = None,
695
+ ) -> NativeEventBackendSelection:
696
+ """Resolve the native-event selector under the release rollout policy.
697
+
698
+ ``auto`` intentionally resolves to Python for the first dual-backend
699
+ release, even with the wheel installed. ``rust`` is explicit and fails
700
+ loudly unless the installed extension advertises the required capability.
701
+ """
702
+ selected = str(requested or os.getenv("QUANTBT_NATIVE_BACKEND", "auto")).lower().strip()
703
+ if selected not in _VALID_BACKENDS:
704
+ valid = ", ".join(sorted(_VALID_BACKENDS))
705
+ raise ValueError(f"QUANTBT_NATIVE_BACKEND must be one of: {valid}")
706
+
707
+ status = extension_status
708
+ if selected == "rust":
709
+ status = status or probe_native_event_rust_extension()
710
+ if not status.available or not status.compatible or not status.executable:
711
+ detail = status.reason or "unknown native extension state"
712
+ raise NativeEventRustBackendError(f"native-event backend='rust' is unavailable: {detail}")
713
+ return NativeEventBackendSelection(requested=selected, resolved="rust", extension=status)
714
+
715
+ # R0 rollout contract: never auto-enable a just-built extension.
716
+ status = status or _empty_status("Rust extension was not queried because the Python backend was selected")
717
+ resolved = "replay_certified" if selected == "replay_certified" else "python"
718
+ return NativeEventBackendSelection(requested=selected, resolved=resolved, extension=status)
719
+
720
+
721
+ def _require_r1_extension() -> ModuleType:
722
+ module = _load_extension()
723
+ status = probe_native_event_rust_extension(module=module)
724
+ if not status.available or not status.compatible or not status.executable:
725
+ detail = status.reason or "unknown native extension state"
726
+ raise NativeEventRustBackendError(f"native-event Rust R1 is unavailable: {detail}")
727
+ if not hasattr(module, "ReactiveSessionCore"):
728
+ raise NativeEventRustBackendError("_quantbt_native is compatible but lacks ReactiveSessionCore")
729
+ return module
730
+
731
+
732
+ def validate_rust_r1_support(
733
+ *,
734
+ symbols: Sequence[str],
735
+ constraints,
736
+ use_funding: bool,
737
+ maintenance_ratio: float,
738
+ ) -> None:
739
+ """Reject every feature outside the R2 single-symbol surface.
740
+
741
+ The historic function name remains an internal compatibility alias for
742
+ callers introduced with R1. R2 adds lifecycle commands and quantity
743
+ filters, but funding/liquidation and multi-symbol remain Python-only.
744
+ """
745
+ if len(symbols) != 1:
746
+ raise NativeEventRustBackendError("Rust R1 supports exactly one symbol; use backend='python' for multi-symbol")
747
+ if use_funding:
748
+ raise NativeEventRustBackendError("Rust R2 does not support funding; use backend='python'")
749
+ if float(maintenance_ratio) != 0.0:
750
+ raise NativeEventRustBackendError(
751
+ "Rust R2 does not support liquidation semantics; set maintenance_ratio=0.0 or use backend='python'"
752
+ )
753
+
754
+
755
+ def compile_rust_r1_command_batch(
756
+ commands: Sequence[OrderCommand],
757
+ *,
758
+ symbol: str,
759
+ intern_id: Callable[[Optional[str]], int],
760
+ buffer: Optional[RustCommandBuffer] = None,
761
+ ) -> RustCommandBatch:
762
+ """Compile the R2 lifecycle subset into contiguous primitive buffers.
763
+
764
+ Field layout is stable from R1: ``[action, side, type, flags, order_id,
765
+ target_id, mutate_mask, sequence]`` and ``[qty, price, trigger]``. This
766
+ lets the optional extension evolve without adding Python object work to the
767
+ bar loop.
768
+ """
769
+ command_tuple = tuple(commands)
770
+ if buffer is None:
771
+ codes = np.full((len(command_tuple), _R1_CODE_WIDTH), -1, dtype=np.int64)
772
+ values = np.zeros((len(command_tuple), _R1_VALUE_WIDTH), dtype=np.float64)
773
+ expiry = np.full(len(command_tuple), -1, dtype=np.int64)
774
+ else:
775
+ codes, values, expiry = buffer.reserve(len(command_tuple))
776
+ codes.fill(-1)
777
+ values.fill(0.0)
778
+ expiry.fill(-1)
779
+
780
+ for sequence, command in enumerate(command_tuple):
781
+ codes[sequence, 7] = sequence
782
+ if command.action in (OrderAction.PLACE, OrderAction.REPLACE):
783
+ if command.symbol != symbol:
784
+ raise NativeEventRustBackendError(f"Rust R2 command symbol must be {symbol!r}")
785
+ if command.side not in (OrderSide.BUY, OrderSide.SELL):
786
+ raise NativeEventRustBackendError("Rust R2 PLACE/REPLACE requires BUY or SELL")
787
+ if command.order_type not in (
788
+ OrderType.MARKET,
789
+ OrderType.LIMIT,
790
+ OrderType.STOP_MARKET,
791
+ OrderType.STOP_LIMIT,
792
+ ):
793
+ raise NativeEventRustBackendError("Rust R2 supports MARKET, LIMIT, STOP_MARKET, and STOP_LIMIT only")
794
+ if command.tif is not TimeInForce.GTC:
795
+ raise NativeEventRustBackendError("Rust R2 supports GTC only")
796
+ if command.parent_order_id or command.oco_group_id or command.group_id:
797
+ raise NativeEventRustBackendError("Rust R2 does not support parent, group, or OCO orders")
798
+ if command.activation_policy is not OrderActivationPolicy.IMMEDIATE:
799
+ raise NativeEventRustBackendError("Rust R2 supports immediate order activation only")
800
+ if command.expires_at is not None or command.trigger_price is not None:
801
+ if command.expires_at is not None:
802
+ raise NativeEventRustBackendError("Rust R2 does not support expiry; use backend='python'")
803
+ codes[sequence, 0] = _R1_ACTION_PLACE if command.action is OrderAction.PLACE else _R2_ACTION_REPLACE
804
+ codes[sequence, 1] = command.side.sign
805
+ codes[sequence, 2] = {
806
+ OrderType.MARKET: _R1_ORDER_MARKET,
807
+ OrderType.LIMIT: _R1_ORDER_LIMIT,
808
+ OrderType.STOP_MARKET: _R2_ORDER_STOP_MARKET,
809
+ OrderType.STOP_LIMIT: _R2_ORDER_STOP_LIMIT,
810
+ }[command.order_type]
811
+ codes[sequence, 3] = _R2_FLAG_REDUCE_ONLY if command.reduce_only else 0
812
+ codes[sequence, 4] = intern_id(command.order_id)
813
+ codes[sequence, 5] = intern_id(command.target_order_id)
814
+ values[sequence, 0] = float(command.qty or 0.0)
815
+ values[sequence, 1] = float(command.price or 0.0)
816
+ values[sequence, 2] = float(command.trigger_price or 0.0)
817
+ elif command.action is OrderAction.CANCEL:
818
+ codes[sequence, 0] = _R1_ACTION_CANCEL
819
+ codes[sequence, 5] = intern_id(command.target_order_id)
820
+ elif command.action is OrderAction.AMEND:
821
+ codes[sequence, 0] = _R2_ACTION_AMEND
822
+ codes[sequence, 5] = intern_id(command.target_order_id)
823
+ mask = 0
824
+ if command.qty is not None:
825
+ mask |= _R2_MUTATE_QTY
826
+ values[sequence, 0] = float(command.qty)
827
+ if command.price is not None:
828
+ mask |= _R2_MUTATE_PRICE
829
+ values[sequence, 1] = float(command.price)
830
+ if command.trigger_price is not None:
831
+ mask |= _R2_MUTATE_TRIGGER
832
+ values[sequence, 2] = float(command.trigger_price)
833
+ codes[sequence, 6] = mask
834
+ else:
835
+ raise NativeEventRustBackendError("Rust R2 supports PLACE, CANCEL, AMEND, and REPLACE commands only")
836
+ return RustCommandBatch(codes=codes, values=values, expiry=expiry, commands=command_tuple)
837
+
838
+
839
+ def compile_rust_batched_tape(
840
+ compiled_commands: CompiledOrderCommandArrays,
841
+ *,
842
+ symbol: str,
843
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
844
+ """Convert the canonical command compiler output to the Rust tape ABI.
845
+
846
+ The conversion is deliberately performed once per static tape, not once
847
+ per bar or per trial. The canonical compiler remains the source of truth
848
+ for bar ordering and dense order identifiers.
849
+ """
850
+ if tuple(compiled_commands.symbols) != (symbol,):
851
+ raise NativeEventRustBackendError("Rust batched tape supports exactly one symbol")
852
+ commands = tuple(command for _, command in compiled_commands.sorted_commands)
853
+ n = len(commands)
854
+ codes = np.full((n, _R1_CODE_WIDTH), -1, dtype=np.int64)
855
+ values = np.zeros((n, _R1_VALUE_WIDTH), dtype=np.float64)
856
+ expiry = np.ascontiguousarray(compiled_commands.command_expires_bar, dtype=np.int64)
857
+ if n:
858
+ codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64)
859
+ codes[:, 1] = np.asarray(compiled_commands.command_side, dtype=np.int64)
860
+ codes[:, 2] = np.asarray(compiled_commands.command_type, dtype=np.int64)
861
+ codes[:, 3] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64)
862
+ codes[:, 4] = np.asarray(compiled_commands.command_order_id, dtype=np.int64)
863
+ codes[:, 5] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64)
864
+ values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64)
865
+ values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64)
866
+ values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64)
867
+ codes[:, 7] = np.arange(n, dtype=np.int64)
868
+
869
+ for row, command in enumerate(commands):
870
+ if command.symbol not in (None, symbol):
871
+ raise NativeEventRustBackendError(f"Rust batched command symbol must be {symbol!r}")
872
+ if command.action in (OrderAction.PLACE, OrderAction.REPLACE):
873
+ if command.tif is not TimeInForce.GTC:
874
+ raise NativeEventRustBackendError("Rust batched tape supports GTC only")
875
+ if command.parent_order_id or command.group_id or command.oco_group_id:
876
+ raise NativeEventRustBackendError("Rust batched tape does not support parent, group, or OCO orders")
877
+ if command.activation_policy is not OrderActivationPolicy.IMMEDIATE:
878
+ raise NativeEventRustBackendError("Rust batched tape supports immediate activation only")
879
+ if command.expires_at is not None:
880
+ raise NativeEventRustBackendError("Rust batched tape does not support expiry")
881
+ if command.action is OrderAction.REPLACE:
882
+ # CompiledOrderCommandArrays uses the canonical compiler
883
+ # codes (REPLACE=2, AMEND=3), while the stable reactive R2
884
+ # ABI uses AMEND=2, REPLACE=3.
885
+ codes[row, 0] = _R2_ACTION_REPLACE
886
+ elif command.action is OrderAction.CANCEL:
887
+ if command.tif is not TimeInForce.GTC:
888
+ raise NativeEventRustBackendError("Rust batched tape supports GTC only")
889
+ elif command.action is OrderAction.AMEND:
890
+ codes[row, 0] = _R2_ACTION_AMEND
891
+ mask = 0
892
+ if command.qty is not None:
893
+ mask |= _R2_MUTATE_QTY
894
+ if command.price is not None:
895
+ mask |= _R2_MUTATE_PRICE
896
+ if command.trigger_price is not None:
897
+ mask |= _R2_MUTATE_TRIGGER
898
+ codes[row, 6] = mask
899
+ else:
900
+ raise NativeEventRustBackendError("Rust batched tape supports PLACE, CANCEL, AMEND, and REPLACE only")
901
+ if command.expires_at is not None or int(expiry[row]) != -1:
902
+ raise NativeEventRustBackendError("Rust batched tape does not support expiry")
903
+
904
+ return (
905
+ np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64),
906
+ np.ascontiguousarray(codes, dtype=np.int64),
907
+ np.ascontiguousarray(values, dtype=np.float64),
908
+ np.ascontiguousarray(expiry, dtype=np.int64),
909
+ )
910
+
911
+
912
+ def compile_rust_full_tape(
913
+ compiled_commands: CompiledOrderCommandArrays,
914
+ *,
915
+ buffer: Optional[RustFullCommandBuffer] = None,
916
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
917
+ """Compile the complete V2 command schema into the Rust 0.4 ABI.
918
+
919
+ Code layout is intentionally explicit and integer-only for relationship
920
+ fields. The compiler's stable row order remains authoritative.
921
+ """
922
+ commands = tuple(command for _, command in compiled_commands.sorted_commands)
923
+ n = len(commands)
924
+ if buffer is None:
925
+ codes = np.full((n, _FULL_CODE_WIDTH), -1, dtype=np.int64)
926
+ values = np.zeros((n, _FULL_VALUE_WIDTH), dtype=np.float64)
927
+ expiry = np.full(n, -1, dtype=np.int64)
928
+ else:
929
+ codes, values, expiry = buffer.reserve(n)
930
+ if n:
931
+ expiry[:] = np.asarray(compiled_commands.command_expires_bar, dtype=np.int64)
932
+ if n:
933
+ codes[:, 0] = np.asarray(compiled_commands.command_action, dtype=np.int64)
934
+ codes[:, 1] = np.asarray(compiled_commands.command_symbol, dtype=np.int64)
935
+ codes[:, 2] = np.asarray(compiled_commands.command_side, dtype=np.int64)
936
+ codes[:, 3] = np.asarray(compiled_commands.command_type, dtype=np.int64)
937
+ codes[:, 4] = np.asarray(compiled_commands.command_tif, dtype=np.int64)
938
+ codes[:, 5] = np.asarray(compiled_commands.command_reduce_only, dtype=np.int64)
939
+ codes[:, 6] = np.asarray(compiled_commands.command_order_id, dtype=np.int64)
940
+ codes[:, 7] = np.asarray(compiled_commands.command_target_order_id, dtype=np.int64)
941
+ codes[:, 8] = np.asarray(compiled_commands.command_parent_order_id, dtype=np.int64)
942
+ codes[:, 9] = np.asarray(compiled_commands.command_group_id, dtype=np.int64)
943
+ codes[:, 10] = np.asarray(compiled_commands.command_oco_group_id, dtype=np.int64)
944
+ codes[:, 11] = np.asarray(compiled_commands.command_activation, dtype=np.int64)
945
+ codes[:, 12] = np.arange(n, dtype=np.int64)
946
+ values[:, 0] = np.asarray(compiled_commands.command_qty, dtype=np.float64)
947
+ values[:, 1] = np.asarray(compiled_commands.command_price, dtype=np.float64)
948
+ values[:, 2] = np.asarray(compiled_commands.command_trigger_price, dtype=np.float64)
949
+ for row, command in enumerate(commands):
950
+ if command.action.value not in {"place", "cancel", "cancel_all", "amend", "replace"}:
951
+ raise NativeEventRustBackendError(f"unsupported full-contract action={command.action!r}")
952
+ if command.expires_at is not None and int(expiry[row]) < 0:
953
+ raise NativeEventRustBackendError("compiled full tape lost command expiry")
954
+ return (
955
+ np.ascontiguousarray(compiled_commands.command_ptr, dtype=np.int64),
956
+ codes,
957
+ values,
958
+ expiry,
959
+ )
960
+
961
+
962
+ def compile_rust_full_reactive_batch(
963
+ commands: Sequence[OrderCommand],
964
+ *,
965
+ symbols: Sequence[str],
966
+ intern_id: Callable[[Optional[str]], int],
967
+ idx: pd.DatetimeIndex,
968
+ buffer: Optional[RustFullCommandBuffer] = None,
969
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
970
+ """Compile one callback batch for the full ABI without Python objects."""
971
+ rows = tuple(commands)
972
+ if buffer is None:
973
+ codes = np.full((len(rows), _FULL_CODE_WIDTH), -1, dtype=np.int64)
974
+ values = np.zeros((len(rows), _FULL_VALUE_WIDTH), dtype=np.float64)
975
+ expiry = np.full(len(rows), -1, dtype=np.int64)
976
+ else:
977
+ codes, values, expiry = buffer.reserve(len(rows))
978
+ symbol_to_code = {symbol: col for col, symbol in enumerate(symbols)}
979
+ order_type = {OrderType.MARKET: 0, OrderType.LIMIT: 1, OrderType.STOP_MARKET: 2, OrderType.STOP_LIMIT: 3}
980
+ tif = {TimeInForce.GTC: 0, TimeInForce.IOC: 1, TimeInForce.FOK: 2, TimeInForce.GTD: 3}
981
+ action = {OrderAction.PLACE: 0, OrderAction.CANCEL: 1, OrderAction.REPLACE: 2, OrderAction.AMEND: 3, OrderAction.CANCEL_ALL: 4}
982
+ activation = {
983
+ OrderActivationPolicy.IMMEDIATE: 0,
984
+ OrderActivationPolicy.ON_PARENT_FIRST_FILL: 1,
985
+ OrderActivationPolicy.ON_PARENT_FULL_FILL: 2,
986
+ }
987
+ for row, command in enumerate(rows):
988
+ codes[row, 0] = action[command.action]
989
+ codes[row, 1] = -1 if command.symbol is None else symbol_to_code[command.symbol]
990
+ codes[row, 2] = 0 if command.side is None else int(command.side.sign)
991
+ codes[row, 3] = -1 if command.order_type is None else order_type[command.order_type]
992
+ codes[row, 4] = tif[command.tif]
993
+ codes[row, 5] = 1 if command.reduce_only else 0
994
+ codes[row, 6] = intern_id(command.order_id)
995
+ codes[row, 7] = intern_id(command.target_order_id)
996
+ codes[row, 8] = intern_id(command.parent_order_id)
997
+ codes[row, 9] = intern_id(command.group_id)
998
+ codes[row, 10] = intern_id(command.oco_group_id)
999
+ codes[row, 11] = activation[command.activation_policy]
1000
+ codes[row, 12] = row
1001
+ values[row, 0] = 0.0 if command.qty is None else float(command.qty)
1002
+ values[row, 1] = 0.0 if command.price is None else float(command.price)
1003
+ values[row, 2] = 0.0 if command.trigger_price is None else float(command.trigger_price)
1004
+ if command.expires_at is not None:
1005
+ ts = pd.Timestamp(command.expires_at)
1006
+ if ts.tz is None:
1007
+ ts = ts.tz_localize("UTC")
1008
+ else:
1009
+ ts = ts.tz_convert("UTC")
1010
+ expiry[row] = int(np.searchsorted(idx.asi8, ts.value, side="left"))
1011
+ return np.ascontiguousarray(codes), np.ascontiguousarray(values), np.ascontiguousarray(expiry)
1012
+
1013
+
1014
+ def _command_tape_fingerprint(compiled_commands: CompiledOrderCommandArrays) -> str:
1015
+ """Return the compile-time identity of an immutable primitive tape."""
1016
+
1017
+ stored = getattr(compiled_commands, "tape_fingerprint", "")
1018
+ return stored or command_tape_fingerprint(compiled_commands)
1019
+
1020
+
1021
+ def _build_rust_command_intent_report(
1022
+ compiled_commands: CompiledOrderCommandArrays,
1023
+ ) -> pd.DataFrame:
1024
+ """Build the command-intent surface independently from lifecycle events.
1025
+
1026
+ Rust owns execution lifecycle rows. The immutable compiler tape owns the
1027
+ requested command semantics, so this report is deliberately an intent
1028
+ table rather than an alias of ``order_report``.
1029
+ """
1030
+
1031
+ rows = []
1032
+ for sorted_index, (original_index, command) in enumerate(compiled_commands.sorted_commands):
1033
+ metadata = dict(command.metadata)
1034
+ rows.append(
1035
+ {
1036
+ "original_index": int(original_index),
1037
+ "sorted_index": int(sorted_index),
1038
+ "timestamp": command.timestamp,
1039
+ "action": command.action.value,
1040
+ "symbol": command.symbol,
1041
+ "side": None if command.side is None else command.side.value,
1042
+ "order_type": None if command.order_type is None else command.order_type.value,
1043
+ "order_id": command.order_id,
1044
+ "target_order_id": command.target_order_id,
1045
+ "parent_order_id": command.parent_order_id,
1046
+ "group_id": command.group_id,
1047
+ "oco_group_id": command.oco_group_id,
1048
+ "qty": None if command.qty is None else float(command.qty),
1049
+ "price": None if command.price is None else float(command.price),
1050
+ "trigger_price": None if command.trigger_price is None else float(command.trigger_price),
1051
+ "tif": command.tif.value,
1052
+ "reduce_only": bool(command.reduce_only),
1053
+ "activation_policy": command.activation_policy.value,
1054
+ "expires_at": command.expires_at,
1055
+ "tag": command.tag,
1056
+ "tag_prefix": command.tag_prefix,
1057
+ "campaign_id": metadata.get("campaign_id"),
1058
+ "cycle_id": metadata.get("cycle_id"),
1059
+ "level_id": metadata.get("level_id"),
1060
+ "report_kind": "command_intent",
1061
+ }
1062
+ )
1063
+ return pd.DataFrame(rows)
1064
+
1065
+
1066
+ def _payload_value(payload, key: str):
1067
+ """Read both the R2 dict boundary and the R2.1 typed score boundary."""
1068
+
1069
+ if isinstance(payload, Mapping):
1070
+ return payload[key]
1071
+ return getattr(payload, key)
1072
+
1073
+
1074
+ class RustFullRunner:
1075
+ """Prepared full-contract Rust tape runner for explicit Rust execution."""
1076
+
1077
+ def __init__(
1078
+ self,
1079
+ *,
1080
+ idx: pd.DatetimeIndex,
1081
+ symbols: Sequence[str],
1082
+ market_arrays,
1083
+ contract_sizes: np.ndarray,
1084
+ leverages: np.ndarray,
1085
+ fee_rates: np.ndarray,
1086
+ initial_capital: float,
1087
+ maintenance_ratio: float,
1088
+ slippage: float,
1089
+ use_funding: bool,
1090
+ opens_arr: Optional[np.ndarray] = None,
1091
+ volumes_arr: Optional[np.ndarray] = None,
1092
+ prepared_market_core=None,
1093
+ max_tape_cache_bytes: int = 64 * 1024 * 1024,
1094
+ ) -> None:
1095
+ self.idx = pd.DatetimeIndex(idx)
1096
+ self.symbols = tuple(symbols)
1097
+ self.contract_sizes = np.ascontiguousarray(contract_sizes, dtype=np.float64)
1098
+ self.leverages = np.ascontiguousarray(leverages, dtype=np.float64)
1099
+ self.fee_rates = np.ascontiguousarray(fee_rates, dtype=np.float64)
1100
+ self.initial_capital = float(initial_capital)
1101
+ self.maintenance_ratio = float(maintenance_ratio)
1102
+ self.slippage = float(slippage)
1103
+ self.use_funding = bool(use_funding)
1104
+ if int(max_tape_cache_bytes) < 0:
1105
+ raise ValueError("max_tape_cache_bytes must be >= 0")
1106
+ self.max_tape_cache_bytes = int(max_tape_cache_bytes)
1107
+ if len(self.symbols) == 0 or market_arrays.closes.shape[1] != len(self.symbols):
1108
+ raise NativeEventRustBackendError("full Rust runner symbols do not match prepared market arrays")
1109
+ self._module = _require_r1_extension()
1110
+ status = probe_native_event_rust_extension(module=self._module)
1111
+ required = {
1112
+ "native_event_v2_full_contract", "native_event_v2_multisymbol",
1113
+ "native_event_v2_funding", "native_event_v2_liquidation",
1114
+ "native_event_v2_cancel_all_oco", "native_event_v2_tif_expiry",
1115
+ "native_event_v2_relationships", "native_event_v2_quantity_preflight",
1116
+ }
1117
+ missing = sorted(name for name in required if not status.capabilities.get(name, False))
1118
+ if missing:
1119
+ raise NativeEventRustBackendError(
1120
+ "installed _quantbt_native wheel lacks Rust full-contract capabilities: " + ", ".join(missing)
1121
+ )
1122
+ self.prepared_market_core = prepared_market_core
1123
+ if self.prepared_market_core is None:
1124
+ shape = market_arrays.closes.shape
1125
+ zeros = np.zeros(shape, dtype=np.float64)
1126
+ opens = zeros if opens_arr is None else np.ascontiguousarray(opens_arr, dtype=np.float64)
1127
+ volumes = zeros if volumes_arr is None else np.ascontiguousarray(volumes_arr, dtype=np.float64)
1128
+ self.prepared_market_core = self._module.FullPreparedMarketCore(
1129
+ np.ascontiguousarray(self.idx.asi8, dtype=np.int64),
1130
+ opens,
1131
+ np.ascontiguousarray(market_arrays.highs, dtype=np.float64),
1132
+ np.ascontiguousarray(market_arrays.lows, dtype=np.float64),
1133
+ np.ascontiguousarray(market_arrays.closes, dtype=np.float64),
1134
+ volumes,
1135
+ np.ascontiguousarray(market_arrays.funding, dtype=np.float64),
1136
+ np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_),
1137
+ )
1138
+ self._command_buffer = RustFullCommandBuffer()
1139
+ self._cached_tape_fingerprint: Optional[str] = None
1140
+ self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None
1141
+ self._cached_tape_bytes = 0
1142
+ self._session = None
1143
+
1144
+ def _new_session(self):
1145
+ if self._session is None:
1146
+ self._session = self._module.FullReactiveSessionCore.from_prepared(
1147
+ self.prepared_market_core,
1148
+ self.contract_sizes,
1149
+ self.leverages,
1150
+ self.fee_rates,
1151
+ self.initial_capital,
1152
+ self.maintenance_ratio,
1153
+ self.slippage,
1154
+ self.use_funding,
1155
+ )
1156
+ else:
1157
+ self._session.reset()
1158
+ return self._session
1159
+
1160
+ def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays):
1161
+ fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint(
1162
+ compiled_commands
1163
+ )
1164
+ if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None:
1165
+ return self._cached_tape_arrays
1166
+ arrays = compile_rust_full_tape(compiled_commands, buffer=self._command_buffer)
1167
+ byte_size = sum(int(array.nbytes) for array in arrays)
1168
+ if byte_size <= self.max_tape_cache_bytes:
1169
+ self._cached_tape_fingerprint = fingerprint
1170
+ self._cached_tape_arrays = arrays
1171
+ self._cached_tape_bytes = byte_size
1172
+ else:
1173
+ self.clear_tape_cache()
1174
+ return arrays
1175
+
1176
+ @property
1177
+ def tape_cache_bytes(self) -> int:
1178
+ """Resident bytes held by the bounded full-contract tape cache."""
1179
+
1180
+ return int(self._cached_tape_bytes)
1181
+
1182
+ def clear_tape_cache(self) -> None:
1183
+ """Release compiled tape arrays while retaining prepared market state."""
1184
+
1185
+ self._cached_tape_fingerprint = None
1186
+ self._cached_tape_arrays = None
1187
+ self._cached_tape_bytes = 0
1188
+ self._command_buffer.clear()
1189
+
1190
+ def clear_caches(self) -> None:
1191
+ """Release runner-local tape/session caches without mutating market data."""
1192
+
1193
+ self.clear_tape_cache()
1194
+ self._session = None
1195
+
1196
+ def cache_info(self) -> Mapping[str, int]:
1197
+ """Return observable bounded-cache and command-buffer counters."""
1198
+
1199
+ info = {
1200
+ "tape_cache_bytes": self.tape_cache_bytes,
1201
+ "tape_cache_entries": int(self._cached_tape_arrays is not None),
1202
+ "command_buffer_capacity": self._command_buffer.capacity,
1203
+ "command_buffer_growth_count": self._command_buffer.growth_count,
1204
+ "commands_compiled": self._command_buffer.commands_compiled,
1205
+ }
1206
+ if self._session is not None and hasattr(self._session, "order_arena_counters"):
1207
+ slots, capacity, compactions, removed = self._session.order_arena_counters()
1208
+ info.update(
1209
+ {
1210
+ "order_arena_slots": int(slots),
1211
+ "order_arena_capacity": int(capacity),
1212
+ "order_compactions": int(compactions),
1213
+ "terminal_orders_removed": int(removed),
1214
+ }
1215
+ )
1216
+ if self._session is not None and hasattr(self._session, "step_buffer_capacities"):
1217
+ fills, events, active = self._session.step_buffer_capacities()
1218
+ info.update(
1219
+ {
1220
+ "step_fill_buffer_capacity": int(fills),
1221
+ "step_event_buffer_capacity": int(events),
1222
+ "step_active_order_buffer_capacity": int(active),
1223
+ }
1224
+ )
1225
+ if self._session is not None and hasattr(self._session, "margin_recompute_count"):
1226
+ info["margin_recompute_count"] = int(self._session.margin_recompute_count())
1227
+ return info
1228
+
1229
+ def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> Mapping[str, object]:
1230
+ ptr, codes, values, expiry = self._tape_arrays(compiled_commands)
1231
+ return self._new_session().run_tape_score(ptr, codes, values, expiry)
1232
+
1233
+ def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustFullAuditResult:
1234
+ ptr, codes, values, expiry = self._tape_arrays(compiled_commands)
1235
+ payload = self._new_session().run_tape_audit(ptr, codes, values, expiry)
1236
+ keys = (
1237
+ "equity", "positions", "fees", "turnover", "funding", "initial_margin", "maintenance_margin",
1238
+ "fill_bar", "fill_order_id", "fill_symbol", "fill_side", "fill_qty", "fill_price", "fill_fee",
1239
+ "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id", "event_symbol", "event_reject_code",
1240
+ )
1241
+ arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in keys}
1242
+ arrays["positions"] = np.asarray(arrays["positions"], dtype=np.float64).reshape(len(self.idx), len(self.symbols))
1243
+ return RustFullAuditResult(
1244
+ **arrays,
1245
+ total_fee=float(payload["total_fee"]), total_turnover=float(payload["total_turnover"]),
1246
+ total_funding=float(payload["total_funding"]), fill_count=int(payload["fill_count"]),
1247
+ event_count=int(payload["event_count"]), rejected_count=int(payload["rejected_count"]),
1248
+ canceled_count=int(payload["canceled_count"]), max_initial_margin=float(payload["max_initial_margin"]),
1249
+ max_maintenance_margin=float(payload["max_maintenance_margin"]), liquidated=bool(payload["liquidated"]),
1250
+ liquidation_bar=int(payload["liquidation_bar"]), liquidation_reason=int(payload["liquidation_reason"]),
1251
+ id_values=tuple(compiled_commands.id_values),
1252
+ command_report=_build_rust_command_intent_report(compiled_commands),
1253
+ command_metadata={
1254
+ command.order_id: dict(command.metadata)
1255
+ for _, command in compiled_commands.sorted_commands
1256
+ if command.order_id
1257
+ },
1258
+ )
1259
+
1260
+
1261
+ class RustBatchedRunner:
1262
+ """Single-symbol Rust full-tape runner with prepared-market reuse.
1263
+
1264
+ This is an explicit experimental backend. It accepts a precompiled
1265
+ static command tape and never invokes arbitrary Python strategy callbacks.
1266
+ Unsupported funding, liquidation, quantity constraints, TIF and package
1267
+ semantics fail before crossing the Rust boundary.
1268
+ """
1269
+
1270
+ def __init__(
1271
+ self,
1272
+ *,
1273
+ idx: pd.DatetimeIndex,
1274
+ symbols: Sequence[str],
1275
+ market_arrays,
1276
+ contract_size: float = 1.0,
1277
+ leverage: float = 1.0,
1278
+ fee_rate: float = 0.0,
1279
+ initial_capital: float = 1_000.0,
1280
+ maintenance_ratio: float = 0.0,
1281
+ slippage: float = 0.0,
1282
+ use_funding: bool = False,
1283
+ prepared_market_core=None,
1284
+ max_tape_cache_bytes: int = 64 * 1024 * 1024,
1285
+ ) -> None:
1286
+ if len(symbols) != 1:
1287
+ raise NativeEventRustBackendError("Rust batched runner supports exactly one symbol")
1288
+ if use_funding:
1289
+ raise NativeEventRustBackendError("Rust batched runner does not support funding")
1290
+ if float(maintenance_ratio) != 0.0:
1291
+ raise NativeEventRustBackendError("Rust batched runner does not support liquidation")
1292
+ if float(contract_size) <= 0.0 or float(leverage) <= 0.0:
1293
+ raise ValueError("contract_size and leverage must be > 0")
1294
+ if float(fee_rate) < 0.0 or float(slippage) < 0.0:
1295
+ raise ValueError("fee_rate and slippage must be >= 0")
1296
+ if int(max_tape_cache_bytes) < 0:
1297
+ raise ValueError("max_tape_cache_bytes must be >= 0")
1298
+ self.idx = pd.DatetimeIndex(idx)
1299
+ self.symbols = tuple(symbols)
1300
+ self.contract_size = float(contract_size)
1301
+ self.leverage = float(leverage)
1302
+ self.fee_rate = float(fee_rate)
1303
+ self.initial_capital = float(initial_capital)
1304
+ self.maintenance_ratio = float(maintenance_ratio)
1305
+ self.slippage = float(slippage)
1306
+ self.max_tape_cache_bytes = int(max_tape_cache_bytes)
1307
+ self._module = _require_r1_extension()
1308
+ status = probe_native_event_rust_extension(module=self._module)
1309
+ required = (
1310
+ "rust_batched_tape",
1311
+ "rust_batched_tape_score",
1312
+ "rust_batched_tape_audit",
1313
+ "rust_batched_tape_sparse",
1314
+ )
1315
+ missing = [name for name in required if not status.capabilities.get(name, False)]
1316
+ if missing:
1317
+ raise NativeEventRustBackendError(
1318
+ "installed _quantbt_native wheel lacks Rust batched capabilities: " + ", ".join(missing)
1319
+ )
1320
+ self.prepared_market_core = prepared_market_core
1321
+ self._cached_tape_fingerprint: Optional[str] = None
1322
+ self._cached_tape_arrays: Optional[tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]] = None
1323
+ self._cached_tape_bytes = 0
1324
+ if self.prepared_market_core is None:
1325
+ close = np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64)
1326
+ self.prepared_market_core = self._module.PreparedMarketCore(
1327
+ np.ascontiguousarray(self.idx.asi8, dtype=np.int64),
1328
+ close,
1329
+ np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64),
1330
+ np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64),
1331
+ close,
1332
+ np.zeros(len(self.idx), dtype=np.float64),
1333
+ np.zeros(len(self.idx), dtype=np.float64),
1334
+ np.zeros(len(self.idx), dtype=np.bool_),
1335
+ )
1336
+
1337
+ def open_sparse_session(
1338
+ self,
1339
+ compiled_commands: Optional[CompiledOrderCommandArrays] = None,
1340
+ ) -> "RustBatchedSession":
1341
+ """Open a stateful sparse session over one compiled command tape.
1342
+
1343
+ ``run_until`` keeps the Rust lifecycle state between calls. The
1344
+ tape is compiled once and the session only returns sparse fills/events
1345
+ plus scalar accounting, so strategy services do not pay for a dense
1346
+ per-bar result path on every chunk.
1347
+ """
1348
+ return RustBatchedSession(self, compiled_commands)
1349
+
1350
+ # A descriptive alias for callers that use the shorter session wording.
1351
+ new_sparse_session = open_sparse_session
1352
+
1353
+ def _new_session(self):
1354
+ return self._module.ReactiveSessionCore.from_prepared(
1355
+ self.prepared_market_core,
1356
+ self.contract_size,
1357
+ self.leverage,
1358
+ self.fee_rate,
1359
+ self.initial_capital,
1360
+ self.maintenance_ratio,
1361
+ self.slippage,
1362
+ False,
1363
+ )
1364
+
1365
+ def _tape_arrays(self, compiled_commands: CompiledOrderCommandArrays):
1366
+ fingerprint = getattr(compiled_commands, "tape_fingerprint", "") or _command_tape_fingerprint(
1367
+ compiled_commands
1368
+ )
1369
+ if fingerprint == self._cached_tape_fingerprint and self._cached_tape_arrays is not None:
1370
+ return self._cached_tape_arrays
1371
+ arrays = compile_rust_batched_tape(compiled_commands, symbol=self.symbols[0])
1372
+ byte_size = sum(int(array.nbytes) for array in arrays)
1373
+ if byte_size <= self.max_tape_cache_bytes:
1374
+ self._cached_tape_fingerprint = fingerprint
1375
+ self._cached_tape_arrays = arrays
1376
+ self._cached_tape_bytes = byte_size
1377
+ else:
1378
+ self.clear_tape_cache()
1379
+ return arrays
1380
+
1381
+ @property
1382
+ def tape_cache_bytes(self) -> int:
1383
+ """Current resident size of the bounded primitive tape cache."""
1384
+
1385
+ return int(self._cached_tape_bytes)
1386
+
1387
+ def clear_tape_cache(self) -> None:
1388
+ """Release cached command arrays and their fingerprint immediately."""
1389
+
1390
+ self._cached_tape_fingerprint = None
1391
+ self._cached_tape_arrays = None
1392
+ self._cached_tape_bytes = 0
1393
+
1394
+ def run_tape_score(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedScoreResult:
1395
+ """Run a complete static tape through one PyO3 call and return scalars."""
1396
+ ptr, codes, values, expiry = self._tape_arrays(compiled_commands)
1397
+ payload = self._new_session().run_tape_score(ptr, codes, values, expiry)
1398
+ return RustBatchedScoreResult(
1399
+ final_equity=float(_payload_value(payload, "final_equity")),
1400
+ final_position=float(_payload_value(payload, "final_position")),
1401
+ total_fee=float(_payload_value(payload, "total_fee")),
1402
+ total_turnover=float(_payload_value(payload, "total_turnover")),
1403
+ fill_count=int(_payload_value(payload, "fill_count")),
1404
+ event_count=int(_payload_value(payload, "event_count")),
1405
+ rejected_count=int(_payload_value(payload, "rejected_count")),
1406
+ canceled_count=int(_payload_value(payload, "canceled_count")),
1407
+ max_initial_margin=float(_payload_value(payload, "max_initial_margin")),
1408
+ max_maintenance_margin=float(_payload_value(payload, "max_maintenance_margin")),
1409
+ bars=int(_payload_value(payload, "bars")),
1410
+ metadata={"backend": "rust_batched", "mode": "score", "pycalls": 1},
1411
+ )
1412
+
1413
+ def run_tape_audit(self, compiled_commands: CompiledOrderCommandArrays) -> RustBatchedAuditResult:
1414
+ """Run a complete tape and return contiguous struct-of-arrays audit data."""
1415
+ ptr, codes, values, expiry = self._tape_arrays(compiled_commands)
1416
+ payload = self._new_session().run_tape_audit(ptr, codes, values, expiry)
1417
+ arrays = {key: np.ascontiguousarray(np.asarray(payload[key])) for key in (
1418
+ "equity", "positions", "fees", "turnover", "initial_margin", "maintenance_margin",
1419
+ "fill_bar", "fill_order_id", "fill_side", "fill_qty", "fill_price", "fill_fee",
1420
+ "event_bar", "event_kind", "event_status", "event_order_id", "event_target_id",
1421
+ )}
1422
+ return RustBatchedAuditResult(
1423
+ **arrays,
1424
+ total_fee=float(payload["total_fee"]),
1425
+ total_turnover=float(payload["total_turnover"]),
1426
+ fill_count=int(payload["fill_count"]),
1427
+ event_count=int(payload["event_count"]),
1428
+ rejected_count=int(payload["rejected_count"]),
1429
+ canceled_count=int(payload["canceled_count"]),
1430
+ max_initial_margin=float(payload["max_initial_margin"]),
1431
+ max_maintenance_margin=float(payload["max_maintenance_margin"]),
1432
+ metadata={"backend": "rust_batched", "mode": "audit", "pycalls": 1},
1433
+ id_values=tuple(compiled_commands.id_values),
1434
+ )
1435
+
1436
+
1437
+ class RustBatchedSession:
1438
+ """Stateful single-symbol sparse continuation over a static tape."""
1439
+
1440
+ def __init__(
1441
+ self,
1442
+ runner: RustBatchedRunner,
1443
+ compiled_commands: Optional[CompiledOrderCommandArrays] = None,
1444
+ ) -> None:
1445
+ self.runner = runner
1446
+ self.compiled_commands = compiled_commands
1447
+ self._core = runner._new_session()
1448
+ self._tape_arrays_cache = (
1449
+ None if compiled_commands is None else runner._tape_arrays(compiled_commands)
1450
+ )
1451
+ self.next_bar = 0
1452
+
1453
+ @staticmethod
1454
+ def _arrays(payload: Mapping[str, object]) -> dict[str, np.ndarray]:
1455
+ return {
1456
+ key: np.ascontiguousarray(np.asarray(payload[key]))
1457
+ for key in (
1458
+ "wake_bar",
1459
+ "wake_kind",
1460
+ "fill_bar",
1461
+ "fill_order_id",
1462
+ "fill_side",
1463
+ "fill_qty",
1464
+ "fill_price",
1465
+ "fill_fee",
1466
+ "event_bar",
1467
+ "event_kind",
1468
+ "event_status",
1469
+ "event_order_id",
1470
+ "event_target_id",
1471
+ )
1472
+ }
1473
+
1474
+ def run_until(
1475
+ self,
1476
+ stop_bar: int,
1477
+ command_batch: Optional[CompiledOrderCommandArrays] = None,
1478
+ *,
1479
+ wake_on_fill: bool = True,
1480
+ wake_on_order_event: bool = True,
1481
+ wake_on_liquidation: bool = True,
1482
+ ) -> RustBatchedChunkResult:
1483
+ """Advance through ``stop_bar`` without crossing Python per bar.
1484
+
1485
+ The first call starts at bar zero and later calls continue at the bar
1486
+ after the previous chunk. ``command_batch`` is optional after a tape
1487
+ was supplied to :meth:`open_sparse_session`; replacing the tape
1488
+ mid-session is rejected to avoid an accounting mismatch.
1489
+ """
1490
+ if command_batch is not None:
1491
+ if self.compiled_commands is not None and command_batch is not self.compiled_commands:
1492
+ raise NativeEventRustBackendError("cannot replace the command tape during a sparse session")
1493
+ self.compiled_commands = command_batch
1494
+ if self.compiled_commands is None:
1495
+ raise NativeEventRustBackendError("run_until requires a compiled command tape")
1496
+ stop = int(stop_bar)
1497
+ if stop < self.next_bar:
1498
+ raise ValueError("run_until stop_bar must advance beyond the previous chunk")
1499
+ if self._tape_arrays_cache is None:
1500
+ self._tape_arrays_cache = self.runner._tape_arrays(self.compiled_commands)
1501
+ ptr, codes, values, expiry = self._tape_arrays_cache
1502
+ payload = self._core.run_until(
1503
+ stop,
1504
+ ptr,
1505
+ codes,
1506
+ values,
1507
+ expiry,
1508
+ bool(wake_on_fill),
1509
+ bool(wake_on_order_event),
1510
+ bool(wake_on_liquidation),
1511
+ )
1512
+ arrays = self._arrays(payload)
1513
+ self.next_bar = stop + 1
1514
+ return RustBatchedChunkResult(
1515
+ start_bar=int(payload["start_bar"]),
1516
+ stop_bar=int(payload["stop_bar"]),
1517
+ final_equity=float(payload["final_equity"]),
1518
+ final_position=float(payload["final_position"]),
1519
+ total_fee=float(payload["total_fee"]),
1520
+ total_turnover=float(payload["total_turnover"]),
1521
+ fill_count=int(payload["fill_count"]),
1522
+ event_count=int(payload["event_count"]),
1523
+ rejected_count=int(payload["rejected_count"]),
1524
+ canceled_count=int(payload["canceled_count"]),
1525
+ max_initial_margin=float(payload["max_initial_margin"]),
1526
+ max_maintenance_margin=float(payload["max_maintenance_margin"]),
1527
+ liquidation_seen=bool(payload["liquidation_seen"]),
1528
+ **arrays,
1529
+ metadata={
1530
+ "backend": "rust_batched",
1531
+ "mode": "sparse",
1532
+ "pycalls": 1,
1533
+ "dense_paths_materialized": False,
1534
+ "wake_on_fill": bool(wake_on_fill),
1535
+ "wake_on_order_event": bool(wake_on_order_event),
1536
+ "wake_on_liquidation": bool(wake_on_liquidation),
1537
+ },
1538
+ )
1539
+
1540
+ def reset(self) -> None:
1541
+ """Reset lifecycle/accounting while retaining Rust buffer capacity."""
1542
+
1543
+ self._core.reset()
1544
+ self.next_bar = 0
1545
+
1546
+
1547
+ class RustReactiveSessionAdapter:
1548
+ """R2 bridge: Python callbacks around one Rust state transition per bar."""
1549
+
1550
+ def __init__(
1551
+ self,
1552
+ *,
1553
+ idx: pd.DatetimeIndex,
1554
+ symbols: Sequence[str],
1555
+ market_arrays,
1556
+ opens_arr: np.ndarray,
1557
+ volumes_arr: np.ndarray,
1558
+ constraints,
1559
+ contract_sizes: np.ndarray,
1560
+ leverages: np.ndarray,
1561
+ fee_rates: np.ndarray,
1562
+ initial_capital: float,
1563
+ maintenance_ratio: float,
1564
+ slippage: float,
1565
+ use_funding: bool,
1566
+ retain_terminal_orders: bool = True,
1567
+ score_requirements=None,
1568
+ prepared_market_core=None,
1569
+ ) -> None:
1570
+ self._module = _require_r1_extension()
1571
+ extension_status = probe_native_event_rust_extension(module=self._module)
1572
+ self._full_contract = bool(extension_status.capabilities.get("native_event_v2_full_contract", False))
1573
+ if not self._full_contract:
1574
+ validate_rust_r1_support(
1575
+ symbols=symbols,
1576
+ constraints=constraints,
1577
+ use_funding=use_funding,
1578
+ maintenance_ratio=maintenance_ratio,
1579
+ )
1580
+ self.idx = idx
1581
+ self.symbols = list(symbols)
1582
+ self.symbols_tuple = tuple(symbols)
1583
+ self.market_arrays = market_arrays
1584
+ self.opens_arr = opens_arr
1585
+ self.volumes_arr = volumes_arr
1586
+ self.constraints = constraints
1587
+ self.contract_sizes = np.asarray(contract_sizes, dtype=np.float64)
1588
+ self.leverages = np.asarray(leverages, dtype=np.float64)
1589
+ self.fee_rates = np.asarray(fee_rates, dtype=np.float64)
1590
+ self.initial_capital = float(initial_capital)
1591
+ self.maintenance_ratio = float(maintenance_ratio)
1592
+ self.slippage = float(slippage)
1593
+ self.use_funding = bool(use_funding)
1594
+ self.retain_terminal_orders = bool(retain_terminal_orders)
1595
+ self.score_requirements = score_requirements
1596
+ self.scalar_score = bool(
1597
+ score_requirements is not None
1598
+ and score_requirements.need_trade_stats
1599
+ and not score_requirements.need_equity_path
1600
+ and not score_requirements.need_position_path
1601
+ and not score_requirements.need_fee_path
1602
+ and not score_requirements.need_funding_path
1603
+ and not score_requirements.need_margin_path
1604
+ )
1605
+ self.retain_fill_ledger = bool(score_requirements is None or score_requirements.need_fill_ledger)
1606
+ self.retain_event_ledger = bool(score_requirements is None or score_requirements.need_event_ledger)
1607
+ self.emit_context_fills = bool(
1608
+ score_requirements is None or score_requirements.need_context_fills
1609
+ )
1610
+ self.emit_context_events = bool(
1611
+ score_requirements is None or score_requirements.need_context_events
1612
+ )
1613
+ self.emit_context_active_orders = bool(
1614
+ score_requirements is None or score_requirements.need_context_active_orders
1615
+ )
1616
+ self.emit_context_positions = bool(
1617
+ score_requirements is None or score_requirements.need_context_positions
1618
+ )
1619
+ self.emit_context_margin = bool(
1620
+ score_requirements is None or score_requirements.need_context_margin
1621
+ )
1622
+ self.compact_score_state = bool(
1623
+ score_requirements is not None
1624
+ and not score_requirements.need_context_fills
1625
+ and not score_requirements.need_context_events
1626
+ and not score_requirements.need_context_active_orders
1627
+ and not score_requirements.need_context_positions
1628
+ and not score_requirements.need_context_margin
1629
+ and not score_requirements.need_fill_ledger
1630
+ and not score_requirements.need_event_ledger
1631
+ and not score_requirements.need_terminal_orders
1632
+ )
1633
+ self._r2_capable = bool(extension_status.capabilities.get("r2_stop_amend_replace_reduce_only_constraints", False))
1634
+ self._prepared_market_core_capable = bool(extension_status.capabilities.get("prepared_market_core", False))
1635
+ if self.constraints.enabled and not self._r2_capable:
1636
+ raise NativeEventRustBackendError(
1637
+ "installed _quantbt_native wheel is R1-only and cannot apply quantity constraints; rebuild/install R2 or use backend='python'"
1638
+ )
1639
+ self._id_to_code: dict[str, int] = {}
1640
+ self._id_values: list[str] = []
1641
+ self._commands_by_id: dict[str, OrderCommand] = {}
1642
+ self._command_buffer = RustCommandBuffer()
1643
+ self._full_command_buffer = RustFullCommandBuffer()
1644
+ self.execution_counters = {
1645
+ "bars_processed": 0,
1646
+ "bars_with_commands": 0,
1647
+ "contexts_materialized": 0,
1648
+ "timestamp_objects_materialized": 0,
1649
+ "commands_compiled": 0,
1650
+ "command_buffer_growths": 0,
1651
+ "bytes_copied_to_rust": 0,
1652
+ "active_snapshot_materializations": 0,
1653
+ "empty_command_batches_skipped": 0,
1654
+ "constraint_preflight_calls": 0,
1655
+ "constraint_preflight_skipped": 0,
1656
+ "commands_retimed": 0,
1657
+ "commands_quantized": 0,
1658
+ }
1659
+ self.scheduled: dict[int, list[OrderCommand]] = {}
1660
+ self.pending: list[_RustPendingOrder] = []
1661
+ self.orders: list[_RustPendingOrder] = []
1662
+ self.fills: list[NativeFillEvent] = []
1663
+ self.events: list[NativeOrderEvent] = []
1664
+ self.fill_count = 0
1665
+ self.event_count = 0
1666
+ self.rejected_count = 0
1667
+ self.canceled_count = 0
1668
+ self.total_fee = 0.0
1669
+ self.total_funding = 0.0
1670
+ self.total_turnover = 0.0
1671
+ self.fills_by_bar: dict[int, list[NativeFillEvent]] = {}
1672
+ self.events_by_bar: dict[int, list[NativeOrderEvent]] = {}
1673
+ self.current_pos = np.zeros(len(self.symbols), dtype=np.float64)
1674
+ self.equity = float(initial_capital)
1675
+ self.liquidated = False
1676
+ self.liquidation_bar = -1
1677
+ self.liquidation_reason = 0
1678
+ self.last_initial_margin = 0.0
1679
+ self.last_maintenance_margin = 0.0
1680
+ self.processed_bar = -1
1681
+ n_bars = len(idx)
1682
+ self.equity_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1683
+ self.pos_path = None if self.scalar_score else np.zeros((n_bars, len(self.symbols)), dtype=np.float64)
1684
+ self.fee_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1685
+ self.turnover_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1686
+ self.funding_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1687
+ self.initial_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1688
+ self.maintenance_margin_path = None if self.scalar_score else np.zeros(n_bars, dtype=np.float64)
1689
+ self.rejected_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64)
1690
+ self.canceled_bar = None if self.scalar_score else np.zeros(n_bars, dtype=np.int64)
1691
+ self.empty_fills: tuple[NativeFillEvent, ...] = ()
1692
+ self.empty_events: tuple[NativeOrderEvent, ...] = ()
1693
+ self.empty_active_orders: tuple[NativeActiveOrderSnapshot, ...] = ()
1694
+ self._active_snapshot_cache: tuple[NativeActiveOrderSnapshot, ...] = ()
1695
+ if self.scalar_score:
1696
+ # Import lazily to avoid the native_event <-> Rust adapter import
1697
+ # cycle. The class is shared with Python scalar scoring so metric
1698
+ # definitions remain identical across backends.
1699
+ from .native_event import _OnlineScoreState
1700
+
1701
+ self.online_score = _OnlineScoreState(self.initial_capital, len(self.symbols))
1702
+ else:
1703
+ self.online_score = None
1704
+ self.prepared_market_core = prepared_market_core
1705
+ if self._full_contract and hasattr(self._module, "FullPreparedMarketCore"):
1706
+ if self.prepared_market_core is None:
1707
+ self.prepared_market_core = self._module.FullPreparedMarketCore(
1708
+ np.ascontiguousarray(idx.asi8, dtype=np.int64),
1709
+ np.ascontiguousarray(opens_arr, dtype=np.float64),
1710
+ np.ascontiguousarray(market_arrays.highs, dtype=np.float64),
1711
+ np.ascontiguousarray(market_arrays.lows, dtype=np.float64),
1712
+ np.ascontiguousarray(market_arrays.closes, dtype=np.float64),
1713
+ np.ascontiguousarray(volumes_arr, dtype=np.float64),
1714
+ np.ascontiguousarray(market_arrays.funding, dtype=np.float64),
1715
+ np.ascontiguousarray(market_arrays.is_funding_bar, dtype=np.bool_),
1716
+ )
1717
+ self._core = self._module.FullReactiveSessionCore.from_prepared(
1718
+ self.prepared_market_core,
1719
+ np.ascontiguousarray(self.contract_sizes, dtype=np.float64),
1720
+ np.ascontiguousarray(self.leverages, dtype=np.float64),
1721
+ np.ascontiguousarray(self.fee_rates, dtype=np.float64),
1722
+ float(initial_capital), float(maintenance_ratio), float(slippage), bool(use_funding),
1723
+ )
1724
+ # Accounting and the live position vector are always required by
1725
+ # the Python adapter. Other projections are requested only when
1726
+ # the strategy/ledger can observe them.
1727
+ output_mask = _FULL_OUTPUT_POSITIONS
1728
+ if self.retain_fill_ledger or self.emit_context_fills:
1729
+ output_mask |= _FULL_OUTPUT_FILLS
1730
+ if self.retain_event_ledger or self.emit_context_events:
1731
+ output_mask |= _FULL_OUTPUT_EVENTS
1732
+ if self.emit_context_active_orders:
1733
+ output_mask |= _FULL_OUTPUT_ACTIVE_ORDERS
1734
+ self._core.set_output_mask(output_mask)
1735
+ elif self._prepared_market_core_capable and hasattr(self._module, "PreparedMarketCore"):
1736
+ if self.prepared_market_core is None:
1737
+ self.prepared_market_core = self._module.PreparedMarketCore(
1738
+ np.ascontiguousarray(idx.asi8, dtype=np.int64),
1739
+ np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64),
1740
+ np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64),
1741
+ np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64),
1742
+ np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64),
1743
+ np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64),
1744
+ np.zeros(n_bars, dtype=np.float64),
1745
+ np.zeros(n_bars, dtype=np.bool_),
1746
+ )
1747
+ self._core = self._module.ReactiveSessionCore.from_prepared(
1748
+ self.prepared_market_core,
1749
+ float(self.contract_sizes[0]),
1750
+ float(self.leverages[0]),
1751
+ float(self.fee_rates[0]),
1752
+ float(initial_capital),
1753
+ float(maintenance_ratio),
1754
+ float(slippage),
1755
+ False,
1756
+ )
1757
+ else:
1758
+ self.prepared_market_core = None
1759
+ self._core = self._module.ReactiveSessionCore(
1760
+ np.ascontiguousarray(idx.asi8, dtype=np.int64),
1761
+ np.ascontiguousarray(opens_arr[:, 0], dtype=np.float64),
1762
+ np.ascontiguousarray(market_arrays.highs[:, 0], dtype=np.float64),
1763
+ np.ascontiguousarray(market_arrays.lows[:, 0], dtype=np.float64),
1764
+ np.ascontiguousarray(market_arrays.closes[:, 0], dtype=np.float64),
1765
+ np.ascontiguousarray(volumes_arr[:, 0], dtype=np.float64),
1766
+ np.zeros(n_bars, dtype=np.float64),
1767
+ np.zeros(n_bars, dtype=np.bool_),
1768
+ float(self.contract_sizes[0]),
1769
+ float(self.leverages[0]),
1770
+ float(self.fee_rates[0]),
1771
+ float(initial_capital),
1772
+ float(maintenance_ratio),
1773
+ float(slippage),
1774
+ False,
1775
+ )
1776
+ self.size_helper = self._size_order
1777
+
1778
+ def _intern_id(self, value: Optional[str]) -> int:
1779
+ if value is None:
1780
+ return -1
1781
+ if value not in self._id_to_code:
1782
+ self._id_to_code[value] = len(self._id_values)
1783
+ self._id_values.append(value)
1784
+ return self._id_to_code[value]
1785
+
1786
+ def _id_from_code(self, value: int) -> Optional[str]:
1787
+ return self._id_values[value] if 0 <= int(value) < len(self._id_values) else None
1788
+
1789
+ def _size_order(self, symbol: str, notional: float, price: float, side: OrderSide = OrderSide.BUY) -> float:
1790
+ if symbol not in self.symbols:
1791
+ raise ValueError(f"unknown symbol={symbol!r}")
1792
+ if price <= 0.0:
1793
+ raise ValueError("price must be > 0")
1794
+ column = self.symbols.index(symbol)
1795
+ return abs(float(notional) / (float(price) * float(self.contract_sizes[column])))
1796
+
1797
+ def _quantize_r2_commands(self, bar: int, commands: Sequence[OrderCommand]) -> tuple[OrderCommand, ...]:
1798
+ """Apply the canonical quantity filter at the same bar as replay preflight.
1799
+
1800
+ Reactive commands cannot be preflighted before a strategy emits them.
1801
+ The static replay performs the equivalent filtering over the emitted
1802
+ tape; this method makes explicit Rust follow that exact exchange-rule
1803
+ contract without changing the command tape or endpoint API.
1804
+ """
1805
+ if not self.constraints.enabled:
1806
+ self.execution_counters["constraint_preflight_skipped"] += int(bool(commands))
1807
+ return tuple(commands)
1808
+ self.execution_counters["constraint_preflight_calls"] += int(bool(commands))
1809
+ out: list[OrderCommand] = []
1810
+ for command in commands:
1811
+ if command.action not in (OrderAction.PLACE, OrderAction.REPLACE) or command.qty is None:
1812
+ out.append(command)
1813
+ continue
1814
+ try:
1815
+ column = self.symbols.index(command.symbol)
1816
+ except ValueError as exc:
1817
+ raise NativeEventRustBackendError(
1818
+ f"quantity preflight received unknown symbol={command.symbol!r}"
1819
+ ) from exc
1820
+ close = float(self.market_arrays.closes[int(bar), column])
1821
+ price = float(command.price) if command.price is not None else close
1822
+ signed = command.signed_qty
1823
+ quantity = abs(
1824
+ quantize_signed_quantity(
1825
+ signed,
1826
+ price,
1827
+ float(self.contract_sizes[column]),
1828
+ float(self.constraints.qty_step[column]),
1829
+ float(self.constraints.min_qty[column]),
1830
+ float(self.constraints.min_notional[column]),
1831
+ )
1832
+ )
1833
+ if quantity <= 0.0:
1834
+ continue
1835
+ if abs(quantity - float(command.qty)) > 1e-12:
1836
+ out.append(replace(command, qty=quantity))
1837
+ else:
1838
+ out.append(command)
1839
+ self.execution_counters["commands_quantized"] += len(commands)
1840
+ return tuple(out)
1841
+
1842
+ @staticmethod
1843
+ def _commands_require_r2(commands: Sequence[OrderCommand]) -> bool:
1844
+ return any(
1845
+ command.action in (OrderAction.AMEND, OrderAction.REPLACE)
1846
+ or command.reduce_only
1847
+ or command.order_type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT)
1848
+ for command in commands
1849
+ )
1850
+
1851
+ def _require_r2_for_commands(self, commands: Sequence[OrderCommand]) -> None:
1852
+ if self._commands_require_r2(commands) and not self._r2_capable:
1853
+ raise NativeEventRustBackendError(
1854
+ "installed _quantbt_native wheel is R1-only and cannot execute R2 lifecycle commands; rebuild/install R2 or use backend='python'"
1855
+ )
1856
+
1857
+ def schedule(self, bar: int, commands: Sequence[OrderCommand]) -> None:
1858
+ if commands and int(bar) < len(self.idx):
1859
+ self.scheduled.setdefault(int(bar), []).extend(commands)
1860
+
1861
+ def release_bar_payload(self, bar: int) -> None:
1862
+ self.fills_by_bar.pop(int(bar), None)
1863
+ self.events_by_bar.pop(int(bar), None)
1864
+
1865
+ def process_bar(self, bar: int) -> None:
1866
+ if bar <= self.processed_bar:
1867
+ return
1868
+ for current_bar in range(self.processed_bar + 1, int(bar) + 1):
1869
+ commands = self._quantize_r2_commands(current_bar, self.scheduled.pop(current_bar, ()))
1870
+ self._require_r2_for_commands(commands)
1871
+ if self._full_contract:
1872
+ full_codes, full_values, full_expiry = compile_rust_full_reactive_batch(
1873
+ commands,
1874
+ symbols=self.symbols,
1875
+ intern_id=self._intern_id,
1876
+ idx=self.idx,
1877
+ buffer=self._full_command_buffer,
1878
+ )
1879
+ batch = None
1880
+ else:
1881
+ batch = compile_rust_r1_command_batch(
1882
+ commands,
1883
+ symbol=self.symbols[0],
1884
+ intern_id=self._intern_id,
1885
+ buffer=self._command_buffer,
1886
+ )
1887
+ if self._full_contract:
1888
+ for command in commands:
1889
+ if command.order_id:
1890
+ self._commands_by_id[command.order_id] = command
1891
+ step_method = getattr(self._core, "step_typed", self._core.step)
1892
+ payload = step_method(current_bar, full_codes, full_values, full_expiry)
1893
+ else:
1894
+ for command in batch.commands:
1895
+ if command.order_id:
1896
+ self._commands_by_id[command.order_id] = command
1897
+ payload = self._core.step(current_bar, batch.codes, batch.values, batch.expiry)
1898
+ self._consume_step(current_bar, payload)
1899
+ self.processed_bar = current_bar
1900
+ self.execution_counters["bars_processed"] += 1
1901
+ self.execution_counters["commands_compiled"] += len(commands)
1902
+ self.execution_counters["command_buffer_growths"] = self._full_command_buffer.growth_count
1903
+ self.execution_counters["bytes_copied_to_rust"] += int(
1904
+ full_codes.nbytes + full_values.nbytes + full_expiry.nbytes
1905
+ if self._full_contract
1906
+ else batch.codes.nbytes + batch.values.nbytes + batch.expiry.nbytes
1907
+ )
1908
+
1909
+ def _consume_step(self, bar: int, payload) -> None:
1910
+ self.equity = float(_step_value(payload, "equity", 0.0))
1911
+ if self._full_contract:
1912
+ positions = _step_value(payload, "positions")
1913
+ if positions is not None:
1914
+ self.current_pos[:] = np.asarray(positions, dtype=np.float64)
1915
+ else:
1916
+ self.current_pos[0] = float(_step_value(payload, "position", 0.0))
1917
+ fee = float(_step_value(payload, "fee", 0.0))
1918
+ turnover = float(_step_value(payload, "turnover", 0.0))
1919
+ funding = float(_step_value(payload, "funding", 0.0)) if self._full_contract else 0.0
1920
+ initial_margin = float(_step_value(payload, "initial_margin", 0.0))
1921
+ maintenance_margin = float(_step_value(payload, "maintenance_margin", 0.0))
1922
+ self.last_initial_margin = initial_margin
1923
+ self.last_maintenance_margin = maintenance_margin
1924
+ self.total_fee += fee
1925
+ self.total_turnover += turnover
1926
+ self.total_funding += funding
1927
+ if self.equity_path is not None:
1928
+ self.equity_path[bar] = self.equity
1929
+ if self.pos_path is not None:
1930
+ self.pos_path[bar, :] = self.current_pos
1931
+ if self.fee_path is not None:
1932
+ self.fee_path[bar] = fee
1933
+ if self.turnover_path is not None:
1934
+ self.turnover_path[bar] = turnover
1935
+ if self.funding_path is not None:
1936
+ self.funding_path[bar] = funding
1937
+ if self.initial_margin_path is not None:
1938
+ self.initial_margin_path[bar] = initial_margin
1939
+ if self.maintenance_margin_path is not None:
1940
+ self.maintenance_margin_path[bar] = maintenance_margin
1941
+ self.liquidated = bool(_step_value(payload, "liquidated", False))
1942
+ self.liquidation_bar = int(_step_value(payload, "liquidation_bar", -1))
1943
+ self.liquidation_reason = int(_step_value(payload, "liquidation_reason", 0))
1944
+ if self.online_score is not None:
1945
+ self.online_score.observe(
1946
+ self.idx.asi8[bar],
1947
+ self.equity,
1948
+ self.current_pos,
1949
+ initial_margin,
1950
+ maintenance_margin,
1951
+ )
1952
+ reported_fill_count = _step_has(payload, "fill_count")
1953
+ reported_event_counts = _step_has(payload, "event_count")
1954
+ if reported_fill_count:
1955
+ self.fill_count += int(_step_value(payload, "fill_count", 0))
1956
+ if reported_event_counts:
1957
+ self.event_count += int(_step_value(payload, "event_count", 0))
1958
+ rejected = int(_step_value(payload, "rejected_count", 0))
1959
+ canceled = int(_step_value(payload, "canceled_count", 0))
1960
+ self.rejected_count += rejected
1961
+ self.canceled_count += canceled
1962
+ if self.rejected_bar is not None:
1963
+ self.rejected_bar[bar] += rejected
1964
+ if self.canceled_bar is not None:
1965
+ self.canceled_bar[bar] += canceled
1966
+ fills = []
1967
+ for fill_row in (_step_value(payload, "fills") or ()):
1968
+ if self._full_contract:
1969
+ order_code, symbol_code, side_sign, qty, price, fee = fill_row
1970
+ symbol = self.symbols[int(symbol_code)]
1971
+ else:
1972
+ order_code, side_sign, qty, price, fee = fill_row
1973
+ symbol = self.symbols[0]
1974
+ order_id = self._id_from_code(int(order_code))
1975
+ command = self._commands_by_id.get(order_id or "")
1976
+ fill = NativeFillEvent(
1977
+ timestamp=self.idx[bar],
1978
+ symbol=symbol,
1979
+ side=OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL,
1980
+ qty=float(qty),
1981
+ price=float(price),
1982
+ fee=float(fee),
1983
+ order_id=order_id,
1984
+ tag=None if command is None else command.tag,
1985
+ metadata={} if command is None else dict(command.metadata),
1986
+ )
1987
+ fills.append(fill)
1988
+ if not reported_fill_count:
1989
+ self.fill_count += 1
1990
+ if self.retain_fill_ledger:
1991
+ self.fills.append(fill)
1992
+ if fills:
1993
+ self.fills_by_bar[bar] = fills
1994
+ events = []
1995
+ for event_row in (_step_value(payload, "events") or ()):
1996
+ if self._full_contract:
1997
+ event_kind, status, order_code, target_code, symbol_code = event_row[:5]
1998
+ reject_code = int(event_row[5]) if len(event_row) > 5 else 0
1999
+ event_symbol = None if int(symbol_code) < 0 else self.symbols[int(symbol_code)]
2000
+ else:
2001
+ event_kind, status, order_code, target_code = event_row
2002
+ reject_code = 0
2003
+ event_symbol = None
2004
+ name = ({0: "place", 1: "cancel", 2: "replace", 3: "amend", 4: "fill", 5: "expire", 6: "activate", 7: "reject"} if self._full_contract else {0: "place", 1: "cancel", 2: "fill", 3: "reject", 4: "amend", 5: "replace"}).get(
2005
+ int(event_kind), "reject"
2006
+ )
2007
+ event = NativeOrderEvent(
2008
+ timestamp=self.idx[bar],
2009
+ bar=bar,
2010
+ event_name=name,
2011
+ status=int(status),
2012
+ order_id=self._id_from_code(int(order_code)),
2013
+ target_order_id=self._id_from_code(int(target_code)),
2014
+ metadata={"reject_code": reject_code},
2015
+ )
2016
+ events.append(event)
2017
+ if not reported_event_counts:
2018
+ self.event_count += 1
2019
+ if name == "reject":
2020
+ if self.rejected_bar is not None:
2021
+ self.rejected_bar[bar] += 1
2022
+ self.rejected_count += 1
2023
+ if name == "cancel":
2024
+ if self.canceled_bar is not None:
2025
+ self.canceled_bar[bar] += 1
2026
+ self.canceled_count += 1
2027
+ if self.retain_event_ledger:
2028
+ self.events.append(event)
2029
+ if events:
2030
+ self.events_by_bar[bar] = events
2031
+ pending = []
2032
+ snapshots = []
2033
+ for active_row in (_step_value(payload, "active_orders") or ()):
2034
+ if self._full_contract:
2035
+ order_code, symbol_code, side_sign, order_type, qty, price, trigger_price, tif, flags, parent, group, oco, activation, waiting_parent = active_row
2036
+ active_symbol = self.symbols[int(symbol_code)]
2037
+ parent_order_id = self._id_from_code(int(parent))
2038
+ group_id = self._id_from_code(int(group))
2039
+ oco_group_id = self._id_from_code(int(oco))
2040
+ else:
2041
+ order_code, side_sign, order_type, qty, price, trigger_price, flags = active_row
2042
+ active_symbol = self.symbols[0]
2043
+ parent_order_id = None
2044
+ group_id = None
2045
+ oco_group_id = None
2046
+ order_id = self._id_from_code(int(order_code))
2047
+ command = self._commands_by_id.get(order_id or "")
2048
+ side = OrderSide.BUY if int(side_sign) > 0 else OrderSide.SELL
2049
+ kind = {
2050
+ _R1_ORDER_MARKET: OrderType.MARKET,
2051
+ _R1_ORDER_LIMIT: OrderType.LIMIT,
2052
+ _R2_ORDER_STOP_MARKET: OrderType.STOP_MARKET,
2053
+ _R2_ORDER_STOP_LIMIT: OrderType.STOP_LIMIT,
2054
+ }.get(int(order_type), OrderType.MARKET)
2055
+ reduce_only = bool(int(flags) & _R2_FLAG_REDUCE_ONLY)
2056
+ pending.append(
2057
+ _RustPendingOrder(
2058
+ order_id=order_id,
2059
+ side=side,
2060
+ order_type=kind,
2061
+ qty=float(qty),
2062
+ price=float(price),
2063
+ trigger_price=float(trigger_price),
2064
+ reduce_only=reduce_only,
2065
+ )
2066
+ )
2067
+ snapshots.append(
2068
+ NativeActiveOrderSnapshot(
2069
+ order_id=order_id,
2070
+ symbol=active_symbol,
2071
+ side=side.value,
2072
+ order_type=kind.value,
2073
+ status=ORDER_STATUS_PENDING,
2074
+ remaining_qty=float(qty),
2075
+ price=float(price),
2076
+ trigger_price=float(trigger_price),
2077
+ reduce_only=reduce_only,
2078
+ parent_order_id=parent_order_id,
2079
+ group_id=group_id,
2080
+ oco_group_id=oco_group_id,
2081
+ tag=None if command is None else command.tag,
2082
+ campaign_id=None if command is None else command.metadata.get("campaign_id"),
2083
+ cycle_id=None if command is None else command.metadata.get("cycle_id"),
2084
+ level_id=None if command is None else command.metadata.get("level_id"),
2085
+ )
2086
+ )
2087
+ self.pending = pending
2088
+ if self.emit_context_active_orders:
2089
+ self._active_snapshot_cache = tuple(snapshots)
2090
+ self.execution_counters["active_snapshot_materializations"] += 1
2091
+ else:
2092
+ self._active_snapshot_cache = self.empty_active_orders
2093
+
2094
+ @staticmethod
2095
+ def _is_pending(state: _RustPendingOrder) -> bool:
2096
+ return True
2097
+
2098
+ def context(self, bar: int) -> NativeStrategyContext:
2099
+ self.process_bar(bar)
2100
+ self.execution_counters["contexts_materialized"] += 1
2101
+ initial_margin = (
2102
+ float(self.initial_margin_path[int(bar)])
2103
+ if self.initial_margin_path is not None
2104
+ else float(self.last_initial_margin)
2105
+ )
2106
+ maintenance_margin = (
2107
+ float(self.maintenance_margin_path[int(bar)])
2108
+ if self.maintenance_margin_path is not None
2109
+ else float(self.last_maintenance_margin)
2110
+ )
2111
+ return NativeStrategyContext(
2112
+ bar_index=int(bar),
2113
+ timestamp=self.idx[int(bar)],
2114
+ open=self.opens_arr[int(bar)],
2115
+ high=self.market_arrays.highs[int(bar)],
2116
+ low=self.market_arrays.lows[int(bar)],
2117
+ close=self.market_arrays.closes[int(bar)],
2118
+ volume=self.volumes_arr[int(bar)],
2119
+ equity=float(self.equity),
2120
+ available_equity=float(self.equity - initial_margin),
2121
+ initial_margin=initial_margin if self.emit_context_margin else 0.0,
2122
+ maintenance_margin=maintenance_margin if self.emit_context_margin else 0.0,
2123
+ positions=(
2124
+ {symbol: float(self.current_pos[col]) for col, symbol in enumerate(self.symbols)}
2125
+ if self.emit_context_positions else {}
2126
+ ),
2127
+ fills_this_bar=(
2128
+ tuple(self.fills_by_bar.get(int(bar), ()))
2129
+ if self.emit_context_fills else self.empty_fills
2130
+ ),
2131
+ order_events_this_bar=(
2132
+ tuple(self.events_by_bar.get(int(bar), ()))
2133
+ if self.emit_context_events else self.empty_events
2134
+ ),
2135
+ active_orders=(
2136
+ self._active_snapshot_cache
2137
+ if self.emit_context_active_orders else self.empty_active_orders
2138
+ ),
2139
+ liquidated=bool(self.liquidated),
2140
+ symbols=self.symbols_tuple,
2141
+ size_order=self.size_helper,
2142
+ )
2143
+
2144
+
2145
+ __all__ = [
2146
+ "NativeEventBackendSelection",
2147
+ "NativeEventRustBackendError",
2148
+ "NativeEventRustExtensionStatus",
2149
+ "RUST_NATIVE_API_VERSION",
2150
+ "RustCommandBatch",
2151
+ "RustCommandBuffer",
2152
+ "RustFullCommandBuffer",
2153
+ "RustBatchedAuditResult",
2154
+ "RustFullAuditResult",
2155
+ "RustBatchedChunkResult",
2156
+ "RustBatchedRunner",
2157
+ "RustFullRunner",
2158
+ "RustBatchedScoreResult",
2159
+ "RustBatchedSession",
2160
+ "RustReactiveSessionAdapter",
2161
+ "compile_rust_batched_tape",
2162
+ "compile_rust_full_tape",
2163
+ "compile_rust_full_reactive_batch",
2164
+ "compile_rust_r1_command_batch",
2165
+ "probe_native_event_rust_extension",
2166
+ "resolve_native_event_backend",
2167
+ "validate_rust_r1_support",
2168
+ ]