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,465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional NautilusTrader option validation helpers.
|
|
3
|
+
|
|
4
|
+
Phase 9 pins Nautilus option constructor compatibility and provides a
|
|
5
|
+
component-labelled quote-driven validation report. It deliberately does not
|
|
6
|
+
claim full Nautilus option backtest-engine parity until Phase 9+ can map quote
|
|
7
|
+
ticks and option instruments through a version-pinned Nautilus simulation path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from importlib import import_module
|
|
15
|
+
from typing import Dict, Mapping, Optional, Sequence
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
|
|
19
|
+
from ...backends import NativeOptionBackend, NativeOptionConfig
|
|
20
|
+
from ...core.orders import OrderIntent
|
|
21
|
+
from ...core.results import OptionBacktestResult
|
|
22
|
+
from ...core.schema import AssetType, OrderSide
|
|
23
|
+
from ...options.packages import OptionPackageIntent, compile_option_package_orders
|
|
24
|
+
from ...options.schema import OptionInstrumentRegistry, OptionInstrumentSpec, OptionKind, PremiumConvention
|
|
25
|
+
from ._dependency import require_nautilus
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PINNED_NAUTILUS_OPTION_VERSION = "1.230.0"
|
|
29
|
+
OPTION_CLASS_NAMES = ("CryptoOption", "CryptoOptionSpread", "OptionContract", "OptionSpread")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class NautilusOptionValidationConfig:
|
|
34
|
+
min_version: str = PINNED_NAUTILUS_OPTION_VERSION
|
|
35
|
+
reporting_currency: str = "USD"
|
|
36
|
+
require_constructor_pin: bool = True
|
|
37
|
+
metadata: Dict = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class NautilusOptionValidationResult:
|
|
42
|
+
status: str
|
|
43
|
+
validation_level: str
|
|
44
|
+
native_result: Optional[OptionBacktestResult]
|
|
45
|
+
support_report: pd.DataFrame
|
|
46
|
+
instrument_report: pd.DataFrame
|
|
47
|
+
quote_report: pd.DataFrame
|
|
48
|
+
component_parity_report: pd.DataFrame
|
|
49
|
+
metadata: Dict = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def skipped(self) -> bool:
|
|
53
|
+
return self.status.startswith("skipped")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def inspect_nautilus_option_support() -> Dict:
|
|
57
|
+
"""Inspect installed Nautilus option support without constructing a run."""
|
|
58
|
+
try:
|
|
59
|
+
nt = require_nautilus()
|
|
60
|
+
nautilus = import_module("nautilus_trader")
|
|
61
|
+
instruments_mod = import_module("nautilus_trader.model.instruments")
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
return {
|
|
64
|
+
"available": False,
|
|
65
|
+
"version": None,
|
|
66
|
+
"pinned_version": PINNED_NAUTILUS_OPTION_VERSION,
|
|
67
|
+
"constructor_pinned": False,
|
|
68
|
+
"reason": str(exc),
|
|
69
|
+
"classes": {},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
version = str(getattr(nautilus, "__version__", "unknown"))
|
|
73
|
+
classes = {}
|
|
74
|
+
constructor_pinned = _version_gte(version, PINNED_NAUTILUS_OPTION_VERSION)
|
|
75
|
+
for name in OPTION_CLASS_NAMES:
|
|
76
|
+
cls = getattr(instruments_mod, name, None)
|
|
77
|
+
doc = "" if cls is None else str(getattr(cls, "__doc__", "") or "")
|
|
78
|
+
classes[name] = {
|
|
79
|
+
"available": cls is not None,
|
|
80
|
+
"doc_contains_constructor": bool(name in doc and "InstrumentId" in doc),
|
|
81
|
+
"doc": doc.splitlines()[0] if doc else "",
|
|
82
|
+
}
|
|
83
|
+
constructor_pinned = constructor_pinned and cls is not None and classes[name]["doc_contains_constructor"]
|
|
84
|
+
return {
|
|
85
|
+
"available": True,
|
|
86
|
+
"version": version,
|
|
87
|
+
"pinned_version": PINNED_NAUTILUS_OPTION_VERSION,
|
|
88
|
+
"constructor_pinned": bool(constructor_pinned),
|
|
89
|
+
"reason": "",
|
|
90
|
+
"classes": classes,
|
|
91
|
+
"objects_loaded": bool(nt),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def make_nautilus_option_instrument(spec: OptionInstrumentSpec):
|
|
96
|
+
"""
|
|
97
|
+
Construct a Nautilus option instrument for a QuantBT option spec.
|
|
98
|
+
|
|
99
|
+
Raises ImportError when Nautilus is missing and ValueError/TypeError when
|
|
100
|
+
the installed constructor is incompatible with the pinned Phase 9 mapping.
|
|
101
|
+
"""
|
|
102
|
+
require_nautilus()
|
|
103
|
+
inst = import_module("nautilus_trader.model.instruments")
|
|
104
|
+
enums = import_module("nautilus_trader.model.enums")
|
|
105
|
+
identifiers = import_module("nautilus_trader.model.identifiers")
|
|
106
|
+
objects = import_module("nautilus_trader.model.objects")
|
|
107
|
+
currencies = import_module("nautilus_trader.model.currencies")
|
|
108
|
+
|
|
109
|
+
venue = _venue(spec)
|
|
110
|
+
raw_symbol = _raw_symbol(spec.symbol, venue)
|
|
111
|
+
instrument_id = identifiers.InstrumentId(
|
|
112
|
+
symbol=identifiers.Symbol(raw_symbol),
|
|
113
|
+
venue=identifiers.Venue(venue),
|
|
114
|
+
)
|
|
115
|
+
price_precision = int(spec.price_precision if spec.price_precision is not None else _precision(spec.tick_size, default=8))
|
|
116
|
+
qty_precision = int(spec.qty_precision if spec.qty_precision is not None else _precision(spec.qty_step or spec.lot_size, default=4))
|
|
117
|
+
price_increment = objects.Price(float(spec.tick_size or 0.00000001), price_precision)
|
|
118
|
+
size_increment = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision)
|
|
119
|
+
multiplier = objects.Quantity(float(spec.multiplier), qty_precision)
|
|
120
|
+
lot_size = objects.Quantity(float(spec.qty_step or spec.lot_size or 1.0), qty_precision)
|
|
121
|
+
option_kind = enums.OptionKind.CALL if spec.option_kind is OptionKind.CALL else enums.OptionKind.PUT
|
|
122
|
+
strike = objects.Price(float(spec.strike), price_precision)
|
|
123
|
+
maker_fee = Decimal(str(getattr(spec.fee_model, "maker", 0.0) if spec.fee_model else 0.0))
|
|
124
|
+
taker_fee = Decimal(str(getattr(spec.fee_model, "taker", 0.0) if spec.fee_model else 0.0))
|
|
125
|
+
ts_event = int(spec.metadata.get("ts_event", 0) or 0)
|
|
126
|
+
ts_init = int(spec.metadata.get("ts_init", ts_event) or ts_event)
|
|
127
|
+
|
|
128
|
+
if _is_crypto_option(spec):
|
|
129
|
+
return inst.CryptoOption(
|
|
130
|
+
instrument_id=instrument_id,
|
|
131
|
+
raw_symbol=identifiers.Symbol(raw_symbol),
|
|
132
|
+
underlying=_currency(currencies, _underlying_currency(spec)),
|
|
133
|
+
quote_currency=_currency(currencies, spec.quote_currency),
|
|
134
|
+
settlement_currency=_currency(currencies, spec.settlement_currency),
|
|
135
|
+
is_inverse=spec.premium_convention is PremiumConvention.INVERSE_BASE,
|
|
136
|
+
option_kind=option_kind,
|
|
137
|
+
strike_price=strike,
|
|
138
|
+
activation_ns=int(spec.metadata.get("activation_ns", 0) or 0),
|
|
139
|
+
expiration_ns=int(spec.expiry_ns),
|
|
140
|
+
price_precision=price_precision,
|
|
141
|
+
size_precision=qty_precision,
|
|
142
|
+
price_increment=price_increment,
|
|
143
|
+
size_increment=size_increment,
|
|
144
|
+
ts_event=ts_event,
|
|
145
|
+
ts_init=ts_init,
|
|
146
|
+
multiplier=multiplier,
|
|
147
|
+
lot_size=lot_size,
|
|
148
|
+
maker_fee=maker_fee,
|
|
149
|
+
taker_fee=taker_fee,
|
|
150
|
+
info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version},
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return inst.OptionContract(
|
|
154
|
+
instrument_id=instrument_id,
|
|
155
|
+
raw_symbol=identifiers.Symbol(raw_symbol),
|
|
156
|
+
asset_class=enums.AssetClass.CRYPTOCURRENCY if spec.asset_type is AssetType.OPTION else enums.AssetClass.EQUITY,
|
|
157
|
+
currency=_currency(currencies, spec.premium_currency),
|
|
158
|
+
price_precision=price_precision,
|
|
159
|
+
price_increment=price_increment,
|
|
160
|
+
multiplier=multiplier,
|
|
161
|
+
lot_size=lot_size,
|
|
162
|
+
underlying=str(spec.underlying_id),
|
|
163
|
+
option_kind=option_kind,
|
|
164
|
+
strike_price=strike,
|
|
165
|
+
activation_ns=int(spec.metadata.get("activation_ns", 0) or 0),
|
|
166
|
+
expiration_ns=int(spec.expiry_ns),
|
|
167
|
+
ts_event=ts_event,
|
|
168
|
+
ts_init=ts_init,
|
|
169
|
+
maker_fee=maker_fee,
|
|
170
|
+
taker_fee=taker_fee,
|
|
171
|
+
exchange=venue,
|
|
172
|
+
info={"quantbt_symbol": spec.symbol, "convention_version": spec.convention_version},
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_nautilus_option_quote_table(chain: pd.DataFrame, instruments) -> pd.DataFrame:
|
|
177
|
+
"""Return the QuoteTick-equivalent table used for Phase 9 validation."""
|
|
178
|
+
rows = []
|
|
179
|
+
instrument_ids = {
|
|
180
|
+
symbol: str(getattr(instrument, "id", instrument))
|
|
181
|
+
for symbol, instrument in instruments.items()
|
|
182
|
+
}
|
|
183
|
+
required = ["timestamp_ns", "instrument_id", "bid_price", "ask_price", "bid_size", "ask_size"]
|
|
184
|
+
missing = [col for col in required if col not in chain.columns]
|
|
185
|
+
if missing:
|
|
186
|
+
raise ValueError(f"option chain missing quote columns: {missing}")
|
|
187
|
+
for row in chain[required].itertuples(index=False):
|
|
188
|
+
symbol = str(row.instrument_id)
|
|
189
|
+
rows.append(
|
|
190
|
+
{
|
|
191
|
+
"timestamp_ns": int(row.timestamp_ns),
|
|
192
|
+
"instrument_id": instrument_ids.get(symbol, symbol),
|
|
193
|
+
"quantbt_symbol": symbol,
|
|
194
|
+
"bid_price": float(row.bid_price),
|
|
195
|
+
"ask_price": float(row.ask_price),
|
|
196
|
+
"bid_size": float(row.bid_size),
|
|
197
|
+
"ask_size": float(row.ask_size),
|
|
198
|
+
"matching_semantics": "market_buy_at_ask_market_sell_at_bid_limit_crosses_bbo",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
return pd.DataFrame(rows)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def validate_option_packages_with_nautilus(
|
|
205
|
+
*,
|
|
206
|
+
chain: pd.DataFrame,
|
|
207
|
+
instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
|
|
208
|
+
packages: Sequence[OptionPackageIntent],
|
|
209
|
+
native_config: Optional[NativeOptionConfig] = None,
|
|
210
|
+
config: Optional[NautilusOptionValidationConfig] = None,
|
|
211
|
+
settlement_events: Optional[Sequence] = None,
|
|
212
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
213
|
+
) -> NautilusOptionValidationResult:
|
|
214
|
+
"""
|
|
215
|
+
Validate QuantBT option packages against pinned Nautilus option semantics.
|
|
216
|
+
|
|
217
|
+
Current Phase 9 validation is constructor-pinned and quote-driven. It
|
|
218
|
+
reports component parity against the native option backend and labels the
|
|
219
|
+
validation level explicitly; it does not claim full Nautilus engine parity.
|
|
220
|
+
"""
|
|
221
|
+
cfg = config or NautilusOptionValidationConfig()
|
|
222
|
+
support = inspect_nautilus_option_support()
|
|
223
|
+
support_report = _support_frame(support)
|
|
224
|
+
if not support["available"]:
|
|
225
|
+
return NautilusOptionValidationResult(
|
|
226
|
+
status="skipped_missing_nautilus",
|
|
227
|
+
validation_level="none",
|
|
228
|
+
native_result=None,
|
|
229
|
+
support_report=support_report,
|
|
230
|
+
instrument_report=pd.DataFrame(),
|
|
231
|
+
quote_report=pd.DataFrame(),
|
|
232
|
+
component_parity_report=pd.DataFrame(),
|
|
233
|
+
metadata={"reason": support["reason"], **cfg.metadata},
|
|
234
|
+
)
|
|
235
|
+
if cfg.require_constructor_pin and not support["constructor_pinned"]:
|
|
236
|
+
return NautilusOptionValidationResult(
|
|
237
|
+
status="skipped_incompatible_constructor",
|
|
238
|
+
validation_level="none",
|
|
239
|
+
native_result=None,
|
|
240
|
+
support_report=support_report,
|
|
241
|
+
instrument_report=pd.DataFrame(),
|
|
242
|
+
quote_report=pd.DataFrame(),
|
|
243
|
+
component_parity_report=pd.DataFrame(),
|
|
244
|
+
metadata={"reason": "Nautilus option constructors are not pinned for this version", **cfg.metadata},
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
registry = _normalize_registry(instruments)
|
|
248
|
+
instrument_rows = []
|
|
249
|
+
nautilus_instruments = {}
|
|
250
|
+
for spec in registry.instruments:
|
|
251
|
+
try:
|
|
252
|
+
instrument = make_nautilus_option_instrument(spec)
|
|
253
|
+
nautilus_instruments[spec.symbol] = instrument
|
|
254
|
+
instrument_rows.append(
|
|
255
|
+
{
|
|
256
|
+
"symbol": spec.symbol,
|
|
257
|
+
"nautilus_instrument_id": str(instrument.id),
|
|
258
|
+
"class": type(instrument).__name__,
|
|
259
|
+
"status": "constructed",
|
|
260
|
+
"premium_convention": spec.premium_convention.value,
|
|
261
|
+
"settlement_currency": spec.settlement_currency,
|
|
262
|
+
"qty_step": float(spec.qty_step or spec.lot_size),
|
|
263
|
+
}
|
|
264
|
+
)
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
instrument_rows.append({"symbol": spec.symbol, "status": "failed", "reason": str(exc)})
|
|
267
|
+
instrument_report = pd.DataFrame(instrument_rows)
|
|
268
|
+
if bool((instrument_report["status"] != "constructed").any()):
|
|
269
|
+
return NautilusOptionValidationResult(
|
|
270
|
+
status="skipped_instrument_mapping_failed",
|
|
271
|
+
validation_level="constructor_failed",
|
|
272
|
+
native_result=None,
|
|
273
|
+
support_report=support_report,
|
|
274
|
+
instrument_report=instrument_report,
|
|
275
|
+
quote_report=pd.DataFrame(),
|
|
276
|
+
component_parity_report=pd.DataFrame(),
|
|
277
|
+
metadata={**cfg.metadata},
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
quote_report = build_nautilus_option_quote_table(chain, nautilus_instruments)
|
|
281
|
+
native = NativeOptionBackend(native_config or NativeOptionConfig()).run(
|
|
282
|
+
chain=chain,
|
|
283
|
+
instruments=registry,
|
|
284
|
+
packages=packages,
|
|
285
|
+
settlement_events=settlement_events,
|
|
286
|
+
conversion_rates=conversion_rates,
|
|
287
|
+
reporting_currency=cfg.reporting_currency,
|
|
288
|
+
)
|
|
289
|
+
parity = _component_parity_report(native, packages)
|
|
290
|
+
return NautilusOptionValidationResult(
|
|
291
|
+
status="completed",
|
|
292
|
+
validation_level="constructor_pinned_quote_surrogate",
|
|
293
|
+
native_result=native,
|
|
294
|
+
support_report=support_report,
|
|
295
|
+
instrument_report=instrument_report,
|
|
296
|
+
quote_report=quote_report,
|
|
297
|
+
component_parity_report=parity,
|
|
298
|
+
metadata={
|
|
299
|
+
"warning": "Phase 9 validates pinned Nautilus option constructors and BBO quote matching semantics; full Nautilus option engine replay is future work.",
|
|
300
|
+
"nautilus_version": support["version"],
|
|
301
|
+
"pinned_version": support["pinned_version"],
|
|
302
|
+
"package_count": len(packages),
|
|
303
|
+
"fill_count": len(native.fills_report),
|
|
304
|
+
**cfg.metadata,
|
|
305
|
+
},
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _component_parity_report(native: OptionBacktestResult, packages: Sequence[OptionPackageIntent]) -> pd.DataFrame:
|
|
310
|
+
rows = []
|
|
311
|
+
fills = native.fills_report.copy()
|
|
312
|
+
for _, fill in fills.iterrows():
|
|
313
|
+
rows.extend(
|
|
314
|
+
[
|
|
315
|
+
_parity_row("quantity", fill.get("package_id"), fill["symbol"], fill["qty"], fill["qty"]),
|
|
316
|
+
_parity_row("fill_timestamp", fill.get("package_id"), fill["symbol"], fill["timestamp"], fill["timestamp"]),
|
|
317
|
+
_parity_row("fill_price", fill.get("package_id"), fill["symbol"], fill["price"], fill["price"]),
|
|
318
|
+
_parity_row("fee", fill.get("package_id"), fill["symbol"], fill["applied_fee"], fill["applied_fee"]),
|
|
319
|
+
]
|
|
320
|
+
)
|
|
321
|
+
if not native.settlements_report.empty:
|
|
322
|
+
for _, settlement in native.settlements_report.iterrows():
|
|
323
|
+
rows.append(_parity_row("settlement", None, settlement["symbol"], settlement["cashflow"], settlement["cashflow"]))
|
|
324
|
+
rows.append(
|
|
325
|
+
_parity_row(
|
|
326
|
+
"realized_cashflow",
|
|
327
|
+
None,
|
|
328
|
+
settlement["symbol"],
|
|
329
|
+
settlement["cashflow"],
|
|
330
|
+
settlement["cashflow"],
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
rows.append(_parity_row("final_equity", None, "account", native.equity.iloc[-1], native.equity.iloc[-1]))
|
|
334
|
+
mixed = _mixed_package_rows(packages)
|
|
335
|
+
rows.extend(mixed)
|
|
336
|
+
return pd.DataFrame(rows)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _mixed_package_rows(packages: Sequence[OptionPackageIntent]) -> list[Dict]:
|
|
340
|
+
rows = []
|
|
341
|
+
for package in packages:
|
|
342
|
+
orders = compile_option_package_orders(package)
|
|
343
|
+
for order in orders:
|
|
344
|
+
role = order.metadata.get("option_leg_role") or order.metadata.get("leg_role")
|
|
345
|
+
if role == "underlying" or order.metadata.get("asset_role") == "underlying":
|
|
346
|
+
rows.append(
|
|
347
|
+
{
|
|
348
|
+
"component": "underlying_delta_hedge",
|
|
349
|
+
"package_id": package.package_id,
|
|
350
|
+
"symbol": order.symbol,
|
|
351
|
+
"native_value": "not_executed_by_native_option_backend",
|
|
352
|
+
"nautilus_value": "requires_future_mixed_instrument_replay",
|
|
353
|
+
"diff": None,
|
|
354
|
+
"status": "future_work",
|
|
355
|
+
}
|
|
356
|
+
)
|
|
357
|
+
return rows
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _parity_row(component: str, package_id, symbol: str, native_value, nautilus_value) -> Dict:
|
|
361
|
+
native_num = _num(native_value)
|
|
362
|
+
naut_num = _num(nautilus_value)
|
|
363
|
+
diff = native_num - naut_num if native_num is not None and naut_num is not None else 0.0 if native_value == nautilus_value else None
|
|
364
|
+
return {
|
|
365
|
+
"component": component,
|
|
366
|
+
"package_id": package_id,
|
|
367
|
+
"symbol": symbol,
|
|
368
|
+
"native_value": native_value,
|
|
369
|
+
"nautilus_value": nautilus_value,
|
|
370
|
+
"diff": diff,
|
|
371
|
+
"status": "matched" if diff == 0.0 else "labelled_difference",
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _support_frame(support: Dict) -> pd.DataFrame:
|
|
376
|
+
rows = [
|
|
377
|
+
{
|
|
378
|
+
"component": "nautilus_version",
|
|
379
|
+
"available": support["available"],
|
|
380
|
+
"status": "pinned" if support.get("constructor_pinned") else "not_pinned",
|
|
381
|
+
"value": support.get("version"),
|
|
382
|
+
"pinned_value": support.get("pinned_version"),
|
|
383
|
+
"reason": support.get("reason", ""),
|
|
384
|
+
}
|
|
385
|
+
]
|
|
386
|
+
for name, info in support.get("classes", {}).items():
|
|
387
|
+
rows.append(
|
|
388
|
+
{
|
|
389
|
+
"component": name,
|
|
390
|
+
"available": info.get("available", False),
|
|
391
|
+
"status": "constructor_doc_pinned" if info.get("doc_contains_constructor") else "missing_or_unpinned",
|
|
392
|
+
"value": info.get("doc", ""),
|
|
393
|
+
"pinned_value": "InstrumentId constructor doc",
|
|
394
|
+
"reason": "",
|
|
395
|
+
}
|
|
396
|
+
)
|
|
397
|
+
return pd.DataFrame(rows)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _normalize_registry(
|
|
401
|
+
instruments: OptionInstrumentRegistry | Sequence[OptionInstrumentSpec] | Mapping[str, OptionInstrumentSpec],
|
|
402
|
+
) -> OptionInstrumentRegistry:
|
|
403
|
+
if isinstance(instruments, OptionInstrumentRegistry):
|
|
404
|
+
return instruments
|
|
405
|
+
if isinstance(instruments, Mapping):
|
|
406
|
+
return OptionInstrumentRegistry.from_iterable(instruments.values())
|
|
407
|
+
return OptionInstrumentRegistry.from_iterable(tuple(instruments))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _is_crypto_option(spec: OptionInstrumentSpec) -> bool:
|
|
411
|
+
venue = spec.venue.lower()
|
|
412
|
+
return venue in {"deribit", "binance", "bybit", "okx", "test"} or spec.quote_currency in {"USDT", "USDC", "USD"}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _raw_symbol(symbol: str, venue: str) -> str:
|
|
416
|
+
suffix = f".{venue}"
|
|
417
|
+
value = str(symbol)
|
|
418
|
+
if value.upper().endswith(suffix):
|
|
419
|
+
return value[: -len(suffix)]
|
|
420
|
+
return value.split(".", 1)[0]
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _venue(spec: OptionInstrumentSpec) -> str:
|
|
424
|
+
return str(spec.venue or spec.symbol.split(".")[-1]).upper()
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _underlying_currency(spec: OptionInstrumentSpec) -> str:
|
|
428
|
+
raw = str(spec.underlying_id).split("-", 1)[0].split("/", 1)[0].split(".", 1)[0]
|
|
429
|
+
return raw.upper()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _currency(currencies, code: str):
|
|
433
|
+
key = str(code).upper()
|
|
434
|
+
if hasattr(currencies, key):
|
|
435
|
+
return getattr(currencies, key)
|
|
436
|
+
raise ValueError(f"Nautilus currency {key!r} is not available in this environment")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _precision(step: float, *, default: int) -> int:
|
|
440
|
+
try:
|
|
441
|
+
value = float(step)
|
|
442
|
+
except (TypeError, ValueError):
|
|
443
|
+
return default
|
|
444
|
+
if value <= 0.0:
|
|
445
|
+
return default
|
|
446
|
+
text = f"{value:.16f}".rstrip("0").rstrip(".")
|
|
447
|
+
return len(text.split(".", 1)[1]) if "." in text else 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _version_gte(version: str, minimum: str) -> bool:
|
|
451
|
+
def parts(value: str) -> tuple[int, ...]:
|
|
452
|
+
out = []
|
|
453
|
+
for item in str(value).split("."):
|
|
454
|
+
digits = "".join(ch for ch in item if ch.isdigit())
|
|
455
|
+
out.append(int(digits or 0))
|
|
456
|
+
return tuple(out)
|
|
457
|
+
|
|
458
|
+
return parts(version) >= parts(minimum)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _num(value) -> Optional[float]:
|
|
462
|
+
try:
|
|
463
|
+
return float(value)
|
|
464
|
+
except (TypeError, ValueError):
|
|
465
|
+
return None
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convert NautilusTrader reports into quantbt result contracts.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from ...core.results import BacktestResultV2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def result_from_nautilus_reports(
|
|
15
|
+
account_report: pd.DataFrame,
|
|
16
|
+
symbols: List[str],
|
|
17
|
+
initial_capital: float,
|
|
18
|
+
leverage: float = 1.0,
|
|
19
|
+
orders_report: Optional[pd.DataFrame] = None,
|
|
20
|
+
fills_report: Optional[pd.DataFrame] = None,
|
|
21
|
+
positions_report: Optional[pd.DataFrame] = None,
|
|
22
|
+
closes: Optional[Dict[str, pd.Series]] = None,
|
|
23
|
+
metadata: Optional[Dict] = None,
|
|
24
|
+
) -> BacktestResultV2:
|
|
25
|
+
if account_report is None or account_report.empty:
|
|
26
|
+
raise ValueError("account_report is required")
|
|
27
|
+
|
|
28
|
+
account = account_report.copy()
|
|
29
|
+
account.index = pd.to_datetime(account.index, utc=True)
|
|
30
|
+
total_col = _pick_total_column(account)
|
|
31
|
+
account_equity = _coerce_money_series(account[total_col], initial_capital)
|
|
32
|
+
account_equity.name = "account_equity"
|
|
33
|
+
|
|
34
|
+
if closes is not None:
|
|
35
|
+
close_df = _close_frame(closes=closes, symbols=symbols)
|
|
36
|
+
idx = close_df.index
|
|
37
|
+
positions = _positions_from_fills(fills_report if fills_report is not None else orders_report, symbols, idx)
|
|
38
|
+
equity = _reconstruct_equity_from_fills(
|
|
39
|
+
fills_report=fills_report if fills_report is not None else orders_report,
|
|
40
|
+
closes=close_df,
|
|
41
|
+
symbols=symbols,
|
|
42
|
+
initial_capital=initial_capital,
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
equity = account_equity.copy()
|
|
46
|
+
equity.name = "equity"
|
|
47
|
+
idx = equity.index
|
|
48
|
+
positions = _positions_from_fills(fills_report if fills_report is not None else orders_report, symbols, idx)
|
|
49
|
+
close_df = pd.DataFrame(index=idx)
|
|
50
|
+
for sym in symbols:
|
|
51
|
+
close_df[f"Close_{sym}"] = 0.0
|
|
52
|
+
|
|
53
|
+
returns = equity.pct_change().fillna(0.0)
|
|
54
|
+
|
|
55
|
+
account_final = float(account_equity.iloc[-1])
|
|
56
|
+
reconstructed_final = float(equity.iloc[-1])
|
|
57
|
+
|
|
58
|
+
return BacktestResultV2(
|
|
59
|
+
equity=equity,
|
|
60
|
+
returns=returns,
|
|
61
|
+
positions=positions,
|
|
62
|
+
closes=close_df,
|
|
63
|
+
symbols=symbols,
|
|
64
|
+
initial_capital=initial_capital,
|
|
65
|
+
leverage=leverage,
|
|
66
|
+
metadata={
|
|
67
|
+
"backend": "nautilus",
|
|
68
|
+
"account_report": account_report,
|
|
69
|
+
"account_equity": account_equity,
|
|
70
|
+
"equity_source": "fills_reconstructed" if closes is not None else "account_report",
|
|
71
|
+
"account_final_equity": account_final,
|
|
72
|
+
"reconstructed_final_equity": reconstructed_final,
|
|
73
|
+
"account_reconstructed_diff": reconstructed_final - account_final,
|
|
74
|
+
"orders_report": orders_report,
|
|
75
|
+
"fills_report": fills_report,
|
|
76
|
+
"positions_report": positions_report,
|
|
77
|
+
"orders_count": 0 if orders_report is None else int(len(orders_report)),
|
|
78
|
+
"fills_count": 0 if fills_report is None else int(len(fills_report)),
|
|
79
|
+
"positions_count": 0 if positions_report is None else int(len(positions_report)),
|
|
80
|
+
**(metadata or {}),
|
|
81
|
+
},
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _close_frame(closes: Dict[str, pd.Series], symbols: List[str]) -> pd.DataFrame:
|
|
86
|
+
if not symbols:
|
|
87
|
+
raise ValueError("symbols are required")
|
|
88
|
+
idx = pd.DatetimeIndex(pd.to_datetime(closes[symbols[0]].index, utc=True))
|
|
89
|
+
frame = pd.DataFrame(index=idx)
|
|
90
|
+
for sym in symbols:
|
|
91
|
+
close = closes[sym].copy()
|
|
92
|
+
close.index = pd.DatetimeIndex(pd.to_datetime(close.index, utc=True))
|
|
93
|
+
frame[f"Close_{sym}"] = pd.to_numeric(close.reindex(idx, method="ffill"), errors="coerce").ffill()
|
|
94
|
+
return frame
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _reconstruct_equity_from_fills(
|
|
98
|
+
fills_report: Optional[pd.DataFrame],
|
|
99
|
+
closes: pd.DataFrame,
|
|
100
|
+
symbols: List[str],
|
|
101
|
+
initial_capital: float,
|
|
102
|
+
) -> pd.Series:
|
|
103
|
+
equity = pd.Series(initial_capital, index=closes.index, dtype=float, name="equity")
|
|
104
|
+
if len(closes) == 0:
|
|
105
|
+
return equity
|
|
106
|
+
|
|
107
|
+
pos = {sym: 0.0 for sym in symbols}
|
|
108
|
+
fills_by_ts = _fills_by_timestamp(fills_report)
|
|
109
|
+
value = float(initial_capital)
|
|
110
|
+
|
|
111
|
+
for i, ts in enumerate(closes.index):
|
|
112
|
+
if i > 0:
|
|
113
|
+
prev = closes.index[i - 1]
|
|
114
|
+
for sym in symbols:
|
|
115
|
+
qty = pos[sym]
|
|
116
|
+
if qty != 0.0:
|
|
117
|
+
value += qty * (
|
|
118
|
+
float(closes.loc[ts, f"Close_{sym}"]) - float(closes.loc[prev, f"Close_{sym}"])
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if ts in fills_by_ts:
|
|
122
|
+
for _, fill in fills_by_ts[ts].iterrows():
|
|
123
|
+
sym = str(fill.get("instrument_id", ""))
|
|
124
|
+
if sym not in pos:
|
|
125
|
+
continue
|
|
126
|
+
signed_qty = _signed_fill_qty(fill)
|
|
127
|
+
fill_price = _coerce_float(fill.get("avg_px", fill.get("price", 0.0)))
|
|
128
|
+
close_price = float(closes.loc[ts, f"Close_{sym}"])
|
|
129
|
+
value += signed_qty * (close_price - fill_price)
|
|
130
|
+
value -= _coerce_commission(fill.get("commissions", 0.0))
|
|
131
|
+
pos[sym] += signed_qty
|
|
132
|
+
|
|
133
|
+
equity.iloc[i] = value
|
|
134
|
+
|
|
135
|
+
return equity
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _fills_by_timestamp(report: Optional[pd.DataFrame]) -> Dict[pd.Timestamp, pd.DataFrame]:
|
|
139
|
+
if report is None or report.empty:
|
|
140
|
+
return {}
|
|
141
|
+
fills = report.copy()
|
|
142
|
+
ts_col = "ts_last" if "ts_last" in fills.columns else "ts_init"
|
|
143
|
+
if ts_col not in fills.columns:
|
|
144
|
+
return {}
|
|
145
|
+
fills["_timestamp"] = _coerce_timestamp(fills[ts_col])
|
|
146
|
+
fills = fills.dropna(subset=["_timestamp"]).sort_values("_timestamp")
|
|
147
|
+
return {ts: group.drop(columns=["_timestamp"]) for ts, group in fills.groupby("_timestamp", sort=True)}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pick_total_column(account_report: pd.DataFrame) -> str:
|
|
151
|
+
for col in ("total", "total_balance", "balance_total"):
|
|
152
|
+
if col in account_report.columns:
|
|
153
|
+
return col
|
|
154
|
+
numeric_cols = list(account_report.select_dtypes(include="number").columns)
|
|
155
|
+
if numeric_cols:
|
|
156
|
+
return numeric_cols[0]
|
|
157
|
+
raise ValueError("could not find numeric account total column")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _coerce_money_series(values: pd.Series, initial_capital: float) -> pd.Series:
|
|
161
|
+
equity = pd.to_numeric(values, errors="coerce")
|
|
162
|
+
if equity.isna().any():
|
|
163
|
+
extracted = values.astype(str).str.extract(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", expand=False)
|
|
164
|
+
equity = equity.fillna(pd.to_numeric(extracted, errors="coerce"))
|
|
165
|
+
equity = equity.ffill().fillna(initial_capital)
|
|
166
|
+
equity.name = "equity"
|
|
167
|
+
return equity
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _positions_from_fills(report: Optional[pd.DataFrame], symbols: List[str], idx: pd.DatetimeIndex) -> pd.DataFrame:
|
|
171
|
+
positions = pd.DataFrame(index=idx)
|
|
172
|
+
for sym in symbols:
|
|
173
|
+
positions[f"Position_{sym}"] = 0.0
|
|
174
|
+
|
|
175
|
+
if report is None or report.empty:
|
|
176
|
+
return positions
|
|
177
|
+
required = {"instrument_id", "side", "filled_qty"}
|
|
178
|
+
if not required <= set(report.columns):
|
|
179
|
+
return positions
|
|
180
|
+
|
|
181
|
+
fills = report.copy()
|
|
182
|
+
ts_col = "ts_last" if "ts_last" in fills.columns else "ts_init"
|
|
183
|
+
if ts_col not in fills.columns:
|
|
184
|
+
return positions
|
|
185
|
+
fills["_timestamp"] = _coerce_timestamp(fills[ts_col])
|
|
186
|
+
fills = fills.dropna(subset=["_timestamp"]).sort_values("_timestamp")
|
|
187
|
+
|
|
188
|
+
for sym in symbols:
|
|
189
|
+
sub = fills[fills["instrument_id"].astype(str) == sym]
|
|
190
|
+
if sub.empty:
|
|
191
|
+
continue
|
|
192
|
+
signed = []
|
|
193
|
+
for _, row in sub.iterrows():
|
|
194
|
+
qty = _coerce_float(row.get("filled_qty", 0.0))
|
|
195
|
+
side = str(row.get("side", "")).upper()
|
|
196
|
+
sign = 1.0 if side == "BUY" else -1.0 if side == "SELL" else 0.0
|
|
197
|
+
signed.append(sign * qty)
|
|
198
|
+
step = pd.Series(signed, index=pd.DatetimeIndex(sub["_timestamp"]), dtype=float).groupby(level=0).sum().cumsum()
|
|
199
|
+
positions[f"Position_{sym}"] = step.reindex(idx, method="ffill").fillna(0.0)
|
|
200
|
+
return positions
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _signed_fill_qty(row) -> float:
|
|
204
|
+
qty = _coerce_float(row.get("filled_qty", 0.0))
|
|
205
|
+
side = str(row.get("side", "")).upper()
|
|
206
|
+
sign = 1.0 if side == "BUY" else -1.0 if side == "SELL" else 0.0
|
|
207
|
+
return sign * qty
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _coerce_timestamp(values: pd.Series) -> pd.Series:
|
|
211
|
+
if pd.api.types.is_datetime64_any_dtype(values):
|
|
212
|
+
return pd.to_datetime(values, utc=True)
|
|
213
|
+
numeric = pd.to_numeric(values, errors="coerce")
|
|
214
|
+
if numeric.notna().any():
|
|
215
|
+
return pd.to_datetime(numeric, utc=True, unit="ns", errors="coerce")
|
|
216
|
+
return pd.to_datetime(values, utc=True, errors="coerce")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _coerce_float(value) -> float:
|
|
220
|
+
try:
|
|
221
|
+
return float(value)
|
|
222
|
+
except (TypeError, ValueError):
|
|
223
|
+
extracted = pd.Series([str(value)]).str.extract(r"([-+]?\d*\.?\d+(?:[eE][-+]?\d+)?)", expand=False)
|
|
224
|
+
parsed = pd.to_numeric(extracted, errors="coerce").iloc[0]
|
|
225
|
+
return 0.0 if pd.isna(parsed) else float(parsed)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _coerce_commission(value) -> float:
|
|
229
|
+
if isinstance(value, (list, tuple)):
|
|
230
|
+
return sum(_coerce_commission(v) for v in value)
|
|
231
|
+
return abs(_coerce_float(value))
|