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/endpoint.py
ADDED
|
@@ -0,0 +1,4465 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified public endpoint for notebooks and services.
|
|
3
|
+
|
|
4
|
+
`QuantBTEndpoint` is the stable integration surface above legacy and V2
|
|
5
|
+
backtest engines. It stores *how* to run a backtest at construction time, while
|
|
6
|
+
`backtest()` / `simulate()` receive the actual data, signals, orders, or basket
|
|
7
|
+
objects.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import asdict, dataclass, field, is_dataclass, replace
|
|
13
|
+
from enum import Enum
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING, Dict, Optional, Sequence, Union
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .backtester import BacktestEngine
|
|
24
|
+
from .backends import (
|
|
25
|
+
NativeEventBackend,
|
|
26
|
+
NativeEventConfig,
|
|
27
|
+
NativeEventScoreRequirements,
|
|
28
|
+
NativeOptionConfig,
|
|
29
|
+
NativePortfolioBackend,
|
|
30
|
+
NativePortfolioConfig,
|
|
31
|
+
NativeVectorizedBackend,
|
|
32
|
+
NativeVectorizedConfig,
|
|
33
|
+
)
|
|
34
|
+
from .core.arbitrage import (
|
|
35
|
+
BasisArbitrageSpec,
|
|
36
|
+
CalendarSpreadSpec,
|
|
37
|
+
CrossExchangeArbSpec,
|
|
38
|
+
FundingArbitrageSpec,
|
|
39
|
+
IndexBasketArbSpec,
|
|
40
|
+
OptionsVolArbSpec,
|
|
41
|
+
SpotPerpCashCarrySpec,
|
|
42
|
+
StatArbPairSpec,
|
|
43
|
+
TriangularArbSpec,
|
|
44
|
+
build_arbitrage_order_plan,
|
|
45
|
+
)
|
|
46
|
+
from .core.basket import build_frozen_basket_orders
|
|
47
|
+
from .core.execution_depth import (
|
|
48
|
+
NautilusExecutionDepthConfig,
|
|
49
|
+
simulate_nautilus_order_package_depth,
|
|
50
|
+
)
|
|
51
|
+
from .core.execution_contract import ExecutionContract
|
|
52
|
+
from .core.constraints import build_quantity_constraints
|
|
53
|
+
from .core.intrabar_reference import (
|
|
54
|
+
IntrabarIntentTape,
|
|
55
|
+
IntrabarLevelMode,
|
|
56
|
+
IntrabarSizingMode,
|
|
57
|
+
run_intrabar_reference,
|
|
58
|
+
)
|
|
59
|
+
from .core.intrabar_session import IntrabarSessionTape, SessionExecutionPolicy
|
|
60
|
+
from .core.intrabar_kernel import FillReplayTape, run_fill_replay_kernel, run_intrabar_kernel, run_intrabar_session_kernel
|
|
61
|
+
from .core.market_tape import PreparedMarketTape, prepare_market_tape
|
|
62
|
+
from .core.orders import OrderCommand, OrderIntent, order_intents_to_lifecycle_commands
|
|
63
|
+
from .core.results import (
|
|
64
|
+
BacktestResultV2,
|
|
65
|
+
NativeEventScalarScoreResult,
|
|
66
|
+
NativeEventScoreResult,
|
|
67
|
+
)
|
|
68
|
+
from .core.schema import AccountConfig, BasketLegSpec, BasketSpec, ExecutionConfig, InstrumentSpec, OrderSide, OrderType, TimeInForce
|
|
69
|
+
from .core.structured_orders import (
|
|
70
|
+
BracketOrderSpec,
|
|
71
|
+
DcaGridSpec,
|
|
72
|
+
build_bracket_order_plan,
|
|
73
|
+
build_dca_grid_order_plan,
|
|
74
|
+
)
|
|
75
|
+
from .core.types import BacktestResult
|
|
76
|
+
from .engines import BacktestEngineV2, OptionBacktestEngine, PortfolioBacktestEngine
|
|
77
|
+
from .sizing.modes import compute_target_units
|
|
78
|
+
from .options.execution import OptionExecutionConfig
|
|
79
|
+
from .options.fees import OptionFeeSchedule
|
|
80
|
+
from .options.hedging import OptionHedgeConfig
|
|
81
|
+
from .options.margin import OptionMarginConfig
|
|
82
|
+
from .options.cache import OptionPreparedRunCache
|
|
83
|
+
from .options.packages import OptionPackageIntent
|
|
84
|
+
from .options.schema import OptionInstrumentRegistry, OptionInstrumentSpec
|
|
85
|
+
from .options.strategy import OptionStrategyRun
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from .walkforward import WalkForwardConfig
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
SeriesMap = Dict[str, pd.Series]
|
|
92
|
+
FrameMap = Dict[str, pd.DataFrame]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class NativeEventProfile(str, Enum):
|
|
96
|
+
"""Stable high-level retention/execution profile for event-driven runs."""
|
|
97
|
+
|
|
98
|
+
RESEARCH = "research"
|
|
99
|
+
OPTIMIZE = "optimize"
|
|
100
|
+
AUDIT = "audit"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
_NATIVE_EVENT_PROFILE_OPTIONS = {
|
|
104
|
+
NativeEventProfile.RESEARCH: {
|
|
105
|
+
"reactive_execution_mode": "fast",
|
|
106
|
+
"reactive_kernel_mode": "single_pass",
|
|
107
|
+
"report_level": "minimal",
|
|
108
|
+
"audit_sink": "none",
|
|
109
|
+
},
|
|
110
|
+
NativeEventProfile.OPTIMIZE: {
|
|
111
|
+
"reactive_execution_mode": "fast",
|
|
112
|
+
"reactive_kernel_mode": "single_pass",
|
|
113
|
+
"report_level": "score",
|
|
114
|
+
"audit_sink": "none",
|
|
115
|
+
},
|
|
116
|
+
NativeEventProfile.AUDIT: {
|
|
117
|
+
"reactive_execution_mode": "audit",
|
|
118
|
+
"reactive_kernel_mode": "replay_certified",
|
|
119
|
+
"report_level": "audit",
|
|
120
|
+
"audit_sink": "memory",
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
_NATIVE_EVENT_PUBLIC_BACKENDS = frozenset({"auto", "python", "rust"})
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _normalize_native_event_profile(profile: Union[str, NativeEventProfile]) -> NativeEventProfile:
|
|
127
|
+
value = profile.value if isinstance(profile, NativeEventProfile) else str(profile).lower().strip()
|
|
128
|
+
try:
|
|
129
|
+
return NativeEventProfile(value)
|
|
130
|
+
except ValueError as exc:
|
|
131
|
+
valid = ", ".join(item.value for item in NativeEventProfile)
|
|
132
|
+
raise ValueError(f"profile must be one of: {valid}; received {profile!r}") from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _resolve_event_driven_kwargs(
|
|
136
|
+
*,
|
|
137
|
+
input_mode: str,
|
|
138
|
+
profile: Union[str, NativeEventProfile],
|
|
139
|
+
backend: str,
|
|
140
|
+
kwargs: Dict,
|
|
141
|
+
) -> Dict:
|
|
142
|
+
"""Resolve the small public facade into one legacy endpoint config.
|
|
143
|
+
|
|
144
|
+
This function only resolves configuration. Matching, accounting, and
|
|
145
|
+
result construction remain owned by the existing endpoint constructors.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
mode = str(input_mode).lower().strip()
|
|
149
|
+
if mode not in {"strategy", "orders"}:
|
|
150
|
+
raise ValueError("input_mode must be 'strategy' or 'orders'")
|
|
151
|
+
|
|
152
|
+
profile_value = _normalize_native_event_profile(profile)
|
|
153
|
+
public_backend = str(backend).lower().strip()
|
|
154
|
+
if public_backend not in _NATIVE_EVENT_PUBLIC_BACKENDS:
|
|
155
|
+
raise ValueError("backend must be one of: auto, python, rust")
|
|
156
|
+
|
|
157
|
+
resolved = dict(kwargs)
|
|
158
|
+
profile_options = _NATIVE_EVENT_PROFILE_OPTIONS[profile_value]
|
|
159
|
+
for key, value in profile_options.items():
|
|
160
|
+
if key in resolved:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
f"profile='{profile_value.value}' controls {key}; "
|
|
163
|
+
f"use the advanced native_event_{'strategy' if mode == 'strategy' else 'lifecycle'} "
|
|
164
|
+
"constructor for custom low-level combinations"
|
|
165
|
+
)
|
|
166
|
+
resolved[key] = value
|
|
167
|
+
|
|
168
|
+
advanced_backend = resolved.get("native_backend")
|
|
169
|
+
if advanced_backend is not None and public_backend != "auto":
|
|
170
|
+
raise ValueError("pass either backend=... or advanced native_backend=..., not both")
|
|
171
|
+
if advanced_backend is None:
|
|
172
|
+
resolved["native_backend"] = public_backend
|
|
173
|
+
|
|
174
|
+
metadata = dict(resolved.pop("metadata", {}) or {})
|
|
175
|
+
metadata.setdefault(
|
|
176
|
+
"event_driven_facade",
|
|
177
|
+
{
|
|
178
|
+
"input_mode": mode,
|
|
179
|
+
"profile": profile_value.value,
|
|
180
|
+
"backend": public_backend,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
resolved["metadata"] = metadata
|
|
184
|
+
return resolved
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True)
|
|
188
|
+
class EndpointConfig:
|
|
189
|
+
"""
|
|
190
|
+
Configuration for `QuantBTEndpoint`.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
mode:
|
|
195
|
+
Strategy integration mode. Supported values are `single_signal`,
|
|
196
|
+
`pct_equity`, `signal_notional`, `dca_ladder`, `orders`, `basket`,
|
|
197
|
+
`portfolio`, `arbitrage`, `options`, `walk_forward`, and
|
|
198
|
+
`nautilus_validation`.
|
|
199
|
+
backend:
|
|
200
|
+
Engine selector. Use `auto` for domain-safe defaults, or explicitly set
|
|
201
|
+
`legacy`, `native_vectorized`, `native_event`, or `nautilus`.
|
|
202
|
+
native_backend:
|
|
203
|
+
Native-event implementation selector: `python`, `rust`, `auto`, or
|
|
204
|
+
`replay_certified`. It is only consulted by native-event backends;
|
|
205
|
+
omitted means preserve the environment/default selection policy.
|
|
206
|
+
sizing:
|
|
207
|
+
Position sizing contract for signal modes. Examples: `%_equity`,
|
|
208
|
+
`signal_notional`, `notional`, `unit`, `dca_ladder`.
|
|
209
|
+
account:
|
|
210
|
+
Account/margin config used by V2 engines. Legacy runs also read
|
|
211
|
+
`initial_capital`, `leverage`, and `maintenance_ratio` from this object.
|
|
212
|
+
execution:
|
|
213
|
+
Execution/slippage config used by V2 engines. Legacy runs use the
|
|
214
|
+
endpoint `slippage` fraction.
|
|
215
|
+
fee:
|
|
216
|
+
Legacy compatibility round-trip fee. It is converted to canonical
|
|
217
|
+
one-way `fee_rate` at the endpoint boundary when explicit `fee_rate`
|
|
218
|
+
is omitted.
|
|
219
|
+
fee_rate:
|
|
220
|
+
Canonical one-way fee. If supplied, it has priority over `fee`.
|
|
221
|
+
alloc_per_trade:
|
|
222
|
+
Notional allocation for notional sizing modes, or equity fraction for
|
|
223
|
+
`%_equity`.
|
|
224
|
+
use_pyramiding:
|
|
225
|
+
If false, raw signals are snapped to {-1, 0, 1}.
|
|
226
|
+
use_funding:
|
|
227
|
+
Whether funding should be applied where the selected engine supports it.
|
|
228
|
+
funding_rate:
|
|
229
|
+
Scalar, series, or per-symbol mapping of funding rates.
|
|
230
|
+
contract_size:
|
|
231
|
+
Contract multiplier, scalar or per-symbol mapping.
|
|
232
|
+
slippage:
|
|
233
|
+
Legacy execution slippage fraction. Example: `0.0001` is 1 bp.
|
|
234
|
+
portfolio_mode:
|
|
235
|
+
Multi-symbol allocation mode for portfolio endpoint.
|
|
236
|
+
asset_type:
|
|
237
|
+
Asset type used by portfolio endpoint defaults.
|
|
238
|
+
basket:
|
|
239
|
+
Optional basket spec stored at construction time for basket simulations.
|
|
240
|
+
arbitrage_spec:
|
|
241
|
+
Optional arbitrage spec stored at construction time for the future
|
|
242
|
+
ArbitrageBacktestEngine.
|
|
243
|
+
structured_order_spec:
|
|
244
|
+
Optional DCA/grid or bracket/OCO package spec compiled into explicit
|
|
245
|
+
orders for Nautilus structured-order validation.
|
|
246
|
+
symbols:
|
|
247
|
+
Optional symbol list. Single-symbol endpoints use the first symbol.
|
|
248
|
+
dca_kwargs:
|
|
249
|
+
Extra DCA ladder parameters forwarded to legacy `BacktestEngine`.
|
|
250
|
+
nautilus_config:
|
|
251
|
+
Optional `NautilusBackendConfig` instance for Nautilus validation runs.
|
|
252
|
+
nautilus_depth_config:
|
|
253
|
+
Optional `NautilusExecutionDepthConfig` for package-order preflight.
|
|
254
|
+
Existing endpoints are unchanged when this is omitted.
|
|
255
|
+
report_level:
|
|
256
|
+
Native portfolio artifact policy. `full` preserves all audit reports;
|
|
257
|
+
`standard` keeps core audit tables; `minimal` keeps accounting outputs
|
|
258
|
+
for optimizer/service loops. Existing calls default to `full`.
|
|
259
|
+
option_config:
|
|
260
|
+
Optional `NativeOptionConfig` for native option simulations.
|
|
261
|
+
strategy_class:
|
|
262
|
+
Optional strategy callable/class for `walk_forward` mode. The strategy
|
|
263
|
+
must return a Series, DataFrame, or `{symbol: Series}` OOS output.
|
|
264
|
+
walkforward_config:
|
|
265
|
+
Optional `WalkForwardConfig` for split/stitch behavior.
|
|
266
|
+
metadata:
|
|
267
|
+
Free-form service metadata carried by the endpoint.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
mode: str = "single_signal"
|
|
271
|
+
backend: str = "auto"
|
|
272
|
+
native_backend: Optional[str] = None
|
|
273
|
+
sizing: str = "signal_notional"
|
|
274
|
+
account: AccountConfig = field(default_factory=lambda: AccountConfig(initial_capital=100_000.0))
|
|
275
|
+
execution: ExecutionConfig = field(default_factory=ExecutionConfig)
|
|
276
|
+
fee: float = 0.0004
|
|
277
|
+
fee_rate: Optional[float] = None
|
|
278
|
+
alloc_per_trade: Union[float, Dict[str, float]] = 100_000.0
|
|
279
|
+
use_pyramiding: bool = True
|
|
280
|
+
use_funding: bool = True
|
|
281
|
+
funding_rate: Union[float, pd.Series, Dict] = 0.0
|
|
282
|
+
contract_size: Union[float, Dict[str, float]] = 1.0
|
|
283
|
+
instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None
|
|
284
|
+
qty_step: Optional[Union[float, Dict[str, float]]] = None
|
|
285
|
+
lot_size: Optional[Union[float, Dict[str, float]]] = None
|
|
286
|
+
slot_size: Optional[Union[float, Dict[str, float]]] = None
|
|
287
|
+
min_qty: Optional[Union[float, Dict[str, float]]] = None
|
|
288
|
+
min_notional: Optional[Union[float, Dict[str, float]]] = None
|
|
289
|
+
slippage: float = 0.0001
|
|
290
|
+
portfolio_mode: str = "longshort"
|
|
291
|
+
betas: Union[float, Dict[str, float], None] = None
|
|
292
|
+
risk_lookback: int = 60
|
|
293
|
+
asset_type: str = "crypto"
|
|
294
|
+
basket: Optional[BasketSpec] = None
|
|
295
|
+
arbitrage_spec: object = None
|
|
296
|
+
structured_order_spec: object = None
|
|
297
|
+
event_engine_version: str = "v1"
|
|
298
|
+
reactive_execution_mode: str = "fast"
|
|
299
|
+
reactive_kernel_mode: str = "replay_certified"
|
|
300
|
+
symbols: Optional[Sequence[str]] = None
|
|
301
|
+
dca_kwargs: Dict = field(default_factory=dict)
|
|
302
|
+
nautilus_config: object = None
|
|
303
|
+
nautilus_depth_config: Optional[NautilusExecutionDepthConfig] = None
|
|
304
|
+
option_config: object = None
|
|
305
|
+
report_level: str = "full"
|
|
306
|
+
audit_sink: str = "memory"
|
|
307
|
+
audit_sink_path: Optional[str] = None
|
|
308
|
+
strategy_class: object = None
|
|
309
|
+
walkforward_config: Optional[WalkForwardConfig] = None
|
|
310
|
+
walkforward_target_mode: str = "signal_notional"
|
|
311
|
+
metadata: Dict = field(default_factory=dict)
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def v2_fee_rate(self) -> float:
|
|
315
|
+
return self.fee / 2.0 if self.fee_rate is None else float(self.fee_rate)
|
|
316
|
+
|
|
317
|
+
@property
|
|
318
|
+
def canonical_one_way_fee_rate(self) -> float:
|
|
319
|
+
return self.v2_fee_rate
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@dataclass(frozen=True)
|
|
323
|
+
class PreparedIntrabarRunner:
|
|
324
|
+
"""Prepared single-symbol intrabar runner for repeated WFO/Optuna runs."""
|
|
325
|
+
|
|
326
|
+
endpoint: "QuantBTEndpoint"
|
|
327
|
+
tape: PreparedMarketTape
|
|
328
|
+
symbol: str
|
|
329
|
+
contract: ExecutionContract
|
|
330
|
+
profile_metadata: Dict
|
|
331
|
+
session_policy: Optional[SessionExecutionPolicy] = None
|
|
332
|
+
session_tape: Optional[IntrabarSessionTape] = None
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def market(self) -> PreparedMarketTape:
|
|
336
|
+
return self.tape
|
|
337
|
+
|
|
338
|
+
def run(self, intent: IntrabarIntentTape, *, report_level: Optional[str] = None) -> BacktestResultV2:
|
|
339
|
+
config = self.endpoint.config
|
|
340
|
+
level = report_level or config.report_level
|
|
341
|
+
kwargs = {
|
|
342
|
+
"tape": self.tape,
|
|
343
|
+
"intent": intent,
|
|
344
|
+
"account": config.account,
|
|
345
|
+
"contract": self.contract,
|
|
346
|
+
"fee_rate": config.v2_fee_rate,
|
|
347
|
+
"slippage_rate": float(config.execution.slippage_rate),
|
|
348
|
+
"contract_size": _scalar_for_symbol(config.contract_size, self.symbol),
|
|
349
|
+
**self.endpoint._intrabar_execution_kwargs(self.symbol),
|
|
350
|
+
"report_level": level,
|
|
351
|
+
}
|
|
352
|
+
if self.session_policy is not None:
|
|
353
|
+
kernel = run_intrabar_session_kernel(
|
|
354
|
+
**kwargs,
|
|
355
|
+
session_policy=self.session_policy,
|
|
356
|
+
session_tape=self.session_tape,
|
|
357
|
+
)
|
|
358
|
+
else:
|
|
359
|
+
kernel = run_intrabar_kernel(**kwargs)
|
|
360
|
+
idx = kernel.equity.index
|
|
361
|
+
returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
362
|
+
diagnostics = pd.DataFrame(
|
|
363
|
+
{
|
|
364
|
+
"average_entry": kernel.average_entry,
|
|
365
|
+
"active_stop": kernel.active_stop,
|
|
366
|
+
"active_take_profit": kernel.active_take_profit,
|
|
367
|
+
"event_flags": kernel.event_flags,
|
|
368
|
+
"initial_margin": kernel.initial_margin,
|
|
369
|
+
"maintenance_margin": kernel.maintenance_margin,
|
|
370
|
+
"fees": kernel.fees,
|
|
371
|
+
"funding": kernel.funding,
|
|
372
|
+
},
|
|
373
|
+
index=idx,
|
|
374
|
+
)
|
|
375
|
+
metadata = {
|
|
376
|
+
**kernel.metadata,
|
|
377
|
+
"input_mode": "intrabar_intent",
|
|
378
|
+
"symbol": self.symbol,
|
|
379
|
+
"phase": "31F_prepared_intrabar_runner",
|
|
380
|
+
"prepared_runner": True,
|
|
381
|
+
"profile_metadata": dict(self.profile_metadata),
|
|
382
|
+
"fills_report": kernel.fills_report,
|
|
383
|
+
"positions_report": pd.DataFrame({f"Position_{self.symbol}": kernel.position}, index=idx),
|
|
384
|
+
}
|
|
385
|
+
result = BacktestResultV2(
|
|
386
|
+
equity=kernel.equity,
|
|
387
|
+
returns=returns,
|
|
388
|
+
positions=pd.DataFrame({f"Position_{self.symbol}": kernel.position.to_numpy(dtype=float)}, index=idx),
|
|
389
|
+
closes=pd.DataFrame({f"Close_{self.symbol}": self.tape.closes[:, 0]}, index=idx),
|
|
390
|
+
symbols=[self.symbol],
|
|
391
|
+
initial_capital=float(config.account.initial_capital),
|
|
392
|
+
leverage=float(config.account.leverage),
|
|
393
|
+
liquidated=bool(kernel.liquidated),
|
|
394
|
+
liquidation_bar=int(kernel.liquidation_bar),
|
|
395
|
+
fills=kernel.fills,
|
|
396
|
+
fees=kernel.fees,
|
|
397
|
+
funding=kernel.funding,
|
|
398
|
+
margin=diagnostics[["initial_margin", "maintenance_margin"]],
|
|
399
|
+
diagnostics=diagnostics,
|
|
400
|
+
metadata=metadata,
|
|
401
|
+
)
|
|
402
|
+
self.endpoint.engine = kernel
|
|
403
|
+
self.endpoint._store_result(result)
|
|
404
|
+
return self.endpoint.result
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@dataclass(frozen=True)
|
|
408
|
+
class PreparedNativeEventStrategyRunner:
|
|
409
|
+
"""Prepared native-event reactive runner for repeated strategy scoring."""
|
|
410
|
+
|
|
411
|
+
endpoint: "QuantBTEndpoint"
|
|
412
|
+
idx: pd.DatetimeIndex
|
|
413
|
+
symbols: list
|
|
414
|
+
close_map: SeriesMap
|
|
415
|
+
high_map: SeriesMap
|
|
416
|
+
low_map: SeriesMap
|
|
417
|
+
opens_arr: np.ndarray
|
|
418
|
+
volumes_arr: np.ndarray
|
|
419
|
+
market_arrays: object
|
|
420
|
+
backend: NativeEventBackend
|
|
421
|
+
profile_metadata: Dict
|
|
422
|
+
runs: int = 0
|
|
423
|
+
scores: int = 0
|
|
424
|
+
|
|
425
|
+
def run(self, strategy, *, report_level: Optional[str] = None) -> BacktestResultV2:
|
|
426
|
+
"""Run the prepared strategy and return the public BacktestResultV2."""
|
|
427
|
+
if strategy is None:
|
|
428
|
+
raise ValueError("prepared native-event runner requires strategy=...")
|
|
429
|
+
config = self.endpoint.config
|
|
430
|
+
level = report_level or config.report_level
|
|
431
|
+
result = self.backend.run_strategy(
|
|
432
|
+
datetime_index=self.idx,
|
|
433
|
+
strategy=strategy,
|
|
434
|
+
closes=self.close_map,
|
|
435
|
+
highs=self.high_map,
|
|
436
|
+
lows=self.low_map,
|
|
437
|
+
opens=None,
|
|
438
|
+
volumes=None,
|
|
439
|
+
funding_rate=config.funding_rate,
|
|
440
|
+
contract_size=config.contract_size,
|
|
441
|
+
leverage=config.account.leverage,
|
|
442
|
+
fee_rate=config.v2_fee_rate,
|
|
443
|
+
symbols=self.symbols,
|
|
444
|
+
instruments=config.instruments,
|
|
445
|
+
qty_step=config.qty_step,
|
|
446
|
+
lot_size=config.lot_size,
|
|
447
|
+
slot_size=config.slot_size,
|
|
448
|
+
min_qty=config.min_qty,
|
|
449
|
+
min_notional=config.min_notional,
|
|
450
|
+
execution_mode=config.reactive_execution_mode,
|
|
451
|
+
reactive_kernel_mode=config.reactive_kernel_mode,
|
|
452
|
+
report_level=level,
|
|
453
|
+
audit_sink=config.audit_sink,
|
|
454
|
+
audit_sink_path=config.audit_sink_path,
|
|
455
|
+
market_arrays=self.market_arrays,
|
|
456
|
+
opens_arr=self.opens_arr,
|
|
457
|
+
volumes_arr=self.volumes_arr,
|
|
458
|
+
)
|
|
459
|
+
result.metadata.setdefault("prepared_native_event_strategy", self.metadata)
|
|
460
|
+
object.__setattr__(self, "runs", self.runs + 1)
|
|
461
|
+
self.endpoint._store_result(result)
|
|
462
|
+
return self.endpoint.result
|
|
463
|
+
|
|
464
|
+
simulate = run
|
|
465
|
+
|
|
466
|
+
def score(
|
|
467
|
+
self,
|
|
468
|
+
strategy,
|
|
469
|
+
*,
|
|
470
|
+
trading_days: int = 365,
|
|
471
|
+
score_requirements: Optional[NativeEventScoreRequirements] = None,
|
|
472
|
+
) -> Union[NativeEventScoreResult, NativeEventScalarScoreResult]:
|
|
473
|
+
"""
|
|
474
|
+
Run the prepared strategy through the direct score path.
|
|
475
|
+
|
|
476
|
+
The default compatibility contract stores ndarray accounting arrays and
|
|
477
|
+
scalar metrics. Passing ``NativeEventScoreRequirements.scalar_score_contract()``
|
|
478
|
+
returns the low-retention scalar result instead. Neither form updates
|
|
479
|
+
``endpoint.result``.
|
|
480
|
+
"""
|
|
481
|
+
if strategy is None:
|
|
482
|
+
raise ValueError("prepared native-event score requires strategy=...")
|
|
483
|
+
config = self.endpoint.config
|
|
484
|
+
score = self.backend.run_strategy_score(
|
|
485
|
+
datetime_index=self.idx,
|
|
486
|
+
strategy=strategy,
|
|
487
|
+
closes=self.close_map,
|
|
488
|
+
highs=self.high_map,
|
|
489
|
+
lows=self.low_map,
|
|
490
|
+
opens=None,
|
|
491
|
+
volumes=None,
|
|
492
|
+
funding_rate=config.funding_rate,
|
|
493
|
+
contract_size=config.contract_size,
|
|
494
|
+
leverage=config.account.leverage,
|
|
495
|
+
fee_rate=config.v2_fee_rate,
|
|
496
|
+
symbols=self.symbols,
|
|
497
|
+
instruments=config.instruments,
|
|
498
|
+
qty_step=config.qty_step,
|
|
499
|
+
lot_size=config.lot_size,
|
|
500
|
+
slot_size=config.slot_size,
|
|
501
|
+
min_qty=config.min_qty,
|
|
502
|
+
min_notional=config.min_notional,
|
|
503
|
+
execution_mode=config.reactive_execution_mode,
|
|
504
|
+
market_arrays=self.market_arrays,
|
|
505
|
+
opens_arr=self.opens_arr,
|
|
506
|
+
volumes_arr=self.volumes_arr,
|
|
507
|
+
trading_days=trading_days,
|
|
508
|
+
score_requirements=score_requirements,
|
|
509
|
+
)
|
|
510
|
+
object.__setattr__(self, "scores", self.scores + 1)
|
|
511
|
+
return replace(
|
|
512
|
+
score,
|
|
513
|
+
metadata={
|
|
514
|
+
**dict(score.metadata),
|
|
515
|
+
"prepared_native_event_strategy": self.metadata,
|
|
516
|
+
},
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
@property
|
|
520
|
+
def metadata(self) -> Dict[str, object]:
|
|
521
|
+
return {
|
|
522
|
+
**self.profile_metadata,
|
|
523
|
+
"runs": int(self.runs),
|
|
524
|
+
"scores": int(self.scores),
|
|
525
|
+
"market_signature": self.market_arrays.signature,
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
class QuantBTEndpoint:
|
|
530
|
+
"""
|
|
531
|
+
Stable notebook/service facade for all QuantBT backtest modes.
|
|
532
|
+
|
|
533
|
+
Create the endpoint with a factory constructor such as
|
|
534
|
+
`QuantBTEndpoint.pct_equity(...)`, then pass market data and signals to
|
|
535
|
+
`backtest()`. The instance stores the latest `result` and exposes report and
|
|
536
|
+
visualization helpers.
|
|
537
|
+
"""
|
|
538
|
+
|
|
539
|
+
def __init__(self, config: Optional[EndpointConfig] = None, **kwargs):
|
|
540
|
+
"""
|
|
541
|
+
Build an endpoint from an `EndpointConfig` or keyword arguments.
|
|
542
|
+
|
|
543
|
+
Examples
|
|
544
|
+
--------
|
|
545
|
+
>>> endpoint = QuantBTEndpoint(mode="single_signal", sizing="signal_notional")
|
|
546
|
+
>>> result = endpoint.backtest(data=df, signal_col="position")
|
|
547
|
+
"""
|
|
548
|
+
self.config = config or _config_from_kwargs(**kwargs)
|
|
549
|
+
self.result: Optional[Union[BacktestResult, BacktestResultV2]] = None
|
|
550
|
+
self.engine = None
|
|
551
|
+
|
|
552
|
+
def prepare_service_context(
|
|
553
|
+
self,
|
|
554
|
+
*,
|
|
555
|
+
data=None,
|
|
556
|
+
closes=None,
|
|
557
|
+
highs=None,
|
|
558
|
+
lows=None,
|
|
559
|
+
datetime_index=None,
|
|
560
|
+
symbols=None,
|
|
561
|
+
) -> "QuantBTPreparedContext":
|
|
562
|
+
"""
|
|
563
|
+
Normalize market data once for repeated service/WFO-style replays.
|
|
564
|
+
|
|
565
|
+
This is an opt-in performance helper. It does not change normal
|
|
566
|
+
`backtest(...)` behavior and only supports routes whose prepared-array
|
|
567
|
+
parity is locked by tests: single-symbol `signal_notional` with
|
|
568
|
+
`native_vectorized`, and `portfolio` with `native_portfolio`.
|
|
569
|
+
"""
|
|
570
|
+
return QuantBTPreparedContext.from_endpoint(
|
|
571
|
+
self,
|
|
572
|
+
data=data,
|
|
573
|
+
closes=closes,
|
|
574
|
+
highs=highs,
|
|
575
|
+
lows=lows,
|
|
576
|
+
datetime_index=datetime_index,
|
|
577
|
+
symbols=symbols,
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
def prepare_intrabar(
|
|
581
|
+
self,
|
|
582
|
+
*,
|
|
583
|
+
data,
|
|
584
|
+
datetime_index=None,
|
|
585
|
+
symbols: Optional[Sequence[str]] = None,
|
|
586
|
+
session_tape: Optional[IntrabarSessionTape] = None,
|
|
587
|
+
funding_event_timestamps=None,
|
|
588
|
+
funding_event_rates=None,
|
|
589
|
+
) -> PreparedIntrabarRunner:
|
|
590
|
+
"""
|
|
591
|
+
Prepare strict intrabar market tape once and reuse it for many intents.
|
|
592
|
+
|
|
593
|
+
This is an opt-in service/WFO helper. Normal `.backtest(...)` remains
|
|
594
|
+
backward-compatible, while optimizer loops can avoid rebuilding OHLCV,
|
|
595
|
+
funding, validation certificate, data signature, and quantity profiles
|
|
596
|
+
on every trial.
|
|
597
|
+
"""
|
|
598
|
+
symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
|
|
599
|
+
if len(symbol_list) != 1:
|
|
600
|
+
raise ValueError("prepare_intrabar currently supports exactly one symbol")
|
|
601
|
+
tape = prepare_market_tape(
|
|
602
|
+
data=data,
|
|
603
|
+
datetime_index=datetime_index,
|
|
604
|
+
symbols=symbol_list,
|
|
605
|
+
funding_rate=self.config.funding_rate,
|
|
606
|
+
funding_event_timestamps=funding_event_timestamps,
|
|
607
|
+
funding_event_rates=funding_event_rates,
|
|
608
|
+
use_funding=self.config.use_funding,
|
|
609
|
+
validation_mode="strict",
|
|
610
|
+
missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")),
|
|
611
|
+
source_timezone=self.config.metadata.get("source_timezone"),
|
|
612
|
+
bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
|
|
613
|
+
)
|
|
614
|
+
contract = _execution_contract_from_config(self.config)
|
|
615
|
+
session_policy = _session_policy_from_config(self.config)
|
|
616
|
+
if session_policy is not None and session_tape is None:
|
|
617
|
+
raise ValueError("session_tape is required when session_policy is configured")
|
|
618
|
+
if session_tape is not None and len(session_tape.session_id) != tape.n_bars:
|
|
619
|
+
raise ValueError("session_tape length must match prepared market tape length")
|
|
620
|
+
symbol = symbol_list[0]
|
|
621
|
+
profile = {
|
|
622
|
+
"mode": self.config.mode,
|
|
623
|
+
"backend": self.config.backend,
|
|
624
|
+
"account": asdict(self.config.account),
|
|
625
|
+
"execution": asdict(self.config.execution),
|
|
626
|
+
"fee_rate": self.config.v2_fee_rate,
|
|
627
|
+
"contract_size": _scalar_for_symbol(self.config.contract_size, symbol),
|
|
628
|
+
"intrabar": self._intrabar_execution_kwargs(symbol),
|
|
629
|
+
"data_signature": tape.signature,
|
|
630
|
+
"session_policy": None if session_policy is None else session_policy.to_metadata(),
|
|
631
|
+
"session_tape_signature": None if session_tape is None else session_tape.signature,
|
|
632
|
+
}
|
|
633
|
+
profile["prepared_signature"] = _prepared_profile_signature(tape.signature, profile)
|
|
634
|
+
return PreparedIntrabarRunner(
|
|
635
|
+
endpoint=self,
|
|
636
|
+
tape=tape,
|
|
637
|
+
symbol=symbol,
|
|
638
|
+
contract=contract,
|
|
639
|
+
profile_metadata=profile,
|
|
640
|
+
session_policy=session_policy,
|
|
641
|
+
session_tape=session_tape,
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
def prepare_native_event_strategy(
|
|
645
|
+
self,
|
|
646
|
+
*,
|
|
647
|
+
data=None,
|
|
648
|
+
closes=None,
|
|
649
|
+
highs=None,
|
|
650
|
+
lows=None,
|
|
651
|
+
datetime_index=None,
|
|
652
|
+
symbols: Optional[Sequence[str]] = None,
|
|
653
|
+
) -> PreparedNativeEventStrategyRunner:
|
|
654
|
+
"""
|
|
655
|
+
Prepare native-event reactive market state once for repeated scoring.
|
|
656
|
+
|
|
657
|
+
Normal `native_event_strategy(...).simulate(...)` remains unchanged.
|
|
658
|
+
This helper is for WFO/Optuna/service loops where the same market tape
|
|
659
|
+
is replayed many times with different strategy parameters.
|
|
660
|
+
"""
|
|
661
|
+
config = self.config
|
|
662
|
+
if str(config.backend).lower().strip() not in {"native_event", "auto"}:
|
|
663
|
+
raise ValueError("prepare_native_event_strategy requires backend='native_event' or auto")
|
|
664
|
+
symbol_list = list(symbols or config.symbols or (closes.keys() if closes is not None else []))
|
|
665
|
+
if data is not None and not isinstance(data, dict) and not symbol_list:
|
|
666
|
+
symbol_list = ["asset"]
|
|
667
|
+
if not symbol_list:
|
|
668
|
+
raise ValueError("prepare_native_event_strategy requires symbols")
|
|
669
|
+
if data is not None and not isinstance(data, dict):
|
|
670
|
+
if len(symbol_list) != 1:
|
|
671
|
+
raise ValueError("single DataFrame native-event preparation requires exactly one symbol")
|
|
672
|
+
frame = _standardize_frame(data, datetime_index=datetime_index)
|
|
673
|
+
symbol = symbol_list[0]
|
|
674
|
+
idx = frame.index
|
|
675
|
+
close_map = {symbol: frame["close"]}
|
|
676
|
+
high_map = {symbol: frame.get("high", frame["close"])}
|
|
677
|
+
low_map = {symbol: frame.get("low", frame["close"])}
|
|
678
|
+
opens_arr = np.ascontiguousarray(frame[["open"]].to_numpy(dtype=np.float64))
|
|
679
|
+
volumes_arr = np.ascontiguousarray(frame[["volume"]].to_numpy(dtype=np.float64))
|
|
680
|
+
else:
|
|
681
|
+
close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
|
|
682
|
+
data=data,
|
|
683
|
+
closes=closes,
|
|
684
|
+
highs=highs,
|
|
685
|
+
lows=lows,
|
|
686
|
+
datetime_index=datetime_index,
|
|
687
|
+
symbols=symbol_list,
|
|
688
|
+
)
|
|
689
|
+
opens_arr, volumes_arr = _prepared_native_event_open_volume_arrays(data, idx, symbol_list, close_map)
|
|
690
|
+
backend = NativeEventBackend(
|
|
691
|
+
NativeEventConfig(
|
|
692
|
+
account=config.account,
|
|
693
|
+
execution=config.execution,
|
|
694
|
+
fee_rate=config.v2_fee_rate,
|
|
695
|
+
use_funding=bool(config.use_funding),
|
|
696
|
+
report_level=config.report_level,
|
|
697
|
+
audit_sink=config.audit_sink,
|
|
698
|
+
audit_sink_path=config.audit_sink_path,
|
|
699
|
+
reactive_kernel_mode=config.reactive_kernel_mode,
|
|
700
|
+
native_backend=config.native_backend,
|
|
701
|
+
)
|
|
702
|
+
)
|
|
703
|
+
market = backend.prepare_market_arrays(
|
|
704
|
+
datetime_index=idx,
|
|
705
|
+
closes=close_map,
|
|
706
|
+
highs=high_map,
|
|
707
|
+
lows=low_map,
|
|
708
|
+
funding_rate=config.funding_rate,
|
|
709
|
+
symbols=symbol_list,
|
|
710
|
+
)
|
|
711
|
+
profile = {
|
|
712
|
+
"mode": config.mode,
|
|
713
|
+
"backend": "native_event",
|
|
714
|
+
"event_engine_version": "v2",
|
|
715
|
+
"reactive_execution_mode": config.reactive_execution_mode,
|
|
716
|
+
"reactive_kernel_mode": config.reactive_kernel_mode,
|
|
717
|
+
"account": asdict(config.account),
|
|
718
|
+
"execution": asdict(config.execution),
|
|
719
|
+
"fee_rate": config.v2_fee_rate,
|
|
720
|
+
"report_level": config.report_level,
|
|
721
|
+
"symbols": tuple(symbol_list),
|
|
722
|
+
"bars": int(len(idx)),
|
|
723
|
+
"data_signature": market.signature,
|
|
724
|
+
}
|
|
725
|
+
return PreparedNativeEventStrategyRunner(
|
|
726
|
+
endpoint=self,
|
|
727
|
+
idx=idx,
|
|
728
|
+
symbols=list(symbol_list),
|
|
729
|
+
close_map=close_map,
|
|
730
|
+
high_map=high_map,
|
|
731
|
+
low_map=low_map,
|
|
732
|
+
opens_arr=opens_arr,
|
|
733
|
+
volumes_arr=volumes_arr,
|
|
734
|
+
market_arrays=market,
|
|
735
|
+
backend=backend,
|
|
736
|
+
profile_metadata=profile,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
@classmethod
|
|
740
|
+
def pct_equity(cls, **kwargs) -> "QuantBTEndpoint":
|
|
741
|
+
"""
|
|
742
|
+
Create a legacy `%_equity` endpoint.
|
|
743
|
+
|
|
744
|
+
Use this for strategies whose signal is a direction/weight and whose
|
|
745
|
+
order notional should be recomputed from live equity on signal changes.
|
|
746
|
+
`alloc_per_trade` is interpreted as an equity fraction when <= 1.0
|
|
747
|
+
(`0.5` means 50% of current equity), or as a percent when > 1.0.
|
|
748
|
+
|
|
749
|
+
Data requirement for `backtest()`:
|
|
750
|
+
a single OHLCV DataFrame with a DatetimeIndex and `close`; `high` and
|
|
751
|
+
`low` are strongly recommended for liquidation checks.
|
|
752
|
+
"""
|
|
753
|
+
return cls(_config_from_kwargs(mode="pct_equity", sizing="%_equity", backend="legacy", **kwargs))
|
|
754
|
+
|
|
755
|
+
@classmethod
|
|
756
|
+
def signal_notional(cls, backend: str = "native_vectorized", **kwargs) -> "QuantBTEndpoint":
|
|
757
|
+
"""
|
|
758
|
+
Create a signal-notional endpoint.
|
|
759
|
+
|
|
760
|
+
Signal changes anchor target units at the current price. Between signal
|
|
761
|
+
changes, units are frozen, avoiding price-drift micro-rebalancing. This
|
|
762
|
+
is the recommended default for systematic single-symbol alpha research.
|
|
763
|
+
|
|
764
|
+
`backend` can be `native_vectorized` for speed or `native_event` when
|
|
765
|
+
you want generated market rebalance orders and fill records.
|
|
766
|
+
"""
|
|
767
|
+
return cls(_config_from_kwargs(mode="signal_notional", sizing="signal_notional", backend=backend, **kwargs))
|
|
768
|
+
|
|
769
|
+
@classmethod
|
|
770
|
+
def intrabar_bracket_reference(
|
|
771
|
+
cls,
|
|
772
|
+
*,
|
|
773
|
+
level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE,
|
|
774
|
+
intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS,
|
|
775
|
+
close_on_last_bar: bool = True,
|
|
776
|
+
execution_contract: Optional[ExecutionContract] = None,
|
|
777
|
+
session_policy: Optional[SessionExecutionPolicy] = None,
|
|
778
|
+
**kwargs,
|
|
779
|
+
) -> "QuantBTEndpoint":
|
|
780
|
+
"""
|
|
781
|
+
Create the Phase 31B readable intrabar reference endpoint.
|
|
782
|
+
|
|
783
|
+
This endpoint is the causal Python oracle for `intrabar_bracket_v1`.
|
|
784
|
+
Strategy output can stay compact: pass a signed `signal`/`signal_col`
|
|
785
|
+
where positive means long entry size, negative means short entry size,
|
|
786
|
+
and zero means no new entry. Optional stop, take-profit, trailing, and
|
|
787
|
+
technical-exit arrays are supplied through `intent_cols` at run time.
|
|
788
|
+
|
|
789
|
+
It is intentionally not the future Numba production kernel. Use it to
|
|
790
|
+
verify SL/TP/trailing/reversal semantics and audit fill timing before
|
|
791
|
+
promoting an alpha to the fast intrabar backend.
|
|
792
|
+
"""
|
|
793
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
794
|
+
mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode)
|
|
795
|
+
metadata.setdefault("intrabar_level_mode", mode_value)
|
|
796
|
+
metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value)
|
|
797
|
+
metadata.setdefault("execution_contract_id", "intrabar_bracket_v1")
|
|
798
|
+
contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar)
|
|
799
|
+
metadata.setdefault("execution_contract", contract.to_metadata())
|
|
800
|
+
if session_policy is not None:
|
|
801
|
+
metadata["session_policy"] = session_policy.to_metadata()
|
|
802
|
+
return cls(
|
|
803
|
+
_config_from_kwargs(
|
|
804
|
+
mode="intrabar_bracket_reference",
|
|
805
|
+
backend="intrabar_reference",
|
|
806
|
+
sizing="intrabar_intent",
|
|
807
|
+
metadata=metadata,
|
|
808
|
+
**kwargs,
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
@classmethod
|
|
813
|
+
def intrabar_bracket(
|
|
814
|
+
cls,
|
|
815
|
+
*,
|
|
816
|
+
level_mode: Union[str, IntrabarLevelMode] = IntrabarLevelMode.PERCENT_DISTANCE,
|
|
817
|
+
intrabar_sizing_mode: Union[str, IntrabarSizingMode] = IntrabarSizingMode.UNITS,
|
|
818
|
+
close_on_last_bar: bool = True,
|
|
819
|
+
execution_contract: Optional[ExecutionContract] = None,
|
|
820
|
+
session_policy: Optional[SessionExecutionPolicy] = None,
|
|
821
|
+
report_level: str = "standard",
|
|
822
|
+
**kwargs,
|
|
823
|
+
) -> "QuantBTEndpoint":
|
|
824
|
+
"""
|
|
825
|
+
Create the Phase 31C fast Numba intrabar bracket endpoint.
|
|
826
|
+
|
|
827
|
+
Use the same compact input contract as
|
|
828
|
+
`intrabar_bracket_reference(...)`. `report_level="minimal"` is meant
|
|
829
|
+
for optimizers, `standard` returns diagnostics, and `audit` runs a
|
|
830
|
+
deterministic second pass to materialize exact sparse fills.
|
|
831
|
+
"""
|
|
832
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
833
|
+
mode_value = level_mode.value if hasattr(level_mode, "value") else str(level_mode)
|
|
834
|
+
metadata.setdefault("intrabar_level_mode", mode_value)
|
|
835
|
+
metadata.setdefault("intrabar_sizing_mode", IntrabarSizingMode(intrabar_sizing_mode).value)
|
|
836
|
+
metadata.setdefault("execution_contract_id", "intrabar_bracket_v1")
|
|
837
|
+
contract = execution_contract or ExecutionContract.intrabar_bracket(close_on_last_bar=close_on_last_bar)
|
|
838
|
+
metadata.setdefault("execution_contract", contract.to_metadata())
|
|
839
|
+
if session_policy is not None:
|
|
840
|
+
metadata["session_policy"] = session_policy.to_metadata()
|
|
841
|
+
return cls(
|
|
842
|
+
_config_from_kwargs(
|
|
843
|
+
mode="intrabar_bracket",
|
|
844
|
+
backend="native_intrabar",
|
|
845
|
+
sizing="intrabar_intent",
|
|
846
|
+
report_level=report_level,
|
|
847
|
+
metadata=metadata,
|
|
848
|
+
**kwargs,
|
|
849
|
+
)
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
@classmethod
|
|
853
|
+
def fill_replay(cls, *, report_level: str = "audit", **kwargs) -> "QuantBTEndpoint":
|
|
854
|
+
"""
|
|
855
|
+
Create a fast accounting replay endpoint for explicit fills.
|
|
856
|
+
|
|
857
|
+
Use `backtest(data=df, fill_replay=FillReplayTape_or_DataFrame)`. This
|
|
858
|
+
certifies accounting from supplied fills but does not certify how those
|
|
859
|
+
fills were generated.
|
|
860
|
+
"""
|
|
861
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
862
|
+
metadata.setdefault("execution_contract_id", "fill_replay_v1")
|
|
863
|
+
metadata.setdefault("execution_contract", ExecutionContract.fill_replay().to_metadata())
|
|
864
|
+
return cls(
|
|
865
|
+
_config_from_kwargs(
|
|
866
|
+
mode="fill_replay",
|
|
867
|
+
backend="native_intrabar",
|
|
868
|
+
sizing="explicit_fills",
|
|
869
|
+
report_level=report_level,
|
|
870
|
+
metadata=metadata,
|
|
871
|
+
**kwargs,
|
|
872
|
+
)
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
@classmethod
|
|
876
|
+
def dca_ladder(cls, **kwargs) -> "QuantBTEndpoint":
|
|
877
|
+
"""
|
|
878
|
+
Create a structural DCA/grid ladder endpoint.
|
|
879
|
+
|
|
880
|
+
`signal` is a structural level series, not a target weight:
|
|
881
|
+
0 is flat, +1 is base long, +2 allows the first safety order, and so on.
|
|
882
|
+
Negative levels model short ladders. `high` and `low` are required
|
|
883
|
+
because safety orders are simulated as limit fills at grid trigger
|
|
884
|
+
prices.
|
|
885
|
+
"""
|
|
886
|
+
return cls(_config_from_kwargs(mode="dca_ladder", sizing="dca_ladder", backend="legacy", **kwargs))
|
|
887
|
+
|
|
888
|
+
@classmethod
|
|
889
|
+
def orders(cls, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
|
|
890
|
+
"""
|
|
891
|
+
Create an explicit order simulation endpoint.
|
|
892
|
+
|
|
893
|
+
Use `simulate(data=df, orders=[OrderIntent(...), ...])`. Orders are run
|
|
894
|
+
through the selected event backend with market/limit fill lifecycle, TIF
|
|
895
|
+
handling, fees, margin checks, and fills in `result.fills`.
|
|
896
|
+
"""
|
|
897
|
+
return cls(_config_from_kwargs(mode="orders", backend=backend, **kwargs))
|
|
898
|
+
|
|
899
|
+
@classmethod
|
|
900
|
+
def event_driven(
|
|
901
|
+
cls,
|
|
902
|
+
*,
|
|
903
|
+
input_mode: str = "strategy",
|
|
904
|
+
profile: Union[str, NativeEventProfile] = NativeEventProfile.RESEARCH,
|
|
905
|
+
backend: str = "auto",
|
|
906
|
+
**kwargs,
|
|
907
|
+
) -> "QuantBTEndpoint":
|
|
908
|
+
"""Create the stable public native-event facade.
|
|
909
|
+
|
|
910
|
+
Parameters
|
|
911
|
+
----------
|
|
912
|
+
input_mode:
|
|
913
|
+
``"strategy"`` for a stateful strategy implementing the reactive
|
|
914
|
+
callback protocol, or ``"orders"`` for an explicit
|
|
915
|
+
``OrderCommand``/``OrderIntent`` tape.
|
|
916
|
+
profile:
|
|
917
|
+
``"research"`` keeps a compact public result, ``"optimize"``
|
|
918
|
+
selects the scalar score retention contract, and ``"audit"``
|
|
919
|
+
retains replay-certified accounting and event artifacts.
|
|
920
|
+
backend:
|
|
921
|
+
``"auto"`` follows the release policy (currently Python),
|
|
922
|
+
``"python"`` selects the canonical backend, or ``"rust"``
|
|
923
|
+
explicitly requests the capability-gated native wheel.
|
|
924
|
+
|
|
925
|
+
The facade resolves profiles and delegates to
|
|
926
|
+
:meth:`native_event_strategy` or :meth:`native_event_lifecycle`.
|
|
927
|
+
It does not implement a second matcher or accounting engine. Advanced
|
|
928
|
+
callers may continue using those constructors directly when they need
|
|
929
|
+
custom ``reactive_execution_mode``, ``reactive_kernel_mode``,
|
|
930
|
+
``report_level``, or ``audit_sink`` combinations.
|
|
931
|
+
|
|
932
|
+
Examples
|
|
933
|
+
--------
|
|
934
|
+
>>> endpoint = QuantBTEndpoint.event_driven(
|
|
935
|
+
... profile="research", backend="auto", initial_capital=20_000,
|
|
936
|
+
... )
|
|
937
|
+
>>> result = endpoint.simulate(data=data, strategy=strategy)
|
|
938
|
+
|
|
939
|
+
>>> endpoint = QuantBTEndpoint.event_driven(
|
|
940
|
+
... input_mode="orders", profile="audit", backend="python",
|
|
941
|
+
... initial_capital=20_000,
|
|
942
|
+
... )
|
|
943
|
+
>>> result = endpoint.simulate(data=data, order_commands=commands)
|
|
944
|
+
"""
|
|
945
|
+
|
|
946
|
+
resolved = _resolve_event_driven_kwargs(
|
|
947
|
+
input_mode=input_mode,
|
|
948
|
+
profile=profile,
|
|
949
|
+
backend=backend,
|
|
950
|
+
kwargs=kwargs,
|
|
951
|
+
)
|
|
952
|
+
if str(input_mode).lower().strip() == "strategy":
|
|
953
|
+
return cls.native_event_strategy(**resolved)
|
|
954
|
+
return cls.native_event_lifecycle(**resolved)
|
|
955
|
+
|
|
956
|
+
@classmethod
|
|
957
|
+
def native_event_lifecycle(cls, **kwargs) -> "QuantBTEndpoint":
|
|
958
|
+
"""
|
|
959
|
+
Create an explicit native-event v2 lifecycle endpoint.
|
|
960
|
+
|
|
961
|
+
Use `simulate(..., order_commands=[OrderCommand(...), ...])` for
|
|
962
|
+
cancel/replace/amend/OCO/parent/stop/GTD lifecycle simulations. Passing
|
|
963
|
+
legacy `orders=[OrderIntent(...)]` is also accepted and converted to
|
|
964
|
+
immediate PLACE commands.
|
|
965
|
+
"""
|
|
966
|
+
return cls(
|
|
967
|
+
_config_from_kwargs(
|
|
968
|
+
mode="orders",
|
|
969
|
+
backend="native_event",
|
|
970
|
+
event_engine_version="v2",
|
|
971
|
+
**kwargs,
|
|
972
|
+
)
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
@classmethod
|
|
976
|
+
def native_event_strategy(cls, **kwargs) -> "QuantBTEndpoint":
|
|
977
|
+
"""
|
|
978
|
+
Create a reactive native-event v2 strategy endpoint.
|
|
979
|
+
|
|
980
|
+
Use `simulate(data=df, strategy=obj)` where `obj` optionally implements
|
|
981
|
+
`initialize(context)`, `on_bar_close(context)`, and `finalize(context)`.
|
|
982
|
+
Commands emitted by callbacks become effective from the next bar.
|
|
983
|
+
"""
|
|
984
|
+
return cls(
|
|
985
|
+
_config_from_kwargs(
|
|
986
|
+
mode="native_event_strategy",
|
|
987
|
+
backend="native_event",
|
|
988
|
+
event_engine_version="v2",
|
|
989
|
+
**kwargs,
|
|
990
|
+
)
|
|
991
|
+
)
|
|
992
|
+
|
|
993
|
+
@classmethod
|
|
994
|
+
def options(
|
|
995
|
+
cls,
|
|
996
|
+
backend: str = "native_option",
|
|
997
|
+
*,
|
|
998
|
+
option_config: Optional[NativeOptionConfig] = None,
|
|
999
|
+
option_execution: Optional[OptionExecutionConfig] = None,
|
|
1000
|
+
option_margin: Optional[OptionMarginConfig] = None,
|
|
1001
|
+
fee_schedule: Optional[OptionFeeSchedule] = None,
|
|
1002
|
+
reporting_currency: str = "USD",
|
|
1003
|
+
initial_balances: Optional[Dict[str, float]] = None,
|
|
1004
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
1005
|
+
settle_expired: bool = False,
|
|
1006
|
+
max_spread_bps: Optional[float] = None,
|
|
1007
|
+
max_source_latency_ns: Optional[int] = None,
|
|
1008
|
+
**kwargs,
|
|
1009
|
+
) -> "QuantBTEndpoint":
|
|
1010
|
+
"""
|
|
1011
|
+
Create a native option simulation endpoint.
|
|
1012
|
+
|
|
1013
|
+
Strategy/template code supplies canonical option-chain rows,
|
|
1014
|
+
`OptionInstrumentSpec` definitions, and `OptionPackageIntent` packages
|
|
1015
|
+
to `backtest(...)` or `simulate(...)`. The endpoint routes packages
|
|
1016
|
+
through snapshot-level option execution, applies fills to the
|
|
1017
|
+
multi-currency option ledger, calculates margin, and returns an
|
|
1018
|
+
`OptionBacktestResult` with fills/packages/cash/marks/Greeks/settlement
|
|
1019
|
+
artifacts.
|
|
1020
|
+
|
|
1021
|
+
Required `backtest()` inputs:
|
|
1022
|
+
`chain`, `instruments`, and optional `packages`.
|
|
1023
|
+
"""
|
|
1024
|
+
if backend.lower().strip() != "native_option":
|
|
1025
|
+
raise ValueError("options endpoint currently supports backend='native_option' only")
|
|
1026
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
1027
|
+
metadata.setdefault("mode_family", "options")
|
|
1028
|
+
endpoint_config = _config_from_kwargs(mode="options", backend=backend, metadata=metadata, **kwargs)
|
|
1029
|
+
if option_config is None:
|
|
1030
|
+
option_config = NativeOptionConfig(
|
|
1031
|
+
account=endpoint_config.account,
|
|
1032
|
+
execution=endpoint_config.execution,
|
|
1033
|
+
option_execution=option_execution
|
|
1034
|
+
or OptionExecutionConfig(fee_rate=endpoint_config.v2_fee_rate, metadata={"source": "QuantBTEndpoint.options"}),
|
|
1035
|
+
margin=option_margin or OptionMarginConfig(),
|
|
1036
|
+
fee_schedule=fee_schedule,
|
|
1037
|
+
reporting_currency=reporting_currency,
|
|
1038
|
+
initial_balances=initial_balances,
|
|
1039
|
+
conversion_rates=dict(conversion_rates or {}),
|
|
1040
|
+
settle_expired=settle_expired,
|
|
1041
|
+
max_spread_bps=max_spread_bps,
|
|
1042
|
+
max_source_latency_ns=max_source_latency_ns,
|
|
1043
|
+
metadata=metadata,
|
|
1044
|
+
)
|
|
1045
|
+
endpoint_config = replace(endpoint_config, option_config=option_config)
|
|
1046
|
+
return cls(endpoint_config)
|
|
1047
|
+
|
|
1048
|
+
@classmethod
|
|
1049
|
+
def nautilus_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint":
|
|
1050
|
+
"""
|
|
1051
|
+
Create a Nautilus DCA/grid structured-order validation endpoint.
|
|
1052
|
+
|
|
1053
|
+
The endpoint compiles a `DcaGridSpec` into explicit orders:
|
|
1054
|
+
base market entry, safety limit orders, and optional reduce-only
|
|
1055
|
+
TP/SL exits. The resulting orders are replayed by Nautilus through the
|
|
1056
|
+
same package-order adapter as `orders(backend="nautilus")`.
|
|
1057
|
+
"""
|
|
1058
|
+
if spec is None:
|
|
1059
|
+
spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec))
|
|
1060
|
+
return cls(
|
|
1061
|
+
_config_from_kwargs(
|
|
1062
|
+
mode="nautilus_dca_grid",
|
|
1063
|
+
backend="nautilus",
|
|
1064
|
+
structured_order_spec=spec,
|
|
1065
|
+
symbols=[spec.symbol],
|
|
1066
|
+
**kwargs,
|
|
1067
|
+
)
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
@classmethod
|
|
1071
|
+
def nautilus_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint":
|
|
1072
|
+
"""
|
|
1073
|
+
Create a Nautilus bracket/OCO structured-order validation endpoint.
|
|
1074
|
+
|
|
1075
|
+
The endpoint compiles a `BracketOrderSpec` into entry plus linked
|
|
1076
|
+
reduce-only take-profit and/or stop-loss exits. OCO group metadata is
|
|
1077
|
+
preserved and Nautilus cancels sibling exit orders on first exit fill.
|
|
1078
|
+
"""
|
|
1079
|
+
if spec is None:
|
|
1080
|
+
spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec))
|
|
1081
|
+
return cls(
|
|
1082
|
+
_config_from_kwargs(
|
|
1083
|
+
mode="nautilus_bracket_orders",
|
|
1084
|
+
backend="nautilus",
|
|
1085
|
+
structured_order_spec=spec,
|
|
1086
|
+
symbols=[spec.symbol],
|
|
1087
|
+
**kwargs,
|
|
1088
|
+
)
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
@classmethod
|
|
1092
|
+
def native_event_dca_grid(cls, spec: Optional[DcaGridSpec] = None, **kwargs) -> "QuantBTEndpoint":
|
|
1093
|
+
"""
|
|
1094
|
+
Create a native-event v2 DCA/grid lifecycle endpoint.
|
|
1095
|
+
|
|
1096
|
+
The structured package is compiled into `OrderCommand` records so base,
|
|
1097
|
+
safety orders, reduce-only exits, and OCO metadata are audited in
|
|
1098
|
+
`command_report` and `order_events`.
|
|
1099
|
+
"""
|
|
1100
|
+
if spec is None:
|
|
1101
|
+
spec = DcaGridSpec(**_pop_dataclass_kwargs(kwargs, DcaGridSpec))
|
|
1102
|
+
return cls(
|
|
1103
|
+
_config_from_kwargs(
|
|
1104
|
+
mode="native_event_dca_grid",
|
|
1105
|
+
backend="native_event",
|
|
1106
|
+
event_engine_version="v2",
|
|
1107
|
+
structured_order_spec=spec,
|
|
1108
|
+
symbols=[spec.symbol],
|
|
1109
|
+
**kwargs,
|
|
1110
|
+
)
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
@classmethod
|
|
1114
|
+
def native_event_bracket_orders(cls, spec: Optional[BracketOrderSpec] = None, **kwargs) -> "QuantBTEndpoint":
|
|
1115
|
+
"""
|
|
1116
|
+
Create a native-event v2 bracket/OCO lifecycle endpoint.
|
|
1117
|
+
|
|
1118
|
+
Entry, take-profit, and stop-loss legs are linked through parent/OCO
|
|
1119
|
+
command fields and simulated by the deterministic OHLC lifecycle kernel.
|
|
1120
|
+
"""
|
|
1121
|
+
if spec is None:
|
|
1122
|
+
spec = BracketOrderSpec(**_pop_dataclass_kwargs(kwargs, BracketOrderSpec))
|
|
1123
|
+
return cls(
|
|
1124
|
+
_config_from_kwargs(
|
|
1125
|
+
mode="native_event_bracket_orders",
|
|
1126
|
+
backend="native_event",
|
|
1127
|
+
event_engine_version="v2",
|
|
1128
|
+
structured_order_spec=spec,
|
|
1129
|
+
symbols=[spec.symbol],
|
|
1130
|
+
**kwargs,
|
|
1131
|
+
)
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
@classmethod
|
|
1135
|
+
def basket(cls, basket: Optional[BasketSpec] = None, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
|
|
1136
|
+
"""
|
|
1137
|
+
Create a basket/pair endpoint.
|
|
1138
|
+
|
|
1139
|
+
Use for pair trades and frozen hedge-ratio baskets. Provide a
|
|
1140
|
+
`BasketSpec` either here or to `simulate(..., basket=...)`, then pass a
|
|
1141
|
+
scalar entry/exit signal and per-symbol price data.
|
|
1142
|
+
"""
|
|
1143
|
+
return cls(_config_from_kwargs(mode="basket", backend=backend, basket=basket, **kwargs))
|
|
1144
|
+
|
|
1145
|
+
@classmethod
|
|
1146
|
+
def arbitrage(cls, arb_type: str, spec, backend: str = "native_event", **kwargs) -> "QuantBTEndpoint":
|
|
1147
|
+
"""
|
|
1148
|
+
Create an arbitrage endpoint.
|
|
1149
|
+
|
|
1150
|
+
Supported today:
|
|
1151
|
+
|
|
1152
|
+
- `BasisArbitrageSpec`: native event, native vectorized, Nautilus
|
|
1153
|
+
package-order validation.
|
|
1154
|
+
- `StatArbPairSpec`: native event, native vectorized, Nautilus
|
|
1155
|
+
package-order validation.
|
|
1156
|
+
- `CalendarSpreadSpec`, `FundingArbitrageSpec`,
|
|
1157
|
+
`SpotPerpCashCarrySpec`, and `IndexBasketArbSpec`: native event and
|
|
1158
|
+
native vectorized package-style execution.
|
|
1159
|
+
|
|
1160
|
+
`CrossExchangeArbSpec`, `TriangularArbSpec`, and `OptionsVolArbSpec`
|
|
1161
|
+
are schema-validated but intentionally not executable through the
|
|
1162
|
+
generic package route because they require specialized account,
|
|
1163
|
+
sequence, latency, or Greek-aware engines.
|
|
1164
|
+
"""
|
|
1165
|
+
metadata = dict(kwargs.pop("metadata", {}))
|
|
1166
|
+
metadata["arb_type"] = arb_type
|
|
1167
|
+
return cls(
|
|
1168
|
+
_config_from_kwargs(
|
|
1169
|
+
mode="arbitrage",
|
|
1170
|
+
backend=backend,
|
|
1171
|
+
arbitrage_spec=spec,
|
|
1172
|
+
metadata=metadata,
|
|
1173
|
+
**kwargs,
|
|
1174
|
+
)
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
@staticmethod
|
|
1178
|
+
def arbitrage_support_matrix() -> Dict[str, Dict[str, str]]:
|
|
1179
|
+
"""
|
|
1180
|
+
Return the public arbitrage endpoint support matrix.
|
|
1181
|
+
|
|
1182
|
+
Services can call this helper to decide which spec/backend pair is safe
|
|
1183
|
+
before constructing a run. A status of `supported` means the endpoint
|
|
1184
|
+
can execute the spec. A status of `schema_only` means the dataclass and
|
|
1185
|
+
validation exist, but execution should wait for a specialized engine.
|
|
1186
|
+
"""
|
|
1187
|
+
return {
|
|
1188
|
+
"BasisArbitrageSpec": {
|
|
1189
|
+
"status": "supported",
|
|
1190
|
+
"backends": "native_event,native_vectorized,nautilus",
|
|
1191
|
+
"route": "run_basis_arbitrage",
|
|
1192
|
+
"sizing": "target_notional_to_base_qty or target_base_qty; linear contracts only",
|
|
1193
|
+
},
|
|
1194
|
+
"StatArbPairSpec": {
|
|
1195
|
+
"status": "supported",
|
|
1196
|
+
"backends": "native_event,native_vectorized,nautilus",
|
|
1197
|
+
"route": "run_stat_arb_pair_arbitrage",
|
|
1198
|
+
"sizing": "target_gross_notional; optional dynamic hedge_ratios",
|
|
1199
|
+
},
|
|
1200
|
+
"CalendarSpreadSpec": {
|
|
1201
|
+
"status": "supported",
|
|
1202
|
+
"backends": "native_event,native_vectorized",
|
|
1203
|
+
"route": "run_package_arbitrage",
|
|
1204
|
+
"sizing": "target_notional_to_base_qty or target_base_qty",
|
|
1205
|
+
},
|
|
1206
|
+
"FundingArbitrageSpec": {
|
|
1207
|
+
"status": "supported",
|
|
1208
|
+
"backends": "native_event,native_vectorized",
|
|
1209
|
+
"route": "run_package_arbitrage",
|
|
1210
|
+
"sizing": "target_notional_to_base_qty or target_base_qty",
|
|
1211
|
+
},
|
|
1212
|
+
"SpotPerpCashCarrySpec": {
|
|
1213
|
+
"status": "supported",
|
|
1214
|
+
"backends": "native_event,native_vectorized",
|
|
1215
|
+
"route": "run_package_arbitrage",
|
|
1216
|
+
"sizing": "target_notional_to_base_qty or target_base_qty",
|
|
1217
|
+
},
|
|
1218
|
+
"IndexBasketArbSpec": {
|
|
1219
|
+
"status": "supported",
|
|
1220
|
+
"backends": "native_event,native_vectorized",
|
|
1221
|
+
"route": "run_package_arbitrage",
|
|
1222
|
+
"sizing": "target_gross_notional",
|
|
1223
|
+
},
|
|
1224
|
+
"CrossExchangeArbSpec": {
|
|
1225
|
+
"status": "schema_only",
|
|
1226
|
+
"backends": "none",
|
|
1227
|
+
"route": "needs venue/account split engine",
|
|
1228
|
+
"sizing": "not executable yet",
|
|
1229
|
+
},
|
|
1230
|
+
"TriangularArbSpec": {
|
|
1231
|
+
"status": "schema_only",
|
|
1232
|
+
"backends": "none",
|
|
1233
|
+
"route": "needs sequenced path execution engine",
|
|
1234
|
+
"sizing": "not executable yet",
|
|
1235
|
+
},
|
|
1236
|
+
"OptionsVolArbSpec": {
|
|
1237
|
+
"status": "specialized_route",
|
|
1238
|
+
"backends": "native_option",
|
|
1239
|
+
"route": "QuantBTEndpoint.options(...) with OptionPackageIntent and Greeks reports",
|
|
1240
|
+
"sizing": "option package quantities; Greeks-aware risk belongs to option route",
|
|
1241
|
+
},
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
@staticmethod
|
|
1245
|
+
def options_support_matrix() -> Dict[str, Dict[str, str]]:
|
|
1246
|
+
"""
|
|
1247
|
+
Return the native option endpoint support matrix.
|
|
1248
|
+
|
|
1249
|
+
`supported` means the Phase 7 endpoint can execute the workflow through
|
|
1250
|
+
current native option components. `future` means the public schema is
|
|
1251
|
+
intentionally reserved but should wait for later phases.
|
|
1252
|
+
"""
|
|
1253
|
+
return {
|
|
1254
|
+
"canonical_chain_tape": {
|
|
1255
|
+
"status": "supported",
|
|
1256
|
+
"backend": "native_option",
|
|
1257
|
+
"route": "prepare_option_tape",
|
|
1258
|
+
"notes": "long-form option chain with bid/ask/mark/IV/Greeks columns",
|
|
1259
|
+
},
|
|
1260
|
+
"option_packages": {
|
|
1261
|
+
"status": "supported",
|
|
1262
|
+
"backend": "native_option",
|
|
1263
|
+
"route": "execute_option_package -> OptionLedger",
|
|
1264
|
+
"notes": "atomic_all_or_none, best_effort, sequential, hedge_after_primary, rebalance_only",
|
|
1265
|
+
},
|
|
1266
|
+
"multi_currency_ledger": {
|
|
1267
|
+
"status": "supported",
|
|
1268
|
+
"backend": "native_option",
|
|
1269
|
+
"route": "OptionLedger",
|
|
1270
|
+
"notes": "premium cash, fees, realized PnL, settlement cashflow and marked equity",
|
|
1271
|
+
},
|
|
1272
|
+
"margin": {
|
|
1273
|
+
"status": "supported_approx",
|
|
1274
|
+
"backend": "native_option",
|
|
1275
|
+
"route": "calculate_option_margin",
|
|
1276
|
+
"notes": "venue-exact margin requires external validator or later Nautilus/venue adapter",
|
|
1277
|
+
},
|
|
1278
|
+
"OptionsVolArbSpec": {
|
|
1279
|
+
"status": "specialized_route",
|
|
1280
|
+
"backend": "native_option",
|
|
1281
|
+
"route": "strategy/template emits option packages; endpoint returns Greeks and attribution reports",
|
|
1282
|
+
"notes": "not executable through generic arbitrage package route",
|
|
1283
|
+
},
|
|
1284
|
+
"nautilus_options": {
|
|
1285
|
+
"status": "experimental",
|
|
1286
|
+
"backend": "nautilus",
|
|
1287
|
+
"route": "quantbt.adapters.nautilus.options.validate_option_packages_with_nautilus",
|
|
1288
|
+
"notes": "Phase 9 pins Nautilus option constructors and BBO quote semantics; full Nautilus option engine replay remains future",
|
|
1289
|
+
},
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
@staticmethod
|
|
1293
|
+
def nautilus_support_matrix() -> Dict[str, Dict[str, str]]:
|
|
1294
|
+
"""
|
|
1295
|
+
Return the public Nautilus adapter support matrix.
|
|
1296
|
+
|
|
1297
|
+
`supported` means the route is executable through current QuantBT
|
|
1298
|
+
endpoints. `planned` means the endpoint contract is reserved in the
|
|
1299
|
+
roadmap but runtime execution should not be used yet. `experimental`
|
|
1300
|
+
means the route exists for controlled validation, usually with a
|
|
1301
|
+
narrower instrument/order scope than native engines.
|
|
1302
|
+
"""
|
|
1303
|
+
return {
|
|
1304
|
+
"signal_series": {
|
|
1305
|
+
"status": "supported",
|
|
1306
|
+
"endpoint": "QuantBTEndpoint.nautilus_validation(...)",
|
|
1307
|
+
"scope": "single-symbol target signal replay",
|
|
1308
|
+
"order_types": "market delta orders generated by adapter",
|
|
1309
|
+
"notes": "supports signal_notional, notional, unit, and %_equity sizing",
|
|
1310
|
+
},
|
|
1311
|
+
"explicit_orders": {
|
|
1312
|
+
"status": "supported",
|
|
1313
|
+
"endpoint": "QuantBTEndpoint.orders(backend='nautilus', ...)",
|
|
1314
|
+
"scope": "single-symbol OrderIntent replay",
|
|
1315
|
+
"order_types": "market, limit, stop_market, stop_limit",
|
|
1316
|
+
"notes": "preserves TIF, reduce_only, tags, price and trigger_price where Nautilus supports them",
|
|
1317
|
+
},
|
|
1318
|
+
"lifecycle_commands": {
|
|
1319
|
+
"status": "supported_native_event_adapter_aligned",
|
|
1320
|
+
"endpoint": "QuantBTEndpoint.native_event_lifecycle(...) or QuantBTEndpoint.orders(event_engine_version='v2', ...)",
|
|
1321
|
+
"scope": "native-event v2 command lifecycle; Nautilus package adapter accepts executable PLACE/REPLACE payloads",
|
|
1322
|
+
"order_types": "market, limit, stop_market, stop_limit plus cancel/replace/amend/cancel_all in native-event v2",
|
|
1323
|
+
"notes": "Nautilus command path is payload-aligned, not exchange-native cancel/amend parity yet",
|
|
1324
|
+
},
|
|
1325
|
+
"reactive_strategy": {
|
|
1326
|
+
"status": "supported_native_event_mvp",
|
|
1327
|
+
"endpoint": "QuantBTEndpoint.native_event_strategy(...)",
|
|
1328
|
+
"scope": "on_bar_close strategy callbacks emitting next-bar OrderCommand objects",
|
|
1329
|
+
"order_types": "native-event v2 lifecycle commands",
|
|
1330
|
+
"notes": "Phase 30D replay-backed MVP with captured command tape and static replay parity; incremental session is Phase 30E",
|
|
1331
|
+
},
|
|
1332
|
+
"dca_grid": {
|
|
1333
|
+
"status": "experimental",
|
|
1334
|
+
"endpoint": "QuantBTEndpoint.nautilus_dca_grid(...)",
|
|
1335
|
+
"scope": "base order, safety limit orders, TP/SL package",
|
|
1336
|
+
"order_types": "market, limit, bracket/OCO exits",
|
|
1337
|
+
"notes": "Phase 5.2C; compiles to explicit OrderIntent packages for Nautilus validation",
|
|
1338
|
+
},
|
|
1339
|
+
"bracket_oco": {
|
|
1340
|
+
"status": "experimental",
|
|
1341
|
+
"endpoint": "QuantBTEndpoint.nautilus_bracket_orders(...)",
|
|
1342
|
+
"scope": "entry plus linked stop-loss/take-profit exits",
|
|
1343
|
+
"order_types": "bracket/OCO package",
|
|
1344
|
+
"notes": "Phase 5.2C; sibling cancellation is handled by the Nautilus package strategy",
|
|
1345
|
+
},
|
|
1346
|
+
"basket_pair": {
|
|
1347
|
+
"status": "experimental",
|
|
1348
|
+
"endpoint": "QuantBTEndpoint.basket(backend='nautilus', ...)",
|
|
1349
|
+
"scope": "multi-leg frozen hedge-ratio packages",
|
|
1350
|
+
"order_types": "per-leg explicit market/limit orders",
|
|
1351
|
+
"notes": "Phase 5.2D; compiles BasketSpec signals into Nautilus package orders",
|
|
1352
|
+
},
|
|
1353
|
+
"multi_symbol_portfolio": {
|
|
1354
|
+
"status": "experimental",
|
|
1355
|
+
"endpoint": "QuantBTEndpoint.portfolio(backend='nautilus', ...)",
|
|
1356
|
+
"scope": "position-matrix transitions across one Nautilus venue/account",
|
|
1357
|
+
"order_types": "per-symbol target delta orders",
|
|
1358
|
+
"notes": "Phase 5.2D; supports pre-scalable signal_notional/notional/unit modes",
|
|
1359
|
+
},
|
|
1360
|
+
"arbitrage_package_orders": {
|
|
1361
|
+
"status": "experimental",
|
|
1362
|
+
"endpoint": "QuantBTEndpoint.arbitrage(..., backend='nautilus')",
|
|
1363
|
+
"scope": "basis/stat-arb package validation",
|
|
1364
|
+
"order_types": "package market orders",
|
|
1365
|
+
"notes": "supported for selected arbitrage specs; not a general basket endpoint yet",
|
|
1366
|
+
},
|
|
1367
|
+
"parity_audit": {
|
|
1368
|
+
"status": "supported",
|
|
1369
|
+
"endpoint": "build_native_nautilus_parity_report(native, nautilus)",
|
|
1370
|
+
"scope": "native-vs-Nautilus order/fill/equity comparison",
|
|
1371
|
+
"order_types": "reporting helper",
|
|
1372
|
+
"notes": "row-level audit exists; summary artifacts live in report bundle and tests",
|
|
1373
|
+
},
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
@classmethod
|
|
1377
|
+
def portfolio(cls, portfolio_mode: str = "longshort", backend: str = "native_portfolio", **kwargs) -> "QuantBTEndpoint":
|
|
1378
|
+
"""
|
|
1379
|
+
Create a multi-symbol portfolio endpoint.
|
|
1380
|
+
|
|
1381
|
+
Use `backtest(positions=positions_df, data=data_dict)` where
|
|
1382
|
+
`positions_df.columns` are symbols and `data_dict[symbol]` is an OHLCV
|
|
1383
|
+
DataFrame. The endpoint wraps `PortfolioBacktestEngine`.
|
|
1384
|
+
"""
|
|
1385
|
+
return cls(_config_from_kwargs(mode="portfolio", backend=backend, portfolio_mode=portfolio_mode, **kwargs))
|
|
1386
|
+
|
|
1387
|
+
@classmethod
|
|
1388
|
+
def nautilus_validation(cls, **kwargs) -> "QuantBTEndpoint":
|
|
1389
|
+
"""
|
|
1390
|
+
Create a Nautilus validation endpoint.
|
|
1391
|
+
|
|
1392
|
+
This is for smaller high-fidelity validation runs. It currently supports
|
|
1393
|
+
single-symbol signal series using the optional NautilusTrader adapter.
|
|
1394
|
+
Nautilus must be installed in the active environment.
|
|
1395
|
+
|
|
1396
|
+
`use_pyramiding` is forwarded to the Nautilus strategy adapter. When it
|
|
1397
|
+
is false, fractional signals such as `1.4` are snapped to `1.0`; when it
|
|
1398
|
+
is true, the raw signal scale is preserved.
|
|
1399
|
+
"""
|
|
1400
|
+
sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", "signal_notional"))
|
|
1401
|
+
return cls(_config_from_kwargs(mode="nautilus_validation", backend="nautilus", sizing=sizing, **kwargs))
|
|
1402
|
+
|
|
1403
|
+
@classmethod
|
|
1404
|
+
def walk_forward(
|
|
1405
|
+
cls,
|
|
1406
|
+
strategy_class,
|
|
1407
|
+
split_mode: Union[str, int, pd.Timestamp] = "walk_forward_2022",
|
|
1408
|
+
split_frequency: str = "quarterly",
|
|
1409
|
+
target_mode: str = "signal_notional",
|
|
1410
|
+
window_mode: str = "expanding",
|
|
1411
|
+
train_window: Optional[str] = None,
|
|
1412
|
+
optimization_mode: str = "none",
|
|
1413
|
+
optimization_config: Optional[Dict] = None,
|
|
1414
|
+
optuna_trials: int = 0,
|
|
1415
|
+
optuna_early_stopping: Optional[int] = None,
|
|
1416
|
+
random_seed: int = 42,
|
|
1417
|
+
**kwargs,
|
|
1418
|
+
) -> "QuantBTEndpoint":
|
|
1419
|
+
"""
|
|
1420
|
+
Create a walk-forward endpoint.
|
|
1421
|
+
|
|
1422
|
+
The strategy callable/class is invoked once per fold and must return OOS
|
|
1423
|
+
signal/position output indexed by timestamp. The stitched OOS output is
|
|
1424
|
+
then routed into an existing QuantBT backtest path, so boundary trades
|
|
1425
|
+
are charged by the normal engine instead of averaging fold equities.
|
|
1426
|
+
Supported optimization modes are `mode_1_decay`, `mode_2_sbb`,
|
|
1427
|
+
`mode_3_flat_minima`, `mode_4_is_only_robust`, and
|
|
1428
|
+
`mode_5_full_robust`.
|
|
1429
|
+
Fixed-parameter runs can leave
|
|
1430
|
+
`optimization_mode="none"` and pass `params=...` to `backtest()`.
|
|
1431
|
+
"""
|
|
1432
|
+
optimization_config = dict(optimization_config or {})
|
|
1433
|
+
wf_config = kwargs.pop("walkforward_config", None)
|
|
1434
|
+
scoring_backend = str(
|
|
1435
|
+
optimization_config.get(
|
|
1436
|
+
"scoring_backend",
|
|
1437
|
+
_default_walkforward_scoring_backend(target_mode=target_mode, optimization_mode=optimization_mode),
|
|
1438
|
+
)
|
|
1439
|
+
)
|
|
1440
|
+
wf_metadata = dict(optimization_config.get("metadata", {}) or {})
|
|
1441
|
+
wf_metadata.setdefault("use_prepared_scoring_cache", bool(optimization_config.get("use_prepared_scoring_cache", True)))
|
|
1442
|
+
if wf_config is None:
|
|
1443
|
+
from .walkforward import WalkForwardConfig
|
|
1444
|
+
|
|
1445
|
+
wf_config = WalkForwardConfig(
|
|
1446
|
+
split_mode=split_mode,
|
|
1447
|
+
split_frequency=split_frequency,
|
|
1448
|
+
window_mode=window_mode,
|
|
1449
|
+
train_window=train_window,
|
|
1450
|
+
target_mode=target_mode,
|
|
1451
|
+
optimization_mode=optimization_mode,
|
|
1452
|
+
optuna_trials=optuna_trials,
|
|
1453
|
+
optuna_early_stopping=optuna_early_stopping,
|
|
1454
|
+
random_seed=random_seed,
|
|
1455
|
+
decay_lambda=float(optimization_config.get("decay_lambda", 0.5)),
|
|
1456
|
+
decay_gamma=float(optimization_config.get("decay_gamma", 0.5)),
|
|
1457
|
+
top_is_fraction=float(optimization_config.get("top_is_fraction", 0.10)),
|
|
1458
|
+
top_is_k=optimization_config.get("top_is_k"),
|
|
1459
|
+
candidate_selection_metric=str(
|
|
1460
|
+
optimization_config.get(
|
|
1461
|
+
"candidate_selection_metric",
|
|
1462
|
+
(
|
|
1463
|
+
"is_only_robust"
|
|
1464
|
+
if str(optimization_mode).lower().strip() == "mode_4_is_only_robust"
|
|
1465
|
+
else (
|
|
1466
|
+
"full_robust"
|
|
1467
|
+
if str(optimization_mode).lower().strip() == "mode_5_full_robust"
|
|
1468
|
+
else "robust_decay"
|
|
1469
|
+
)
|
|
1470
|
+
),
|
|
1471
|
+
)
|
|
1472
|
+
),
|
|
1473
|
+
candidate_decay_lambda=optimization_config.get("candidate_decay_lambda"),
|
|
1474
|
+
candidate_decay_gamma=optimization_config.get("candidate_decay_gamma"),
|
|
1475
|
+
sbb_samples=int(optimization_config.get("sbb_samples", 256)),
|
|
1476
|
+
sbb_block_length=int(optimization_config.get("sbb_block_length", 20)),
|
|
1477
|
+
sbb_decay_lambda=float(optimization_config.get("sbb_decay_lambda", 0.5)),
|
|
1478
|
+
sbb_std_penalty=float(optimization_config.get("sbb_std_penalty", 0.1)),
|
|
1479
|
+
sbb_simulation=str(optimization_config.get("sbb_simulation", "stationary")),
|
|
1480
|
+
regime_count=int(optimization_config.get("regime_count", 3)),
|
|
1481
|
+
regime_lookback=int(optimization_config.get("regime_lookback", 20)),
|
|
1482
|
+
regime_weights=optimization_config.get("regime_weights"),
|
|
1483
|
+
stress_vol_multiplier=float(optimization_config.get("stress_vol_multiplier", 1.0)),
|
|
1484
|
+
garch_p=int(optimization_config.get("garch_p", 1)),
|
|
1485
|
+
garch_q=int(optimization_config.get("garch_q", 1)),
|
|
1486
|
+
garch_dist=str(optimization_config.get("garch_dist", "t")),
|
|
1487
|
+
garch_vol_multiplier=float(optimization_config.get("garch_vol_multiplier", 1.0)),
|
|
1488
|
+
flat_top_fraction=float(optimization_config.get("flat_top_fraction", 0.1)),
|
|
1489
|
+
flat_eps=float(optimization_config.get("flat_eps", 0.15)),
|
|
1490
|
+
flat_min_samples=int(optimization_config.get("flat_min_samples", 3)),
|
|
1491
|
+
flat_selector=str(optimization_config.get("flat_selector", "medoid")),
|
|
1492
|
+
plateau_quantile=float(optimization_config.get("plateau_quantile", 0.25)),
|
|
1493
|
+
plateau_median_weight=float(optimization_config.get("plateau_median_weight", 0.25)),
|
|
1494
|
+
plateau_std_penalty=float(optimization_config.get("plateau_std_penalty", 0.50)),
|
|
1495
|
+
plateau_size_bonus=float(optimization_config.get("plateau_size_bonus", 0.01)),
|
|
1496
|
+
is_subperiods=int(optimization_config.get("is_subperiods", 6)),
|
|
1497
|
+
q25_weight=float(optimization_config.get("q25_weight", 0.30)),
|
|
1498
|
+
dispersion_penalty=float(optimization_config.get("dispersion_penalty", 0.50)),
|
|
1499
|
+
temporal_weight=float(optimization_config.get("temporal_weight", 0.65)),
|
|
1500
|
+
plateau_weight=float(optimization_config.get("plateau_weight", 0.35)),
|
|
1501
|
+
use_bootstrap_penalty=bool(optimization_config.get("use_bootstrap_penalty", False)),
|
|
1502
|
+
use_complexity_penalty=bool(optimization_config.get("use_complexity_penalty", False)),
|
|
1503
|
+
scoring_backend=scoring_backend,
|
|
1504
|
+
scoring_trading_days=int(optimization_config.get("scoring_trading_days", 365)),
|
|
1505
|
+
min_trades_per_year=optimization_config.get("min_trades_per_year"),
|
|
1506
|
+
trade_penalty_factor=optimization_config.get("trade_penalty_factor"),
|
|
1507
|
+
use_numba=bool(optimization_config.get("use_numba", True)),
|
|
1508
|
+
metadata=wf_metadata,
|
|
1509
|
+
)
|
|
1510
|
+
default_sizing = "signal_notional" if target_mode in {"portfolio", "basket", "arbitrage"} else target_mode
|
|
1511
|
+
sizing = kwargs.pop("sizing", kwargs.pop("hedge_type", default_sizing))
|
|
1512
|
+
backend = kwargs.pop("backend", "auto")
|
|
1513
|
+
return cls(
|
|
1514
|
+
_config_from_kwargs(
|
|
1515
|
+
mode="walk_forward",
|
|
1516
|
+
backend=backend,
|
|
1517
|
+
sizing=sizing,
|
|
1518
|
+
strategy_class=strategy_class,
|
|
1519
|
+
walkforward_config=wf_config,
|
|
1520
|
+
walkforward_target_mode=target_mode,
|
|
1521
|
+
**kwargs,
|
|
1522
|
+
)
|
|
1523
|
+
)
|
|
1524
|
+
|
|
1525
|
+
@classmethod
|
|
1526
|
+
def train_test_split(
|
|
1527
|
+
cls,
|
|
1528
|
+
strategy_class,
|
|
1529
|
+
test_start: Union[str, int, pd.Timestamp],
|
|
1530
|
+
target_mode: str = "signal_notional",
|
|
1531
|
+
window_mode: str = "expanding",
|
|
1532
|
+
train_window: Optional[str] = None,
|
|
1533
|
+
optimization_mode: str = "none",
|
|
1534
|
+
optimization_config: Optional[Dict] = None,
|
|
1535
|
+
optuna_trials: int = 0,
|
|
1536
|
+
optuna_early_stopping: Optional[int] = None,
|
|
1537
|
+
random_seed: int = 42,
|
|
1538
|
+
**kwargs,
|
|
1539
|
+
) -> "QuantBTEndpoint":
|
|
1540
|
+
"""
|
|
1541
|
+
Create a single holdout train/test endpoint.
|
|
1542
|
+
|
|
1543
|
+
This is a convenience wrapper around `walk_forward(...)` with
|
|
1544
|
+
`split_frequency="single"`. The strategy is optimized on the train
|
|
1545
|
+
segment before `test_start`, emits OOS output on the holdout segment,
|
|
1546
|
+
then the stitched holdout signal is routed into the selected QuantBT
|
|
1547
|
+
target mode. `optimization_mode` accepts the same values as
|
|
1548
|
+
walk-forward: `none`, `mode_1_decay`, `mode_2_sbb`,
|
|
1549
|
+
`mode_3_flat_minima`, `mode_4_is_only_robust`, and
|
|
1550
|
+
`mode_5_full_robust`.
|
|
1551
|
+
"""
|
|
1552
|
+
return cls.walk_forward(
|
|
1553
|
+
strategy_class=strategy_class,
|
|
1554
|
+
split_mode=test_start,
|
|
1555
|
+
split_frequency="single",
|
|
1556
|
+
target_mode=target_mode,
|
|
1557
|
+
window_mode=window_mode,
|
|
1558
|
+
train_window=train_window,
|
|
1559
|
+
optimization_mode=optimization_mode,
|
|
1560
|
+
optimization_config=optimization_config,
|
|
1561
|
+
optuna_trials=optuna_trials,
|
|
1562
|
+
optuna_early_stopping=optuna_early_stopping,
|
|
1563
|
+
random_seed=random_seed,
|
|
1564
|
+
**kwargs,
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
def backtest(
|
|
1568
|
+
self,
|
|
1569
|
+
data=None,
|
|
1570
|
+
signal: Optional[pd.Series] = None,
|
|
1571
|
+
signal_col: Optional[str] = None,
|
|
1572
|
+
positions: Optional[Union[pd.DataFrame, SeriesMap]] = None,
|
|
1573
|
+
orders: Optional[Sequence[OrderIntent]] = None,
|
|
1574
|
+
order_commands: Optional[Sequence[OrderCommand]] = None,
|
|
1575
|
+
strategy=None,
|
|
1576
|
+
basket: Optional[BasketSpec] = None,
|
|
1577
|
+
closes: Optional[SeriesMap] = None,
|
|
1578
|
+
highs: Optional[SeriesMap] = None,
|
|
1579
|
+
lows: Optional[SeriesMap] = None,
|
|
1580
|
+
hedge_ratios: Optional[SeriesMap] = None,
|
|
1581
|
+
datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None,
|
|
1582
|
+
symbols: Optional[Sequence[str]] = None,
|
|
1583
|
+
params: Optional[Dict] = None,
|
|
1584
|
+
param_ranges: Optional[Dict] = None,
|
|
1585
|
+
chain: Optional[pd.DataFrame] = None,
|
|
1586
|
+
instruments: Optional[Union[OptionInstrumentRegistry, Sequence[OptionInstrumentSpec], Dict[str, OptionInstrumentSpec]]] = None,
|
|
1587
|
+
packages: Optional[Sequence[OptionPackageIntent]] = None,
|
|
1588
|
+
strategy_run: Optional[OptionStrategyRun] = None,
|
|
1589
|
+
intent: Optional[IntrabarIntentTape] = None,
|
|
1590
|
+
intent_cols: Optional[Dict[str, str]] = None,
|
|
1591
|
+
session_tape: Optional[IntrabarSessionTape] = None,
|
|
1592
|
+
funding_event_timestamps=None,
|
|
1593
|
+
funding_event_rates=None,
|
|
1594
|
+
fill_replay: Optional[Union[FillReplayTape, pd.DataFrame]] = None,
|
|
1595
|
+
underlying: Optional[Union[pd.DataFrame, pd.Series]] = None,
|
|
1596
|
+
hedge_policy: Optional[OptionHedgeConfig] = None,
|
|
1597
|
+
net_option_delta: Optional[pd.Series] = None,
|
|
1598
|
+
settlement_events: Optional[Sequence] = None,
|
|
1599
|
+
conversion_rates: Optional[Dict[str, float]] = None,
|
|
1600
|
+
prepared_cache: Optional[OptionPreparedRunCache] = None,
|
|
1601
|
+
):
|
|
1602
|
+
"""
|
|
1603
|
+
Run the configured backtest and store the result.
|
|
1604
|
+
|
|
1605
|
+
Parameters
|
|
1606
|
+
----------
|
|
1607
|
+
data:
|
|
1608
|
+
For single-symbol modes, an OHLCV DataFrame. For portfolio/basket
|
|
1609
|
+
modes, either a `{symbol: DataFrame}` mapping or omitted when
|
|
1610
|
+
`closes/highs/lows` are supplied explicitly.
|
|
1611
|
+
signal:
|
|
1612
|
+
Single-symbol signal series, or basket entry/exit signal.
|
|
1613
|
+
signal_col:
|
|
1614
|
+
Column name to read from `data` when `signal` is omitted.
|
|
1615
|
+
positions:
|
|
1616
|
+
Portfolio positions as DataFrame or `{symbol: Series}` mapping.
|
|
1617
|
+
orders:
|
|
1618
|
+
Explicit `OrderIntent` sequence for order simulations.
|
|
1619
|
+
basket:
|
|
1620
|
+
Optional `BasketSpec` overriding the config basket for this run.
|
|
1621
|
+
closes/highs/lows:
|
|
1622
|
+
Explicit per-symbol price series maps.
|
|
1623
|
+
datetime_index:
|
|
1624
|
+
Optional common datetime index. Defaults to data/signal index.
|
|
1625
|
+
symbols:
|
|
1626
|
+
Optional symbol override for this run.
|
|
1627
|
+
"""
|
|
1628
|
+
mode = self.config.mode.lower().strip()
|
|
1629
|
+
if mode == "options":
|
|
1630
|
+
return self._run_options(
|
|
1631
|
+
chain=chain if chain is not None else data,
|
|
1632
|
+
instruments=instruments,
|
|
1633
|
+
packages=packages,
|
|
1634
|
+
strategy_run=strategy_run,
|
|
1635
|
+
underlying=underlying,
|
|
1636
|
+
hedge_policy=hedge_policy,
|
|
1637
|
+
net_option_delta=net_option_delta,
|
|
1638
|
+
settlement_events=settlement_events,
|
|
1639
|
+
conversion_rates=conversion_rates,
|
|
1640
|
+
prepared_cache=prepared_cache,
|
|
1641
|
+
)
|
|
1642
|
+
if mode == "walk_forward":
|
|
1643
|
+
return self._run_walk_forward(
|
|
1644
|
+
data=data,
|
|
1645
|
+
signal=signal,
|
|
1646
|
+
signal_col=signal_col,
|
|
1647
|
+
positions=positions,
|
|
1648
|
+
closes=closes,
|
|
1649
|
+
highs=highs,
|
|
1650
|
+
lows=lows,
|
|
1651
|
+
hedge_ratios=hedge_ratios,
|
|
1652
|
+
datetime_index=datetime_index,
|
|
1653
|
+
symbols=symbols,
|
|
1654
|
+
params=params,
|
|
1655
|
+
param_ranges=param_ranges,
|
|
1656
|
+
)
|
|
1657
|
+
if mode == "arbitrage":
|
|
1658
|
+
return self._run_arbitrage(
|
|
1659
|
+
data=data,
|
|
1660
|
+
signal=signal,
|
|
1661
|
+
signal_col=signal_col,
|
|
1662
|
+
closes=closes,
|
|
1663
|
+
highs=highs,
|
|
1664
|
+
lows=lows,
|
|
1665
|
+
hedge_ratios=hedge_ratios,
|
|
1666
|
+
datetime_index=datetime_index,
|
|
1667
|
+
symbols=symbols,
|
|
1668
|
+
)
|
|
1669
|
+
if mode == "intrabar_bracket_reference":
|
|
1670
|
+
return self._run_intrabar_bracket_reference(
|
|
1671
|
+
data=data,
|
|
1672
|
+
signal=signal,
|
|
1673
|
+
signal_col=signal_col,
|
|
1674
|
+
datetime_index=datetime_index,
|
|
1675
|
+
symbols=symbols,
|
|
1676
|
+
intent=intent,
|
|
1677
|
+
intent_cols=intent_cols,
|
|
1678
|
+
session_tape=session_tape,
|
|
1679
|
+
funding_event_timestamps=funding_event_timestamps,
|
|
1680
|
+
funding_event_rates=funding_event_rates,
|
|
1681
|
+
)
|
|
1682
|
+
if mode == "intrabar_bracket":
|
|
1683
|
+
return self._run_intrabar_bracket_fast(
|
|
1684
|
+
data=data,
|
|
1685
|
+
signal=signal,
|
|
1686
|
+
signal_col=signal_col,
|
|
1687
|
+
datetime_index=datetime_index,
|
|
1688
|
+
symbols=symbols,
|
|
1689
|
+
intent=intent,
|
|
1690
|
+
intent_cols=intent_cols,
|
|
1691
|
+
session_tape=session_tape,
|
|
1692
|
+
funding_event_timestamps=funding_event_timestamps,
|
|
1693
|
+
funding_event_rates=funding_event_rates,
|
|
1694
|
+
)
|
|
1695
|
+
if mode == "fill_replay":
|
|
1696
|
+
return self._run_fill_replay(
|
|
1697
|
+
data=data,
|
|
1698
|
+
datetime_index=datetime_index,
|
|
1699
|
+
symbols=symbols,
|
|
1700
|
+
fill_replay=fill_replay,
|
|
1701
|
+
)
|
|
1702
|
+
if mode in ("single_signal", "pct_equity", "signal_notional", "dca_ladder", "nautilus_validation"):
|
|
1703
|
+
return self._run_single(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index, symbols=symbols)
|
|
1704
|
+
if mode == "orders":
|
|
1705
|
+
return self._run_orders(
|
|
1706
|
+
data=data,
|
|
1707
|
+
orders=orders,
|
|
1708
|
+
order_commands=order_commands,
|
|
1709
|
+
datetime_index=datetime_index,
|
|
1710
|
+
symbols=symbols,
|
|
1711
|
+
)
|
|
1712
|
+
if mode == "native_event_strategy":
|
|
1713
|
+
return self._run_native_event_strategy(
|
|
1714
|
+
data=data,
|
|
1715
|
+
strategy=strategy,
|
|
1716
|
+
datetime_index=datetime_index,
|
|
1717
|
+
symbols=symbols,
|
|
1718
|
+
)
|
|
1719
|
+
if mode in ("nautilus_dca_grid", "nautilus_bracket_orders", "native_event_dca_grid", "native_event_bracket_orders"):
|
|
1720
|
+
return self._run_structured_orders(data=data, datetime_index=datetime_index, symbols=symbols)
|
|
1721
|
+
if mode == "basket":
|
|
1722
|
+
return self._run_basket(
|
|
1723
|
+
data=data,
|
|
1724
|
+
signal=signal,
|
|
1725
|
+
signal_col=signal_col,
|
|
1726
|
+
basket=basket,
|
|
1727
|
+
closes=closes,
|
|
1728
|
+
highs=highs,
|
|
1729
|
+
lows=lows,
|
|
1730
|
+
datetime_index=datetime_index,
|
|
1731
|
+
symbols=symbols,
|
|
1732
|
+
)
|
|
1733
|
+
if mode == "portfolio":
|
|
1734
|
+
return self._run_portfolio(
|
|
1735
|
+
data=data,
|
|
1736
|
+
positions=positions,
|
|
1737
|
+
closes=closes,
|
|
1738
|
+
highs=highs,
|
|
1739
|
+
lows=lows,
|
|
1740
|
+
datetime_index=datetime_index,
|
|
1741
|
+
symbols=symbols,
|
|
1742
|
+
)
|
|
1743
|
+
raise ValueError(f"unsupported endpoint mode={self.config.mode!r}")
|
|
1744
|
+
|
|
1745
|
+
def simulate(
|
|
1746
|
+
self,
|
|
1747
|
+
*args,
|
|
1748
|
+
show_order_logs: bool = False,
|
|
1749
|
+
order_log_mode: str = "fills_only",
|
|
1750
|
+
order_log_limit: int = 500,
|
|
1751
|
+
**kwargs,
|
|
1752
|
+
):
|
|
1753
|
+
"""
|
|
1754
|
+
Alias for `backtest()` used by order, basket, and Nautilus workflows.
|
|
1755
|
+
|
|
1756
|
+
Services can call `simulate()` when the input is closer to an execution
|
|
1757
|
+
simulation than a pure signal backtest. The routing and return contract
|
|
1758
|
+
are identical to `backtest()`.
|
|
1759
|
+
"""
|
|
1760
|
+
result = self.backtest(*args, **kwargs)
|
|
1761
|
+
if show_order_logs:
|
|
1762
|
+
_print_order_logs(result, mode=order_log_mode, limit=order_log_limit)
|
|
1763
|
+
return result
|
|
1764
|
+
|
|
1765
|
+
def full_report(self, trading_days: int = 365, scope: str = "auto") -> Dict:
|
|
1766
|
+
"""
|
|
1767
|
+
Return the full QuantBT metrics dictionary for the latest result.
|
|
1768
|
+
|
|
1769
|
+
Parameters
|
|
1770
|
+
----------
|
|
1771
|
+
trading_days:
|
|
1772
|
+
Annualization calendar. Use 365 for crypto and 252 for equities.
|
|
1773
|
+
scope:
|
|
1774
|
+
`auto` uses the natural reporting scope for the endpoint. For
|
|
1775
|
+
walk-forward and train/test split runs this means OOS/test bars
|
|
1776
|
+
only; other endpoints use the full result. Pass `full` to audit the
|
|
1777
|
+
complete stitched timeline, or `test`/`oos` to force OOS reporting.
|
|
1778
|
+
|
|
1779
|
+
Raises
|
|
1780
|
+
------
|
|
1781
|
+
RuntimeError
|
|
1782
|
+
If no backtest has been run yet.
|
|
1783
|
+
"""
|
|
1784
|
+
from .metrics import full_report as _full_report
|
|
1785
|
+
|
|
1786
|
+
return _full_report(self._result_for_report_scope(scope), trading_days=trading_days)
|
|
1787
|
+
|
|
1788
|
+
def show_metrics(self, trading_days: int = 365, scope: str = "auto") -> Dict:
|
|
1789
|
+
"""
|
|
1790
|
+
Print key metrics and return the full metrics dictionary.
|
|
1791
|
+
|
|
1792
|
+
This intentionally mirrors the convenience style of legacy
|
|
1793
|
+
`BacktestEngine.analyze()` without forcing a plot.
|
|
1794
|
+
"""
|
|
1795
|
+
rpt = self.full_report(trading_days=trading_days, scope=scope)
|
|
1796
|
+
print(format_metrics_report(rpt))
|
|
1797
|
+
return rpt
|
|
1798
|
+
|
|
1799
|
+
def quick_plot(self, theme: str = "dark", figsize: tuple = (14, 6), scope: str = "auto"):
|
|
1800
|
+
"""
|
|
1801
|
+
Plot cumulative return and drawdown for the latest result.
|
|
1802
|
+
"""
|
|
1803
|
+
from .viz import quick_plot as _quick_plot
|
|
1804
|
+
|
|
1805
|
+
return _quick_plot(self._require_result(), theme=theme, figsize=figsize, scope=scope)
|
|
1806
|
+
|
|
1807
|
+
def tearsheet(self, theme: str = "dark", benchmark=None, scope: str = "auto"):
|
|
1808
|
+
"""
|
|
1809
|
+
Render the full QuantBT tearsheet for the latest result.
|
|
1810
|
+
"""
|
|
1811
|
+
from .viz import tearsheet as _tearsheet
|
|
1812
|
+
|
|
1813
|
+
return _tearsheet(self._require_result(), theme=theme, benchmark=benchmark, scope=scope)
|
|
1814
|
+
|
|
1815
|
+
def export_orders(self, path: Union[str, Path]) -> None:
|
|
1816
|
+
"""
|
|
1817
|
+
Export latest event/Nautilus order report to CSV.
|
|
1818
|
+
|
|
1819
|
+
Native event runs store order diagnostics in
|
|
1820
|
+
`result.metadata["order_report"]`; Nautilus runs store raw
|
|
1821
|
+
`orders_report`.
|
|
1822
|
+
"""
|
|
1823
|
+
result = self._require_result()
|
|
1824
|
+
report = result.metadata.get("order_report")
|
|
1825
|
+
if report is None:
|
|
1826
|
+
report = result.metadata.get("orders_report")
|
|
1827
|
+
if report is None:
|
|
1828
|
+
raise RuntimeError("latest result does not contain an order report")
|
|
1829
|
+
report.to_csv(path)
|
|
1830
|
+
|
|
1831
|
+
def export_fills(self, path: Union[str, Path]) -> None:
|
|
1832
|
+
"""
|
|
1833
|
+
Export latest fills to CSV.
|
|
1834
|
+
|
|
1835
|
+
Native event fills are converted from `result.fills`; Nautilus fills use
|
|
1836
|
+
the raw `fills_report` when available.
|
|
1837
|
+
"""
|
|
1838
|
+
result = self._require_result()
|
|
1839
|
+
report = result.metadata.get("fills_report")
|
|
1840
|
+
if report is None:
|
|
1841
|
+
rows = [getattr(fill, "__dict__", dict(fill=fill)) for fill in getattr(result, "fills", ())]
|
|
1842
|
+
report = pd.DataFrame(rows)
|
|
1843
|
+
if report.empty:
|
|
1844
|
+
raise RuntimeError("latest result does not contain fills")
|
|
1845
|
+
report.to_csv(path, index=False)
|
|
1846
|
+
|
|
1847
|
+
@property
|
|
1848
|
+
def metrics(self) -> Dict:
|
|
1849
|
+
"""Return `full_report()` for the latest result."""
|
|
1850
|
+
return self.full_report()
|
|
1851
|
+
|
|
1852
|
+
@property
|
|
1853
|
+
def latest_orders(self):
|
|
1854
|
+
"""Return latest explicit/generated orders, or an empty tuple."""
|
|
1855
|
+
return getattr(self._require_result(), "orders", ())
|
|
1856
|
+
|
|
1857
|
+
@property
|
|
1858
|
+
def fills(self):
|
|
1859
|
+
"""Return latest fills, or an empty tuple for non-event results."""
|
|
1860
|
+
return getattr(self._require_result(), "fills", ())
|
|
1861
|
+
|
|
1862
|
+
@property
|
|
1863
|
+
def order_report(self) -> pd.DataFrame:
|
|
1864
|
+
"""Return latest order report, or an empty DataFrame."""
|
|
1865
|
+
return self._require_result().metadata.get("order_report", pd.DataFrame())
|
|
1866
|
+
|
|
1867
|
+
@property
|
|
1868
|
+
def fills_report(self) -> pd.DataFrame:
|
|
1869
|
+
"""Return latest fills report, or an empty DataFrame."""
|
|
1870
|
+
return self._require_result().metadata.get("fills_report", pd.DataFrame())
|
|
1871
|
+
|
|
1872
|
+
def nautilus_pct_equity_diagnostic(
|
|
1873
|
+
self,
|
|
1874
|
+
*,
|
|
1875
|
+
data,
|
|
1876
|
+
signal=None,
|
|
1877
|
+
signal_col: Optional[str] = None,
|
|
1878
|
+
native_fee_round_trip: Optional[float] = None,
|
|
1879
|
+
native_fee_one_way: Optional[float] = None,
|
|
1880
|
+
native_use_funding: Optional[bool] = None,
|
|
1881
|
+
native_slippage: Optional[float] = None,
|
|
1882
|
+
) -> Dict:
|
|
1883
|
+
"""
|
|
1884
|
+
Diagnose why a Nautilus `%_equity` validation run differs from native.
|
|
1885
|
+
|
|
1886
|
+
This helper reports signal transition count, Nautilus order/fill count,
|
|
1887
|
+
fee/slippage/funding semantic differences, and exchange lot-size
|
|
1888
|
+
constraints. It is diagnostic-only and does not mutate the result.
|
|
1889
|
+
"""
|
|
1890
|
+
from .reporting import build_nautilus_pct_equity_diagnostic
|
|
1891
|
+
|
|
1892
|
+
frame, _, sig = _normalize_single_data(
|
|
1893
|
+
data=data,
|
|
1894
|
+
signal=signal,
|
|
1895
|
+
signal_col=signal_col,
|
|
1896
|
+
datetime_index=None,
|
|
1897
|
+
)
|
|
1898
|
+
return build_nautilus_pct_equity_diagnostic(
|
|
1899
|
+
self._require_result(),
|
|
1900
|
+
data=frame,
|
|
1901
|
+
signal=sig,
|
|
1902
|
+
native_fee_round_trip=native_fee_round_trip,
|
|
1903
|
+
native_fee_one_way=native_fee_one_way,
|
|
1904
|
+
native_use_funding=native_use_funding,
|
|
1905
|
+
native_slippage=native_slippage,
|
|
1906
|
+
)
|
|
1907
|
+
|
|
1908
|
+
def _run_options(
|
|
1909
|
+
self,
|
|
1910
|
+
chain,
|
|
1911
|
+
instruments,
|
|
1912
|
+
packages,
|
|
1913
|
+
strategy_run,
|
|
1914
|
+
underlying,
|
|
1915
|
+
hedge_policy,
|
|
1916
|
+
net_option_delta,
|
|
1917
|
+
settlement_events,
|
|
1918
|
+
conversion_rates,
|
|
1919
|
+
prepared_cache,
|
|
1920
|
+
):
|
|
1921
|
+
if chain is None:
|
|
1922
|
+
raise ValueError("options endpoint requires chain=option_chain_dataframe or data=option_chain_dataframe")
|
|
1923
|
+
if instruments is None:
|
|
1924
|
+
instruments = self.config.instruments
|
|
1925
|
+
if instruments is None:
|
|
1926
|
+
raise ValueError("options endpoint requires instruments=OptionInstrumentRegistry/list/mapping")
|
|
1927
|
+
config = self.config.option_config
|
|
1928
|
+
if config is None:
|
|
1929
|
+
config = NativeOptionConfig(
|
|
1930
|
+
account=self.config.account,
|
|
1931
|
+
execution=self.config.execution,
|
|
1932
|
+
option_execution=OptionExecutionConfig(fee_rate=self.config.v2_fee_rate),
|
|
1933
|
+
margin=OptionMarginConfig(),
|
|
1934
|
+
metadata=dict(self.config.metadata),
|
|
1935
|
+
)
|
|
1936
|
+
self.engine = OptionBacktestEngine(
|
|
1937
|
+
chain=chain,
|
|
1938
|
+
instruments=instruments,
|
|
1939
|
+
packages=packages or (),
|
|
1940
|
+
strategy_run=strategy_run,
|
|
1941
|
+
underlying=underlying,
|
|
1942
|
+
hedge_policy=hedge_policy,
|
|
1943
|
+
net_option_delta=net_option_delta,
|
|
1944
|
+
config=config,
|
|
1945
|
+
settlement_events=settlement_events or (),
|
|
1946
|
+
conversion_rates=conversion_rates,
|
|
1947
|
+
prepared_cache=prepared_cache,
|
|
1948
|
+
)
|
|
1949
|
+
self._store_result(self.engine.result)
|
|
1950
|
+
return self.result
|
|
1951
|
+
|
|
1952
|
+
def _run_intrabar_bracket_reference(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None):
|
|
1953
|
+
tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates)
|
|
1954
|
+
contract = _execution_contract_from_config(self.config)
|
|
1955
|
+
session_policy = _session_policy_from_config(self.config)
|
|
1956
|
+
if session_policy is not None and session_tape is None:
|
|
1957
|
+
raise ValueError("session_tape is required when session_policy is configured")
|
|
1958
|
+
oracle = run_intrabar_reference(
|
|
1959
|
+
tape=tape,
|
|
1960
|
+
intent=intent,
|
|
1961
|
+
account=self.config.account,
|
|
1962
|
+
contract=contract,
|
|
1963
|
+
fee_rate=self.config.v2_fee_rate,
|
|
1964
|
+
slippage_rate=float(self.config.execution.slippage_rate),
|
|
1965
|
+
contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
|
|
1966
|
+
session_policy=session_policy,
|
|
1967
|
+
session_tape=session_tape,
|
|
1968
|
+
**self._intrabar_execution_kwargs(symbol),
|
|
1969
|
+
)
|
|
1970
|
+
idx = oracle.equity.index
|
|
1971
|
+
returns = oracle.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
1972
|
+
diagnostics = pd.DataFrame(
|
|
1973
|
+
{
|
|
1974
|
+
"average_entry": oracle.average_entry,
|
|
1975
|
+
"active_stop": oracle.active_stop,
|
|
1976
|
+
"active_take_profit": oracle.active_take_profit,
|
|
1977
|
+
"event_flags": oracle.event_flags,
|
|
1978
|
+
"fees": oracle.fees,
|
|
1979
|
+
"funding": oracle.funding,
|
|
1980
|
+
},
|
|
1981
|
+
index=idx,
|
|
1982
|
+
)
|
|
1983
|
+
metadata = {
|
|
1984
|
+
**oracle.metadata,
|
|
1985
|
+
"backend": "intrabar_reference",
|
|
1986
|
+
"backend_alias": "intrabar_bracket_reference",
|
|
1987
|
+
"engine_id": "intrabar_reference_v1",
|
|
1988
|
+
"input_mode": "intrabar_intent",
|
|
1989
|
+
"symbol": symbol,
|
|
1990
|
+
"validation_certificate": asdict(tape.validation_certificate),
|
|
1991
|
+
"strict_market_tape": True,
|
|
1992
|
+
"phase": "31B_python_reference_oracle",
|
|
1993
|
+
"fills_report": _intrabar_fills_to_frame(oracle.fills),
|
|
1994
|
+
"positions_report": pd.DataFrame({f"Position_{symbol}": oracle.position}, index=idx),
|
|
1995
|
+
}
|
|
1996
|
+
result = BacktestResultV2(
|
|
1997
|
+
equity=oracle.equity,
|
|
1998
|
+
returns=returns,
|
|
1999
|
+
positions=pd.DataFrame({f"Position_{symbol}": oracle.position.to_numpy(dtype=float)}, index=idx),
|
|
2000
|
+
closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
|
|
2001
|
+
symbols=[symbol],
|
|
2002
|
+
initial_capital=float(self.config.account.initial_capital),
|
|
2003
|
+
leverage=float(self.config.account.leverage),
|
|
2004
|
+
fills=oracle.fills,
|
|
2005
|
+
fees=oracle.fees,
|
|
2006
|
+
funding=oracle.funding,
|
|
2007
|
+
diagnostics=diagnostics,
|
|
2008
|
+
metadata=metadata,
|
|
2009
|
+
)
|
|
2010
|
+
self.engine = oracle
|
|
2011
|
+
self._store_result(result)
|
|
2012
|
+
return self.result
|
|
2013
|
+
|
|
2014
|
+
def _run_intrabar_bracket_fast(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, session_tape=None, funding_event_timestamps=None, funding_event_rates=None):
|
|
2015
|
+
tape, intent, symbol = self._prepare_intrabar_run(data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps, funding_event_rates)
|
|
2016
|
+
contract = _execution_contract_from_config(self.config)
|
|
2017
|
+
session_policy = _session_policy_from_config(self.config)
|
|
2018
|
+
if session_policy is not None and session_tape is None:
|
|
2019
|
+
raise ValueError("session_tape is required when session_policy is configured")
|
|
2020
|
+
if session_policy is None and session_tape is not None:
|
|
2021
|
+
raise ValueError("session_policy is required when session_tape is supplied")
|
|
2022
|
+
kwargs = {
|
|
2023
|
+
"tape": tape,
|
|
2024
|
+
"intent": intent,
|
|
2025
|
+
"account": self.config.account,
|
|
2026
|
+
"contract": contract,
|
|
2027
|
+
"fee_rate": self.config.v2_fee_rate,
|
|
2028
|
+
"slippage_rate": float(self.config.execution.slippage_rate),
|
|
2029
|
+
"contract_size": _scalar_for_symbol(self.config.contract_size, symbol),
|
|
2030
|
+
**self._intrabar_execution_kwargs(symbol),
|
|
2031
|
+
"report_level": self.config.report_level,
|
|
2032
|
+
}
|
|
2033
|
+
if session_policy is not None:
|
|
2034
|
+
kernel = run_intrabar_session_kernel(
|
|
2035
|
+
**kwargs,
|
|
2036
|
+
session_policy=session_policy,
|
|
2037
|
+
session_tape=session_tape,
|
|
2038
|
+
)
|
|
2039
|
+
else:
|
|
2040
|
+
kernel = run_intrabar_kernel(**kwargs)
|
|
2041
|
+
idx = kernel.equity.index
|
|
2042
|
+
returns = kernel.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
2043
|
+
diagnostics = pd.DataFrame(
|
|
2044
|
+
{
|
|
2045
|
+
"average_entry": kernel.average_entry,
|
|
2046
|
+
"active_stop": kernel.active_stop,
|
|
2047
|
+
"active_take_profit": kernel.active_take_profit,
|
|
2048
|
+
"event_flags": kernel.event_flags,
|
|
2049
|
+
"initial_margin": kernel.initial_margin,
|
|
2050
|
+
"maintenance_margin": kernel.maintenance_margin,
|
|
2051
|
+
"fees": kernel.fees,
|
|
2052
|
+
"funding": kernel.funding,
|
|
2053
|
+
},
|
|
2054
|
+
index=idx,
|
|
2055
|
+
)
|
|
2056
|
+
metadata = {
|
|
2057
|
+
**kernel.metadata,
|
|
2058
|
+
"input_mode": "intrabar_intent",
|
|
2059
|
+
"symbol": symbol,
|
|
2060
|
+
"phase": "31C_numba_intrabar_kernel",
|
|
2061
|
+
"fills_report": kernel.fills_report,
|
|
2062
|
+
"positions_report": pd.DataFrame({f"Position_{symbol}": kernel.position}, index=idx),
|
|
2063
|
+
}
|
|
2064
|
+
result = BacktestResultV2(
|
|
2065
|
+
equity=kernel.equity,
|
|
2066
|
+
returns=returns,
|
|
2067
|
+
positions=pd.DataFrame({f"Position_{symbol}": kernel.position.to_numpy(dtype=float)}, index=idx),
|
|
2068
|
+
closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
|
|
2069
|
+
symbols=[symbol],
|
|
2070
|
+
initial_capital=float(self.config.account.initial_capital),
|
|
2071
|
+
leverage=float(self.config.account.leverage),
|
|
2072
|
+
liquidated=bool(kernel.liquidated),
|
|
2073
|
+
liquidation_bar=int(kernel.liquidation_bar),
|
|
2074
|
+
fills=kernel.fills,
|
|
2075
|
+
fees=kernel.fees,
|
|
2076
|
+
funding=kernel.funding,
|
|
2077
|
+
margin=diagnostics[["initial_margin", "maintenance_margin"]],
|
|
2078
|
+
diagnostics=diagnostics,
|
|
2079
|
+
metadata=metadata,
|
|
2080
|
+
)
|
|
2081
|
+
self.engine = kernel
|
|
2082
|
+
self._store_result(result)
|
|
2083
|
+
return self.result
|
|
2084
|
+
|
|
2085
|
+
def _run_fill_replay(self, data, datetime_index, symbols, fill_replay):
|
|
2086
|
+
if fill_replay is None:
|
|
2087
|
+
raise ValueError("fill_replay endpoint requires fill_replay=FillReplayTape or DataFrame")
|
|
2088
|
+
symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
|
|
2089
|
+
if len(symbol_list) != 1:
|
|
2090
|
+
raise ValueError("fill_replay currently supports exactly one symbol")
|
|
2091
|
+
symbol = symbol_list[0]
|
|
2092
|
+
tape = prepare_market_tape(
|
|
2093
|
+
data=data,
|
|
2094
|
+
datetime_index=datetime_index,
|
|
2095
|
+
symbols=symbol_list,
|
|
2096
|
+
funding_rate=self.config.funding_rate,
|
|
2097
|
+
use_funding=False,
|
|
2098
|
+
validation_mode="strict",
|
|
2099
|
+
source_timezone=self.config.metadata.get("source_timezone"),
|
|
2100
|
+
bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
|
|
2101
|
+
)
|
|
2102
|
+
if isinstance(fill_replay, FillReplayTape):
|
|
2103
|
+
fill_tape = fill_replay
|
|
2104
|
+
elif isinstance(fill_replay, pd.DataFrame):
|
|
2105
|
+
fill_tape = FillReplayTape.from_frame(
|
|
2106
|
+
fill_replay,
|
|
2107
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2108
|
+
contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
|
|
2109
|
+
)
|
|
2110
|
+
else:
|
|
2111
|
+
raise TypeError("fill_replay must be a FillReplayTape or pandas DataFrame")
|
|
2112
|
+
replay = run_fill_replay_kernel(
|
|
2113
|
+
tape=tape,
|
|
2114
|
+
fill_tape=fill_tape,
|
|
2115
|
+
account=self.config.account,
|
|
2116
|
+
contract_size=_scalar_for_symbol(self.config.contract_size, symbol),
|
|
2117
|
+
)
|
|
2118
|
+
idx = replay.equity.index
|
|
2119
|
+
returns = replay.equity.pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0)
|
|
2120
|
+
metadata = {
|
|
2121
|
+
**replay.metadata,
|
|
2122
|
+
"symbol": symbol,
|
|
2123
|
+
"phase": "31C_fill_replay_kernel",
|
|
2124
|
+
"fills_report": fill_replay.copy() if isinstance(fill_replay, pd.DataFrame) else pd.DataFrame(),
|
|
2125
|
+
}
|
|
2126
|
+
result = BacktestResultV2(
|
|
2127
|
+
equity=replay.equity,
|
|
2128
|
+
returns=returns,
|
|
2129
|
+
positions=pd.DataFrame({f"Position_{symbol}": replay.position.to_numpy(dtype=float)}, index=idx),
|
|
2130
|
+
closes=pd.DataFrame({f"Close_{symbol}": tape.closes[:, 0]}, index=idx),
|
|
2131
|
+
symbols=[symbol],
|
|
2132
|
+
initial_capital=float(self.config.account.initial_capital),
|
|
2133
|
+
leverage=float(self.config.account.leverage),
|
|
2134
|
+
fees=replay.fees,
|
|
2135
|
+
diagnostics=pd.DataFrame({"event_flags": replay.event_flags, "fees": replay.fees}, index=idx),
|
|
2136
|
+
metadata=metadata,
|
|
2137
|
+
)
|
|
2138
|
+
self.engine = replay
|
|
2139
|
+
self._store_result(result)
|
|
2140
|
+
return self.result
|
|
2141
|
+
|
|
2142
|
+
def _prepare_intrabar_run(self, data, signal, signal_col, datetime_index, symbols, intent, intent_cols, funding_event_timestamps=None, funding_event_rates=None):
|
|
2143
|
+
symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
|
|
2144
|
+
if len(symbol_list) != 1:
|
|
2145
|
+
raise ValueError(f"{self.config.mode} currently supports exactly one symbol")
|
|
2146
|
+
symbol = symbol_list[0]
|
|
2147
|
+
tape = prepare_market_tape(
|
|
2148
|
+
data=data,
|
|
2149
|
+
datetime_index=datetime_index,
|
|
2150
|
+
symbols=symbol_list,
|
|
2151
|
+
funding_rate=self.config.funding_rate,
|
|
2152
|
+
funding_event_timestamps=funding_event_timestamps,
|
|
2153
|
+
funding_event_rates=funding_event_rates,
|
|
2154
|
+
use_funding=self.config.use_funding,
|
|
2155
|
+
validation_mode="strict",
|
|
2156
|
+
missing_funding_policy=str(self.config.metadata.get("missing_funding_policy", "raise")),
|
|
2157
|
+
source_timezone=self.config.metadata.get("source_timezone"),
|
|
2158
|
+
bar_timestamp_semantics=str(self.config.metadata.get("bar_timestamp_semantics", "close")),
|
|
2159
|
+
)
|
|
2160
|
+
lookup_frame = None if isinstance(data, PreparedMarketTape) else _strict_lookup_frame(data, datetime_index, source_timezone=self.config.metadata.get("source_timezone"))
|
|
2161
|
+
if intent is None:
|
|
2162
|
+
level_mode = IntrabarLevelMode(str(self.config.metadata.get("intrabar_level_mode", IntrabarLevelMode.PERCENT_DISTANCE.value)))
|
|
2163
|
+
intent = _intrabar_intent_from_endpoint_input(
|
|
2164
|
+
frame=lookup_frame,
|
|
2165
|
+
index=pd.DatetimeIndex(pd.to_datetime(tape.timestamps_ns, utc=True)),
|
|
2166
|
+
signal=signal,
|
|
2167
|
+
signal_col=signal_col,
|
|
2168
|
+
intent_cols=intent_cols or {},
|
|
2169
|
+
level_mode=level_mode,
|
|
2170
|
+
)
|
|
2171
|
+
return tape, intent, symbol
|
|
2172
|
+
|
|
2173
|
+
def _intrabar_execution_kwargs(self, symbol: str) -> Dict:
|
|
2174
|
+
constraints = build_quantity_constraints(
|
|
2175
|
+
[symbol],
|
|
2176
|
+
instruments=self.config.instruments,
|
|
2177
|
+
qty_step=self.config.qty_step,
|
|
2178
|
+
lot_size=self.config.lot_size,
|
|
2179
|
+
slot_size=self.config.slot_size,
|
|
2180
|
+
min_qty=self.config.min_qty,
|
|
2181
|
+
min_notional=self.config.min_notional,
|
|
2182
|
+
)
|
|
2183
|
+
sizing_mode = IntrabarSizingMode(str(self.config.metadata.get("intrabar_sizing_mode", IntrabarSizingMode.UNITS.value)))
|
|
2184
|
+
fixed_notional = float(self.config.metadata.get("fixed_notional", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0)))
|
|
2185
|
+
equity_fraction = float(self.config.metadata.get("equity_fraction", self.config.alloc_per_trade if not isinstance(self.config.alloc_per_trade, dict) else self.config.alloc_per_trade.get(symbol, 0.0)))
|
|
2186
|
+
risk_fraction = float(self.config.metadata.get("risk_fraction", 0.0))
|
|
2187
|
+
return {
|
|
2188
|
+
"sizing_mode": sizing_mode,
|
|
2189
|
+
"fixed_notional": fixed_notional,
|
|
2190
|
+
"equity_fraction": equity_fraction,
|
|
2191
|
+
"risk_fraction": risk_fraction,
|
|
2192
|
+
"qty_step": float(constraints.qty_step[0]),
|
|
2193
|
+
"min_qty": float(constraints.min_qty[0]),
|
|
2194
|
+
"min_notional": float(constraints.min_notional[0]),
|
|
2195
|
+
"tick_size": _tick_size_for_symbol(self.config.instruments, symbol, self.config.metadata.get("tick_size", 0.0)),
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
def _run_single(self, data, signal, signal_col, datetime_index, symbols):
|
|
2199
|
+
frame, idx, sig = _normalize_single_data(data=data, signal=signal, signal_col=signal_col, datetime_index=datetime_index)
|
|
2200
|
+
backend = _resolve_backend(self.config)
|
|
2201
|
+
symbol_list = list(symbols or self.config.symbols or ["DEFAULT"])
|
|
2202
|
+
if backend == "legacy":
|
|
2203
|
+
self.engine = BacktestEngine(
|
|
2204
|
+
Datetime=idx,
|
|
2205
|
+
Position=sig,
|
|
2206
|
+
Close=frame["close"],
|
|
2207
|
+
High=frame.get("high"),
|
|
2208
|
+
Low=frame.get("low"),
|
|
2209
|
+
fee=self.config.fee,
|
|
2210
|
+
use_pyramiding=self.config.use_pyramiding,
|
|
2211
|
+
initial_capital=self.config.account.initial_capital,
|
|
2212
|
+
leverage=self.config.account.leverage,
|
|
2213
|
+
maintenance_ratio=self.config.account.maintenance_ratio,
|
|
2214
|
+
contract_size=self.config.contract_size,
|
|
2215
|
+
use_funding_rate=self.config.use_funding,
|
|
2216
|
+
funding_rate=self.config.funding_rate,
|
|
2217
|
+
alloc_per_trade=self.config.alloc_per_trade,
|
|
2218
|
+
hedge_type=self.config.sizing,
|
|
2219
|
+
slippage=self.config.slippage,
|
|
2220
|
+
symbols=None,
|
|
2221
|
+
instruments=self.config.instruments,
|
|
2222
|
+
qty_step=self.config.qty_step,
|
|
2223
|
+
lot_size=self.config.lot_size,
|
|
2224
|
+
slot_size=self.config.slot_size,
|
|
2225
|
+
min_qty=self.config.min_qty,
|
|
2226
|
+
min_notional=self.config.min_notional,
|
|
2227
|
+
**self.config.dca_kwargs,
|
|
2228
|
+
)
|
|
2229
|
+
self._store_result(self.engine.result)
|
|
2230
|
+
return self.result
|
|
2231
|
+
|
|
2232
|
+
self.engine = BacktestEngineV2(
|
|
2233
|
+
data=frame,
|
|
2234
|
+
signals=sig,
|
|
2235
|
+
symbols=symbol_list,
|
|
2236
|
+
backend=backend,
|
|
2237
|
+
native_backend=self.config.native_backend,
|
|
2238
|
+
account=self.config.account,
|
|
2239
|
+
execution=self.config.execution,
|
|
2240
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2241
|
+
use_funding=self.config.use_funding,
|
|
2242
|
+
funding_rate=self.config.funding_rate,
|
|
2243
|
+
alloc_per_trade=self.config.alloc_per_trade,
|
|
2244
|
+
hedge_type=self.config.sizing,
|
|
2245
|
+
use_pyramiding=self.config.use_pyramiding,
|
|
2246
|
+
contract_size=self.config.contract_size,
|
|
2247
|
+
nautilus_config=self.config.nautilus_config,
|
|
2248
|
+
instruments=self.config.instruments,
|
|
2249
|
+
qty_step=self.config.qty_step,
|
|
2250
|
+
lot_size=self.config.lot_size,
|
|
2251
|
+
slot_size=self.config.slot_size,
|
|
2252
|
+
min_qty=self.config.min_qty,
|
|
2253
|
+
min_notional=self.config.min_notional,
|
|
2254
|
+
report_level=self.config.report_level,
|
|
2255
|
+
audit_sink=self.config.audit_sink,
|
|
2256
|
+
audit_sink_path=self.config.audit_sink_path,
|
|
2257
|
+
reactive_kernel_mode=self.config.reactive_kernel_mode,
|
|
2258
|
+
)
|
|
2259
|
+
markers = _intrabar_marker_columns(frame)
|
|
2260
|
+
if backend == "native_vectorized" and markers:
|
|
2261
|
+
warnings.warn(
|
|
2262
|
+
"native_vectorized is close_target_v2 and does not certify intrabar SL/TP/trailing columns "
|
|
2263
|
+
f"{markers}; use a future intrabar/fill-replay/event backend for those semantics.",
|
|
2264
|
+
RuntimeWarning,
|
|
2265
|
+
stacklevel=2,
|
|
2266
|
+
)
|
|
2267
|
+
self.engine.result.metadata["intrabar_misuse_markers"] = markers
|
|
2268
|
+
self.engine.result.metadata["certification_status"] = "uncertified_intrabar_columns_on_close_target"
|
|
2269
|
+
self._store_result(self.engine.result)
|
|
2270
|
+
return self.result
|
|
2271
|
+
|
|
2272
|
+
def _run_orders(self, data, orders, order_commands, datetime_index, symbols):
|
|
2273
|
+
if not orders and not order_commands:
|
|
2274
|
+
raise ValueError("orders endpoint requires orders=[OrderIntent(...)] or order_commands=[OrderCommand(...)]")
|
|
2275
|
+
frame, idx, _ = _normalize_single_data(data=data, signal=pd.Series(0.0, index=_infer_index(data, datetime_index)), signal_col=None, datetime_index=datetime_index)
|
|
2276
|
+
backend = _resolve_backend(self.config)
|
|
2277
|
+
event_version = str(self.config.event_engine_version).lower().strip()
|
|
2278
|
+
if order_commands is not None:
|
|
2279
|
+
event_version = "v2"
|
|
2280
|
+
self.engine = BacktestEngineV2(
|
|
2281
|
+
data=frame,
|
|
2282
|
+
symbols=list(symbols or self.config.symbols or ["asset"]),
|
|
2283
|
+
backend=backend,
|
|
2284
|
+
native_backend=self.config.native_backend,
|
|
2285
|
+
orders=orders,
|
|
2286
|
+
order_commands=order_commands,
|
|
2287
|
+
event_engine_version=event_version,
|
|
2288
|
+
account=self.config.account,
|
|
2289
|
+
execution=self.config.execution,
|
|
2290
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2291
|
+
use_funding=self.config.use_funding,
|
|
2292
|
+
funding_rate=self.config.funding_rate,
|
|
2293
|
+
contract_size=self.config.contract_size,
|
|
2294
|
+
instruments=self.config.instruments,
|
|
2295
|
+
qty_step=self.config.qty_step,
|
|
2296
|
+
lot_size=self.config.lot_size,
|
|
2297
|
+
slot_size=self.config.slot_size,
|
|
2298
|
+
min_qty=self.config.min_qty,
|
|
2299
|
+
min_notional=self.config.min_notional,
|
|
2300
|
+
report_level=self.config.report_level,
|
|
2301
|
+
audit_sink=self.config.audit_sink,
|
|
2302
|
+
audit_sink_path=self.config.audit_sink_path,
|
|
2303
|
+
reactive_kernel_mode=self.config.reactive_kernel_mode,
|
|
2304
|
+
)
|
|
2305
|
+
self._store_result(self.engine.result)
|
|
2306
|
+
return self.result
|
|
2307
|
+
|
|
2308
|
+
def _run_native_event_strategy(self, data, strategy, datetime_index, symbols):
|
|
2309
|
+
if strategy is None:
|
|
2310
|
+
raise ValueError("native_event_strategy endpoint requires strategy=...")
|
|
2311
|
+
frame, idx, _ = _normalize_single_data(
|
|
2312
|
+
data=data,
|
|
2313
|
+
signal=pd.Series(0.0, index=_infer_index(data, datetime_index)),
|
|
2314
|
+
signal_col=None,
|
|
2315
|
+
datetime_index=datetime_index,
|
|
2316
|
+
)
|
|
2317
|
+
symbol_list = list(symbols or self.config.symbols or ["asset"])
|
|
2318
|
+
self.engine = BacktestEngineV2(
|
|
2319
|
+
data=frame,
|
|
2320
|
+
symbols=symbol_list,
|
|
2321
|
+
backend="native_event",
|
|
2322
|
+
native_backend=self.config.native_backend,
|
|
2323
|
+
strategy=strategy,
|
|
2324
|
+
event_engine_version="v2",
|
|
2325
|
+
reactive_execution_mode=self.config.reactive_execution_mode,
|
|
2326
|
+
account=self.config.account,
|
|
2327
|
+
execution=self.config.execution,
|
|
2328
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2329
|
+
use_funding=self.config.use_funding,
|
|
2330
|
+
funding_rate=self.config.funding_rate,
|
|
2331
|
+
contract_size=self.config.contract_size,
|
|
2332
|
+
instruments=self.config.instruments,
|
|
2333
|
+
qty_step=self.config.qty_step,
|
|
2334
|
+
lot_size=self.config.lot_size,
|
|
2335
|
+
slot_size=self.config.slot_size,
|
|
2336
|
+
min_qty=self.config.min_qty,
|
|
2337
|
+
min_notional=self.config.min_notional,
|
|
2338
|
+
report_level=self.config.report_level,
|
|
2339
|
+
audit_sink=self.config.audit_sink,
|
|
2340
|
+
audit_sink_path=self.config.audit_sink_path,
|
|
2341
|
+
reactive_kernel_mode=self.config.reactive_kernel_mode,
|
|
2342
|
+
)
|
|
2343
|
+
self._store_result(self.engine.result)
|
|
2344
|
+
return self.result
|
|
2345
|
+
|
|
2346
|
+
def _run_structured_orders(self, data, datetime_index, symbols):
|
|
2347
|
+
spec = self.config.structured_order_spec
|
|
2348
|
+
if spec is None:
|
|
2349
|
+
raise ValueError(f"{self.config.mode} endpoint requires a structured order spec")
|
|
2350
|
+
frame = _standardize_frame(data, datetime_index=datetime_index)
|
|
2351
|
+
symbol_list = list(symbols or self.config.symbols or [spec.symbol])
|
|
2352
|
+
if spec.symbol not in symbol_list:
|
|
2353
|
+
symbol_list = [spec.symbol]
|
|
2354
|
+
if isinstance(spec, DcaGridSpec):
|
|
2355
|
+
plan = build_dca_grid_order_plan(spec, close=frame["close"])
|
|
2356
|
+
elif isinstance(spec, BracketOrderSpec):
|
|
2357
|
+
plan = build_bracket_order_plan(spec)
|
|
2358
|
+
else:
|
|
2359
|
+
raise TypeError(f"unsupported structured_order_spec={type(spec).__name__}")
|
|
2360
|
+
|
|
2361
|
+
params = {
|
|
2362
|
+
"input_mode": plan.package_type,
|
|
2363
|
+
"structured_order_plan": plan,
|
|
2364
|
+
"structured_order_table": plan.order_table,
|
|
2365
|
+
"package_id": plan.package_id,
|
|
2366
|
+
"package_type": plan.package_type,
|
|
2367
|
+
"package_metadata": plan.metadata,
|
|
2368
|
+
"order_count_input": len(plan.orders),
|
|
2369
|
+
}
|
|
2370
|
+
backend = _resolve_backend(self.config)
|
|
2371
|
+
if backend == "native_event":
|
|
2372
|
+
commands = order_intents_to_lifecycle_commands(plan.orders)
|
|
2373
|
+
self.engine = BacktestEngineV2(
|
|
2374
|
+
data=frame,
|
|
2375
|
+
symbols=[spec.symbol],
|
|
2376
|
+
backend="native_event",
|
|
2377
|
+
native_backend=self.config.native_backend,
|
|
2378
|
+
order_commands=commands,
|
|
2379
|
+
event_engine_version="v2",
|
|
2380
|
+
account=self.config.account,
|
|
2381
|
+
execution=self.config.execution,
|
|
2382
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2383
|
+
use_funding=self.config.use_funding,
|
|
2384
|
+
funding_rate=self.config.funding_rate,
|
|
2385
|
+
contract_size=self.config.contract_size,
|
|
2386
|
+
instruments=self.config.instruments,
|
|
2387
|
+
qty_step=self.config.qty_step,
|
|
2388
|
+
lot_size=self.config.lot_size,
|
|
2389
|
+
slot_size=self.config.slot_size,
|
|
2390
|
+
min_qty=self.config.min_qty,
|
|
2391
|
+
min_notional=self.config.min_notional,
|
|
2392
|
+
report_level=self.config.report_level,
|
|
2393
|
+
audit_sink=self.config.audit_sink,
|
|
2394
|
+
audit_sink_path=self.config.audit_sink_path,
|
|
2395
|
+
)
|
|
2396
|
+
result = self.engine.result
|
|
2397
|
+
result.metadata.update(
|
|
2398
|
+
{
|
|
2399
|
+
**params,
|
|
2400
|
+
"engine": f"event_v2_{plan.package_type}",
|
|
2401
|
+
"lifecycle_command_count": len(commands),
|
|
2402
|
+
"lifecycle_commands": commands,
|
|
2403
|
+
}
|
|
2404
|
+
)
|
|
2405
|
+
elif backend == "nautilus":
|
|
2406
|
+
result = self._run_nautilus_package_orders(
|
|
2407
|
+
data={spec.symbol: frame},
|
|
2408
|
+
orders=plan.orders,
|
|
2409
|
+
symbols=[spec.symbol],
|
|
2410
|
+
params=params,
|
|
2411
|
+
)
|
|
2412
|
+
result.metadata["engine"] = f"nautilus_{plan.package_type}"
|
|
2413
|
+
else:
|
|
2414
|
+
raise ValueError(f"structured order endpoints require backend='native_event' or 'nautilus', got {backend!r}")
|
|
2415
|
+
self._store_result(result)
|
|
2416
|
+
return self.result
|
|
2417
|
+
|
|
2418
|
+
def _run_basket(self, data, signal, signal_col, basket, closes, highs, lows, datetime_index, symbols):
|
|
2419
|
+
spec = basket or self.config.basket
|
|
2420
|
+
if spec is None:
|
|
2421
|
+
raise ValueError("basket endpoint requires a BasketSpec")
|
|
2422
|
+
sig = signal if signal is not None else _signal_from_data(data, signal_col)
|
|
2423
|
+
if sig is None:
|
|
2424
|
+
raise ValueError("basket endpoint requires signal or signal_col")
|
|
2425
|
+
close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
|
|
2426
|
+
data=data,
|
|
2427
|
+
closes=closes,
|
|
2428
|
+
highs=highs,
|
|
2429
|
+
lows=lows,
|
|
2430
|
+
datetime_index=datetime_index,
|
|
2431
|
+
symbols=symbols,
|
|
2432
|
+
)
|
|
2433
|
+
backend = _resolve_backend(self.config)
|
|
2434
|
+
if backend == "nautilus":
|
|
2435
|
+
plan = build_frozen_basket_orders(
|
|
2436
|
+
datetime_index=idx,
|
|
2437
|
+
basket=spec,
|
|
2438
|
+
signal=sig,
|
|
2439
|
+
closes=close_map,
|
|
2440
|
+
order_type=OrderType.MARKET,
|
|
2441
|
+
tif=TimeInForce.IOC,
|
|
2442
|
+
)
|
|
2443
|
+
result = self._run_nautilus_package_orders(
|
|
2444
|
+
data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list),
|
|
2445
|
+
orders=plan.orders,
|
|
2446
|
+
symbols=symbol_list,
|
|
2447
|
+
params={
|
|
2448
|
+
"input_mode": "basket_package",
|
|
2449
|
+
"basket_id": spec.basket_id,
|
|
2450
|
+
"basket_plan": plan,
|
|
2451
|
+
"basket_target_units": plan.target_units,
|
|
2452
|
+
"basket_execution_policy": spec.execution_policy.value,
|
|
2453
|
+
"package_target_units": plan.target_units,
|
|
2454
|
+
"order_count_input": len(plan.orders),
|
|
2455
|
+
},
|
|
2456
|
+
)
|
|
2457
|
+
result.metadata["engine"] = "nautilus_basket_package"
|
|
2458
|
+
self._store_result(result)
|
|
2459
|
+
return self.result
|
|
2460
|
+
|
|
2461
|
+
self.engine = BacktestEngineV2(
|
|
2462
|
+
backend="native_event",
|
|
2463
|
+
native_backend=self.config.native_backend,
|
|
2464
|
+
basket=spec,
|
|
2465
|
+
signal=sig,
|
|
2466
|
+
closes=close_map,
|
|
2467
|
+
highs=high_map,
|
|
2468
|
+
lows=low_map,
|
|
2469
|
+
datetime_index=idx,
|
|
2470
|
+
symbols=symbol_list,
|
|
2471
|
+
account=self.config.account,
|
|
2472
|
+
execution=self.config.execution,
|
|
2473
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2474
|
+
use_funding=self.config.use_funding,
|
|
2475
|
+
funding_rate=self.config.funding_rate,
|
|
2476
|
+
contract_size=self.config.contract_size,
|
|
2477
|
+
)
|
|
2478
|
+
self._store_result(self.engine.result)
|
|
2479
|
+
return self.result
|
|
2480
|
+
|
|
2481
|
+
def _run_arbitrage(self, data, signal, signal_col, closes, highs, lows, hedge_ratios, datetime_index, symbols):
|
|
2482
|
+
spec = self.config.arbitrage_spec
|
|
2483
|
+
if spec is None:
|
|
2484
|
+
raise ValueError("arbitrage endpoint requires an arbitrage spec")
|
|
2485
|
+
phase_g_package_specs = (CalendarSpreadSpec, FundingArbitrageSpec, SpotPerpCashCarrySpec, IndexBasketArbSpec)
|
|
2486
|
+
schema_only_specs = (CrossExchangeArbSpec, TriangularArbSpec, OptionsVolArbSpec)
|
|
2487
|
+
if isinstance(spec, schema_only_specs):
|
|
2488
|
+
if isinstance(spec, OptionsVolArbSpec):
|
|
2489
|
+
raise NotImplementedError(
|
|
2490
|
+
"OptionsVolArbSpec must route through QuantBTEndpoint.options(...), not generic arbitrage execution. "
|
|
2491
|
+
"The option route preserves package fills, multi-currency ledger, Greeks, settlement, and margin reports."
|
|
2492
|
+
)
|
|
2493
|
+
raise NotImplementedError(
|
|
2494
|
+
f"{type(spec).__name__} is schema-validated but requires a specialized arbitrage engine; "
|
|
2495
|
+
"do not route it through generic package execution"
|
|
2496
|
+
)
|
|
2497
|
+
if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec, *phase_g_package_specs)):
|
|
2498
|
+
raise NotImplementedError(
|
|
2499
|
+
"Arbitrage endpoint supports BasisArbitrageSpec, StatArbPairSpec, and package-style Phase G specs; "
|
|
2500
|
+
f"got {type(spec).__name__}"
|
|
2501
|
+
)
|
|
2502
|
+
backend = _resolve_backend(self.config)
|
|
2503
|
+
if backend not in ("native_event", "native_vectorized", "nautilus"):
|
|
2504
|
+
raise NotImplementedError("Phase F arbitrage endpoint supports backend='native_event', 'native_vectorized', or 'nautilus'")
|
|
2505
|
+
sig = signal if signal is not None else _signal_from_data(data, signal_col)
|
|
2506
|
+
if sig is None:
|
|
2507
|
+
raise ValueError("arbitrage endpoint requires signal or signal_col")
|
|
2508
|
+
spec_symbols = [leg.symbol for leg in spec.legs]
|
|
2509
|
+
close_map, high_map, low_map, idx, _ = _normalize_symbol_data(
|
|
2510
|
+
data=data,
|
|
2511
|
+
closes=closes,
|
|
2512
|
+
highs=highs,
|
|
2513
|
+
lows=lows,
|
|
2514
|
+
datetime_index=datetime_index,
|
|
2515
|
+
symbols=symbols or spec_symbols,
|
|
2516
|
+
)
|
|
2517
|
+
if backend == "nautilus":
|
|
2518
|
+
if not isinstance(spec, (BasisArbitrageSpec, StatArbPairSpec)):
|
|
2519
|
+
raise NotImplementedError("Phase F Nautilus arbitrage supports BasisArbitrageSpec and StatArbPairSpec only")
|
|
2520
|
+
result = self._run_nautilus_arbitrage(
|
|
2521
|
+
spec=spec,
|
|
2522
|
+
signal=sig,
|
|
2523
|
+
close_map=close_map,
|
|
2524
|
+
high_map=high_map,
|
|
2525
|
+
low_map=low_map,
|
|
2526
|
+
idx=idx,
|
|
2527
|
+
hedge_ratios=hedge_ratios,
|
|
2528
|
+
)
|
|
2529
|
+
self._store_result(result)
|
|
2530
|
+
return self.result
|
|
2531
|
+
|
|
2532
|
+
if backend == "native_event":
|
|
2533
|
+
self.engine = NativeEventBackend(
|
|
2534
|
+
NativeEventConfig(
|
|
2535
|
+
account=self.config.account,
|
|
2536
|
+
execution=self.config.execution,
|
|
2537
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2538
|
+
use_funding=self.config.use_funding,
|
|
2539
|
+
report_level=self.config.report_level,
|
|
2540
|
+
audit_sink=self.config.audit_sink,
|
|
2541
|
+
audit_sink_path=self.config.audit_sink_path,
|
|
2542
|
+
native_backend=self.config.native_backend,
|
|
2543
|
+
)
|
|
2544
|
+
)
|
|
2545
|
+
else:
|
|
2546
|
+
self.engine = NativeVectorizedBackend(
|
|
2547
|
+
NativeVectorizedConfig(
|
|
2548
|
+
account=self.config.account,
|
|
2549
|
+
execution=self.config.execution,
|
|
2550
|
+
fee_rate=self.config.v2_fee_rate,
|
|
2551
|
+
use_funding=self.config.use_funding,
|
|
2552
|
+
)
|
|
2553
|
+
)
|
|
2554
|
+
if isinstance(spec, BasisArbitrageSpec):
|
|
2555
|
+
result = self.engine.run_basis_arbitrage(
|
|
2556
|
+
datetime_index=idx,
|
|
2557
|
+
spec=spec,
|
|
2558
|
+
signal=sig,
|
|
2559
|
+
closes=close_map,
|
|
2560
|
+
highs=high_map,
|
|
2561
|
+
lows=low_map,
|
|
2562
|
+
funding_rate=self.config.funding_rate,
|
|
2563
|
+
contract_size=self.config.contract_size,
|
|
2564
|
+
leverage=self.config.account.leverage,
|
|
2565
|
+
hedge_ratios=hedge_ratios,
|
|
2566
|
+
)
|
|
2567
|
+
elif isinstance(spec, StatArbPairSpec):
|
|
2568
|
+
result = self.engine.run_stat_arb_pair_arbitrage(
|
|
2569
|
+
datetime_index=idx,
|
|
2570
|
+
spec=spec,
|
|
2571
|
+
signal=sig,
|
|
2572
|
+
closes=close_map,
|
|
2573
|
+
highs=high_map,
|
|
2574
|
+
lows=low_map,
|
|
2575
|
+
funding_rate=self.config.funding_rate,
|
|
2576
|
+
contract_size=self.config.contract_size,
|
|
2577
|
+
leverage=self.config.account.leverage,
|
|
2578
|
+
hedge_ratios=hedge_ratios,
|
|
2579
|
+
)
|
|
2580
|
+
else:
|
|
2581
|
+
result = self.engine.run_package_arbitrage(
|
|
2582
|
+
datetime_index=idx,
|
|
2583
|
+
spec=spec,
|
|
2584
|
+
signal=sig,
|
|
2585
|
+
closes=close_map,
|
|
2586
|
+
highs=high_map,
|
|
2587
|
+
lows=low_map,
|
|
2588
|
+
funding_rate=self.config.funding_rate,
|
|
2589
|
+
contract_size=self.config.contract_size,
|
|
2590
|
+
leverage=self.config.account.leverage,
|
|
2591
|
+
hedge_ratios=hedge_ratios,
|
|
2592
|
+
)
|
|
2593
|
+
self._store_result(result)
|
|
2594
|
+
return self.result
|
|
2595
|
+
|
|
2596
|
+
def _run_walk_forward(
|
|
2597
|
+
self,
|
|
2598
|
+
data,
|
|
2599
|
+
signal,
|
|
2600
|
+
signal_col,
|
|
2601
|
+
positions,
|
|
2602
|
+
closes,
|
|
2603
|
+
highs,
|
|
2604
|
+
lows,
|
|
2605
|
+
hedge_ratios,
|
|
2606
|
+
datetime_index,
|
|
2607
|
+
symbols,
|
|
2608
|
+
params,
|
|
2609
|
+
param_ranges,
|
|
2610
|
+
):
|
|
2611
|
+
if self.config.strategy_class is None:
|
|
2612
|
+
raise ValueError("walk_forward endpoint requires strategy_class")
|
|
2613
|
+
from .walkforward import WalkForwardConfig, WalkForwardEngine
|
|
2614
|
+
|
|
2615
|
+
wf_config = self.config.walkforward_config or WalkForwardConfig(target_mode=self.config.walkforward_target_mode)
|
|
2616
|
+
target_mode = self.config.walkforward_target_mode.lower().strip()
|
|
2617
|
+
scorer = (
|
|
2618
|
+
_make_walkforward_endpoint_scorer(
|
|
2619
|
+
self.config,
|
|
2620
|
+
target_mode=target_mode,
|
|
2621
|
+
symbols=symbols,
|
|
2622
|
+
wf_config=wf_config,
|
|
2623
|
+
market_data=data,
|
|
2624
|
+
market_closes=closes,
|
|
2625
|
+
market_highs=highs,
|
|
2626
|
+
market_lows=lows,
|
|
2627
|
+
market_datetime_index=datetime_index,
|
|
2628
|
+
)
|
|
2629
|
+
if wf_config.scoring_backend == "endpoint"
|
|
2630
|
+
else None
|
|
2631
|
+
)
|
|
2632
|
+
engine = WalkForwardEngine(strategy=self.config.strategy_class, config=wf_config, scorer=scorer)
|
|
2633
|
+
wf_result = engine.run(
|
|
2634
|
+
data=data if data is not None else closes,
|
|
2635
|
+
params=params,
|
|
2636
|
+
param_ranges=param_ranges,
|
|
2637
|
+
datetime_index=datetime_index,
|
|
2638
|
+
)
|
|
2639
|
+
stitched = wf_result.oos_output
|
|
2640
|
+
if stitched is None:
|
|
2641
|
+
raise ValueError("walk-forward strategy produced no OOS output")
|
|
2642
|
+
|
|
2643
|
+
if target_mode == "portfolio":
|
|
2644
|
+
if isinstance(stitched, pd.Series):
|
|
2645
|
+
raise TypeError("portfolio walk_forward target_mode requires DataFrame or {symbol: Series} output")
|
|
2646
|
+
result = self._run_portfolio(
|
|
2647
|
+
data=data,
|
|
2648
|
+
positions=stitched,
|
|
2649
|
+
closes=closes,
|
|
2650
|
+
highs=highs,
|
|
2651
|
+
lows=lows,
|
|
2652
|
+
datetime_index=datetime_index,
|
|
2653
|
+
symbols=symbols,
|
|
2654
|
+
)
|
|
2655
|
+
elif target_mode == "arbitrage":
|
|
2656
|
+
if not isinstance(stitched, pd.Series):
|
|
2657
|
+
raise TypeError("arbitrage walk_forward target_mode requires a scalar signal Series output")
|
|
2658
|
+
result = self._run_arbitrage(
|
|
2659
|
+
data=data,
|
|
2660
|
+
signal=stitched,
|
|
2661
|
+
signal_col=None,
|
|
2662
|
+
closes=closes,
|
|
2663
|
+
highs=highs,
|
|
2664
|
+
lows=lows,
|
|
2665
|
+
hedge_ratios=hedge_ratios,
|
|
2666
|
+
datetime_index=datetime_index,
|
|
2667
|
+
symbols=symbols,
|
|
2668
|
+
)
|
|
2669
|
+
elif target_mode == "basket":
|
|
2670
|
+
if not isinstance(stitched, pd.Series):
|
|
2671
|
+
raise TypeError("basket walk_forward target_mode requires a scalar signal Series output")
|
|
2672
|
+
result = self._run_basket(
|
|
2673
|
+
data=data,
|
|
2674
|
+
signal=stitched,
|
|
2675
|
+
signal_col=None,
|
|
2676
|
+
basket=self.config.basket,
|
|
2677
|
+
closes=closes,
|
|
2678
|
+
highs=highs,
|
|
2679
|
+
lows=lows,
|
|
2680
|
+
datetime_index=datetime_index,
|
|
2681
|
+
symbols=symbols,
|
|
2682
|
+
)
|
|
2683
|
+
else:
|
|
2684
|
+
if not isinstance(stitched, pd.Series):
|
|
2685
|
+
raise TypeError(f"{target_mode} walk_forward target_mode requires a scalar signal Series output")
|
|
2686
|
+
result = self._run_single(
|
|
2687
|
+
data=data,
|
|
2688
|
+
signal=stitched,
|
|
2689
|
+
signal_col=None,
|
|
2690
|
+
datetime_index=datetime_index,
|
|
2691
|
+
symbols=symbols,
|
|
2692
|
+
)
|
|
2693
|
+
|
|
2694
|
+
wf_result.backtest_result = result
|
|
2695
|
+
result.metadata["walk_forward"] = {
|
|
2696
|
+
"engine": wf_result.metadata["engine"],
|
|
2697
|
+
"target_mode": target_mode,
|
|
2698
|
+
"n_folds": wf_result.metadata["n_folds"],
|
|
2699
|
+
"report_scope": "test" if wf_result.metadata.get("split_frequency") == "single" else "oos",
|
|
2700
|
+
"split_frequency": wf_result.metadata.get("split_frequency"),
|
|
2701
|
+
"window_mode": wf_result.metadata.get("window_mode"),
|
|
2702
|
+
"params": wf_result.params,
|
|
2703
|
+
"fold_table": wf_result.fold_table,
|
|
2704
|
+
"trial_table": wf_result.trial_table,
|
|
2705
|
+
"candidate_table": wf_result.candidate_table,
|
|
2706
|
+
"best_trial": wf_result.best_trial,
|
|
2707
|
+
"optimization_mode": wf_result.metadata.get("optimization_mode"),
|
|
2708
|
+
"validation_claim": wf_result.metadata.get("validation_claim"),
|
|
2709
|
+
"full_sample_used_for_selection": wf_result.metadata.get("full_sample_used_for_selection"),
|
|
2710
|
+
"oos_used_for_selection": wf_result.metadata.get("oos_used_for_selection"),
|
|
2711
|
+
"data_hash": wf_result.metadata.get("data_hash"),
|
|
2712
|
+
"config_hash": wf_result.metadata.get("config_hash"),
|
|
2713
|
+
"random_seed": wf_result.metadata.get("random_seed"),
|
|
2714
|
+
"top_is_fraction": wf_result.metadata.get("top_is_fraction"),
|
|
2715
|
+
"top_is_k": wf_result.metadata.get("top_is_k"),
|
|
2716
|
+
"candidate_selection_metric": wf_result.metadata.get("candidate_selection_metric"),
|
|
2717
|
+
"scoring_trading_days": wf_result.metadata.get("scoring_trading_days"),
|
|
2718
|
+
"min_trades_per_year": wf_result.metadata.get("min_trades_per_year"),
|
|
2719
|
+
"trade_penalty_factor": wf_result.metadata.get("trade_penalty_factor"),
|
|
2720
|
+
"sbb_simulation": wf_result.metadata.get("sbb_simulation"),
|
|
2721
|
+
"sbb_samples": wf_result.metadata.get("sbb_samples"),
|
|
2722
|
+
"sbb_block_length": wf_result.metadata.get("sbb_block_length"),
|
|
2723
|
+
"regime_count": wf_result.metadata.get("regime_count"),
|
|
2724
|
+
"regime_lookback": wf_result.metadata.get("regime_lookback"),
|
|
2725
|
+
"regime_weights": wf_result.metadata.get("regime_weights"),
|
|
2726
|
+
"stress_vol_multiplier": wf_result.metadata.get("stress_vol_multiplier"),
|
|
2727
|
+
"garch_p": wf_result.metadata.get("garch_p"),
|
|
2728
|
+
"garch_q": wf_result.metadata.get("garch_q"),
|
|
2729
|
+
"garch_dist": wf_result.metadata.get("garch_dist"),
|
|
2730
|
+
"garch_vol_multiplier": wf_result.metadata.get("garch_vol_multiplier"),
|
|
2731
|
+
"plateau_quantile": wf_result.metadata.get("plateau_quantile"),
|
|
2732
|
+
"plateau_median_weight": wf_result.metadata.get("plateau_median_weight"),
|
|
2733
|
+
"plateau_std_penalty": wf_result.metadata.get("plateau_std_penalty"),
|
|
2734
|
+
"plateau_size_bonus": wf_result.metadata.get("plateau_size_bonus"),
|
|
2735
|
+
"is_subperiods": wf_result.metadata.get("is_subperiods"),
|
|
2736
|
+
"q25_weight": wf_result.metadata.get("q25_weight"),
|
|
2737
|
+
"dispersion_penalty": wf_result.metadata.get("dispersion_penalty"),
|
|
2738
|
+
"temporal_weight": wf_result.metadata.get("temporal_weight"),
|
|
2739
|
+
"plateau_weight": wf_result.metadata.get("plateau_weight"),
|
|
2740
|
+
"use_bootstrap_penalty": wf_result.metadata.get("use_bootstrap_penalty"),
|
|
2741
|
+
"use_complexity_penalty": wf_result.metadata.get("use_complexity_penalty"),
|
|
2742
|
+
"scoring_backend": wf_result.metadata.get("scoring_backend"),
|
|
2743
|
+
"numba_enabled": wf_result.metadata.get("numba_enabled"),
|
|
2744
|
+
}
|
|
2745
|
+
if scorer is not None and hasattr(scorer, "prepared_cache_metadata"):
|
|
2746
|
+
result.metadata["walk_forward"]["prepared_scoring_cache"] = scorer.prepared_cache_metadata()
|
|
2747
|
+
result.metadata["walk_forward_result"] = wf_result
|
|
2748
|
+
self.engine = engine
|
|
2749
|
+
self.result = result
|
|
2750
|
+
return result
|
|
2751
|
+
|
|
2752
|
+
def _run_nautilus_arbitrage(self, spec, signal, close_map, high_map, low_map, idx, hedge_ratios):
|
|
2753
|
+
from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
|
|
2754
|
+
|
|
2755
|
+
symbols = [leg.symbol for leg in spec.legs]
|
|
2756
|
+
if isinstance(spec, BasisArbitrageSpec):
|
|
2757
|
+
plan = build_arbitrage_order_plan(
|
|
2758
|
+
datetime_index=idx,
|
|
2759
|
+
spec=spec,
|
|
2760
|
+
signal=signal,
|
|
2761
|
+
closes=close_map,
|
|
2762
|
+
hedge_ratios=hedge_ratios,
|
|
2763
|
+
)
|
|
2764
|
+
extra_metadata = {
|
|
2765
|
+
"spread_report": NativeVectorizedBackend(
|
|
2766
|
+
NativeVectorizedConfig(account=self.config.account, execution=self.config.execution)
|
|
2767
|
+
)._basis_spread_report(idx, spec, close_map, plan.target_units),
|
|
2768
|
+
"package_rejection_report": plan.rejection_report,
|
|
2769
|
+
}
|
|
2770
|
+
elif isinstance(spec, StatArbPairSpec):
|
|
2771
|
+
basket = BasketSpec(
|
|
2772
|
+
basket_id=spec.arb_id,
|
|
2773
|
+
legs=tuple(BasketLegSpec(symbol=leg.symbol, ratio=float(leg.ratio)) for leg in spec.legs),
|
|
2774
|
+
gross_notional=float(spec.sizing_policy.notional),
|
|
2775
|
+
freeze_hedge=bool(spec.hedge_policy.freeze_on_entry),
|
|
2776
|
+
hedged_margin_offset=float(spec.margin_model.hedged_margin_offset),
|
|
2777
|
+
)
|
|
2778
|
+
rebalance_threshold = spec.hedge_policy.rebalance_threshold
|
|
2779
|
+
if not spec.hedge_policy.freeze_on_entry and rebalance_threshold is None:
|
|
2780
|
+
rebalance_threshold = 0.0
|
|
2781
|
+
plan = build_frozen_basket_orders(
|
|
2782
|
+
datetime_index=idx,
|
|
2783
|
+
basket=basket,
|
|
2784
|
+
signal=signal,
|
|
2785
|
+
closes=close_map,
|
|
2786
|
+
hedge_ratios=hedge_ratios,
|
|
2787
|
+
order_type=OrderType.MARKET,
|
|
2788
|
+
tif=TimeInForce.IOC,
|
|
2789
|
+
rebalance_threshold=rebalance_threshold,
|
|
2790
|
+
)
|
|
2791
|
+
extra_metadata = {
|
|
2792
|
+
"beta_drift_report": NativeVectorizedBackend(
|
|
2793
|
+
NativeVectorizedConfig(account=self.config.account, execution=self.config.execution)
|
|
2794
|
+
)._stat_arb_beta_drift_report(idx, spec, plan, rebalance_threshold),
|
|
2795
|
+
"rebalance_threshold": rebalance_threshold,
|
|
2796
|
+
}
|
|
2797
|
+
else:
|
|
2798
|
+
raise NotImplementedError(f"Nautilus arbitrage does not support {type(spec).__name__}")
|
|
2799
|
+
|
|
2800
|
+
data = _frames_from_symbol_maps(close_map, high_map, low_map, symbols)
|
|
2801
|
+
config = self.config.nautilus_config
|
|
2802
|
+
if config is None:
|
|
2803
|
+
config = NautilusBackendConfig(
|
|
2804
|
+
timeframe=str(self.config.metadata.get("timeframe", "1h")),
|
|
2805
|
+
starting_balance=self.config.account.initial_capital,
|
|
2806
|
+
trade_notional=0.0,
|
|
2807
|
+
sizing_mode="notional",
|
|
2808
|
+
)
|
|
2809
|
+
else:
|
|
2810
|
+
config = replace(
|
|
2811
|
+
config,
|
|
2812
|
+
starting_balance=self.config.account.initial_capital,
|
|
2813
|
+
trade_notional=0.0,
|
|
2814
|
+
sizing_mode="notional",
|
|
2815
|
+
)
|
|
2816
|
+
self.engine = NautilusBacktestEngine(config)
|
|
2817
|
+
result = self.engine.run_order_packages(
|
|
2818
|
+
data=data,
|
|
2819
|
+
orders=plan.orders,
|
|
2820
|
+
symbols=symbols,
|
|
2821
|
+
params={
|
|
2822
|
+
"arb_id": spec.arb_id,
|
|
2823
|
+
"arb_type": spec.arb_type.value,
|
|
2824
|
+
"arbitrage_plan": plan,
|
|
2825
|
+
"package_target_units": plan.target_units,
|
|
2826
|
+
**extra_metadata,
|
|
2827
|
+
},
|
|
2828
|
+
)
|
|
2829
|
+
result.metadata["engine"] = "nautilus_arbitrage_package"
|
|
2830
|
+
return result
|
|
2831
|
+
|
|
2832
|
+
def _run_portfolio(self, data, positions, closes, highs, lows, datetime_index, symbols):
|
|
2833
|
+
pos_map = _positions_to_map(positions)
|
|
2834
|
+
if not pos_map:
|
|
2835
|
+
raise ValueError("portfolio endpoint requires positions DataFrame or mapping")
|
|
2836
|
+
close_map, high_map, low_map, idx, _ = _normalize_symbol_data(
|
|
2837
|
+
data=data,
|
|
2838
|
+
closes=closes,
|
|
2839
|
+
highs=highs,
|
|
2840
|
+
lows=lows,
|
|
2841
|
+
datetime_index=datetime_index,
|
|
2842
|
+
symbols=symbols or list(pos_map.keys()),
|
|
2843
|
+
)
|
|
2844
|
+
if (
|
|
2845
|
+
self.config.mode.lower().strip() == "walk_forward"
|
|
2846
|
+
and self.config.walkforward_target_mode.lower().strip() == "portfolio"
|
|
2847
|
+
and self.config.backend.lower().strip() not in {"legacy_portfolio", "nautilus"}
|
|
2848
|
+
):
|
|
2849
|
+
backend = "native_portfolio"
|
|
2850
|
+
else:
|
|
2851
|
+
backend = _resolve_backend(self.config)
|
|
2852
|
+
if backend == "nautilus":
|
|
2853
|
+
symbol_list = list(symbols or pos_map.keys())
|
|
2854
|
+
native_reference = PortfolioBacktestEngine(
|
|
2855
|
+
positions=pos_map,
|
|
2856
|
+
closes=close_map,
|
|
2857
|
+
highs=high_map,
|
|
2858
|
+
lows=low_map,
|
|
2859
|
+
datetime_index=idx,
|
|
2860
|
+
mode=self.config.portfolio_mode,
|
|
2861
|
+
backend="native_portfolio",
|
|
2862
|
+
account=self.config.account,
|
|
2863
|
+
execution=self.config.execution,
|
|
2864
|
+
fee_rate=self.config.canonical_one_way_fee_rate,
|
|
2865
|
+
alloc_per_trade=self.config.alloc_per_trade,
|
|
2866
|
+
contract_size=self.config.contract_size,
|
|
2867
|
+
hedge_type=self.config.sizing if self.config.sizing else "signal_notional",
|
|
2868
|
+
asset_type=self.config.asset_type,
|
|
2869
|
+
use_funding=self.config.use_funding,
|
|
2870
|
+
funding_rate=self.config.funding_rate,
|
|
2871
|
+
leverage=self.config.account.leverage,
|
|
2872
|
+
maintenance_ratio=self.config.account.maintenance_ratio,
|
|
2873
|
+
use_pyramiding=self.config.use_pyramiding,
|
|
2874
|
+
betas=self.config.betas,
|
|
2875
|
+
risk_lookback=self.config.risk_lookback,
|
|
2876
|
+
report_level=self.config.report_level,
|
|
2877
|
+
).result
|
|
2878
|
+
target_units = native_reference.metadata["target_units_report"].reindex(columns=symbol_list)
|
|
2879
|
+
orders = _build_portfolio_orders_from_target_units_for_nautilus(
|
|
2880
|
+
target_units=target_units,
|
|
2881
|
+
symbols=symbol_list,
|
|
2882
|
+
tag=f"portfolio:{self.config.sizing if self.config.sizing else 'signal_notional'}",
|
|
2883
|
+
)
|
|
2884
|
+
result = self._run_nautilus_package_orders(
|
|
2885
|
+
data=_frames_from_symbol_maps(close_map, high_map, low_map, symbol_list),
|
|
2886
|
+
orders=orders,
|
|
2887
|
+
symbols=symbol_list,
|
|
2888
|
+
params={
|
|
2889
|
+
"input_mode": "portfolio_matrix",
|
|
2890
|
+
"portfolio_mode": self.config.portfolio_mode,
|
|
2891
|
+
"portfolio_target_units": target_units,
|
|
2892
|
+
"package_target_units": target_units,
|
|
2893
|
+
"order_count_input": len(orders),
|
|
2894
|
+
},
|
|
2895
|
+
)
|
|
2896
|
+
result.metadata["engine"] = "nautilus_portfolio_matrix"
|
|
2897
|
+
result.metadata["native_portfolio_reference_final_equity"] = float(native_reference.equity.iloc[-1])
|
|
2898
|
+
from .reporting import build_portfolio_nautilus_validation_report
|
|
2899
|
+
|
|
2900
|
+
result.metadata["portfolio_nautilus_validation_report"] = build_portfolio_nautilus_validation_report(
|
|
2901
|
+
native_reference,
|
|
2902
|
+
result,
|
|
2903
|
+
equity_tolerance=float(self.config.metadata.get("portfolio_nautilus_equity_tolerance", 1e-6)),
|
|
2904
|
+
position_tolerance=float(self.config.metadata.get("portfolio_nautilus_position_tolerance", 1e-6)),
|
|
2905
|
+
)
|
|
2906
|
+
self._store_result(result)
|
|
2907
|
+
return self.result
|
|
2908
|
+
|
|
2909
|
+
self.engine = PortfolioBacktestEngine(
|
|
2910
|
+
positions=pos_map,
|
|
2911
|
+
closes=close_map,
|
|
2912
|
+
highs=high_map,
|
|
2913
|
+
lows=low_map,
|
|
2914
|
+
datetime_index=idx,
|
|
2915
|
+
mode=self.config.portfolio_mode,
|
|
2916
|
+
backend=backend,
|
|
2917
|
+
account=self.config.account,
|
|
2918
|
+
execution=self.config.execution,
|
|
2919
|
+
fee_rate=self.config.canonical_one_way_fee_rate,
|
|
2920
|
+
alloc_per_trade=self.config.alloc_per_trade,
|
|
2921
|
+
contract_size=self.config.contract_size,
|
|
2922
|
+
hedge_type=self.config.sizing if self.config.sizing else "notional",
|
|
2923
|
+
asset_type=self.config.asset_type,
|
|
2924
|
+
use_funding=self.config.use_funding,
|
|
2925
|
+
funding_rate=self.config.funding_rate,
|
|
2926
|
+
leverage=self.config.account.leverage,
|
|
2927
|
+
maintenance_ratio=self.config.account.maintenance_ratio,
|
|
2928
|
+
use_pyramiding=self.config.use_pyramiding,
|
|
2929
|
+
betas=self.config.betas,
|
|
2930
|
+
risk_lookback=self.config.risk_lookback,
|
|
2931
|
+
instruments=self.config.instruments,
|
|
2932
|
+
qty_step=self.config.qty_step,
|
|
2933
|
+
lot_size=self.config.lot_size,
|
|
2934
|
+
slot_size=self.config.slot_size,
|
|
2935
|
+
min_qty=self.config.min_qty,
|
|
2936
|
+
min_notional=self.config.min_notional,
|
|
2937
|
+
report_level=self.config.report_level,
|
|
2938
|
+
)
|
|
2939
|
+
self._store_result(self.engine.result)
|
|
2940
|
+
return self.result
|
|
2941
|
+
|
|
2942
|
+
def _run_nautilus_package_orders(self, data, orders, symbols, params):
|
|
2943
|
+
from .adapters.nautilus import NautilusBackendConfig, NautilusBacktestEngine
|
|
2944
|
+
|
|
2945
|
+
run_params = dict(params or {})
|
|
2946
|
+
run_orders = _annotate_orders_for_depth(orders, run_params)
|
|
2947
|
+
depth_result = None
|
|
2948
|
+
if self.config.nautilus_depth_config is not None:
|
|
2949
|
+
depth_result = simulate_nautilus_order_package_depth(
|
|
2950
|
+
orders=run_orders,
|
|
2951
|
+
data=data,
|
|
2952
|
+
config=self.config.nautilus_depth_config,
|
|
2953
|
+
)
|
|
2954
|
+
run_orders = depth_result.orders
|
|
2955
|
+
run_params.update(
|
|
2956
|
+
{
|
|
2957
|
+
"nautilus_depth_enabled": True,
|
|
2958
|
+
"nautilus_depth_order_report": depth_result.order_report,
|
|
2959
|
+
"nautilus_depth_package_report": depth_result.package_report,
|
|
2960
|
+
"nautilus_depth_metadata": depth_result.metadata,
|
|
2961
|
+
"order_count_before_depth": len(orders),
|
|
2962
|
+
"order_count_after_depth": len(run_orders),
|
|
2963
|
+
}
|
|
2964
|
+
)
|
|
2965
|
+
else:
|
|
2966
|
+
run_params.setdefault("nautilus_depth_enabled", False)
|
|
2967
|
+
|
|
2968
|
+
config = self.config.nautilus_config
|
|
2969
|
+
if config is None:
|
|
2970
|
+
config = NautilusBackendConfig(
|
|
2971
|
+
instrument_id=symbols[0],
|
|
2972
|
+
timeframe=str(self.config.metadata.get("timeframe", "1h")),
|
|
2973
|
+
starting_balance=self.config.account.initial_capital,
|
|
2974
|
+
trade_notional=0.0,
|
|
2975
|
+
sizing_mode="notional",
|
|
2976
|
+
)
|
|
2977
|
+
else:
|
|
2978
|
+
config = replace(
|
|
2979
|
+
config,
|
|
2980
|
+
starting_balance=self.config.account.initial_capital,
|
|
2981
|
+
trade_notional=0.0,
|
|
2982
|
+
sizing_mode="notional",
|
|
2983
|
+
)
|
|
2984
|
+
self.engine = NautilusBacktestEngine(config)
|
|
2985
|
+
if not run_orders:
|
|
2986
|
+
return _empty_nautilus_preflight_result(
|
|
2987
|
+
data=data,
|
|
2988
|
+
symbols=symbols,
|
|
2989
|
+
account=self.config.account,
|
|
2990
|
+
metadata={
|
|
2991
|
+
"backend": "nautilus",
|
|
2992
|
+
"engine": "nautilus_package_orders_preflight_rejected",
|
|
2993
|
+
"input_mode": run_params.get("input_mode", "order_packages"),
|
|
2994
|
+
"orders_count": 0,
|
|
2995
|
+
"fills_count": 0,
|
|
2996
|
+
"positions_count": 0,
|
|
2997
|
+
**run_params,
|
|
2998
|
+
},
|
|
2999
|
+
)
|
|
3000
|
+
return self.engine.run_order_packages(
|
|
3001
|
+
data=data,
|
|
3002
|
+
orders=run_orders,
|
|
3003
|
+
symbols=symbols,
|
|
3004
|
+
params=run_params,
|
|
3005
|
+
)
|
|
3006
|
+
|
|
3007
|
+
def _store_result(self, result):
|
|
3008
|
+
_normalize_result_contract(result)
|
|
3009
|
+
_attach_endpoint_run_config(result, self.config)
|
|
3010
|
+
self.result = result
|
|
3011
|
+
return result
|
|
3012
|
+
|
|
3013
|
+
def _require_result(self):
|
|
3014
|
+
if self.result is None:
|
|
3015
|
+
raise RuntimeError("run backtest() or simulate() before requesting results")
|
|
3016
|
+
return self.result
|
|
3017
|
+
|
|
3018
|
+
def _result_for_report_scope(self, scope: str):
|
|
3019
|
+
from .core.scopes import scoped_result
|
|
3020
|
+
|
|
3021
|
+
return scoped_result(self._require_result(), scope=scope)
|
|
3022
|
+
|
|
3023
|
+
|
|
3024
|
+
def _normalize_result_contract(result) -> None:
|
|
3025
|
+
"""
|
|
3026
|
+
Make common result artifacts safe to access across all endpoint backends.
|
|
3027
|
+
|
|
3028
|
+
Legacy and vectorized results do not naturally have fills/orders. Notebook
|
|
3029
|
+
integrations still benefit from stable empty artifacts instead of
|
|
3030
|
+
AttributeError/KeyError.
|
|
3031
|
+
"""
|
|
3032
|
+
metadata = result.metadata
|
|
3033
|
+
if not hasattr(result, "orders"):
|
|
3034
|
+
setattr(result, "orders", ())
|
|
3035
|
+
if not hasattr(result, "fills"):
|
|
3036
|
+
setattr(result, "fills", ())
|
|
3037
|
+
|
|
3038
|
+
order_report = metadata.get("order_report")
|
|
3039
|
+
if order_report is None:
|
|
3040
|
+
order_report = metadata.get("orders_report")
|
|
3041
|
+
if order_report is None:
|
|
3042
|
+
order_report = pd.DataFrame()
|
|
3043
|
+
metadata["order_report"] = order_report
|
|
3044
|
+
if metadata.get("orders_report") is None:
|
|
3045
|
+
metadata["orders_report"] = order_report
|
|
3046
|
+
|
|
3047
|
+
if metadata.get("fills_report") is None:
|
|
3048
|
+
metadata["fills_report"] = _fills_to_frame(getattr(result, "fills", ()))
|
|
3049
|
+
if metadata.get("positions_report") is None:
|
|
3050
|
+
metadata["positions_report"] = pd.DataFrame()
|
|
3051
|
+
if "orders_count" not in metadata:
|
|
3052
|
+
metadata["orders_count"] = len(getattr(result, "orders", ()))
|
|
3053
|
+
if "fills_count" not in metadata:
|
|
3054
|
+
metadata["fills_count"] = len(getattr(result, "fills", ()))
|
|
3055
|
+
engine = str(metadata.get("engine", "unknown"))
|
|
3056
|
+
backend = str(metadata.get("backend", metadata.get("backend_alias", "unknown")))
|
|
3057
|
+
metadata.setdefault("backend_alias", backend)
|
|
3058
|
+
metadata.setdefault("engine_id", engine)
|
|
3059
|
+
metadata.setdefault("kernel_version", engine)
|
|
3060
|
+
metadata.setdefault(
|
|
3061
|
+
"execution_contract",
|
|
3062
|
+
{
|
|
3063
|
+
"engine_id": metadata["engine_id"],
|
|
3064
|
+
"signal_phase": metadata.get("signal_phase", "unspecified"),
|
|
3065
|
+
"fill_phase": metadata.get("fill_phase", "unspecified"),
|
|
3066
|
+
"intrabar_exit_model": metadata.get("intrabar_exit_model", "unspecified"),
|
|
3067
|
+
},
|
|
3068
|
+
)
|
|
3069
|
+
|
|
3070
|
+
|
|
3071
|
+
def _attach_endpoint_run_config(result, config: EndpointConfig) -> None:
|
|
3072
|
+
metadata = result.metadata
|
|
3073
|
+
payload = _endpoint_run_config_payload(config)
|
|
3074
|
+
_sync_applied_nautilus_config(payload, metadata)
|
|
3075
|
+
metadata["run_config"] = payload
|
|
3076
|
+
metadata.setdefault("initial_capital", payload["account"]["initial_capital"])
|
|
3077
|
+
metadata.setdefault("leverage", payload["account"]["leverage"])
|
|
3078
|
+
metadata.setdefault("maintenance_ratio", payload["account"]["maintenance_ratio"])
|
|
3079
|
+
metadata.setdefault("fee_rate", payload["fees"]["one_way_fee_rate"])
|
|
3080
|
+
metadata.setdefault("canonical_one_way_fee_rate", payload["fees"]["canonical_one_way_fee_rate"])
|
|
3081
|
+
metadata.setdefault("fee_round_trip", payload["fees"]["round_trip_fee"])
|
|
3082
|
+
metadata.setdefault("alloc_per_trade", payload["sizing"]["alloc_per_trade"])
|
|
3083
|
+
metadata.setdefault("slippage", payload["execution"]["legacy_slippage_rate"])
|
|
3084
|
+
metadata.setdefault("slippage_bps", payload["execution"]["slippage_bps"])
|
|
3085
|
+
metadata.setdefault("use_funding", payload["funding"]["use_funding"])
|
|
3086
|
+
|
|
3087
|
+
|
|
3088
|
+
def _sync_applied_nautilus_config(payload: Dict, metadata: Dict) -> None:
|
|
3089
|
+
"""Keep report run_config aligned with the adapter config that actually ran."""
|
|
3090
|
+
if payload.get("backend") != "nautilus":
|
|
3091
|
+
return
|
|
3092
|
+
nautilus = dict(payload.get("nautilus") or {})
|
|
3093
|
+
if not nautilus:
|
|
3094
|
+
return
|
|
3095
|
+
|
|
3096
|
+
for source_key, target_key in (
|
|
3097
|
+
("instrument_id", "instrument_id"),
|
|
3098
|
+
("sizing_mode", "sizing_mode"),
|
|
3099
|
+
("trade_notional", "trade_notional"),
|
|
3100
|
+
("use_pyramiding", "use_pyramiding"),
|
|
3101
|
+
("close_positions_on_stop", "close_positions_on_stop"),
|
|
3102
|
+
):
|
|
3103
|
+
if metadata.get(source_key) is not None:
|
|
3104
|
+
nautilus[target_key] = _jsonable(metadata[source_key])
|
|
3105
|
+
|
|
3106
|
+
if metadata.get("timeframe") is not None:
|
|
3107
|
+
nautilus["timeframe"] = _jsonable(metadata["timeframe"])
|
|
3108
|
+
if metadata.get("initial_capital") is not None:
|
|
3109
|
+
nautilus["starting_balance"] = _jsonable(metadata["initial_capital"])
|
|
3110
|
+
|
|
3111
|
+
payload["nautilus"] = nautilus
|
|
3112
|
+
|
|
3113
|
+
|
|
3114
|
+
def _endpoint_run_config_payload(config: EndpointConfig) -> Dict:
|
|
3115
|
+
intrabar_mode = str(config.mode).lower().strip() in {"intrabar_bracket", "intrabar_bracket_reference", "fill_replay"}
|
|
3116
|
+
payload = {
|
|
3117
|
+
"mode": config.mode,
|
|
3118
|
+
"backend": config.backend,
|
|
3119
|
+
"portfolio_mode": config.portfolio_mode,
|
|
3120
|
+
"asset_type": config.asset_type,
|
|
3121
|
+
"account": _jsonable(asdict(config.account)),
|
|
3122
|
+
"execution": {
|
|
3123
|
+
**_jsonable(asdict(config.execution)),
|
|
3124
|
+
"legacy_slippage_rate": None if intrabar_mode else float(config.slippage),
|
|
3125
|
+
"slippage_bps": float(config.execution.slippage_bps),
|
|
3126
|
+
},
|
|
3127
|
+
"fees": {
|
|
3128
|
+
"round_trip_fee": float(config.fee),
|
|
3129
|
+
"one_way_fee_rate": float(config.canonical_one_way_fee_rate),
|
|
3130
|
+
"canonical_one_way_fee_rate": float(config.canonical_one_way_fee_rate),
|
|
3131
|
+
"explicit_fee_rate": None if config.fee_rate is None else float(config.fee_rate),
|
|
3132
|
+
"legacy_fee_converted": config.fee_rate is None,
|
|
3133
|
+
"applied_fee_source": "fee_rate" if config.fee_rate is not None else "legacy_fee",
|
|
3134
|
+
},
|
|
3135
|
+
"sizing": {
|
|
3136
|
+
"hedge_type": config.sizing,
|
|
3137
|
+
"alloc_per_trade": _jsonable(config.alloc_per_trade),
|
|
3138
|
+
"use_pyramiding": bool(config.use_pyramiding),
|
|
3139
|
+
"contract_size": _jsonable(config.contract_size),
|
|
3140
|
+
},
|
|
3141
|
+
"funding": {
|
|
3142
|
+
"use_funding": bool(config.use_funding),
|
|
3143
|
+
"funding_rate": _jsonable(config.funding_rate),
|
|
3144
|
+
},
|
|
3145
|
+
"dca_kwargs": _jsonable(config.dca_kwargs),
|
|
3146
|
+
"structured_order_spec": _jsonable(config.structured_order_spec),
|
|
3147
|
+
"symbols": _jsonable(config.symbols),
|
|
3148
|
+
"metadata": _jsonable(config.metadata),
|
|
3149
|
+
"report_level": config.report_level,
|
|
3150
|
+
"audit_sink": config.audit_sink,
|
|
3151
|
+
"audit_sink_path": config.audit_sink_path,
|
|
3152
|
+
}
|
|
3153
|
+
if config.nautilus_config is not None:
|
|
3154
|
+
payload["nautilus"] = _jsonable(
|
|
3155
|
+
asdict(config.nautilus_config) if is_dataclass(config.nautilus_config) else vars(config.nautilus_config)
|
|
3156
|
+
)
|
|
3157
|
+
return payload
|
|
3158
|
+
|
|
3159
|
+
|
|
3160
|
+
def _jsonable(value):
|
|
3161
|
+
if is_dataclass(value):
|
|
3162
|
+
return _jsonable(asdict(value))
|
|
3163
|
+
if isinstance(value, dict):
|
|
3164
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
3165
|
+
if isinstance(value, (list, tuple)):
|
|
3166
|
+
return [_jsonable(v) for v in value]
|
|
3167
|
+
if isinstance(value, pd.Series):
|
|
3168
|
+
return {
|
|
3169
|
+
"type": "Series",
|
|
3170
|
+
"name": value.name,
|
|
3171
|
+
"rows": int(len(value)),
|
|
3172
|
+
"start": str(value.index[0]) if len(value) else None,
|
|
3173
|
+
"end": str(value.index[-1]) if len(value) else None,
|
|
3174
|
+
}
|
|
3175
|
+
if isinstance(value, pd.DataFrame):
|
|
3176
|
+
return {
|
|
3177
|
+
"type": "DataFrame",
|
|
3178
|
+
"rows": int(len(value)),
|
|
3179
|
+
"columns": list(value.columns),
|
|
3180
|
+
"start": str(value.index[0]) if len(value) else None,
|
|
3181
|
+
"end": str(value.index[-1]) if len(value) else None,
|
|
3182
|
+
}
|
|
3183
|
+
if hasattr(value, "value"):
|
|
3184
|
+
return value.value
|
|
3185
|
+
return value
|
|
3186
|
+
|
|
3187
|
+
|
|
3188
|
+
def _fills_to_frame(fills) -> pd.DataFrame:
|
|
3189
|
+
rows = []
|
|
3190
|
+
for fill in fills:
|
|
3191
|
+
row = getattr(fill, "__dict__", None)
|
|
3192
|
+
rows.append(dict(row) if row is not None else {"fill": fill})
|
|
3193
|
+
return pd.DataFrame(rows)
|
|
3194
|
+
|
|
3195
|
+
|
|
3196
|
+
def format_metrics_report(report: Dict) -> str:
|
|
3197
|
+
"""
|
|
3198
|
+
Format a metrics dictionary as a legacy-style text report.
|
|
3199
|
+
|
|
3200
|
+
The returned string is intentionally plain monospaced text so notebooks,
|
|
3201
|
+
terminals, logs, and services all render the same high-signal report.
|
|
3202
|
+
"""
|
|
3203
|
+
lines = [
|
|
3204
|
+
("Initial Capital", _fmt_money(report.get("initial_capital"), decimals=0)),
|
|
3205
|
+
("Final Equity", _fmt_money(report.get("final_equity"), decimals=2)),
|
|
3206
|
+
("Total Return", _fmt_pct(report.get("total_return_pct"), signed=True, decimals=2)),
|
|
3207
|
+
("CAGR", _fmt_pct(report.get("cagr_pct"), signed=True, decimals=2)),
|
|
3208
|
+
("Sharpe Ratio", _fmt_float(report.get("sharpe"), decimals=3)),
|
|
3209
|
+
("Sortino Ratio", _fmt_float(report.get("sortino"), decimals=3)),
|
|
3210
|
+
("Calmar Ratio", _fmt_float(report.get("calmar"), decimals=3)),
|
|
3211
|
+
("Omega Ratio", _fmt_float(report.get("omega"), decimals=3)),
|
|
3212
|
+
("Max Drawdown", _fmt_pct(report.get("max_drawdown_pct"), signed=False, decimals=2)),
|
|
3213
|
+
("Avg Drawdown", _fmt_pct(report.get("avg_drawdown_pct"), signed=False, decimals=2)),
|
|
3214
|
+
("Max DD Duration", _fmt_days(report.get("max_dd_duration_days"))),
|
|
3215
|
+
("Profit Factor", _fmt_float(report.get("profit_factor"), decimals=3)),
|
|
3216
|
+
("Long Hit Rate", _fmt_pct(report.get("long_hitrate_pct"), signed=False, decimals=2)),
|
|
3217
|
+
("Short Hit Rate", _fmt_pct(report.get("short_hitrate_pct"), signed=False, decimals=2)),
|
|
3218
|
+
("Avg Win", _fmt_pct(report.get("avg_win_pct"), signed=True, decimals=3)),
|
|
3219
|
+
("Avg Loss", _fmt_pct(report.get("avg_loss_pct"), signed=True, decimals=3)),
|
|
3220
|
+
("Expectancy", _fmt_pct(report.get("expectancy_pct"), signed=True, decimals=3)),
|
|
3221
|
+
("Number of Trades", _fmt_int(report.get("num_trades"))),
|
|
3222
|
+
("Liquidated", f"{'Yes' if report.get('liquidated') else 'No':>14}"),
|
|
3223
|
+
]
|
|
3224
|
+
col_width = max(len(key) for key, _ in lines)
|
|
3225
|
+
body = "\n".join(f" {key:<{col_width}} {value}" for key, value in lines)
|
|
3226
|
+
return f"\n{body}\n"
|
|
3227
|
+
|
|
3228
|
+
|
|
3229
|
+
def _fmt_money(value, decimals: int) -> str:
|
|
3230
|
+
if value is None or pd.isna(value):
|
|
3231
|
+
return f"{'n/a':>15}"
|
|
3232
|
+
return f"$ {float(value):>13,.{decimals}f}"
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
def _fmt_pct(value, signed: bool, decimals: int) -> str:
|
|
3236
|
+
if value is None or pd.isna(value):
|
|
3237
|
+
return f"{'n/a':>15}"
|
|
3238
|
+
sign = "+" if signed else ""
|
|
3239
|
+
return f"{float(value):>{sign}13.{decimals}f}%"
|
|
3240
|
+
|
|
3241
|
+
|
|
3242
|
+
def _fmt_float(value, decimals: int) -> str:
|
|
3243
|
+
if value is None or pd.isna(value):
|
|
3244
|
+
return f"{'n/a':>14}"
|
|
3245
|
+
return f"{float(value):>14.{decimals}f}"
|
|
3246
|
+
|
|
3247
|
+
|
|
3248
|
+
def _fmt_days(value) -> str:
|
|
3249
|
+
if value is None or pd.isna(value):
|
|
3250
|
+
return f"{'n/a':>16}"
|
|
3251
|
+
return f"{int(value):>11d} days"
|
|
3252
|
+
|
|
3253
|
+
|
|
3254
|
+
def _fmt_int(value) -> str:
|
|
3255
|
+
if value is None or pd.isna(value):
|
|
3256
|
+
return f"{'n/a':>14}"
|
|
3257
|
+
return f"{int(value):>14,d}"
|
|
3258
|
+
|
|
3259
|
+
|
|
3260
|
+
def _config_from_kwargs(**kwargs) -> EndpointConfig:
|
|
3261
|
+
mode_name = str(kwargs.get("mode", "")).lower().strip()
|
|
3262
|
+
metadata = dict(kwargs.pop("metadata", {}) or {})
|
|
3263
|
+
if "tick_size" in kwargs:
|
|
3264
|
+
metadata.setdefault("tick_size", kwargs.pop("tick_size"))
|
|
3265
|
+
if "source_timezone" in kwargs:
|
|
3266
|
+
metadata.setdefault("source_timezone", kwargs.pop("source_timezone"))
|
|
3267
|
+
if "missing_funding_policy" in kwargs:
|
|
3268
|
+
metadata.setdefault("missing_funding_policy", kwargs.pop("missing_funding_policy"))
|
|
3269
|
+
if "bar_timestamp_semantics" in kwargs:
|
|
3270
|
+
metadata.setdefault("bar_timestamp_semantics", kwargs.pop("bar_timestamp_semantics"))
|
|
3271
|
+
hedge_type_alias = kwargs.pop("hedge_type", None)
|
|
3272
|
+
if hedge_type_alias is not None and "sizing" not in kwargs:
|
|
3273
|
+
kwargs["sizing"] = hedge_type_alias
|
|
3274
|
+
|
|
3275
|
+
initial_capital = kwargs.pop("initial_capital", None)
|
|
3276
|
+
leverage = kwargs.pop("leverage", None)
|
|
3277
|
+
maintenance_ratio = kwargs.pop("maintenance_ratio", None)
|
|
3278
|
+
account = kwargs.pop("account", None)
|
|
3279
|
+
if account is None:
|
|
3280
|
+
account = AccountConfig(
|
|
3281
|
+
initial_capital=100_000.0 if initial_capital is None else float(initial_capital),
|
|
3282
|
+
leverage=1.0 if leverage is None else float(leverage),
|
|
3283
|
+
maintenance_ratio=0.005 if maintenance_ratio is None else float(maintenance_ratio),
|
|
3284
|
+
)
|
|
3285
|
+
|
|
3286
|
+
legacy_slippage_supplied = "slippage" in kwargs
|
|
3287
|
+
legacy_slippage_value = kwargs.get("slippage")
|
|
3288
|
+
slippage_bps = kwargs.pop("slippage_bps", None)
|
|
3289
|
+
execution = kwargs.pop("execution", None)
|
|
3290
|
+
if slippage_bps is not None and execution is not None:
|
|
3291
|
+
raise ValueError("pass either execution=ExecutionConfig(...) or slippage_bps=..., not both")
|
|
3292
|
+
if slippage_bps is not None and legacy_slippage_supplied:
|
|
3293
|
+
raise ValueError("pass either slippage_bps or legacy slippage, not both")
|
|
3294
|
+
if execution is None:
|
|
3295
|
+
if slippage_bps is not None:
|
|
3296
|
+
execution = ExecutionConfig(slippage_bps=float(slippage_bps))
|
|
3297
|
+
elif mode_name in {"intrabar_bracket", "intrabar_bracket_reference", "portfolio"} and legacy_slippage_supplied:
|
|
3298
|
+
warnings.warn(
|
|
3299
|
+
"QuantBT native endpoints use slippage_bps as the source of truth; "
|
|
3300
|
+
"legacy slippage was converted to slippage_bps for compatibility.",
|
|
3301
|
+
DeprecationWarning,
|
|
3302
|
+
stacklevel=3,
|
|
3303
|
+
)
|
|
3304
|
+
execution = ExecutionConfig(slippage_bps=float(legacy_slippage_value) * 10_000.0)
|
|
3305
|
+
else:
|
|
3306
|
+
execution = ExecutionConfig(slippage_bps=0.0)
|
|
3307
|
+
|
|
3308
|
+
dca_kwargs = kwargs.pop("dca_kwargs", {})
|
|
3309
|
+
for key in (
|
|
3310
|
+
"dca_base_notional",
|
|
3311
|
+
"dca_safety_notional",
|
|
3312
|
+
"dca_step_pct",
|
|
3313
|
+
"dca_step_scale",
|
|
3314
|
+
"dca_volume_scale",
|
|
3315
|
+
"dca_max_safety_orders",
|
|
3316
|
+
"dca_take_profit_pct",
|
|
3317
|
+
"dca_allow_same_bar_exit",
|
|
3318
|
+
):
|
|
3319
|
+
if key in kwargs:
|
|
3320
|
+
dca_kwargs[key] = kwargs.pop(key)
|
|
3321
|
+
|
|
3322
|
+
return EndpointConfig(account=account, execution=execution, dca_kwargs=dca_kwargs, metadata=metadata, **kwargs)
|
|
3323
|
+
|
|
3324
|
+
|
|
3325
|
+
def _pop_dataclass_kwargs(kwargs: Dict, dataclass_type) -> Dict:
|
|
3326
|
+
fields = getattr(dataclass_type, "__dataclass_fields__", {})
|
|
3327
|
+
out = {}
|
|
3328
|
+
for key in list(fields):
|
|
3329
|
+
if key == "metadata":
|
|
3330
|
+
continue
|
|
3331
|
+
if key in kwargs:
|
|
3332
|
+
out[key] = kwargs.pop(key)
|
|
3333
|
+
return out
|
|
3334
|
+
|
|
3335
|
+
|
|
3336
|
+
def _resolve_backend(config: EndpointConfig) -> str:
|
|
3337
|
+
backend = config.backend.lower().strip()
|
|
3338
|
+
if backend != "auto":
|
|
3339
|
+
if backend == "legacy_portfolio":
|
|
3340
|
+
return backend
|
|
3341
|
+
if backend not in {"legacy", "native_vectorized", "native_event", "native_portfolio", "native_option", "nautilus"}:
|
|
3342
|
+
raise ValueError(f"unsupported backend={config.backend!r}")
|
|
3343
|
+
return backend
|
|
3344
|
+
mode = config.mode.lower().strip()
|
|
3345
|
+
sizing = config.sizing.lower().strip()
|
|
3346
|
+
if mode == "portfolio":
|
|
3347
|
+
return "native_portfolio"
|
|
3348
|
+
if mode == "options":
|
|
3349
|
+
return "native_option"
|
|
3350
|
+
if mode in ("pct_equity", "dca_ladder") or sizing in ("%_equity", "pct_equity", "dca_ladder", "dca"):
|
|
3351
|
+
return "legacy"
|
|
3352
|
+
if mode == "nautilus_validation":
|
|
3353
|
+
return "nautilus"
|
|
3354
|
+
if mode in ("orders", "basket", "arbitrage"):
|
|
3355
|
+
return "native_event"
|
|
3356
|
+
return "native_vectorized"
|
|
3357
|
+
|
|
3358
|
+
|
|
3359
|
+
def _default_walkforward_scoring_backend(target_mode: str, optimization_mode: str) -> str:
|
|
3360
|
+
mode = str(target_mode).lower().strip()
|
|
3361
|
+
opt_mode = str(optimization_mode).lower().strip()
|
|
3362
|
+
if opt_mode == "mode_2_sbb":
|
|
3363
|
+
return "proxy"
|
|
3364
|
+
if mode in {"pct_equity", "%_equity", "signal_notional", "single_signal", "dca_ladder"}:
|
|
3365
|
+
return "endpoint"
|
|
3366
|
+
return "proxy"
|
|
3367
|
+
|
|
3368
|
+
|
|
3369
|
+
def _make_walkforward_endpoint_scorer(
|
|
3370
|
+
config: EndpointConfig,
|
|
3371
|
+
target_mode: str,
|
|
3372
|
+
symbols=None,
|
|
3373
|
+
wf_config: Optional[WalkForwardConfig] = None,
|
|
3374
|
+
market_data=None,
|
|
3375
|
+
market_closes=None,
|
|
3376
|
+
market_highs=None,
|
|
3377
|
+
market_lows=None,
|
|
3378
|
+
market_datetime_index=None,
|
|
3379
|
+
):
|
|
3380
|
+
return _WalkForwardEndpointScorer(
|
|
3381
|
+
config=config,
|
|
3382
|
+
target_mode=target_mode,
|
|
3383
|
+
symbols=symbols,
|
|
3384
|
+
wf_config=wf_config,
|
|
3385
|
+
market_data=market_data,
|
|
3386
|
+
market_closes=market_closes,
|
|
3387
|
+
market_highs=market_highs,
|
|
3388
|
+
market_lows=market_lows,
|
|
3389
|
+
market_datetime_index=market_datetime_index,
|
|
3390
|
+
)
|
|
3391
|
+
|
|
3392
|
+
|
|
3393
|
+
@dataclass
|
|
3394
|
+
class QuantBTPreparedContext:
|
|
3395
|
+
"""
|
|
3396
|
+
Run-local prepared market context for repeated endpoint replays.
|
|
3397
|
+
|
|
3398
|
+
The context stores copied prepared market arrays and validates datetime /
|
|
3399
|
+
symbol signatures inside the backend on every replay. It is intentionally
|
|
3400
|
+
caller-owned and never a mutable global cache.
|
|
3401
|
+
"""
|
|
3402
|
+
|
|
3403
|
+
endpoint: QuantBTEndpoint
|
|
3404
|
+
mode: str
|
|
3405
|
+
idx: pd.DatetimeIndex
|
|
3406
|
+
symbols: list
|
|
3407
|
+
close_map: SeriesMap
|
|
3408
|
+
high_map: SeriesMap
|
|
3409
|
+
low_map: SeriesMap
|
|
3410
|
+
market_arrays: object
|
|
3411
|
+
backend: object
|
|
3412
|
+
frame: Optional[pd.DataFrame] = None
|
|
3413
|
+
runs: int = 0
|
|
3414
|
+
|
|
3415
|
+
@classmethod
|
|
3416
|
+
def from_endpoint(
|
|
3417
|
+
cls,
|
|
3418
|
+
endpoint: QuantBTEndpoint,
|
|
3419
|
+
*,
|
|
3420
|
+
data=None,
|
|
3421
|
+
closes=None,
|
|
3422
|
+
highs=None,
|
|
3423
|
+
lows=None,
|
|
3424
|
+
datetime_index=None,
|
|
3425
|
+
symbols=None,
|
|
3426
|
+
) -> "QuantBTPreparedContext":
|
|
3427
|
+
config = endpoint.config
|
|
3428
|
+
backend_name = _resolve_backend(config)
|
|
3429
|
+
mode = config.mode.lower().strip()
|
|
3430
|
+
sizing = config.sizing.lower().strip()
|
|
3431
|
+
|
|
3432
|
+
if mode in {"single_signal", "signal_notional"} and backend_name == "native_vectorized" and sizing in {"signal_notional", "signal"}:
|
|
3433
|
+
frame = _standardize_frame(data, datetime_index=datetime_index)
|
|
3434
|
+
symbol_list = list(symbols or config.symbols or ["DEFAULT"])
|
|
3435
|
+
if len(symbol_list) != 1:
|
|
3436
|
+
raise ValueError("single-symbol prepared context requires exactly one symbol")
|
|
3437
|
+
symbol = symbol_list[0]
|
|
3438
|
+
close_map = {symbol: frame["close"]}
|
|
3439
|
+
high_map = {symbol: frame.get("high", frame["close"])}
|
|
3440
|
+
low_map = {symbol: frame.get("low", frame["close"])}
|
|
3441
|
+
backend = NativeVectorizedBackend(
|
|
3442
|
+
NativeVectorizedConfig(
|
|
3443
|
+
account=config.account,
|
|
3444
|
+
execution=config.execution,
|
|
3445
|
+
fee_rate=config.v2_fee_rate,
|
|
3446
|
+
use_funding=bool(config.use_funding),
|
|
3447
|
+
)
|
|
3448
|
+
)
|
|
3449
|
+
market = backend.prepare_market_arrays(
|
|
3450
|
+
datetime_index=frame.index,
|
|
3451
|
+
closes=close_map,
|
|
3452
|
+
highs=high_map,
|
|
3453
|
+
lows=low_map,
|
|
3454
|
+
funding_rate=config.funding_rate,
|
|
3455
|
+
symbols=symbol_list,
|
|
3456
|
+
)
|
|
3457
|
+
return cls(
|
|
3458
|
+
endpoint=endpoint,
|
|
3459
|
+
mode="single_signal_notional",
|
|
3460
|
+
idx=frame.index,
|
|
3461
|
+
symbols=symbol_list,
|
|
3462
|
+
close_map=close_map,
|
|
3463
|
+
high_map=high_map,
|
|
3464
|
+
low_map=low_map,
|
|
3465
|
+
market_arrays=market,
|
|
3466
|
+
backend=backend,
|
|
3467
|
+
frame=frame,
|
|
3468
|
+
)
|
|
3469
|
+
|
|
3470
|
+
if mode == "portfolio" and backend_name == "native_portfolio":
|
|
3471
|
+
close_map, high_map, low_map, idx, symbol_list = _normalize_symbol_data(
|
|
3472
|
+
data=data,
|
|
3473
|
+
closes=closes,
|
|
3474
|
+
highs=highs,
|
|
3475
|
+
lows=lows,
|
|
3476
|
+
datetime_index=datetime_index,
|
|
3477
|
+
symbols=symbols or config.symbols,
|
|
3478
|
+
)
|
|
3479
|
+
fee_oneway = config.canonical_one_way_fee_rate
|
|
3480
|
+
backend = NativePortfolioBackend(
|
|
3481
|
+
NativePortfolioConfig(
|
|
3482
|
+
account=config.account,
|
|
3483
|
+
execution=config.execution,
|
|
3484
|
+
fee_rate=fee_oneway,
|
|
3485
|
+
use_funding=bool(config.use_funding),
|
|
3486
|
+
report_level=config.report_level,
|
|
3487
|
+
)
|
|
3488
|
+
)
|
|
3489
|
+
market = backend.prepare_market_arrays(
|
|
3490
|
+
datetime_index=idx,
|
|
3491
|
+
closes=close_map,
|
|
3492
|
+
highs=high_map,
|
|
3493
|
+
lows=low_map,
|
|
3494
|
+
funding_rate=config.funding_rate,
|
|
3495
|
+
symbols=symbol_list,
|
|
3496
|
+
)
|
|
3497
|
+
return cls(
|
|
3498
|
+
endpoint=endpoint,
|
|
3499
|
+
mode="portfolio",
|
|
3500
|
+
idx=idx,
|
|
3501
|
+
symbols=list(symbol_list),
|
|
3502
|
+
close_map=close_map,
|
|
3503
|
+
high_map=high_map,
|
|
3504
|
+
low_map=low_map,
|
|
3505
|
+
market_arrays=market,
|
|
3506
|
+
backend=backend,
|
|
3507
|
+
)
|
|
3508
|
+
|
|
3509
|
+
raise NotImplementedError(
|
|
3510
|
+
"prepared service context currently supports native_vectorized signal_notional "
|
|
3511
|
+
"and native_portfolio only; use normal backtest(...) for this endpoint"
|
|
3512
|
+
)
|
|
3513
|
+
|
|
3514
|
+
@property
|
|
3515
|
+
def metadata(self) -> Dict[str, object]:
|
|
3516
|
+
return {
|
|
3517
|
+
"mode": self.mode,
|
|
3518
|
+
"symbols": tuple(self.symbols),
|
|
3519
|
+
"bars": int(len(self.idx)),
|
|
3520
|
+
"runs": int(self.runs),
|
|
3521
|
+
"market_signature": self.market_arrays.signature,
|
|
3522
|
+
}
|
|
3523
|
+
|
|
3524
|
+
def backtest(self, *, signal=None, signal_col: Optional[str] = None, positions=None):
|
|
3525
|
+
"""Replay a new signal or position matrix on the prepared market tape."""
|
|
3526
|
+
if self.mode == "single_signal_notional":
|
|
3527
|
+
result = self._run_single(signal=signal, signal_col=signal_col)
|
|
3528
|
+
elif self.mode == "portfolio":
|
|
3529
|
+
result = self._run_portfolio(positions=positions)
|
|
3530
|
+
else: # pragma: no cover - guarded by constructor
|
|
3531
|
+
raise NotImplementedError(f"unsupported prepared context mode={self.mode!r}")
|
|
3532
|
+
self.runs += 1
|
|
3533
|
+
result.metadata.setdefault("prepared_service_context", self.metadata)
|
|
3534
|
+
self.endpoint._store_result(result)
|
|
3535
|
+
return result
|
|
3536
|
+
|
|
3537
|
+
simulate = backtest
|
|
3538
|
+
|
|
3539
|
+
def _run_single(self, *, signal=None, signal_col: Optional[str] = None):
|
|
3540
|
+
config = self.endpoint.config
|
|
3541
|
+
if signal is None:
|
|
3542
|
+
signal = _signal_from_data(self.frame, signal_col)
|
|
3543
|
+
if signal is None:
|
|
3544
|
+
raise ValueError("prepared single-symbol context requires signal or signal_col")
|
|
3545
|
+
raw = _series_to_raw_matrix(signal, self.idx)
|
|
3546
|
+
symbol = self.symbols[0]
|
|
3547
|
+
return self.backend.run_signals(
|
|
3548
|
+
datetime_index=self.idx,
|
|
3549
|
+
positions={symbol: pd.Series(0.0, index=self.idx)},
|
|
3550
|
+
closes=self.close_map,
|
|
3551
|
+
highs=self.high_map,
|
|
3552
|
+
lows=self.low_map,
|
|
3553
|
+
funding_rate=config.funding_rate,
|
|
3554
|
+
contract_size=config.contract_size,
|
|
3555
|
+
leverage=config.account.leverage,
|
|
3556
|
+
alloc_per_trade=config.alloc_per_trade,
|
|
3557
|
+
hedge_type=config.sizing,
|
|
3558
|
+
use_pyramiding=config.use_pyramiding,
|
|
3559
|
+
symbols=self.symbols,
|
|
3560
|
+
market_arrays=self.market_arrays,
|
|
3561
|
+
raw_signal_matrix=raw,
|
|
3562
|
+
instruments=config.instruments,
|
|
3563
|
+
qty_step=config.qty_step,
|
|
3564
|
+
lot_size=config.lot_size,
|
|
3565
|
+
slot_size=config.slot_size,
|
|
3566
|
+
min_qty=config.min_qty,
|
|
3567
|
+
min_notional=config.min_notional,
|
|
3568
|
+
)
|
|
3569
|
+
|
|
3570
|
+
def _run_portfolio(self, *, positions=None):
|
|
3571
|
+
if positions is None:
|
|
3572
|
+
raise ValueError("prepared portfolio context requires positions")
|
|
3573
|
+
config = self.endpoint.config
|
|
3574
|
+
raw = _positions_to_raw_matrix(positions, self.idx, self.symbols)
|
|
3575
|
+
return self.backend.run_signals(
|
|
3576
|
+
positions=None,
|
|
3577
|
+
closes=self.close_map,
|
|
3578
|
+
highs=self.high_map,
|
|
3579
|
+
lows=self.low_map,
|
|
3580
|
+
datetime_index=self.idx,
|
|
3581
|
+
mode=config.portfolio_mode,
|
|
3582
|
+
alloc_per_trade=config.alloc_per_trade,
|
|
3583
|
+
contract_size=config.contract_size,
|
|
3584
|
+
hedge_type=config.sizing if config.sizing else "notional",
|
|
3585
|
+
funding_rate=config.funding_rate,
|
|
3586
|
+
leverage=config.account.leverage,
|
|
3587
|
+
maintenance_ratio=config.account.maintenance_ratio,
|
|
3588
|
+
asset_type=config.asset_type,
|
|
3589
|
+
use_pyramiding=config.use_pyramiding,
|
|
3590
|
+
betas=config.betas,
|
|
3591
|
+
risk_lookback=config.risk_lookback,
|
|
3592
|
+
market_arrays=self.market_arrays,
|
|
3593
|
+
raw_signal_matrix=raw,
|
|
3594
|
+
instruments=config.instruments,
|
|
3595
|
+
qty_step=config.qty_step,
|
|
3596
|
+
lot_size=config.lot_size,
|
|
3597
|
+
slot_size=config.slot_size,
|
|
3598
|
+
min_qty=config.min_qty,
|
|
3599
|
+
min_notional=config.min_notional,
|
|
3600
|
+
report_level=config.report_level,
|
|
3601
|
+
)
|
|
3602
|
+
|
|
3603
|
+
|
|
3604
|
+
class _WalkForwardEndpointScorer:
|
|
3605
|
+
"""
|
|
3606
|
+
Endpoint-backed WFO scorer with run-local prepared market array reuse.
|
|
3607
|
+
|
|
3608
|
+
The cache is intentionally scoped to one scorer instance, which is created
|
|
3609
|
+
for one `QuantBTEndpoint.backtest(...)` call. It never caches by pandas
|
|
3610
|
+
object identity and every prepared reuse is validated by backend signatures.
|
|
3611
|
+
"""
|
|
3612
|
+
|
|
3613
|
+
def __init__(
|
|
3614
|
+
self,
|
|
3615
|
+
config: EndpointConfig,
|
|
3616
|
+
target_mode: str,
|
|
3617
|
+
symbols=None,
|
|
3618
|
+
wf_config: Optional[WalkForwardConfig] = None,
|
|
3619
|
+
market_data=None,
|
|
3620
|
+
market_closes=None,
|
|
3621
|
+
market_highs=None,
|
|
3622
|
+
market_lows=None,
|
|
3623
|
+
market_datetime_index=None,
|
|
3624
|
+
):
|
|
3625
|
+
self.config = config
|
|
3626
|
+
self.target_mode = str(target_mode).lower().strip()
|
|
3627
|
+
self.score_config = _walkforward_scoring_config(config, self.target_mode)
|
|
3628
|
+
self.symbols = None if symbols is None and config.symbols is None else list(symbols or config.symbols or [])
|
|
3629
|
+
self.wf_config = wf_config
|
|
3630
|
+
self.market_data = market_data
|
|
3631
|
+
self.market_closes = market_closes
|
|
3632
|
+
self.market_highs = market_highs
|
|
3633
|
+
self.market_lows = market_lows
|
|
3634
|
+
self.market_datetime_index = market_datetime_index
|
|
3635
|
+
self.use_prepared_cache = bool((wf_config.metadata if wf_config is not None else {}).get("use_prepared_scoring_cache", True))
|
|
3636
|
+
self.prepared_scoring_report_level = str(
|
|
3637
|
+
(wf_config.metadata if wf_config is not None else {}).get("prepared_scoring_report_level", "minimal")
|
|
3638
|
+
)
|
|
3639
|
+
self._single_backend = None
|
|
3640
|
+
self._single_market_maps = {}
|
|
3641
|
+
self._single_market_cache = {}
|
|
3642
|
+
self._portfolio_backend = None
|
|
3643
|
+
self._portfolio_market_maps = {}
|
|
3644
|
+
self._portfolio_market_cache = {}
|
|
3645
|
+
self._stats = {
|
|
3646
|
+
"enabled": bool(self.use_prepared_cache),
|
|
3647
|
+
"target_mode": self.target_mode,
|
|
3648
|
+
"backend": self.score_config.backend,
|
|
3649
|
+
"market_cache_hits": 0,
|
|
3650
|
+
"market_cache_misses": 0,
|
|
3651
|
+
"market_cache_entries": 0,
|
|
3652
|
+
"prepared_runs": 0,
|
|
3653
|
+
"fallback_runs": 0,
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
def __call__(self, data, output, index, fold, params, context: str, trading_days: int) -> Dict[str, float]:
|
|
3657
|
+
try:
|
|
3658
|
+
if self._can_score_single_vectorized_prepared(output):
|
|
3659
|
+
result = self._score_single_vectorized_prepared(output=output, index=index)
|
|
3660
|
+
elif self._can_score_portfolio_prepared(output):
|
|
3661
|
+
result = self._score_portfolio_prepared(output=output, index=index)
|
|
3662
|
+
else:
|
|
3663
|
+
result = self._score_fallback(data=data, output=output, index=index)
|
|
3664
|
+
report = result.full_report(trading_days=trading_days, scope="full")
|
|
3665
|
+
except Exception as exc:
|
|
3666
|
+
raise RuntimeError(
|
|
3667
|
+
"walk-forward endpoint scoring failed during "
|
|
3668
|
+
f"{context} for fold_id={fold.fold_id}; target_mode={self.target_mode!r}; params={params}"
|
|
3669
|
+
) from exc
|
|
3670
|
+
return {
|
|
3671
|
+
"sharpe": float(report.get("sharpe", 0.0)),
|
|
3672
|
+
"turnover": float(report.get("num_trades", 0.0)),
|
|
3673
|
+
"trade_count": float(report.get("num_trades", 0.0)),
|
|
3674
|
+
"mean_return": float(report.get("total_return_pct", 0.0)) / 100.0,
|
|
3675
|
+
"volatility": 0.0,
|
|
3676
|
+
"max_drawdown_pct": float(report.get("max_drawdown_pct", 0.0)),
|
|
3677
|
+
"profit_factor": float(report.get("profit_factor", 0.0)),
|
|
3678
|
+
}
|
|
3679
|
+
|
|
3680
|
+
def prepared_cache_metadata(self) -> Dict[str, object]:
|
|
3681
|
+
meta = dict(self._stats)
|
|
3682
|
+
meta["market_cache_entries"] = len(self._portfolio_market_cache) + len(self._single_market_cache)
|
|
3683
|
+
meta["prepared_scoring_report_level"] = self.prepared_scoring_report_level
|
|
3684
|
+
meta["available"] = (
|
|
3685
|
+
self._prepared_single_available()
|
|
3686
|
+
or (self.target_mode == "portfolio" and self.score_config.backend == "native_portfolio")
|
|
3687
|
+
)
|
|
3688
|
+
return meta
|
|
3689
|
+
|
|
3690
|
+
def _prepared_single_available(self) -> bool:
|
|
3691
|
+
return (
|
|
3692
|
+
self.score_config.mode == "signal_notional"
|
|
3693
|
+
and _resolve_backend(self.score_config) == "native_vectorized"
|
|
3694
|
+
)
|
|
3695
|
+
|
|
3696
|
+
def _can_score_single_vectorized_prepared(self, output) -> bool:
|
|
3697
|
+
return (
|
|
3698
|
+
self.use_prepared_cache
|
|
3699
|
+
and self._prepared_single_available()
|
|
3700
|
+
and isinstance(output, pd.Series)
|
|
3701
|
+
)
|
|
3702
|
+
|
|
3703
|
+
def _can_score_portfolio_prepared(self, output) -> bool:
|
|
3704
|
+
return (
|
|
3705
|
+
self.use_prepared_cache
|
|
3706
|
+
and self.score_config.mode == "portfolio"
|
|
3707
|
+
and self.score_config.backend == "native_portfolio"
|
|
3708
|
+
and isinstance(output, (pd.DataFrame, dict))
|
|
3709
|
+
)
|
|
3710
|
+
|
|
3711
|
+
def _score_fallback(self, data, output, index):
|
|
3712
|
+
self._stats["fallback_runs"] += 1
|
|
3713
|
+
temp = QuantBTEndpoint(self.score_config)
|
|
3714
|
+
sliced_data = _slice_wf_data_to_index(data, index)
|
|
3715
|
+
symbol_list = self._symbol_list(output)
|
|
3716
|
+
if self.score_config.mode == "portfolio":
|
|
3717
|
+
return temp.backtest(data=sliced_data, positions=output, symbols=symbol_list)
|
|
3718
|
+
return temp.backtest(data=sliced_data, signal=output, symbols=symbol_list)
|
|
3719
|
+
|
|
3720
|
+
def _score_single_vectorized_prepared(self, output: pd.Series, index):
|
|
3721
|
+
idx = _ensure_utc_index(index)
|
|
3722
|
+
symbol_list = self._symbol_list(output)
|
|
3723
|
+
close_map, high_map, low_map = self._single_maps(symbol_list)
|
|
3724
|
+
backend = self._single_backend_instance()
|
|
3725
|
+
cache_key = self._market_cache_key(idx, symbol_list)
|
|
3726
|
+
market = self._single_market_cache.get(cache_key)
|
|
3727
|
+
if market is None:
|
|
3728
|
+
market = backend.prepare_market_arrays(
|
|
3729
|
+
datetime_index=idx,
|
|
3730
|
+
closes=close_map,
|
|
3731
|
+
highs=high_map,
|
|
3732
|
+
lows=low_map,
|
|
3733
|
+
funding_rate=self.score_config.funding_rate,
|
|
3734
|
+
symbols=symbol_list,
|
|
3735
|
+
)
|
|
3736
|
+
self._single_market_cache[cache_key] = market
|
|
3737
|
+
self._stats["market_cache_misses"] += 1
|
|
3738
|
+
else:
|
|
3739
|
+
self._stats["market_cache_hits"] += 1
|
|
3740
|
+
|
|
3741
|
+
self._stats["prepared_runs"] += 1
|
|
3742
|
+
return backend.run_signals(
|
|
3743
|
+
positions={symbol_list[0]: output},
|
|
3744
|
+
closes=close_map,
|
|
3745
|
+
highs=high_map,
|
|
3746
|
+
lows=low_map,
|
|
3747
|
+
datetime_index=idx,
|
|
3748
|
+
funding_rate=self.score_config.funding_rate,
|
|
3749
|
+
contract_size=self.score_config.contract_size,
|
|
3750
|
+
leverage=self.score_config.account.leverage,
|
|
3751
|
+
alloc_per_trade=self.score_config.alloc_per_trade,
|
|
3752
|
+
hedge_type=self.score_config.sizing,
|
|
3753
|
+
use_pyramiding=self.score_config.use_pyramiding,
|
|
3754
|
+
symbols=symbol_list,
|
|
3755
|
+
market_arrays=market,
|
|
3756
|
+
instruments=self.score_config.instruments,
|
|
3757
|
+
qty_step=self.score_config.qty_step,
|
|
3758
|
+
lot_size=self.score_config.lot_size,
|
|
3759
|
+
slot_size=self.score_config.slot_size,
|
|
3760
|
+
min_qty=self.score_config.min_qty,
|
|
3761
|
+
min_notional=self.score_config.min_notional,
|
|
3762
|
+
)
|
|
3763
|
+
|
|
3764
|
+
def _score_portfolio_prepared(self, output, index):
|
|
3765
|
+
idx = _ensure_utc_index(index)
|
|
3766
|
+
symbol_list = self._symbol_list(output)
|
|
3767
|
+
close_map, high_map, low_map = self._portfolio_maps(symbol_list)
|
|
3768
|
+
backend = self._portfolio_backend_instance()
|
|
3769
|
+
cache_key = self._market_cache_key(idx, symbol_list)
|
|
3770
|
+
market = self._portfolio_market_cache.get(cache_key)
|
|
3771
|
+
if market is None:
|
|
3772
|
+
market = backend.prepare_market_arrays(
|
|
3773
|
+
datetime_index=idx,
|
|
3774
|
+
closes=close_map,
|
|
3775
|
+
highs=high_map,
|
|
3776
|
+
lows=low_map,
|
|
3777
|
+
funding_rate=self.score_config.funding_rate,
|
|
3778
|
+
symbols=symbol_list,
|
|
3779
|
+
)
|
|
3780
|
+
self._portfolio_market_cache[cache_key] = market
|
|
3781
|
+
self._stats["market_cache_misses"] += 1
|
|
3782
|
+
else:
|
|
3783
|
+
self._stats["market_cache_hits"] += 1
|
|
3784
|
+
|
|
3785
|
+
pos_map = _positions_to_map(output)
|
|
3786
|
+
raw_signals = NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list)
|
|
3787
|
+
self._stats["prepared_runs"] += 1
|
|
3788
|
+
return backend.run_signals(
|
|
3789
|
+
positions=None,
|
|
3790
|
+
closes=close_map,
|
|
3791
|
+
highs=high_map,
|
|
3792
|
+
lows=low_map,
|
|
3793
|
+
datetime_index=idx,
|
|
3794
|
+
mode=self.score_config.portfolio_mode,
|
|
3795
|
+
alloc_per_trade=self.score_config.alloc_per_trade,
|
|
3796
|
+
contract_size=self.score_config.contract_size,
|
|
3797
|
+
hedge_type=self.score_config.sizing if self.score_config.sizing else "notional",
|
|
3798
|
+
funding_rate=self.score_config.funding_rate,
|
|
3799
|
+
leverage=self.score_config.account.leverage,
|
|
3800
|
+
maintenance_ratio=self.score_config.account.maintenance_ratio,
|
|
3801
|
+
asset_type=self.score_config.asset_type,
|
|
3802
|
+
use_pyramiding=self.score_config.use_pyramiding,
|
|
3803
|
+
betas=self.score_config.betas,
|
|
3804
|
+
risk_lookback=self.score_config.risk_lookback,
|
|
3805
|
+
market_arrays=market,
|
|
3806
|
+
raw_signal_matrix=raw_signals,
|
|
3807
|
+
instruments=self.score_config.instruments,
|
|
3808
|
+
qty_step=self.score_config.qty_step,
|
|
3809
|
+
lot_size=self.score_config.lot_size,
|
|
3810
|
+
slot_size=self.score_config.slot_size,
|
|
3811
|
+
min_qty=self.score_config.min_qty,
|
|
3812
|
+
min_notional=self.score_config.min_notional,
|
|
3813
|
+
report_level=self.prepared_scoring_report_level,
|
|
3814
|
+
)
|
|
3815
|
+
|
|
3816
|
+
def _single_backend_instance(self) -> NativeVectorizedBackend:
|
|
3817
|
+
if self._single_backend is None:
|
|
3818
|
+
self._single_backend = NativeVectorizedBackend(
|
|
3819
|
+
NativeVectorizedConfig(
|
|
3820
|
+
account=self.score_config.account,
|
|
3821
|
+
execution=self.score_config.execution,
|
|
3822
|
+
fee_rate=self.score_config.v2_fee_rate,
|
|
3823
|
+
use_funding=bool(self.score_config.use_funding),
|
|
3824
|
+
)
|
|
3825
|
+
)
|
|
3826
|
+
return self._single_backend
|
|
3827
|
+
|
|
3828
|
+
def _portfolio_backend_instance(self) -> NativePortfolioBackend:
|
|
3829
|
+
if self._portfolio_backend is None:
|
|
3830
|
+
fee_oneway = self.score_config.canonical_one_way_fee_rate
|
|
3831
|
+
self._portfolio_backend = NativePortfolioBackend(
|
|
3832
|
+
NativePortfolioConfig(
|
|
3833
|
+
account=self.score_config.account,
|
|
3834
|
+
execution=self.score_config.execution,
|
|
3835
|
+
fee_rate=fee_oneway,
|
|
3836
|
+
use_funding=bool(self.score_config.use_funding),
|
|
3837
|
+
report_level=self.prepared_scoring_report_level,
|
|
3838
|
+
)
|
|
3839
|
+
)
|
|
3840
|
+
return self._portfolio_backend
|
|
3841
|
+
|
|
3842
|
+
def _single_maps(self, symbol_list):
|
|
3843
|
+
key = tuple(symbol_list)
|
|
3844
|
+
if key not in self._single_market_maps:
|
|
3845
|
+
if self.market_closes is not None or isinstance(self.market_data, dict):
|
|
3846
|
+
close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data(
|
|
3847
|
+
data=self.market_data,
|
|
3848
|
+
closes=self.market_closes,
|
|
3849
|
+
highs=self.market_highs,
|
|
3850
|
+
lows=self.market_lows,
|
|
3851
|
+
datetime_index=self.market_datetime_index,
|
|
3852
|
+
symbols=symbol_list,
|
|
3853
|
+
)
|
|
3854
|
+
else:
|
|
3855
|
+
frame = _standardize_frame(self.market_data, datetime_index=self.market_datetime_index)
|
|
3856
|
+
symbol = symbol_list[0]
|
|
3857
|
+
close_map = {symbol: frame["close"]}
|
|
3858
|
+
high_map = {symbol: frame.get("high", frame["close"])}
|
|
3859
|
+
low_map = {symbol: frame.get("low", frame["close"])}
|
|
3860
|
+
self._single_market_maps[key] = (close_map, high_map, low_map)
|
|
3861
|
+
return self._single_market_maps[key]
|
|
3862
|
+
|
|
3863
|
+
def _portfolio_maps(self, symbol_list):
|
|
3864
|
+
key = tuple(symbol_list)
|
|
3865
|
+
if key not in self._portfolio_market_maps:
|
|
3866
|
+
close_map, high_map, low_map, _idx, _symbols = _normalize_symbol_data(
|
|
3867
|
+
data=self.market_data,
|
|
3868
|
+
closes=self.market_closes,
|
|
3869
|
+
highs=self.market_highs,
|
|
3870
|
+
lows=self.market_lows,
|
|
3871
|
+
datetime_index=self.market_datetime_index,
|
|
3872
|
+
symbols=symbol_list,
|
|
3873
|
+
)
|
|
3874
|
+
self._portfolio_market_maps[key] = (close_map, high_map, low_map)
|
|
3875
|
+
return self._portfolio_market_maps[key]
|
|
3876
|
+
|
|
3877
|
+
def _symbol_list(self, output) -> list:
|
|
3878
|
+
if self.symbols:
|
|
3879
|
+
return list(self.symbols)
|
|
3880
|
+
if isinstance(output, pd.DataFrame):
|
|
3881
|
+
return list(output.columns)
|
|
3882
|
+
if isinstance(output, dict):
|
|
3883
|
+
return list(output.keys())
|
|
3884
|
+
return ["DEFAULT"]
|
|
3885
|
+
|
|
3886
|
+
@staticmethod
|
|
3887
|
+
def _market_cache_key(index: pd.DatetimeIndex, symbols: Sequence[str]):
|
|
3888
|
+
idx = _ensure_utc_index(index)
|
|
3889
|
+
first = None if len(idx) == 0 else int(idx.asi8[0])
|
|
3890
|
+
last = None if len(idx) == 0 else int(idx.asi8[-1])
|
|
3891
|
+
return (tuple(symbols), int(len(idx)), first, last)
|
|
3892
|
+
|
|
3893
|
+
|
|
3894
|
+
def _walkforward_scoring_config(config: EndpointConfig, target_mode: str) -> EndpointConfig:
|
|
3895
|
+
mode = str(target_mode).lower().strip()
|
|
3896
|
+
if mode in {"pct_equity", "%_equity"}:
|
|
3897
|
+
return replace(config, mode="pct_equity", backend="legacy", sizing="%_equity")
|
|
3898
|
+
if mode == "dca_ladder":
|
|
3899
|
+
return replace(config, mode="dca_ladder", backend="legacy", sizing="dca_ladder")
|
|
3900
|
+
if mode in {"signal_notional", "single_signal"}:
|
|
3901
|
+
return replace(config, mode="signal_notional", backend=config.backend, sizing="signal_notional")
|
|
3902
|
+
if mode == "portfolio":
|
|
3903
|
+
return replace(config, mode="portfolio", backend="native_portfolio")
|
|
3904
|
+
raise NotImplementedError(f"endpoint scoring is not implemented for walk-forward target_mode={target_mode!r}")
|
|
3905
|
+
|
|
3906
|
+
|
|
3907
|
+
def _strict_lookup_frame(data, datetime_index=None, *, source_timezone: Optional[str] = None) -> pd.DataFrame:
|
|
3908
|
+
if not isinstance(data, pd.DataFrame):
|
|
3909
|
+
raise ValueError("intrabar endpoint requires a DataFrame when intent is not supplied explicitly")
|
|
3910
|
+
frame = data.copy().rename(
|
|
3911
|
+
columns={
|
|
3912
|
+
"Datetime": "timestamp",
|
|
3913
|
+
"Date": "timestamp",
|
|
3914
|
+
"Timestamp": "timestamp",
|
|
3915
|
+
"Open": "open",
|
|
3916
|
+
"High": "high",
|
|
3917
|
+
"Low": "low",
|
|
3918
|
+
"Close": "close",
|
|
3919
|
+
"Volume": "volume",
|
|
3920
|
+
}
|
|
3921
|
+
)
|
|
3922
|
+
if datetime_index is not None:
|
|
3923
|
+
frame.index = _endpoint_strict_index(datetime_index, source_timezone=source_timezone)
|
|
3924
|
+
elif "timestamp" in frame.columns:
|
|
3925
|
+
frame = frame.set_index(_endpoint_strict_index(frame["timestamp"], source_timezone=source_timezone))
|
|
3926
|
+
else:
|
|
3927
|
+
frame.index = _endpoint_strict_index(frame.index, source_timezone=source_timezone)
|
|
3928
|
+
return frame
|
|
3929
|
+
|
|
3930
|
+
|
|
3931
|
+
def _endpoint_strict_index(value, *, source_timezone: Optional[str] = None) -> pd.DatetimeIndex:
|
|
3932
|
+
raw = pd.DatetimeIndex(pd.to_datetime(value, errors="raise"))
|
|
3933
|
+
if raw.tz is None:
|
|
3934
|
+
if source_timezone is None:
|
|
3935
|
+
raise ValueError("intrabar endpoint received timezone-naive data; pass metadata={'source_timezone': ...}")
|
|
3936
|
+
raw = raw.tz_localize(source_timezone)
|
|
3937
|
+
return raw.tz_convert("UTC")
|
|
3938
|
+
|
|
3939
|
+
|
|
3940
|
+
def _execution_contract_from_config(config: EndpointConfig) -> ExecutionContract:
|
|
3941
|
+
meta = config.metadata.get("execution_contract")
|
|
3942
|
+
if meta:
|
|
3943
|
+
return ExecutionContract.from_metadata(meta)
|
|
3944
|
+
contract_id = str(config.metadata.get("execution_contract_id", "intrabar_bracket_v1"))
|
|
3945
|
+
if contract_id == "intrabar_bracket_v1":
|
|
3946
|
+
return ExecutionContract.intrabar_bracket()
|
|
3947
|
+
return ExecutionContract.from_metadata({"engine_id": contract_id})
|
|
3948
|
+
|
|
3949
|
+
|
|
3950
|
+
def _session_policy_from_config(config: EndpointConfig) -> Optional[SessionExecutionPolicy]:
|
|
3951
|
+
return SessionExecutionPolicy.from_metadata(config.metadata.get("session_policy"))
|
|
3952
|
+
|
|
3953
|
+
|
|
3954
|
+
def _tick_size_for_symbol(instruments, symbol: str, default: float = 0.0) -> float:
|
|
3955
|
+
if isinstance(default, dict):
|
|
3956
|
+
fallback = float(default.get(symbol, 0.0))
|
|
3957
|
+
else:
|
|
3958
|
+
fallback = float(default or 0.0)
|
|
3959
|
+
if instruments is None:
|
|
3960
|
+
return fallback
|
|
3961
|
+
if isinstance(instruments, dict):
|
|
3962
|
+
inst = instruments.get(symbol)
|
|
3963
|
+
return fallback if inst is None else float(getattr(inst, "tick_size", fallback))
|
|
3964
|
+
for inst in instruments:
|
|
3965
|
+
if getattr(inst, "symbol", None) == symbol:
|
|
3966
|
+
return float(getattr(inst, "tick_size", fallback))
|
|
3967
|
+
return fallback
|
|
3968
|
+
|
|
3969
|
+
|
|
3970
|
+
def _prepared_profile_signature(data_signature: str, profile: Dict) -> str:
|
|
3971
|
+
payload = dict(profile)
|
|
3972
|
+
payload["data_signature"] = data_signature
|
|
3973
|
+
raw = json.dumps(_jsonable(payload), sort_keys=True, default=str).encode("utf-8")
|
|
3974
|
+
return hashlib.sha256(raw).hexdigest()
|
|
3975
|
+
|
|
3976
|
+
|
|
3977
|
+
def _intrabar_intent_from_endpoint_input(
|
|
3978
|
+
*,
|
|
3979
|
+
frame: Optional[pd.DataFrame],
|
|
3980
|
+
index: pd.DatetimeIndex,
|
|
3981
|
+
signal,
|
|
3982
|
+
signal_col: Optional[str],
|
|
3983
|
+
intent_cols: Dict[str, str],
|
|
3984
|
+
level_mode: IntrabarLevelMode,
|
|
3985
|
+
) -> IntrabarIntentTape:
|
|
3986
|
+
signed_signal = None
|
|
3987
|
+
if signal is not None:
|
|
3988
|
+
signed_signal = _strict_series_values(signal, index, name="signal")
|
|
3989
|
+
elif signal_col is not None:
|
|
3990
|
+
signed_signal = _strict_frame_col_values(frame, index, signal_col, dtype=float)
|
|
3991
|
+
elif "entry_signal" in intent_cols:
|
|
3992
|
+
signed_signal = _strict_frame_col_values(frame, index, intent_cols["entry_signal"], dtype=float)
|
|
3993
|
+
elif "signal" in intent_cols:
|
|
3994
|
+
signed_signal = _strict_frame_col_values(frame, index, intent_cols["signal"], dtype=float)
|
|
3995
|
+
|
|
3996
|
+
if "entry_side" in intent_cols:
|
|
3997
|
+
entry_side = np.sign(_strict_frame_col_values(frame, index, intent_cols["entry_side"], dtype=float)).astype(np.int8)
|
|
3998
|
+
elif signed_signal is not None:
|
|
3999
|
+
entry_side = np.sign(signed_signal).astype(np.int8)
|
|
4000
|
+
else:
|
|
4001
|
+
raise ValueError("intrabar endpoint requires signal/signal_col or intent_cols['entry_side']")
|
|
4002
|
+
|
|
4003
|
+
if "entry_size" in intent_cols:
|
|
4004
|
+
entry_size = np.abs(_strict_frame_col_values(frame, index, intent_cols["entry_size"], dtype=float))
|
|
4005
|
+
elif signed_signal is not None:
|
|
4006
|
+
entry_size = np.abs(signed_signal)
|
|
4007
|
+
else:
|
|
4008
|
+
raise ValueError("intrabar endpoint requires intent_cols['entry_size'] when no signed signal is supplied")
|
|
4009
|
+
|
|
4010
|
+
return IntrabarIntentTape.from_arrays(
|
|
4011
|
+
entry_side=entry_side,
|
|
4012
|
+
entry_size=entry_size,
|
|
4013
|
+
stop_value=_optional_intent_col(frame, index, intent_cols, "stop_value"),
|
|
4014
|
+
take_profit_value=_optional_intent_col(frame, index, intent_cols, "take_profit_value"),
|
|
4015
|
+
trailing_value=_optional_intent_col(frame, index, intent_cols, "trailing_value"),
|
|
4016
|
+
technical_exit=_optional_intent_col(frame, index, intent_cols, "technical_exit", dtype=bool),
|
|
4017
|
+
exit_long=_optional_intent_col(frame, index, intent_cols, "exit_long", dtype=bool),
|
|
4018
|
+
exit_short=_optional_intent_col(frame, index, intent_cols, "exit_short", dtype=bool),
|
|
4019
|
+
level_mode=level_mode,
|
|
4020
|
+
)
|
|
4021
|
+
|
|
4022
|
+
|
|
4023
|
+
def _strict_series_values(series, index: pd.DatetimeIndex, *, name: str) -> np.ndarray:
|
|
4024
|
+
if not isinstance(series, pd.Series):
|
|
4025
|
+
series = pd.Series(series, index=index)
|
|
4026
|
+
s = series.copy()
|
|
4027
|
+
s.index = pd.DatetimeIndex(pd.to_datetime(s.index, errors="raise", utc=True))
|
|
4028
|
+
if not s.index.equals(index):
|
|
4029
|
+
raise ValueError(f"{name} index must exactly match the strict market tape index")
|
|
4030
|
+
return pd.to_numeric(s, errors="raise").to_numpy(dtype=np.float64)
|
|
4031
|
+
|
|
4032
|
+
|
|
4033
|
+
def _strict_frame_col_values(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, col: str, *, dtype=float) -> np.ndarray:
|
|
4034
|
+
if frame is None:
|
|
4035
|
+
raise ValueError(f"intent column {col!r} requires DataFrame data")
|
|
4036
|
+
if col not in frame.columns:
|
|
4037
|
+
raise ValueError(f"intent column {col!r} not found in data")
|
|
4038
|
+
if not pd.DatetimeIndex(frame.index).equals(index):
|
|
4039
|
+
raise ValueError(f"intent column {col!r} index must exactly match the strict market tape index")
|
|
4040
|
+
if dtype is bool:
|
|
4041
|
+
return frame[col].fillna(False).astype(bool).to_numpy(dtype=np.bool_)
|
|
4042
|
+
return pd.to_numeric(frame[col], errors="raise").to_numpy(dtype=np.float64)
|
|
4043
|
+
|
|
4044
|
+
|
|
4045
|
+
def _optional_intent_col(frame: Optional[pd.DataFrame], index: pd.DatetimeIndex, cols: Dict[str, str], key: str, *, dtype=float):
|
|
4046
|
+
col = cols.get(key)
|
|
4047
|
+
if col is None:
|
|
4048
|
+
return None
|
|
4049
|
+
return _strict_frame_col_values(frame, index, col, dtype=dtype)
|
|
4050
|
+
|
|
4051
|
+
|
|
4052
|
+
def _scalar_for_symbol(value, symbol: str, default: float = 1.0) -> float:
|
|
4053
|
+
if isinstance(value, dict):
|
|
4054
|
+
return float(value.get(symbol, default))
|
|
4055
|
+
return float(default if value is None else value)
|
|
4056
|
+
|
|
4057
|
+
|
|
4058
|
+
def _intrabar_fills_to_frame(fills) -> pd.DataFrame:
|
|
4059
|
+
rows = []
|
|
4060
|
+
for fill in fills:
|
|
4061
|
+
rows.append(
|
|
4062
|
+
{
|
|
4063
|
+
"bar_index": int(fill.bar_index),
|
|
4064
|
+
"sequence": int(fill.sequence),
|
|
4065
|
+
"timestamp": pd.Timestamp(fill.timestamp),
|
|
4066
|
+
"side": int(fill.side),
|
|
4067
|
+
"qty": float(fill.qty),
|
|
4068
|
+
"price": float(fill.price),
|
|
4069
|
+
"fee": float(fill.fee),
|
|
4070
|
+
"reason": fill.reason.value if hasattr(fill.reason, "value") else str(fill.reason),
|
|
4071
|
+
}
|
|
4072
|
+
)
|
|
4073
|
+
return pd.DataFrame(rows)
|
|
4074
|
+
|
|
4075
|
+
|
|
4076
|
+
def _slice_wf_data_to_index(data, index: pd.DatetimeIndex):
|
|
4077
|
+
if isinstance(data, pd.DataFrame):
|
|
4078
|
+
return data.reindex(index).copy()
|
|
4079
|
+
if isinstance(data, pd.Series):
|
|
4080
|
+
return data.reindex(index).copy()
|
|
4081
|
+
if isinstance(data, dict):
|
|
4082
|
+
out = {}
|
|
4083
|
+
for key, value in data.items():
|
|
4084
|
+
if isinstance(value, (pd.DataFrame, pd.Series)):
|
|
4085
|
+
out[key] = value.reindex(index).copy()
|
|
4086
|
+
else:
|
|
4087
|
+
out[key] = value
|
|
4088
|
+
return out
|
|
4089
|
+
return data
|
|
4090
|
+
|
|
4091
|
+
|
|
4092
|
+
def _normalize_single_data(data, signal, signal_col, datetime_index):
|
|
4093
|
+
if data is None:
|
|
4094
|
+
raise ValueError("single-symbol endpoint requires data DataFrame")
|
|
4095
|
+
frame = _standardize_frame(data, datetime_index)
|
|
4096
|
+
sig = signal if signal is not None else _signal_from_data(frame, signal_col)
|
|
4097
|
+
if sig is None:
|
|
4098
|
+
raise ValueError("single-symbol endpoint requires signal or signal_col")
|
|
4099
|
+
sig = sig.copy()
|
|
4100
|
+
if isinstance(sig.index, pd.DatetimeIndex):
|
|
4101
|
+
sig.index = sig.index.tz_localize("UTC") if sig.index.tz is None else sig.index.tz_convert("UTC")
|
|
4102
|
+
sig = sig[~sig.index.duplicated(keep="first")].reindex(frame.index, method="ffill").fillna(0.0)
|
|
4103
|
+
return frame, frame.index, sig
|
|
4104
|
+
|
|
4105
|
+
|
|
4106
|
+
def _intrabar_marker_columns(frame: pd.DataFrame) -> list[str]:
|
|
4107
|
+
markers = {
|
|
4108
|
+
"exit_price",
|
|
4109
|
+
"exit_type",
|
|
4110
|
+
"stop_loss",
|
|
4111
|
+
"stoploss",
|
|
4112
|
+
"sl",
|
|
4113
|
+
"take_profit",
|
|
4114
|
+
"takeprofit",
|
|
4115
|
+
"tp",
|
|
4116
|
+
"trailing",
|
|
4117
|
+
"trailing_stop",
|
|
4118
|
+
"use_sl",
|
|
4119
|
+
"use_tp",
|
|
4120
|
+
"slpercent",
|
|
4121
|
+
"tppercent",
|
|
4122
|
+
}
|
|
4123
|
+
found = []
|
|
4124
|
+
for col in frame.columns:
|
|
4125
|
+
key = str(col).lower()
|
|
4126
|
+
if key in markers or "trailing" in key or "stop_loss" in key or "take_profit" in key:
|
|
4127
|
+
found.append(str(col))
|
|
4128
|
+
return found
|
|
4129
|
+
|
|
4130
|
+
|
|
4131
|
+
def _normalize_symbol_data(data, closes, highs, lows, datetime_index, symbols):
|
|
4132
|
+
if closes is not None:
|
|
4133
|
+
symbol_list = list(symbols or closes.keys())
|
|
4134
|
+
idx = pd.DatetimeIndex(datetime_index if datetime_index is not None else closes[symbol_list[0]].index)
|
|
4135
|
+
idx = _ensure_utc_index(idx)
|
|
4136
|
+
close_map = {s: _align_series(closes[s], idx) for s in symbol_list}
|
|
4137
|
+
high_map = {s: _align_series((highs or closes)[s], idx) for s in symbol_list}
|
|
4138
|
+
low_map = {s: _align_series((lows or closes)[s], idx) for s in symbol_list}
|
|
4139
|
+
return close_map, high_map, low_map, idx, symbol_list
|
|
4140
|
+
if not isinstance(data, dict):
|
|
4141
|
+
raise ValueError("multi-symbol endpoint requires data dict or explicit closes")
|
|
4142
|
+
symbol_list = list(symbols or data.keys())
|
|
4143
|
+
frames = {s: _standardize_frame(data[s], datetime_index=None) for s in symbol_list}
|
|
4144
|
+
idx = _ensure_utc_index(datetime_index if datetime_index is not None else frames[symbol_list[0]].index)
|
|
4145
|
+
close_map = {s: _align_series(frames[s]["close"], idx) for s in symbol_list}
|
|
4146
|
+
high_map = {s: _align_series(frames[s].get("high", frames[s]["close"]), idx) for s in symbol_list}
|
|
4147
|
+
low_map = {s: _align_series(frames[s].get("low", frames[s]["close"]), idx) for s in symbol_list}
|
|
4148
|
+
return close_map, high_map, low_map, idx, symbol_list
|
|
4149
|
+
|
|
4150
|
+
|
|
4151
|
+
def _frames_from_symbol_maps(close_map, high_map, low_map, symbols) -> FrameMap:
|
|
4152
|
+
frames = {}
|
|
4153
|
+
for symbol in symbols:
|
|
4154
|
+
close = close_map[symbol]
|
|
4155
|
+
frames[symbol] = pd.DataFrame(
|
|
4156
|
+
{
|
|
4157
|
+
"open": close,
|
|
4158
|
+
"high": high_map.get(symbol, close),
|
|
4159
|
+
"low": low_map.get(symbol, close),
|
|
4160
|
+
"close": close,
|
|
4161
|
+
"volume": 0.0,
|
|
4162
|
+
},
|
|
4163
|
+
index=close.index,
|
|
4164
|
+
)
|
|
4165
|
+
return frames
|
|
4166
|
+
|
|
4167
|
+
|
|
4168
|
+
def _prepared_native_event_open_volume_arrays(data, idx: pd.DatetimeIndex, symbols, close_map) -> tuple[np.ndarray, np.ndarray]:
|
|
4169
|
+
open_cols = []
|
|
4170
|
+
volume_cols = []
|
|
4171
|
+
for symbol in symbols:
|
|
4172
|
+
close = close_map[symbol]
|
|
4173
|
+
if isinstance(data, dict) and symbol in data and isinstance(data[symbol], pd.DataFrame):
|
|
4174
|
+
frame = _standardize_frame(data[symbol], datetime_index=None)
|
|
4175
|
+
open_cols.append(_align_series(frame.get("open", frame["close"]), idx).to_numpy(dtype=np.float64))
|
|
4176
|
+
volume_cols.append(_align_series(frame.get("volume", pd.Series(0.0, index=frame.index)), idx).to_numpy(dtype=np.float64))
|
|
4177
|
+
else:
|
|
4178
|
+
open_cols.append(close.to_numpy(dtype=np.float64))
|
|
4179
|
+
volume_cols.append(np.zeros(len(idx), dtype=np.float64))
|
|
4180
|
+
return (
|
|
4181
|
+
np.ascontiguousarray(np.column_stack(open_cols), dtype=np.float64),
|
|
4182
|
+
np.ascontiguousarray(np.column_stack(volume_cols), dtype=np.float64),
|
|
4183
|
+
)
|
|
4184
|
+
|
|
4185
|
+
|
|
4186
|
+
def _empty_nautilus_preflight_result(data, symbols, account: AccountConfig, metadata: Dict) -> BacktestResultV2:
|
|
4187
|
+
symbol_list = list(symbols)
|
|
4188
|
+
if not symbol_list:
|
|
4189
|
+
raise ValueError("symbols are required for empty Nautilus preflight result")
|
|
4190
|
+
first = data[symbol_list[0]]
|
|
4191
|
+
idx = _ensure_utc_index(first.index)
|
|
4192
|
+
equity = pd.Series(float(account.initial_capital), index=idx, name="equity")
|
|
4193
|
+
returns = pd.Series(0.0, index=idx, name="returns")
|
|
4194
|
+
positions = pd.DataFrame({f"Position_{symbol}": 0.0 for symbol in symbol_list}, index=idx)
|
|
4195
|
+
closes = {}
|
|
4196
|
+
for symbol in symbol_list:
|
|
4197
|
+
frame = data[symbol]
|
|
4198
|
+
close = frame["close"] if "close" in frame else frame["Close"]
|
|
4199
|
+
close = close.copy()
|
|
4200
|
+
close.index = _ensure_utc_index(close.index)
|
|
4201
|
+
closes[f"Close_{symbol}"] = close.reindex(idx).ffill().bfill().astype(float)
|
|
4202
|
+
return BacktestResultV2(
|
|
4203
|
+
equity=equity,
|
|
4204
|
+
returns=returns,
|
|
4205
|
+
positions=positions,
|
|
4206
|
+
closes=pd.DataFrame(closes, index=idx),
|
|
4207
|
+
symbols=symbol_list,
|
|
4208
|
+
initial_capital=float(account.initial_capital),
|
|
4209
|
+
leverage=float(account.leverage),
|
|
4210
|
+
metadata=dict(metadata),
|
|
4211
|
+
)
|
|
4212
|
+
|
|
4213
|
+
|
|
4214
|
+
def _annotate_orders_for_depth(orders: Sequence[OrderIntent], params: Dict) -> tuple[OrderIntent, ...]:
|
|
4215
|
+
package_type = params.get("package_type") or params.get("input_mode")
|
|
4216
|
+
package_id = params.get("package_id") or params.get("basket_id") or params.get("arb_id")
|
|
4217
|
+
if package_type is None and package_id is None:
|
|
4218
|
+
return tuple(orders)
|
|
4219
|
+
out = []
|
|
4220
|
+
for order in orders:
|
|
4221
|
+
metadata = dict(order.metadata)
|
|
4222
|
+
if package_type is not None:
|
|
4223
|
+
metadata.setdefault("package_type", str(package_type))
|
|
4224
|
+
if package_id is not None:
|
|
4225
|
+
metadata.setdefault("package_id", str(package_id))
|
|
4226
|
+
out.append(replace(order, metadata=metadata))
|
|
4227
|
+
return tuple(out)
|
|
4228
|
+
|
|
4229
|
+
|
|
4230
|
+
def _standardize_frame(data, datetime_index=None) -> pd.DataFrame:
|
|
4231
|
+
if not isinstance(data, pd.DataFrame):
|
|
4232
|
+
raise ValueError("data must be a pandas DataFrame")
|
|
4233
|
+
frame = data.copy()
|
|
4234
|
+
frame = frame.rename(
|
|
4235
|
+
columns={
|
|
4236
|
+
"Datetime": "timestamp",
|
|
4237
|
+
"Date": "timestamp",
|
|
4238
|
+
"Timestamp": "timestamp",
|
|
4239
|
+
"Open": "open",
|
|
4240
|
+
"High": "high",
|
|
4241
|
+
"Low": "low",
|
|
4242
|
+
"Close": "close",
|
|
4243
|
+
"Volume": "volume",
|
|
4244
|
+
}
|
|
4245
|
+
)
|
|
4246
|
+
if datetime_index is not None:
|
|
4247
|
+
frame.index = _ensure_utc_index(datetime_index)
|
|
4248
|
+
elif "timestamp" in frame.columns:
|
|
4249
|
+
frame["timestamp"] = pd.to_datetime(frame["timestamp"], utc=True, errors="coerce")
|
|
4250
|
+
frame = frame.dropna(subset=["timestamp"]).set_index("timestamp")
|
|
4251
|
+
else:
|
|
4252
|
+
frame.index = _ensure_utc_index(frame.index)
|
|
4253
|
+
frame = frame[~frame.index.duplicated(keep="first")].sort_index()
|
|
4254
|
+
if "close" not in frame.columns:
|
|
4255
|
+
raise ValueError("data must contain close/Close")
|
|
4256
|
+
for col in ("high", "low"):
|
|
4257
|
+
if col not in frame.columns:
|
|
4258
|
+
frame[col] = frame["close"]
|
|
4259
|
+
if "open" not in frame.columns:
|
|
4260
|
+
frame["open"] = frame["close"]
|
|
4261
|
+
if "volume" not in frame.columns:
|
|
4262
|
+
frame["volume"] = 0.0
|
|
4263
|
+
return frame
|
|
4264
|
+
|
|
4265
|
+
|
|
4266
|
+
def _signal_from_data(data, signal_col):
|
|
4267
|
+
if signal_col is None:
|
|
4268
|
+
return None
|
|
4269
|
+
if data is None or signal_col not in data:
|
|
4270
|
+
raise ValueError(f"signal_col={signal_col!r} not found in data")
|
|
4271
|
+
return data[signal_col]
|
|
4272
|
+
|
|
4273
|
+
|
|
4274
|
+
def _positions_to_map(positions) -> Dict[str, pd.Series]:
|
|
4275
|
+
if positions is None:
|
|
4276
|
+
return {}
|
|
4277
|
+
if isinstance(positions, pd.DataFrame):
|
|
4278
|
+
return {str(col): positions[col] for col in positions.columns}
|
|
4279
|
+
return dict(positions)
|
|
4280
|
+
|
|
4281
|
+
|
|
4282
|
+
def _series_to_raw_matrix(signal, idx: pd.DatetimeIndex) -> np.ndarray:
|
|
4283
|
+
if isinstance(signal, pd.Series):
|
|
4284
|
+
ser = signal
|
|
4285
|
+
else:
|
|
4286
|
+
ser = pd.Series(signal, index=idx)
|
|
4287
|
+
if _series_index_matches(ser, idx):
|
|
4288
|
+
values = ser.to_numpy(dtype=np.float64, copy=True)
|
|
4289
|
+
else:
|
|
4290
|
+
values = _align_series(ser, idx).fillna(0.0).to_numpy(dtype=np.float64, copy=True)
|
|
4291
|
+
return np.ascontiguousarray(values.reshape(-1, 1), dtype=np.float64)
|
|
4292
|
+
|
|
4293
|
+
|
|
4294
|
+
def _positions_to_raw_matrix(positions, idx: pd.DatetimeIndex, symbols: Sequence[str]) -> np.ndarray:
|
|
4295
|
+
symbol_list = list(symbols)
|
|
4296
|
+
if isinstance(positions, pd.DataFrame) and all(symbol in positions.columns for symbol in symbol_list):
|
|
4297
|
+
frame = positions.loc[:, symbol_list]
|
|
4298
|
+
if _frame_index_matches(frame, idx):
|
|
4299
|
+
return np.ascontiguousarray(frame.to_numpy(dtype=np.float64, copy=True), dtype=np.float64)
|
|
4300
|
+
elif isinstance(positions, dict):
|
|
4301
|
+
exact = True
|
|
4302
|
+
cols = []
|
|
4303
|
+
for symbol in symbol_list:
|
|
4304
|
+
series = positions.get(symbol)
|
|
4305
|
+
if not isinstance(series, pd.Series) or not _series_index_matches(series, idx):
|
|
4306
|
+
exact = False
|
|
4307
|
+
break
|
|
4308
|
+
cols.append(series.to_numpy(dtype=np.float64, copy=True))
|
|
4309
|
+
if exact:
|
|
4310
|
+
return np.ascontiguousarray(np.column_stack(cols), dtype=np.float64)
|
|
4311
|
+
|
|
4312
|
+
pos_map = _positions_to_map(positions)
|
|
4313
|
+
return NativePortfolioBackend.prepare_signal_matrix(pos_map, idx, symbol_list)
|
|
4314
|
+
|
|
4315
|
+
|
|
4316
|
+
def _series_index_matches(series: pd.Series, idx: pd.DatetimeIndex) -> bool:
|
|
4317
|
+
if not isinstance(series.index, pd.DatetimeIndex) or len(series.index) != len(idx):
|
|
4318
|
+
return False
|
|
4319
|
+
return bool(np.array_equal(_ensure_utc_index(series.index).asi8, idx.asi8))
|
|
4320
|
+
|
|
4321
|
+
|
|
4322
|
+
def _frame_index_matches(frame: pd.DataFrame, idx: pd.DatetimeIndex) -> bool:
|
|
4323
|
+
if not isinstance(frame.index, pd.DatetimeIndex) or len(frame.index) != len(idx):
|
|
4324
|
+
return False
|
|
4325
|
+
return bool(np.array_equal(_ensure_utc_index(frame.index).asi8, idx.asi8))
|
|
4326
|
+
|
|
4327
|
+
|
|
4328
|
+
def _build_portfolio_orders_for_nautilus(
|
|
4329
|
+
datetime_index,
|
|
4330
|
+
positions: Dict[str, pd.Series],
|
|
4331
|
+
closes: Dict[str, pd.Series],
|
|
4332
|
+
alloc_per_trade,
|
|
4333
|
+
hedge_type: str,
|
|
4334
|
+
use_pyramiding: bool,
|
|
4335
|
+
symbols,
|
|
4336
|
+
) -> tuple[tuple[OrderIntent, ...], pd.DataFrame]:
|
|
4337
|
+
ht = str(hedge_type).lower().strip()
|
|
4338
|
+
if ht in {"%_equity", "pct_equity", "dca_ladder", "dca"}:
|
|
4339
|
+
raise NotImplementedError(
|
|
4340
|
+
"Nautilus portfolio validation currently supports pre-scalable modes "
|
|
4341
|
+
"('signal_notional', 'notional', 'unit'). Use native portfolio for "
|
|
4342
|
+
f"hedge_type={hedge_type!r}."
|
|
4343
|
+
)
|
|
4344
|
+
idx = _ensure_utc_index(datetime_index)
|
|
4345
|
+
alloc = _alloc_map(alloc_per_trade, symbols)
|
|
4346
|
+
orders = []
|
|
4347
|
+
target_cols = {}
|
|
4348
|
+
for symbol in symbols:
|
|
4349
|
+
signal = _align_series(positions[symbol], idx)
|
|
4350
|
+
close = _align_series(closes[symbol], idx)
|
|
4351
|
+
target = compute_target_units(
|
|
4352
|
+
hedge_type=hedge_type,
|
|
4353
|
+
signal=signal,
|
|
4354
|
+
close=close,
|
|
4355
|
+
alloc=alloc[symbol],
|
|
4356
|
+
use_pyramiding=use_pyramiding,
|
|
4357
|
+
).fillna(0.0)
|
|
4358
|
+
target_cols[symbol] = target
|
|
4359
|
+
prev = 0.0
|
|
4360
|
+
for ts, value in target.items():
|
|
4361
|
+
current = float(value)
|
|
4362
|
+
delta = current - prev
|
|
4363
|
+
if abs(delta) > 1e-12:
|
|
4364
|
+
orders.append(
|
|
4365
|
+
OrderIntent(
|
|
4366
|
+
timestamp=ts,
|
|
4367
|
+
symbol=symbol,
|
|
4368
|
+
side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
|
|
4369
|
+
order_type=OrderType.MARKET,
|
|
4370
|
+
qty=abs(delta),
|
|
4371
|
+
tif=TimeInForce.IOC,
|
|
4372
|
+
tag=f"portfolio:{ht}",
|
|
4373
|
+
metadata={
|
|
4374
|
+
"portfolio_mode": "matrix",
|
|
4375
|
+
"target_units": current,
|
|
4376
|
+
"previous_units": prev,
|
|
4377
|
+
},
|
|
4378
|
+
)
|
|
4379
|
+
)
|
|
4380
|
+
prev = current
|
|
4381
|
+
out = pd.DataFrame({symbol: target_cols[symbol] for symbol in symbols}, index=idx)
|
|
4382
|
+
return tuple(sorted(orders, key=lambda order: pd.Timestamp(order.timestamp).value)), out
|
|
4383
|
+
|
|
4384
|
+
|
|
4385
|
+
def _build_portfolio_orders_from_target_units_for_nautilus(
|
|
4386
|
+
target_units: pd.DataFrame,
|
|
4387
|
+
symbols,
|
|
4388
|
+
tag: str,
|
|
4389
|
+
) -> tuple[OrderIntent, ...]:
|
|
4390
|
+
idx = _ensure_utc_index(target_units.index)
|
|
4391
|
+
target = target_units.copy()
|
|
4392
|
+
target.index = idx
|
|
4393
|
+
orders = []
|
|
4394
|
+
for symbol in symbols:
|
|
4395
|
+
if symbol not in target:
|
|
4396
|
+
raise ValueError(f"target_units missing symbol {symbol!r}")
|
|
4397
|
+
prev = 0.0
|
|
4398
|
+
for ts, value in target[symbol].fillna(0.0).items():
|
|
4399
|
+
current = float(value)
|
|
4400
|
+
delta = current - prev
|
|
4401
|
+
if abs(delta) > 1e-12:
|
|
4402
|
+
orders.append(
|
|
4403
|
+
OrderIntent(
|
|
4404
|
+
timestamp=ts,
|
|
4405
|
+
symbol=symbol,
|
|
4406
|
+
side=OrderSide.BUY if delta > 0.0 else OrderSide.SELL,
|
|
4407
|
+
order_type=OrderType.MARKET,
|
|
4408
|
+
qty=abs(delta),
|
|
4409
|
+
tif=TimeInForce.IOC,
|
|
4410
|
+
tag=tag,
|
|
4411
|
+
metadata={
|
|
4412
|
+
"portfolio_mode": "matrix",
|
|
4413
|
+
"target_units": current,
|
|
4414
|
+
"previous_units": prev,
|
|
4415
|
+
},
|
|
4416
|
+
)
|
|
4417
|
+
)
|
|
4418
|
+
prev = current
|
|
4419
|
+
return tuple(sorted(orders, key=lambda order: (pd.Timestamp(order.timestamp).value, str(order.symbol))))
|
|
4420
|
+
|
|
4421
|
+
|
|
4422
|
+
def _alloc_map(value, symbols) -> Dict[str, float]:
|
|
4423
|
+
if isinstance(value, dict):
|
|
4424
|
+
return {symbol: float(value.get(symbol, 100_000.0)) for symbol in symbols}
|
|
4425
|
+
return {symbol: float(value) for symbol in symbols}
|
|
4426
|
+
|
|
4427
|
+
|
|
4428
|
+
def _print_order_logs(result, mode: str = "fills_only", limit: int = 500) -> None:
|
|
4429
|
+
try:
|
|
4430
|
+
from .reporting.nautilus_bundle import format_nautilus_event_log
|
|
4431
|
+
|
|
4432
|
+
orders_report = result.metadata.get("orders_report")
|
|
4433
|
+
if orders_report is None:
|
|
4434
|
+
orders_report = result.metadata.get("order_report")
|
|
4435
|
+
lines = format_nautilus_event_log(
|
|
4436
|
+
fills_report=result.metadata.get("fills_report"),
|
|
4437
|
+
orders_report=orders_report,
|
|
4438
|
+
positions=getattr(result, "positions", None),
|
|
4439
|
+
mode=mode,
|
|
4440
|
+
limit=int(limit),
|
|
4441
|
+
)
|
|
4442
|
+
except Exception as exc:
|
|
4443
|
+
print(f"Order log unavailable: {type(exc).__name__}: {exc}")
|
|
4444
|
+
return
|
|
4445
|
+
for line in lines:
|
|
4446
|
+
print(line)
|
|
4447
|
+
|
|
4448
|
+
|
|
4449
|
+
def _infer_index(data, datetime_index):
|
|
4450
|
+
if datetime_index is not None:
|
|
4451
|
+
return _ensure_utc_index(datetime_index)
|
|
4452
|
+
if isinstance(data, pd.DataFrame):
|
|
4453
|
+
return _standardize_frame(data).index
|
|
4454
|
+
raise ValueError("could not infer datetime index")
|
|
4455
|
+
|
|
4456
|
+
|
|
4457
|
+
def _ensure_utc_index(index) -> pd.DatetimeIndex:
|
|
4458
|
+
return pd.DatetimeIndex(pd.to_datetime(index, utc=True))
|
|
4459
|
+
|
|
4460
|
+
|
|
4461
|
+
def _align_series(series: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
|
|
4462
|
+
ser = series.copy()
|
|
4463
|
+
if isinstance(ser.index, pd.DatetimeIndex):
|
|
4464
|
+
ser.index = ser.index.tz_localize("UTC") if ser.index.tz is None else ser.index.tz_convert("UTC")
|
|
4465
|
+
return ser[~ser.index.duplicated(keep="first")].reindex(idx, method="ffill")
|