quantbt-engine 1.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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
|
+
]
|