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
quantbt/engines.py
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Public V2 engine facades.
|
|
3
|
+
|
|
4
|
+
These classes keep the old public API untouched while giving new notebooks a
|
|
5
|
+
single backend selector for native vectorized, native event-driven, and optional
|
|
6
|
+
Nautilus validation runs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import replace
|
|
12
|
+
from typing import Dict, List, Optional, Sequence, Tuple, Union
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
from .backends import (
|
|
17
|
+
NativeEventBackend,
|
|
18
|
+
NativeEventConfig,
|
|
19
|
+
NativeOptionBackend,
|
|
20
|
+
NativeOptionConfig,
|
|
21
|
+
NativePortfolioBackend,
|
|
22
|
+
NativePortfolioConfig,
|
|
23
|
+
NativeVectorizedBackend,
|
|
24
|
+
NativeVectorizedConfig,
|
|
25
|
+
)
|
|
26
|
+
from .core.orders import OrderAction, OrderCommand, OrderIntent, order_intents_to_lifecycle_commands
|
|
27
|
+
from .core.preprocessor import validate_datetime
|
|
28
|
+
from .core.results import BacktestResultV2, OptionBacktestResult
|
|
29
|
+
from .core.schema import AccountConfig, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce
|
|
30
|
+
from .options.cache import OptionPreparedRunCache
|
|
31
|
+
from .options.hedging import OptionHedgeConfig
|
|
32
|
+
from .options.packages import OptionPackageIntent
|
|
33
|
+
from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
|
|
34
|
+
from .options.strategy import OptionStrategyRun
|
|
35
|
+
from .portfolio import MultiSymbolPortfolio
|
|
36
|
+
from .sizing.modes import compute_target_units
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
SeriesMap = Dict[str, pd.Series]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BacktestEngineV2:
|
|
43
|
+
"""
|
|
44
|
+
Backend-selecting facade for upgraded quantbt engines.
|
|
45
|
+
|
|
46
|
+
Parameters can be supplied in a dataframe-oriented style (`data` and
|
|
47
|
+
`signals`) or an explicit dictionary style (`closes`, `highs`, `lows`,
|
|
48
|
+
`positions`, `target_units`, `orders`).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
VALID_BACKENDS = {"native_vectorized", "native_event", "nautilus"}
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]] = None,
|
|
56
|
+
signals: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
57
|
+
backend: str = "native_vectorized",
|
|
58
|
+
native_backend: Optional[str] = None,
|
|
59
|
+
account: Optional[AccountConfig] = None,
|
|
60
|
+
execution: Optional[ExecutionConfig] = None,
|
|
61
|
+
fee_rate: float = 0.0,
|
|
62
|
+
use_funding: bool = True,
|
|
63
|
+
alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
|
|
64
|
+
hedge_type: str = "signal_notional",
|
|
65
|
+
use_pyramiding: bool = True,
|
|
66
|
+
positions: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
67
|
+
target_units: Optional[Union[pd.Series, SeriesMap]] = None,
|
|
68
|
+
orders: Optional[Sequence[OrderIntent]] = None,
|
|
69
|
+
order_commands: Optional[Sequence[OrderCommand]] = None,
|
|
70
|
+
strategy=None,
|
|
71
|
+
event_engine_version: str = "v1",
|
|
72
|
+
reactive_execution_mode: str = "fast",
|
|
73
|
+
reactive_kernel_mode: str = "replay_certified",
|
|
74
|
+
report_level: str = "audit",
|
|
75
|
+
audit_sink: str = "memory",
|
|
76
|
+
audit_sink_path: Optional[str] = None,
|
|
77
|
+
datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None,
|
|
78
|
+
closes: Optional[SeriesMap] = None,
|
|
79
|
+
highs: Optional[SeriesMap] = None,
|
|
80
|
+
lows: Optional[SeriesMap] = None,
|
|
81
|
+
funding_rate: Union[float, pd.Series, Dict] = 0.0,
|
|
82
|
+
contract_size: Union[float, Dict[str, float]] = 1.0,
|
|
83
|
+
leverage: Optional[Union[float, Dict[str, float]]] = None,
|
|
84
|
+
symbols: Optional[List[str]] = None,
|
|
85
|
+
basket: Optional[BasketSpec] = None,
|
|
86
|
+
signal: Optional[pd.Series] = None,
|
|
87
|
+
hedge_ratios: Optional[SeriesMap] = None,
|
|
88
|
+
nautilus_config=None,
|
|
89
|
+
instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
|
|
90
|
+
qty_step: Optional[Union[float, Dict[str, float]]] = None,
|
|
91
|
+
lot_size: Optional[Union[float, Dict[str, float]]] = None,
|
|
92
|
+
slot_size: Optional[Union[float, Dict[str, float]]] = None,
|
|
93
|
+
min_qty: Optional[Union[float, Dict[str, float]]] = None,
|
|
94
|
+
min_notional: Optional[Union[float, Dict[str, float]]] = None,
|
|
95
|
+
auto_run: bool = True,
|
|
96
|
+
):
|
|
97
|
+
self.backend = backend.lower().strip()
|
|
98
|
+
if self.backend not in self.VALID_BACKENDS:
|
|
99
|
+
raise ValueError(f"backend must be one of {sorted(self.VALID_BACKENDS)}")
|
|
100
|
+
|
|
101
|
+
self.data = data
|
|
102
|
+
self.native_backend = native_backend
|
|
103
|
+
self.signals = signals
|
|
104
|
+
self.account = account or AccountConfig(initial_capital=100_000.0)
|
|
105
|
+
self.execution = execution or ExecutionConfig()
|
|
106
|
+
self.fee_rate = float(fee_rate)
|
|
107
|
+
self.use_funding = bool(use_funding)
|
|
108
|
+
self.alloc_per_trade = alloc_per_trade
|
|
109
|
+
self.hedge_type = hedge_type
|
|
110
|
+
self.use_pyramiding = use_pyramiding
|
|
111
|
+
self.positions = positions
|
|
112
|
+
self.target_units = target_units
|
|
113
|
+
self.orders = tuple(orders or ())
|
|
114
|
+
self.order_commands = tuple(order_commands or ())
|
|
115
|
+
self.strategy = strategy
|
|
116
|
+
self.event_engine_version = str(event_engine_version).lower().strip()
|
|
117
|
+
self.reactive_execution_mode = str(reactive_execution_mode).lower().strip()
|
|
118
|
+
self.reactive_kernel_mode = str(reactive_kernel_mode).lower().strip()
|
|
119
|
+
self.report_level = str(report_level)
|
|
120
|
+
self.audit_sink = str(audit_sink)
|
|
121
|
+
self.audit_sink_path = audit_sink_path
|
|
122
|
+
self.datetime_index = datetime_index
|
|
123
|
+
self.closes = closes
|
|
124
|
+
self.highs = highs
|
|
125
|
+
self.lows = lows
|
|
126
|
+
self.funding_rate = funding_rate
|
|
127
|
+
self.contract_size = contract_size
|
|
128
|
+
self.leverage = leverage
|
|
129
|
+
self.symbols = symbols
|
|
130
|
+
self.basket = basket
|
|
131
|
+
self.signal = signal
|
|
132
|
+
self.hedge_ratios = hedge_ratios
|
|
133
|
+
self.nautilus_config = nautilus_config
|
|
134
|
+
self.instruments = instruments
|
|
135
|
+
self.qty_step = qty_step
|
|
136
|
+
self.lot_size = lot_size
|
|
137
|
+
self.slot_size = slot_size
|
|
138
|
+
self.min_qty = min_qty
|
|
139
|
+
self.min_notional = min_notional
|
|
140
|
+
self.result: Optional[BacktestResultV2] = None
|
|
141
|
+
|
|
142
|
+
if auto_run:
|
|
143
|
+
self.run()
|
|
144
|
+
|
|
145
|
+
def run(self) -> BacktestResultV2:
|
|
146
|
+
if self.backend == "native_vectorized":
|
|
147
|
+
self.result = self._run_native_vectorized()
|
|
148
|
+
elif self.backend == "native_event":
|
|
149
|
+
self.result = self._run_native_event()
|
|
150
|
+
else:
|
|
151
|
+
self.result = self._run_nautilus()
|
|
152
|
+
return self.result
|
|
153
|
+
|
|
154
|
+
def _run_native_vectorized(self) -> BacktestResultV2:
|
|
155
|
+
idx, closes, highs, lows, symbols = self._market_data()
|
|
156
|
+
backend = NativeVectorizedBackend(
|
|
157
|
+
NativeVectorizedConfig(
|
|
158
|
+
account=self.account,
|
|
159
|
+
execution=self.execution,
|
|
160
|
+
fee_rate=self.fee_rate,
|
|
161
|
+
use_funding=self.use_funding,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
if self.target_units is not None:
|
|
166
|
+
target_units = _as_series_map(self.target_units, symbols)
|
|
167
|
+
return backend.run_target_units(
|
|
168
|
+
datetime_index=idx,
|
|
169
|
+
target_units=target_units,
|
|
170
|
+
closes=closes,
|
|
171
|
+
highs=highs,
|
|
172
|
+
lows=lows,
|
|
173
|
+
funding_rate=self.funding_rate,
|
|
174
|
+
contract_size=self.contract_size,
|
|
175
|
+
leverage=self.leverage,
|
|
176
|
+
symbols=symbols,
|
|
177
|
+
instruments=self.instruments,
|
|
178
|
+
qty_step=self.qty_step,
|
|
179
|
+
lot_size=self.lot_size,
|
|
180
|
+
slot_size=self.slot_size,
|
|
181
|
+
min_qty=self.min_qty,
|
|
182
|
+
min_notional=self.min_notional,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
raw_positions = self.positions if self.positions is not None else self.signals
|
|
186
|
+
if raw_positions is None:
|
|
187
|
+
raise ValueError("native_vectorized requires signals, positions, or target_units")
|
|
188
|
+
|
|
189
|
+
return backend.run_signals(
|
|
190
|
+
datetime_index=idx,
|
|
191
|
+
positions=_as_series_map(raw_positions, symbols),
|
|
192
|
+
closes=closes,
|
|
193
|
+
highs=highs,
|
|
194
|
+
lows=lows,
|
|
195
|
+
funding_rate=self.funding_rate,
|
|
196
|
+
contract_size=self.contract_size,
|
|
197
|
+
leverage=self.leverage,
|
|
198
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
199
|
+
hedge_type=self.hedge_type,
|
|
200
|
+
use_pyramiding=self.use_pyramiding,
|
|
201
|
+
symbols=symbols,
|
|
202
|
+
instruments=self.instruments,
|
|
203
|
+
qty_step=self.qty_step,
|
|
204
|
+
lot_size=self.lot_size,
|
|
205
|
+
slot_size=self.slot_size,
|
|
206
|
+
min_qty=self.min_qty,
|
|
207
|
+
min_notional=self.min_notional,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def _run_native_event(self) -> BacktestResultV2:
|
|
211
|
+
idx, closes, highs, lows, symbols = self._market_data()
|
|
212
|
+
backend = NativeEventBackend(
|
|
213
|
+
NativeEventConfig(
|
|
214
|
+
account=self.account,
|
|
215
|
+
execution=self.execution,
|
|
216
|
+
fee_rate=self.fee_rate,
|
|
217
|
+
use_funding=self.use_funding,
|
|
218
|
+
report_level=self.report_level,
|
|
219
|
+
audit_sink=self.audit_sink,
|
|
220
|
+
audit_sink_path=self.audit_sink_path,
|
|
221
|
+
reactive_kernel_mode=self.reactive_kernel_mode,
|
|
222
|
+
native_backend=self.native_backend,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if self.strategy is not None:
|
|
227
|
+
opens, volumes = _market_open_volume(
|
|
228
|
+
data=self.data,
|
|
229
|
+
datetime_index=idx,
|
|
230
|
+
closes=closes,
|
|
231
|
+
symbols=symbols,
|
|
232
|
+
)
|
|
233
|
+
return backend.run_strategy(
|
|
234
|
+
datetime_index=idx,
|
|
235
|
+
strategy=self.strategy,
|
|
236
|
+
closes=closes,
|
|
237
|
+
highs=highs,
|
|
238
|
+
lows=lows,
|
|
239
|
+
opens=opens,
|
|
240
|
+
volumes=volumes,
|
|
241
|
+
funding_rate=self.funding_rate,
|
|
242
|
+
contract_size=self.contract_size,
|
|
243
|
+
leverage=self.leverage,
|
|
244
|
+
fee_rate=self.fee_rate,
|
|
245
|
+
symbols=symbols,
|
|
246
|
+
instruments=self.instruments,
|
|
247
|
+
qty_step=self.qty_step,
|
|
248
|
+
lot_size=self.lot_size,
|
|
249
|
+
slot_size=self.slot_size,
|
|
250
|
+
min_qty=self.min_qty,
|
|
251
|
+
min_notional=self.min_notional,
|
|
252
|
+
execution_mode=self.reactive_execution_mode,
|
|
253
|
+
reactive_kernel_mode=self.reactive_kernel_mode,
|
|
254
|
+
report_level=self.report_level,
|
|
255
|
+
audit_sink=self.audit_sink,
|
|
256
|
+
audit_sink_path=self.audit_sink_path,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if self.basket is not None:
|
|
260
|
+
basket_signal = self.signal if self.signal is not None else _first_signal(self.signals)
|
|
261
|
+
if basket_signal is None:
|
|
262
|
+
raise ValueError("basket event backtest requires signal or signals")
|
|
263
|
+
return backend.run_basket(
|
|
264
|
+
datetime_index=idx,
|
|
265
|
+
basket=self.basket,
|
|
266
|
+
signal=basket_signal,
|
|
267
|
+
closes=closes,
|
|
268
|
+
highs=highs,
|
|
269
|
+
lows=lows,
|
|
270
|
+
hedge_ratios=self.hedge_ratios,
|
|
271
|
+
funding_rate=self.funding_rate,
|
|
272
|
+
contract_size=self.contract_size,
|
|
273
|
+
leverage=self.leverage,
|
|
274
|
+
symbols=symbols,
|
|
275
|
+
instruments=self.instruments,
|
|
276
|
+
qty_step=self.qty_step,
|
|
277
|
+
lot_size=self.lot_size,
|
|
278
|
+
slot_size=self.slot_size,
|
|
279
|
+
min_qty=self.min_qty,
|
|
280
|
+
min_notional=self.min_notional,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if self.order_commands or self.event_engine_version in {"v2", "event_v2", "lifecycle", "lifecycle_v2"}:
|
|
284
|
+
commands = self.order_commands
|
|
285
|
+
if not commands and self.orders:
|
|
286
|
+
commands = order_intents_to_lifecycle_commands(self.orders)
|
|
287
|
+
if not commands:
|
|
288
|
+
raw_positions = self.positions if self.positions is not None else self.signals
|
|
289
|
+
if raw_positions is None:
|
|
290
|
+
raise ValueError("native_event v2 requires order_commands, orders, signals, positions, or a basket")
|
|
291
|
+
generated_orders = tuple(
|
|
292
|
+
_build_market_rebalance_orders(
|
|
293
|
+
datetime_index=idx,
|
|
294
|
+
positions=_as_series_map(raw_positions, symbols),
|
|
295
|
+
closes=closes,
|
|
296
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
297
|
+
hedge_type=self.hedge_type,
|
|
298
|
+
use_pyramiding=self.use_pyramiding,
|
|
299
|
+
symbols=symbols,
|
|
300
|
+
)
|
|
301
|
+
)
|
|
302
|
+
commands = order_intents_to_lifecycle_commands(generated_orders)
|
|
303
|
+
return backend.run_order_commands(
|
|
304
|
+
datetime_index=idx,
|
|
305
|
+
commands=commands,
|
|
306
|
+
closes=closes,
|
|
307
|
+
highs=highs,
|
|
308
|
+
lows=lows,
|
|
309
|
+
funding_rate=self.funding_rate,
|
|
310
|
+
contract_size=self.contract_size,
|
|
311
|
+
leverage=self.leverage,
|
|
312
|
+
symbols=symbols,
|
|
313
|
+
instruments=self.instruments,
|
|
314
|
+
qty_step=self.qty_step,
|
|
315
|
+
lot_size=self.lot_size,
|
|
316
|
+
slot_size=self.slot_size,
|
|
317
|
+
min_qty=self.min_qty,
|
|
318
|
+
min_notional=self.min_notional,
|
|
319
|
+
report_level=self.report_level,
|
|
320
|
+
audit_sink=self.audit_sink,
|
|
321
|
+
audit_sink_path=self.audit_sink_path,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
orders = self.orders
|
|
325
|
+
if not orders:
|
|
326
|
+
raw_positions = self.positions if self.positions is not None else self.signals
|
|
327
|
+
if raw_positions is None:
|
|
328
|
+
raise ValueError("native_event requires explicit orders, signals, positions, or a basket")
|
|
329
|
+
orders = tuple(
|
|
330
|
+
_build_market_rebalance_orders(
|
|
331
|
+
datetime_index=idx,
|
|
332
|
+
positions=_as_series_map(raw_positions, symbols),
|
|
333
|
+
closes=closes,
|
|
334
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
335
|
+
hedge_type=self.hedge_type,
|
|
336
|
+
use_pyramiding=self.use_pyramiding,
|
|
337
|
+
symbols=symbols,
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
return backend.run_orders(
|
|
341
|
+
datetime_index=idx,
|
|
342
|
+
orders=orders,
|
|
343
|
+
closes=closes,
|
|
344
|
+
highs=highs,
|
|
345
|
+
lows=lows,
|
|
346
|
+
funding_rate=self.funding_rate,
|
|
347
|
+
contract_size=self.contract_size,
|
|
348
|
+
leverage=self.leverage,
|
|
349
|
+
symbols=symbols,
|
|
350
|
+
instruments=self.instruments,
|
|
351
|
+
qty_step=self.qty_step,
|
|
352
|
+
lot_size=self.lot_size,
|
|
353
|
+
slot_size=self.slot_size,
|
|
354
|
+
min_qty=self.min_qty,
|
|
355
|
+
min_notional=self.min_notional,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
def _run_nautilus(self) -> BacktestResultV2:
|
|
359
|
+
from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
|
|
360
|
+
|
|
361
|
+
symbol_override = self.symbols[0] if self.symbols else None
|
|
362
|
+
trade_notional = self.alloc_per_trade if not isinstance(self.alloc_per_trade, dict) else next(
|
|
363
|
+
iter(self.alloc_per_trade.values())
|
|
364
|
+
)
|
|
365
|
+
if self.orders or self.order_commands:
|
|
366
|
+
idx, closes, highs, lows, symbols = self._market_data()
|
|
367
|
+
package_orders = self.orders
|
|
368
|
+
input_mode = "explicit_orders"
|
|
369
|
+
if self.order_commands:
|
|
370
|
+
package_orders = _commands_to_package_order_intents(self.order_commands)
|
|
371
|
+
input_mode = "lifecycle_commands"
|
|
372
|
+
data = _frames_for_nautilus(
|
|
373
|
+
data=self.data,
|
|
374
|
+
datetime_index=idx,
|
|
375
|
+
closes=closes,
|
|
376
|
+
highs=highs,
|
|
377
|
+
lows=lows,
|
|
378
|
+
symbols=symbols,
|
|
379
|
+
)
|
|
380
|
+
config = self.nautilus_config
|
|
381
|
+
updates = {
|
|
382
|
+
"starting_balance": self.account.initial_capital,
|
|
383
|
+
"trade_notional": 0.0,
|
|
384
|
+
"sizing_mode": "notional",
|
|
385
|
+
}
|
|
386
|
+
if symbol_override:
|
|
387
|
+
updates["instrument_id"] = symbol_override
|
|
388
|
+
if config is None:
|
|
389
|
+
config = NautilusBackendConfig(
|
|
390
|
+
instrument_id=symbol_override or symbols[0],
|
|
391
|
+
starting_balance=self.account.initial_capital,
|
|
392
|
+
trade_notional=0.0,
|
|
393
|
+
sizing_mode="notional",
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
config = replace(config, **updates)
|
|
397
|
+
package_params = {
|
|
398
|
+
"input_mode": input_mode,
|
|
399
|
+
"order_count_input": int(len(package_orders)),
|
|
400
|
+
}
|
|
401
|
+
if self.order_commands:
|
|
402
|
+
package_params["command_count_input"] = int(len(self.order_commands))
|
|
403
|
+
return NautilusBacktestEngine(config).run_order_packages(
|
|
404
|
+
data=data,
|
|
405
|
+
orders=package_orders,
|
|
406
|
+
symbols=symbols,
|
|
407
|
+
params=package_params,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
data = _single_frame(self.data)
|
|
411
|
+
if data is None:
|
|
412
|
+
idx, closes, highs, lows, symbols = self._market_data()
|
|
413
|
+
symbol = symbols[0]
|
|
414
|
+
data = pd.DataFrame(
|
|
415
|
+
{
|
|
416
|
+
"open": closes[symbol],
|
|
417
|
+
"high": highs[symbol],
|
|
418
|
+
"low": lows[symbol],
|
|
419
|
+
"close": closes[symbol],
|
|
420
|
+
"volume": 0.0,
|
|
421
|
+
},
|
|
422
|
+
index=idx,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
signal = _first_signal(self.positions if self.positions is not None else self.signals)
|
|
426
|
+
if signal is None:
|
|
427
|
+
raise ValueError("nautilus backend requires a single signal series")
|
|
428
|
+
|
|
429
|
+
config = self.nautilus_config
|
|
430
|
+
if config is None:
|
|
431
|
+
config = NautilusBackendConfig(
|
|
432
|
+
instrument_id=symbol_override or "BTCUSDT-PERP.BINANCE",
|
|
433
|
+
starting_balance=self.account.initial_capital,
|
|
434
|
+
trade_notional=float(trade_notional),
|
|
435
|
+
sizing_mode=self.hedge_type,
|
|
436
|
+
use_pyramiding=self.use_pyramiding,
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
updates = {
|
|
440
|
+
"starting_balance": self.account.initial_capital,
|
|
441
|
+
"trade_notional": float(trade_notional),
|
|
442
|
+
"sizing_mode": self.hedge_type,
|
|
443
|
+
"use_pyramiding": self.use_pyramiding,
|
|
444
|
+
}
|
|
445
|
+
if symbol_override and config.instrument_id == "BTCUSDT-PERP.BINANCE":
|
|
446
|
+
updates["instrument_id"] = symbol_override
|
|
447
|
+
config = replace(config, **updates)
|
|
448
|
+
return NautilusBacktestEngine(config).run_signal_series(data=data, signal=signal)
|
|
449
|
+
|
|
450
|
+
def _market_data(self) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]:
|
|
451
|
+
return _market_data(
|
|
452
|
+
data=self.data,
|
|
453
|
+
datetime_index=self.datetime_index,
|
|
454
|
+
closes=self.closes,
|
|
455
|
+
highs=self.highs,
|
|
456
|
+
lows=self.lows,
|
|
457
|
+
symbols=self.symbols,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
class EventDrivenBacktestEngine(BacktestEngineV2):
|
|
462
|
+
"""Convenience facade pinned to the native event-driven backend."""
|
|
463
|
+
|
|
464
|
+
def __init__(self, *args, **kwargs):
|
|
465
|
+
kwargs["backend"] = "native_event"
|
|
466
|
+
super().__init__(*args, **kwargs)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class OptionBacktestEngine:
|
|
470
|
+
"""
|
|
471
|
+
Native option facade returning `OptionBacktestResult`.
|
|
472
|
+
|
|
473
|
+
Parameters
|
|
474
|
+
----------
|
|
475
|
+
chain:
|
|
476
|
+
Canonical long-form option chain rows.
|
|
477
|
+
instruments:
|
|
478
|
+
Option instrument registry, sequence, or mapping.
|
|
479
|
+
packages:
|
|
480
|
+
Option package intents generated by a strategy/template layer.
|
|
481
|
+
config:
|
|
482
|
+
Native option backend configuration.
|
|
483
|
+
"""
|
|
484
|
+
|
|
485
|
+
def __init__(
|
|
486
|
+
self,
|
|
487
|
+
*,
|
|
488
|
+
chain: Optional[pd.DataFrame] = None,
|
|
489
|
+
instruments: Optional[OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Dict[str, OptionInstrumentSpec]] = None,
|
|
490
|
+
packages: Sequence[OptionPackageIntent] = (),
|
|
491
|
+
strategy_run: Optional[OptionStrategyRun] = None,
|
|
492
|
+
underlying: Optional[Union[pd.DataFrame, pd.Series]] = None,
|
|
493
|
+
hedge_policy: Optional[OptionHedgeConfig] = None,
|
|
494
|
+
net_option_delta: Optional[pd.Series] = None,
|
|
495
|
+
config: Optional[NativeOptionConfig] = None,
|
|
496
|
+
settlement_events: Optional[Sequence] = None,
|
|
497
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
498
|
+
prepared_cache: Optional[OptionPreparedRunCache] = None,
|
|
499
|
+
auto_run: bool = True,
|
|
500
|
+
):
|
|
501
|
+
self.chain = chain
|
|
502
|
+
self.instruments = instruments
|
|
503
|
+
self.strategy_run = strategy_run
|
|
504
|
+
self.packages = tuple(packages or (strategy_run.packages if strategy_run is not None else ()))
|
|
505
|
+
self.underlying = underlying
|
|
506
|
+
self.hedge_policy = hedge_policy or (strategy_run.hedge_policy if strategy_run is not None else None)
|
|
507
|
+
self.net_option_delta = net_option_delta
|
|
508
|
+
self.config = config or NativeOptionConfig()
|
|
509
|
+
self.settlement_events = tuple(settlement_events or ())
|
|
510
|
+
self.conversion_rates = conversion_rates
|
|
511
|
+
self.prepared_cache = prepared_cache
|
|
512
|
+
self.backend = NativeOptionBackend(self.config)
|
|
513
|
+
self.result: Optional[OptionBacktestResult] = None
|
|
514
|
+
|
|
515
|
+
if auto_run:
|
|
516
|
+
self.run()
|
|
517
|
+
|
|
518
|
+
def run(self) -> OptionBacktestResult:
|
|
519
|
+
if self.chain is None:
|
|
520
|
+
raise ValueError("OptionBacktestEngine requires chain")
|
|
521
|
+
if self.instruments is None:
|
|
522
|
+
raise ValueError("OptionBacktestEngine requires instruments")
|
|
523
|
+
self.result = self.backend.run(
|
|
524
|
+
chain=self.chain,
|
|
525
|
+
instruments=self.instruments,
|
|
526
|
+
packages=self.packages,
|
|
527
|
+
settlement_events=self.settlement_events,
|
|
528
|
+
conversion_rates=self.conversion_rates,
|
|
529
|
+
prepared_cache=self.prepared_cache,
|
|
530
|
+
underlying=self.underlying,
|
|
531
|
+
hedge_policy=self.hedge_policy,
|
|
532
|
+
net_option_delta=self.net_option_delta,
|
|
533
|
+
)
|
|
534
|
+
if self.strategy_run is not None:
|
|
535
|
+
self.result.metadata["strategy_run"] = self.strategy_run.metadata
|
|
536
|
+
self.result.metadata["selected_contracts"] = self.strategy_run.selected_contracts
|
|
537
|
+
self.result.run_manifest["strategy_run"] = self.strategy_run.metadata
|
|
538
|
+
self.result.metadata["run_manifest"] = self.result.run_manifest
|
|
539
|
+
return self.result
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
class PortfolioBacktestEngine:
|
|
543
|
+
"""
|
|
544
|
+
V2-compatible multi-symbol portfolio facade.
|
|
545
|
+
|
|
546
|
+
The default backend intentionally wraps the existing `MultiSymbolPortfolio`
|
|
547
|
+
so old portfolio mode semantics stay unchanged while returning
|
|
548
|
+
`BacktestResultV2` to new metrics and migration code.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(
|
|
552
|
+
self,
|
|
553
|
+
positions: Dict[str, pd.Series],
|
|
554
|
+
closes: Dict[str, pd.Series],
|
|
555
|
+
datetime_index: Union[pd.DatetimeIndex, pd.Series],
|
|
556
|
+
mode: str = "longshort",
|
|
557
|
+
backend: str = "native_portfolio",
|
|
558
|
+
account: Optional[AccountConfig] = None,
|
|
559
|
+
execution: Optional[ExecutionConfig] = None,
|
|
560
|
+
fee_rate: Optional[float] = None,
|
|
561
|
+
alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0,
|
|
562
|
+
contract_size: Union[float, Dict[str, float], None] = None,
|
|
563
|
+
hedge_type: str = "notional",
|
|
564
|
+
asset_type: str = "crypto",
|
|
565
|
+
use_funding: Optional[bool] = None,
|
|
566
|
+
funding_rate: Union[float, Dict[str, float], None] = None,
|
|
567
|
+
leverage: Optional[Union[float, Dict[str, float]]] = None,
|
|
568
|
+
maintenance_ratio: Optional[float] = None,
|
|
569
|
+
highs: Optional[Dict[str, pd.Series]] = None,
|
|
570
|
+
lows: Optional[Dict[str, pd.Series]] = None,
|
|
571
|
+
instruments: Optional[Union[Dict[str, InstrumentSpec], List[InstrumentSpec]]] = None,
|
|
572
|
+
qty_step: Optional[Union[float, Dict[str, float]]] = None,
|
|
573
|
+
lot_size: Optional[Union[float, Dict[str, float]]] = None,
|
|
574
|
+
slot_size: Optional[Union[float, Dict[str, float]]] = None,
|
|
575
|
+
min_qty: Optional[Union[float, Dict[str, float]]] = None,
|
|
576
|
+
min_notional: Optional[Union[float, Dict[str, float]]] = None,
|
|
577
|
+
report_level: str = "full",
|
|
578
|
+
auto_run: bool = True,
|
|
579
|
+
**kwargs,
|
|
580
|
+
):
|
|
581
|
+
legacy_slippage = kwargs.pop("slippage", None)
|
|
582
|
+
if execution is None and legacy_slippage is not None:
|
|
583
|
+
execution = ExecutionConfig(slippage_bps=float(legacy_slippage) * 10_000.0)
|
|
584
|
+
self.positions = positions
|
|
585
|
+
self.closes = closes
|
|
586
|
+
self.datetime_index = datetime_index
|
|
587
|
+
self.mode = mode
|
|
588
|
+
self.backend = backend.lower().strip()
|
|
589
|
+
self.account = account or AccountConfig(initial_capital=100_000.0)
|
|
590
|
+
self.execution = execution or ExecutionConfig()
|
|
591
|
+
self.fee_rate = fee_rate
|
|
592
|
+
self.alloc_per_trade = alloc_per_trade
|
|
593
|
+
self.contract_size = contract_size
|
|
594
|
+
self.hedge_type = hedge_type
|
|
595
|
+
self.asset_type = asset_type
|
|
596
|
+
self.use_funding = use_funding if use_funding is not None else asset_type.lower() == "crypto"
|
|
597
|
+
self.funding_rate = funding_rate
|
|
598
|
+
self.leverage = leverage if leverage is not None else self.account.leverage
|
|
599
|
+
self.maintenance_ratio = (
|
|
600
|
+
maintenance_ratio if maintenance_ratio is not None else self.account.maintenance_ratio
|
|
601
|
+
)
|
|
602
|
+
self.highs = highs
|
|
603
|
+
self.lows = lows
|
|
604
|
+
self.instruments = instruments
|
|
605
|
+
self.qty_step = qty_step
|
|
606
|
+
self.lot_size = lot_size
|
|
607
|
+
self.slot_size = slot_size
|
|
608
|
+
self.min_qty = min_qty
|
|
609
|
+
self.min_notional = min_notional
|
|
610
|
+
self.report_level = report_level
|
|
611
|
+
self.kwargs = kwargs
|
|
612
|
+
self.portfolio: Optional[MultiSymbolPortfolio] = None
|
|
613
|
+
self.result: Optional[BacktestResultV2] = None
|
|
614
|
+
|
|
615
|
+
if auto_run:
|
|
616
|
+
self.run()
|
|
617
|
+
|
|
618
|
+
def run(self) -> BacktestResultV2:
|
|
619
|
+
if self.backend in {"legacy", "legacy_portfolio", "portfolio"}:
|
|
620
|
+
asset_type = self.asset_type.lower()
|
|
621
|
+
default_fee = 0.0004 if asset_type == "crypto" else 0.0001
|
|
622
|
+
fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0
|
|
623
|
+
legacy_kwargs = {
|
|
624
|
+
key: value
|
|
625
|
+
for key, value in self.kwargs.items()
|
|
626
|
+
if key not in {"use_pyramiding", "betas", "risk_lookback"}
|
|
627
|
+
}
|
|
628
|
+
self.portfolio = MultiSymbolPortfolio(
|
|
629
|
+
positions=self.positions,
|
|
630
|
+
closes=self.closes,
|
|
631
|
+
datetime_index=self.datetime_index,
|
|
632
|
+
mode=self.mode,
|
|
633
|
+
fee_rate=fee_oneway,
|
|
634
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
635
|
+
contract_size=self.contract_size,
|
|
636
|
+
hedge_type=self.hedge_type,
|
|
637
|
+
initial_capital=self.account.initial_capital,
|
|
638
|
+
asset_type=self.asset_type,
|
|
639
|
+
use_funding=self.use_funding,
|
|
640
|
+
funding_rate=self.funding_rate,
|
|
641
|
+
leverage=self.leverage,
|
|
642
|
+
maintenance_ratio=self.maintenance_ratio,
|
|
643
|
+
highs=self.highs,
|
|
644
|
+
lows=self.lows,
|
|
645
|
+
**legacy_kwargs,
|
|
646
|
+
)
|
|
647
|
+
self.result = BacktestResultV2.from_legacy(self.portfolio.result)
|
|
648
|
+
self.result.metadata["backend"] = "legacy_portfolio"
|
|
649
|
+
return self.result
|
|
650
|
+
|
|
651
|
+
if self.backend == "native_vectorized":
|
|
652
|
+
engine = BacktestEngineV2(
|
|
653
|
+
positions=self.positions,
|
|
654
|
+
closes=self.closes,
|
|
655
|
+
highs=self.highs,
|
|
656
|
+
lows=self.lows,
|
|
657
|
+
datetime_index=self.datetime_index,
|
|
658
|
+
backend="native_vectorized",
|
|
659
|
+
account=self.account,
|
|
660
|
+
execution=self.execution,
|
|
661
|
+
fee_rate=self.fee_rate or 0.0,
|
|
662
|
+
use_funding=bool(self.use_funding),
|
|
663
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
664
|
+
hedge_type=self.hedge_type,
|
|
665
|
+
contract_size=self.contract_size or 1.0,
|
|
666
|
+
leverage=self.leverage,
|
|
667
|
+
instruments=self.instruments,
|
|
668
|
+
qty_step=self.qty_step,
|
|
669
|
+
lot_size=self.lot_size,
|
|
670
|
+
slot_size=self.slot_size,
|
|
671
|
+
min_qty=self.min_qty,
|
|
672
|
+
min_notional=self.min_notional,
|
|
673
|
+
)
|
|
674
|
+
self.result = engine.result
|
|
675
|
+
return self.result
|
|
676
|
+
|
|
677
|
+
if self.backend == "native_portfolio":
|
|
678
|
+
asset_type = self.asset_type.lower()
|
|
679
|
+
default_fee = 0.0004 if asset_type == "crypto" else 0.0001
|
|
680
|
+
fee_oneway = self.fee_rate if self.fee_rate is not None else default_fee / 2.0
|
|
681
|
+
default_contract = 1.0 if asset_type == "crypto" else 100.0
|
|
682
|
+
backend = NativePortfolioBackend(
|
|
683
|
+
NativePortfolioConfig(
|
|
684
|
+
account=self.account,
|
|
685
|
+
execution=self.execution,
|
|
686
|
+
fee_rate=fee_oneway,
|
|
687
|
+
use_funding=bool(self.use_funding),
|
|
688
|
+
report_level=self.report_level,
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
self.result = backend.run_signals(
|
|
692
|
+
positions=self.positions,
|
|
693
|
+
closes=self.closes,
|
|
694
|
+
highs=self.highs,
|
|
695
|
+
lows=self.lows,
|
|
696
|
+
datetime_index=self.datetime_index,
|
|
697
|
+
mode=self.mode,
|
|
698
|
+
alloc_per_trade=self.alloc_per_trade,
|
|
699
|
+
contract_size=self.contract_size if self.contract_size is not None else default_contract,
|
|
700
|
+
hedge_type=self.hedge_type,
|
|
701
|
+
funding_rate=self.funding_rate if self.funding_rate is not None else 0.0001,
|
|
702
|
+
leverage=self.leverage,
|
|
703
|
+
maintenance_ratio=self.maintenance_ratio,
|
|
704
|
+
asset_type=self.asset_type,
|
|
705
|
+
use_pyramiding=bool(self.kwargs.get("use_pyramiding", True)),
|
|
706
|
+
betas=self.kwargs.get("betas"),
|
|
707
|
+
risk_lookback=int(self.kwargs.get("risk_lookback", 60)),
|
|
708
|
+
instruments=self.instruments,
|
|
709
|
+
qty_step=self.qty_step,
|
|
710
|
+
lot_size=self.lot_size,
|
|
711
|
+
slot_size=self.slot_size,
|
|
712
|
+
min_qty=self.min_qty,
|
|
713
|
+
min_notional=self.min_notional,
|
|
714
|
+
report_level=self.report_level,
|
|
715
|
+
)
|
|
716
|
+
return self.result
|
|
717
|
+
|
|
718
|
+
raise ValueError("PortfolioBacktestEngine backend must be legacy_portfolio, native_vectorized, or native_portfolio")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _market_data(
|
|
722
|
+
data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
|
|
723
|
+
datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]],
|
|
724
|
+
closes: Optional[SeriesMap],
|
|
725
|
+
highs: Optional[SeriesMap],
|
|
726
|
+
lows: Optional[SeriesMap],
|
|
727
|
+
symbols: Optional[List[str]],
|
|
728
|
+
) -> Tuple[pd.DatetimeIndex, SeriesMap, SeriesMap, SeriesMap, List[str]]:
|
|
729
|
+
if closes is not None:
|
|
730
|
+
symbol_list = symbols or list(closes.keys())
|
|
731
|
+
idx = validate_datetime(datetime_index if datetime_index is not None else closes[symbol_list[0]].index)
|
|
732
|
+
close_map = {s: closes[s] for s in symbol_list}
|
|
733
|
+
high_map = {s: highs[s] for s in symbol_list} if highs is not None else close_map
|
|
734
|
+
low_map = {s: lows[s] for s in symbol_list} if lows is not None else close_map
|
|
735
|
+
return idx, close_map, high_map, low_map, symbol_list
|
|
736
|
+
|
|
737
|
+
if data is None:
|
|
738
|
+
raise ValueError("market data is required")
|
|
739
|
+
|
|
740
|
+
if isinstance(data, pd.DataFrame):
|
|
741
|
+
symbol = symbols[0] if symbols else "asset"
|
|
742
|
+
idx, close, high, low = _extract_frame_ohlc(data, datetime_index)
|
|
743
|
+
return idx, {symbol: close}, {symbol: high}, {symbol: low}, [symbol]
|
|
744
|
+
|
|
745
|
+
symbol_list = symbols or list(data.keys())
|
|
746
|
+
close_map: SeriesMap = {}
|
|
747
|
+
high_map: SeriesMap = {}
|
|
748
|
+
low_map: SeriesMap = {}
|
|
749
|
+
idx = None
|
|
750
|
+
for symbol in symbol_list:
|
|
751
|
+
value = data[symbol]
|
|
752
|
+
if isinstance(value, pd.Series):
|
|
753
|
+
close = value
|
|
754
|
+
high = value
|
|
755
|
+
low = value
|
|
756
|
+
local_idx = validate_datetime(datetime_index if datetime_index is not None else value.index)
|
|
757
|
+
else:
|
|
758
|
+
local_idx, close, high, low = _extract_frame_ohlc(value, datetime_index)
|
|
759
|
+
idx = local_idx if idx is None else idx
|
|
760
|
+
close_map[symbol] = close
|
|
761
|
+
high_map[symbol] = high
|
|
762
|
+
low_map[symbol] = low
|
|
763
|
+
return idx, close_map, high_map, low_map, symbol_list
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _market_open_volume(
|
|
767
|
+
data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
|
|
768
|
+
datetime_index: pd.DatetimeIndex,
|
|
769
|
+
closes: SeriesMap,
|
|
770
|
+
symbols: List[str],
|
|
771
|
+
) -> Tuple[SeriesMap, SeriesMap]:
|
|
772
|
+
opens: SeriesMap = {}
|
|
773
|
+
volumes: SeriesMap = {}
|
|
774
|
+
if isinstance(data, pd.DataFrame):
|
|
775
|
+
if len(symbols) != 1:
|
|
776
|
+
raise ValueError("single DataFrame reactive run requires one symbol")
|
|
777
|
+
frame = _extract_frame_ohlcv(data, datetime_index)
|
|
778
|
+
opens[symbols[0]] = frame["open"]
|
|
779
|
+
volumes[symbols[0]] = frame["volume"]
|
|
780
|
+
return opens, volumes
|
|
781
|
+
if isinstance(data, dict):
|
|
782
|
+
for symbol in symbols:
|
|
783
|
+
value = data[symbol]
|
|
784
|
+
if isinstance(value, pd.DataFrame):
|
|
785
|
+
frame = _extract_frame_ohlcv(value, datetime_index)
|
|
786
|
+
opens[symbol] = frame["open"]
|
|
787
|
+
volumes[symbol] = frame["volume"]
|
|
788
|
+
else:
|
|
789
|
+
close = closes[symbol]
|
|
790
|
+
opens[symbol] = close
|
|
791
|
+
volumes[symbol] = pd.Series(0.0, index=close.index, name="volume")
|
|
792
|
+
return opens, volumes
|
|
793
|
+
for symbol in symbols:
|
|
794
|
+
close = closes[symbol]
|
|
795
|
+
opens[symbol] = close
|
|
796
|
+
volumes[symbol] = pd.Series(0.0, index=close.index, name="volume")
|
|
797
|
+
return opens, volumes
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def _extract_frame_ohlc(
|
|
801
|
+
data: pd.DataFrame,
|
|
802
|
+
datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]],
|
|
803
|
+
) -> Tuple[pd.DatetimeIndex, pd.Series, pd.Series, pd.Series]:
|
|
804
|
+
frame = data.copy()
|
|
805
|
+
rename = {
|
|
806
|
+
"Datetime": "timestamp",
|
|
807
|
+
"Date": "timestamp",
|
|
808
|
+
"Timestamp": "timestamp",
|
|
809
|
+
"Open": "open",
|
|
810
|
+
"High": "high",
|
|
811
|
+
"Low": "low",
|
|
812
|
+
"Close": "close",
|
|
813
|
+
"Volume": "volume",
|
|
814
|
+
}
|
|
815
|
+
frame = frame.rename(columns=rename)
|
|
816
|
+
if datetime_index is not None:
|
|
817
|
+
frame.index = validate_datetime(datetime_index)
|
|
818
|
+
elif "timestamp" in frame.columns:
|
|
819
|
+
frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce", utc=True)
|
|
820
|
+
frame = frame.dropna(subset=["timestamp"]).set_index("timestamp")
|
|
821
|
+
else:
|
|
822
|
+
frame.index = validate_datetime(frame.index)
|
|
823
|
+
frame = frame[~frame.index.duplicated(keep="first")].sort_index()
|
|
824
|
+
idx = validate_datetime(frame.index)
|
|
825
|
+
if "close" not in frame.columns:
|
|
826
|
+
raise ValueError("data frame must contain close/Close")
|
|
827
|
+
close = pd.Series(frame["close"].to_numpy(), index=idx, name="close")
|
|
828
|
+
high = pd.Series(frame["high"].to_numpy(), index=idx, name="high") if "high" in frame.columns else close
|
|
829
|
+
low = pd.Series(frame["low"].to_numpy(), index=idx, name="low") if "low" in frame.columns else close
|
|
830
|
+
return idx, close, high, low
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _frames_for_nautilus(
|
|
834
|
+
data: Optional[Union[pd.DataFrame, Dict[str, Union[pd.DataFrame, pd.Series]]]],
|
|
835
|
+
datetime_index: pd.DatetimeIndex,
|
|
836
|
+
closes: SeriesMap,
|
|
837
|
+
highs: SeriesMap,
|
|
838
|
+
lows: SeriesMap,
|
|
839
|
+
symbols: List[str],
|
|
840
|
+
) -> Dict[str, pd.DataFrame]:
|
|
841
|
+
if isinstance(data, pd.DataFrame):
|
|
842
|
+
if len(symbols) != 1:
|
|
843
|
+
raise ValueError("single DataFrame Nautilus order replay requires one symbol")
|
|
844
|
+
return {symbols[0]: _extract_frame_ohlcv(data, datetime_index)}
|
|
845
|
+
if isinstance(data, dict):
|
|
846
|
+
frames = {}
|
|
847
|
+
for symbol in symbols:
|
|
848
|
+
value = data[symbol]
|
|
849
|
+
if isinstance(value, pd.DataFrame):
|
|
850
|
+
frames[symbol] = _extract_frame_ohlcv(value, datetime_index)
|
|
851
|
+
else:
|
|
852
|
+
close = pd.Series(value.to_numpy(), index=datetime_index, name="close")
|
|
853
|
+
frames[symbol] = _frame_from_ohlc(close, close, close)
|
|
854
|
+
return frames
|
|
855
|
+
return {symbol: _frame_from_ohlc(closes[symbol], highs[symbol], lows[symbol]) for symbol in symbols}
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _extract_frame_ohlcv(data: pd.DataFrame, datetime_index: pd.DatetimeIndex) -> pd.DataFrame:
|
|
859
|
+
frame = data.copy()
|
|
860
|
+
frame = frame.rename(
|
|
861
|
+
columns={
|
|
862
|
+
"Datetime": "timestamp",
|
|
863
|
+
"Date": "timestamp",
|
|
864
|
+
"Timestamp": "timestamp",
|
|
865
|
+
"Open": "open",
|
|
866
|
+
"High": "high",
|
|
867
|
+
"Low": "low",
|
|
868
|
+
"Close": "close",
|
|
869
|
+
"Volume": "volume",
|
|
870
|
+
}
|
|
871
|
+
)
|
|
872
|
+
frame.index = datetime_index
|
|
873
|
+
if "close" not in frame.columns:
|
|
874
|
+
raise ValueError("data frame must contain close/Close")
|
|
875
|
+
for col in ("open", "high", "low"):
|
|
876
|
+
if col not in frame.columns:
|
|
877
|
+
frame[col] = frame["close"]
|
|
878
|
+
if "volume" not in frame.columns:
|
|
879
|
+
frame["volume"] = 0.0
|
|
880
|
+
return frame[["open", "high", "low", "close", "volume"]].copy()
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _frame_from_ohlc(close: pd.Series, high: pd.Series, low: pd.Series) -> pd.DataFrame:
|
|
884
|
+
return pd.DataFrame(
|
|
885
|
+
{
|
|
886
|
+
"open": close,
|
|
887
|
+
"high": high,
|
|
888
|
+
"low": low,
|
|
889
|
+
"close": close,
|
|
890
|
+
"volume": 0.0,
|
|
891
|
+
},
|
|
892
|
+
index=close.index,
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _as_series_map(value: Union[pd.Series, SeriesMap], symbols: List[str]) -> SeriesMap:
|
|
897
|
+
if isinstance(value, pd.Series):
|
|
898
|
+
if len(symbols) != 1:
|
|
899
|
+
raise ValueError("single series input requires exactly one symbol")
|
|
900
|
+
return {symbols[0]: value}
|
|
901
|
+
return {s: value[s] for s in symbols}
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def _build_market_rebalance_orders(
|
|
905
|
+
datetime_index: pd.DatetimeIndex,
|
|
906
|
+
positions: SeriesMap,
|
|
907
|
+
closes: SeriesMap,
|
|
908
|
+
alloc_per_trade: Union[float, Dict[str, float]],
|
|
909
|
+
hedge_type: str,
|
|
910
|
+
use_pyramiding: bool,
|
|
911
|
+
symbols: List[str],
|
|
912
|
+
) -> List[OrderIntent]:
|
|
913
|
+
ht = hedge_type.lower().strip()
|
|
914
|
+
if ht in ("%_equity", "pct_equity", "dca_ladder", "dca"):
|
|
915
|
+
raise NotImplementedError(
|
|
916
|
+
"native_event signal adapter supports pre-scalable target-unit modes "
|
|
917
|
+
"('signal_notional', 'notional', 'unit'). Use explicit orders for "
|
|
918
|
+
f"hedge_type={hedge_type!r}."
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
alloc = _per_symbol_mapping(alloc_per_trade, symbols, default=100_000.0)
|
|
922
|
+
orders: List[OrderIntent] = []
|
|
923
|
+
for symbol in symbols:
|
|
924
|
+
signal = positions[symbol].copy()
|
|
925
|
+
close = closes[symbol].copy()
|
|
926
|
+
if isinstance(signal.index, pd.DatetimeIndex):
|
|
927
|
+
signal.index = signal.index.tz_localize("UTC") if signal.index.tz is None else signal.index.tz_convert("UTC")
|
|
928
|
+
if isinstance(close.index, pd.DatetimeIndex):
|
|
929
|
+
close.index = close.index.tz_localize("UTC") if close.index.tz is None else close.index.tz_convert("UTC")
|
|
930
|
+
signal = signal[~signal.index.duplicated(keep="first")].reindex(datetime_index, method="ffill").fillna(0.0)
|
|
931
|
+
close = close[~close.index.duplicated(keep="first")].reindex(datetime_index, method="ffill")
|
|
932
|
+
target_units = compute_target_units(
|
|
933
|
+
hedge_type=hedge_type,
|
|
934
|
+
signal=signal,
|
|
935
|
+
close=close,
|
|
936
|
+
alloc=alloc[symbol],
|
|
937
|
+
use_pyramiding=use_pyramiding,
|
|
938
|
+
).fillna(0.0)
|
|
939
|
+
prev = 0.0
|
|
940
|
+
for ts, target in target_units.items():
|
|
941
|
+
target = float(target)
|
|
942
|
+
delta = target - prev
|
|
943
|
+
if abs(delta) > 1e-12:
|
|
944
|
+
orders.append(
|
|
945
|
+
OrderIntent(
|
|
946
|
+
timestamp=ts,
|
|
947
|
+
symbol=symbol,
|
|
948
|
+
side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
|
|
949
|
+
order_type=OrderType.MARKET,
|
|
950
|
+
qty=abs(delta),
|
|
951
|
+
tif=TimeInForce.IOC,
|
|
952
|
+
tag=f"signal_rebalance:{hedge_type}",
|
|
953
|
+
)
|
|
954
|
+
)
|
|
955
|
+
prev = target
|
|
956
|
+
return sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def _per_symbol_mapping(value, symbols: List[str], default: float) -> Dict[str, float]:
|
|
960
|
+
if isinstance(value, dict):
|
|
961
|
+
return {s: float(value.get(s, default)) for s in symbols}
|
|
962
|
+
return {s: float(value) for s in symbols}
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def _commands_to_package_order_intents(commands: Sequence[OrderCommand]) -> Tuple[OrderIntent, ...]:
|
|
966
|
+
orders: List[OrderIntent] = []
|
|
967
|
+
for command in commands:
|
|
968
|
+
if command.action not in (OrderAction.PLACE, OrderAction.REPLACE):
|
|
969
|
+
continue
|
|
970
|
+
if command.symbol is None or command.side is None or command.order_type is None or command.qty is None:
|
|
971
|
+
continue
|
|
972
|
+
metadata = {
|
|
973
|
+
**dict(command.metadata),
|
|
974
|
+
"command_action": command.action.value,
|
|
975
|
+
"target_order_id": command.target_order_id,
|
|
976
|
+
"parent_order_id": command.parent_order_id,
|
|
977
|
+
"group_id": command.group_id,
|
|
978
|
+
"oco_group_id": command.oco_group_id,
|
|
979
|
+
"activation_policy": command.activation_policy.value,
|
|
980
|
+
}
|
|
981
|
+
orders.append(
|
|
982
|
+
OrderIntent(
|
|
983
|
+
timestamp=command.timestamp,
|
|
984
|
+
symbol=command.symbol,
|
|
985
|
+
side=command.side,
|
|
986
|
+
order_type=command.order_type,
|
|
987
|
+
qty=float(command.qty),
|
|
988
|
+
price=command.price,
|
|
989
|
+
trigger_price=command.trigger_price,
|
|
990
|
+
tif=command.tif,
|
|
991
|
+
reduce_only=command.reduce_only,
|
|
992
|
+
order_id=command.order_id,
|
|
993
|
+
tag=command.tag,
|
|
994
|
+
metadata=metadata,
|
|
995
|
+
)
|
|
996
|
+
)
|
|
997
|
+
return tuple(orders)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def _first_signal(value: Optional[Union[pd.Series, SeriesMap]]) -> Optional[pd.Series]:
|
|
1001
|
+
if value is None:
|
|
1002
|
+
return None
|
|
1003
|
+
if isinstance(value, pd.Series):
|
|
1004
|
+
return value
|
|
1005
|
+
return next(iter(value.values()))
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
def _single_frame(data) -> Optional[pd.DataFrame]:
|
|
1009
|
+
if isinstance(data, pd.DataFrame):
|
|
1010
|
+
return data
|
|
1011
|
+
if isinstance(data, dict) and data:
|
|
1012
|
+
first = next(iter(data.values()))
|
|
1013
|
+
return first if isinstance(first, pd.DataFrame) else None
|
|
1014
|
+
return None
|