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/core/basket.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quantbt.core.basket
|
|
3
|
+
-------------------
|
|
4
|
+
Basket and pair-trading order-plan helpers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Dict, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .orders import OrderIntent
|
|
16
|
+
from .preprocessor import align_series, validate_datetime
|
|
17
|
+
from .schema import BasketSpec, OrderSide, OrderType, TimeInForce
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class FrozenBasketPlan:
|
|
22
|
+
basket: BasketSpec
|
|
23
|
+
orders: Tuple[OrderIntent, ...]
|
|
24
|
+
target_units: pd.DataFrame
|
|
25
|
+
signals: pd.Series
|
|
26
|
+
entry_ratios: pd.DataFrame
|
|
27
|
+
metadata: Dict = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_frozen_basket_orders(
|
|
31
|
+
datetime_index,
|
|
32
|
+
basket: BasketSpec,
|
|
33
|
+
signal: pd.Series,
|
|
34
|
+
closes: Dict[str, pd.Series],
|
|
35
|
+
hedge_ratios: Optional[Dict[str, pd.Series]] = None,
|
|
36
|
+
order_type: OrderType = OrderType.MARKET,
|
|
37
|
+
tif: TimeInForce = TimeInForce.IOC,
|
|
38
|
+
rebalance_threshold: Optional[float] = None,
|
|
39
|
+
min_abs_delta: float = 1e-12,
|
|
40
|
+
) -> FrozenBasketPlan:
|
|
41
|
+
"""
|
|
42
|
+
Convert a scalar basket signal into leg orders with hedge freezing.
|
|
43
|
+
|
|
44
|
+
Ratios are interpreted as unit ratios per one basket unit. At every signal
|
|
45
|
+
transition, units are recomputed from the entry bar prices and then held
|
|
46
|
+
unchanged until the next signal transition. Price drift alone does not
|
|
47
|
+
generate micro-rebalancing orders.
|
|
48
|
+
"""
|
|
49
|
+
if order_type is not OrderType.MARKET:
|
|
50
|
+
raise NotImplementedError("Phase 4 basket order generation supports market orders")
|
|
51
|
+
if min_abs_delta < 0.0:
|
|
52
|
+
raise ValueError("min_abs_delta must be >= 0")
|
|
53
|
+
if rebalance_threshold is not None and rebalance_threshold < 0.0:
|
|
54
|
+
raise ValueError("rebalance_threshold must be >= 0")
|
|
55
|
+
|
|
56
|
+
idx = validate_datetime(datetime_index)
|
|
57
|
+
symbols = [leg.symbol for leg in basket.legs]
|
|
58
|
+
if len(set(symbols)) != len(symbols):
|
|
59
|
+
raise ValueError("basket legs must have unique symbols")
|
|
60
|
+
if not set(symbols).issubset(closes.keys()):
|
|
61
|
+
missing = sorted(set(symbols) - set(closes.keys()))
|
|
62
|
+
raise ValueError(f"missing closes for basket legs: {missing}")
|
|
63
|
+
|
|
64
|
+
close_dict = align_series(closes, symbols, idx)
|
|
65
|
+
sig = _align_signal(signal, idx)
|
|
66
|
+
ratio_dict = _build_ratio_series(basket, hedge_ratios, symbols, idx)
|
|
67
|
+
|
|
68
|
+
orders = []
|
|
69
|
+
current_units = {s: 0.0 for s in symbols}
|
|
70
|
+
current_signal = 0.0
|
|
71
|
+
target_rows = []
|
|
72
|
+
ratio_rows = []
|
|
73
|
+
|
|
74
|
+
for ts in idx:
|
|
75
|
+
raw_signal = float(sig.loc[ts])
|
|
76
|
+
if abs(raw_signal) < min_abs_delta:
|
|
77
|
+
raw_signal = 0.0
|
|
78
|
+
|
|
79
|
+
signal_changed = abs(raw_signal - current_signal) > min_abs_delta
|
|
80
|
+
ratio_drift = _max_ratio_drift(current_units, ratio_dict, symbols, ts)
|
|
81
|
+
should_rebalance = (
|
|
82
|
+
rebalance_threshold is not None
|
|
83
|
+
and raw_signal != 0.0
|
|
84
|
+
and not signal_changed
|
|
85
|
+
and ratio_drift > rebalance_threshold
|
|
86
|
+
)
|
|
87
|
+
if signal_changed or should_rebalance:
|
|
88
|
+
target_units = _compute_entry_units(
|
|
89
|
+
basket=basket,
|
|
90
|
+
signal_value=raw_signal,
|
|
91
|
+
symbols=symbols,
|
|
92
|
+
timestamp=ts,
|
|
93
|
+
closes=close_dict,
|
|
94
|
+
ratios=ratio_dict,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
for sym in symbols:
|
|
98
|
+
delta = target_units[sym] - current_units[sym]
|
|
99
|
+
if abs(delta) <= min_abs_delta:
|
|
100
|
+
continue
|
|
101
|
+
side = OrderSide.BUY if delta > 0.0 else OrderSide.SELL
|
|
102
|
+
orders.append(
|
|
103
|
+
OrderIntent(
|
|
104
|
+
timestamp=ts,
|
|
105
|
+
symbol=sym,
|
|
106
|
+
side=side,
|
|
107
|
+
order_type=order_type,
|
|
108
|
+
qty=abs(delta),
|
|
109
|
+
tif=tif,
|
|
110
|
+
tag=basket.basket_id,
|
|
111
|
+
metadata={
|
|
112
|
+
"basket_id": basket.basket_id,
|
|
113
|
+
"basket_signal": raw_signal,
|
|
114
|
+
"basket_policy": basket.execution_policy.value,
|
|
115
|
+
"hedge_frozen": basket.freeze_hedge,
|
|
116
|
+
"rebalance": should_rebalance,
|
|
117
|
+
"ratio_drift": ratio_drift,
|
|
118
|
+
"target_units": target_units[sym],
|
|
119
|
+
"previous_units": current_units[sym],
|
|
120
|
+
},
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
current_units[sym] = target_units[sym]
|
|
124
|
+
|
|
125
|
+
current_signal = raw_signal
|
|
126
|
+
|
|
127
|
+
target_rows.append({s: current_units[s] for s in symbols})
|
|
128
|
+
ratio_rows.append({s: float(ratio_dict[s].loc[ts]) for s in symbols})
|
|
129
|
+
|
|
130
|
+
return FrozenBasketPlan(
|
|
131
|
+
basket=basket,
|
|
132
|
+
orders=tuple(orders),
|
|
133
|
+
target_units=pd.DataFrame(target_rows, index=idx),
|
|
134
|
+
signals=sig,
|
|
135
|
+
entry_ratios=pd.DataFrame(ratio_rows, index=idx),
|
|
136
|
+
metadata={
|
|
137
|
+
"basket_id": basket.basket_id,
|
|
138
|
+
"gross_notional": basket.gross_notional,
|
|
139
|
+
"freeze_hedge": basket.freeze_hedge,
|
|
140
|
+
"execution_policy": basket.execution_policy.value,
|
|
141
|
+
"hedged_margin_offset": basket.hedged_margin_offset,
|
|
142
|
+
"rebalance_threshold": rebalance_threshold,
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _align_signal(signal: pd.Series, idx: pd.DatetimeIndex) -> pd.Series:
|
|
148
|
+
if not isinstance(signal, pd.Series):
|
|
149
|
+
signal = pd.Series(signal, index=idx)
|
|
150
|
+
else:
|
|
151
|
+
signal = signal.copy()
|
|
152
|
+
if isinstance(signal.index, pd.DatetimeIndex):
|
|
153
|
+
if signal.index.tz is None:
|
|
154
|
+
signal.index = signal.index.tz_localize("UTC")
|
|
155
|
+
else:
|
|
156
|
+
signal.index = signal.index.tz_convert("UTC")
|
|
157
|
+
return signal.reindex(idx, method="ffill").fillna(0.0).astype(float)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _build_ratio_series(
|
|
161
|
+
basket: BasketSpec,
|
|
162
|
+
hedge_ratios: Optional[Dict[str, pd.Series]],
|
|
163
|
+
symbols: list[str],
|
|
164
|
+
idx: pd.DatetimeIndex,
|
|
165
|
+
) -> Dict[str, pd.Series]:
|
|
166
|
+
defaults = {leg.symbol: float(leg.ratio) for leg in basket.legs}
|
|
167
|
+
if hedge_ratios is None:
|
|
168
|
+
return {s: pd.Series(defaults[s], index=idx, dtype=float) for s in symbols}
|
|
169
|
+
|
|
170
|
+
out = {}
|
|
171
|
+
for s in symbols:
|
|
172
|
+
value = hedge_ratios.get(s, defaults[s])
|
|
173
|
+
if isinstance(value, pd.Series):
|
|
174
|
+
ser = value.copy()
|
|
175
|
+
if isinstance(ser.index, pd.DatetimeIndex):
|
|
176
|
+
if ser.index.tz is None:
|
|
177
|
+
ser.index = ser.index.tz_localize("UTC")
|
|
178
|
+
else:
|
|
179
|
+
ser.index = ser.index.tz_convert("UTC")
|
|
180
|
+
out[s] = ser.reindex(idx, method="ffill").fillna(defaults[s]).astype(float)
|
|
181
|
+
else:
|
|
182
|
+
out[s] = pd.Series(float(value), index=idx, dtype=float)
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _compute_entry_units(
|
|
187
|
+
basket: BasketSpec,
|
|
188
|
+
signal_value: float,
|
|
189
|
+
symbols: list[str],
|
|
190
|
+
timestamp,
|
|
191
|
+
closes: Dict[str, pd.Series],
|
|
192
|
+
ratios: Dict[str, pd.Series],
|
|
193
|
+
) -> Dict[str, float]:
|
|
194
|
+
if signal_value == 0.0:
|
|
195
|
+
return {s: 0.0 for s in symbols}
|
|
196
|
+
|
|
197
|
+
gross_unit_notional = 0.0
|
|
198
|
+
for s in symbols:
|
|
199
|
+
price = float(closes[s].loc[timestamp])
|
|
200
|
+
ratio = float(ratios[s].loc[timestamp])
|
|
201
|
+
gross_unit_notional += abs(ratio) * price
|
|
202
|
+
|
|
203
|
+
if not np.isfinite(gross_unit_notional) or gross_unit_notional <= 0.0:
|
|
204
|
+
return {s: 0.0 for s in symbols}
|
|
205
|
+
|
|
206
|
+
basket_units = basket.gross_notional * abs(signal_value) / gross_unit_notional
|
|
207
|
+
signal_side = 1.0 if signal_value > 0.0 else -1.0
|
|
208
|
+
return {
|
|
209
|
+
s: basket_units * float(ratios[s].loc[timestamp]) * signal_side
|
|
210
|
+
for s in symbols
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _max_ratio_drift(
|
|
215
|
+
current_units: Dict[str, float],
|
|
216
|
+
ratios: Dict[str, pd.Series],
|
|
217
|
+
symbols: list[str],
|
|
218
|
+
timestamp,
|
|
219
|
+
) -> float:
|
|
220
|
+
if not symbols:
|
|
221
|
+
return 0.0
|
|
222
|
+
ref_symbol = symbols[0]
|
|
223
|
+
frozen_ref = float(current_units[ref_symbol])
|
|
224
|
+
current_ref = float(ratios[ref_symbol].loc[timestamp])
|
|
225
|
+
if abs(frozen_ref) <= 1e-12 or abs(current_ref) <= 1e-12:
|
|
226
|
+
return 0.0
|
|
227
|
+
|
|
228
|
+
max_drift = 0.0
|
|
229
|
+
for symbol in symbols[1:]:
|
|
230
|
+
frozen_ratio = float(current_units[symbol]) / frozen_ref
|
|
231
|
+
current_ratio = float(ratios[symbol].loc[timestamp]) / current_ref
|
|
232
|
+
denom = max(abs(frozen_ratio), 1e-12)
|
|
233
|
+
max_drift = max(max_drift, abs(current_ratio - frozen_ratio) / denom)
|
|
234
|
+
return max_drift
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Alpha execution-contract certification helpers.
|
|
3
|
+
|
|
4
|
+
These helpers are intentionally lightweight and conservative. They do not try
|
|
5
|
+
to prove a strategy has no look-ahead bias from source text alone; they identify
|
|
6
|
+
which execution contract a file appears to require and what certification level
|
|
7
|
+
an already-run result can claim from its metadata.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from enum import IntEnum
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
from typing import Dict, Iterable, List, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CertificationLevel(IntEnum):
|
|
20
|
+
LEGACY = 0
|
|
21
|
+
ACCOUNTING_REPLAY = 1
|
|
22
|
+
ENGINE_CAUSAL = 2
|
|
23
|
+
CROSS_BACKEND = 3
|
|
24
|
+
EXTERNAL_VALIDATION = 4
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
LEVEL_DESCRIPTIONS = {
|
|
28
|
+
CertificationLevel.LEGACY: "legacy_or_unspecified_execution_contract",
|
|
29
|
+
CertificationLevel.ACCOUNTING_REPLAY: "explicit_fills_accounted_but_fill_generation_not_certified",
|
|
30
|
+
CertificationLevel.ENGINE_CAUSAL: "engine_owned_causal_execution_with_oracle_or_kernel_parity",
|
|
31
|
+
CertificationLevel.CROSS_BACKEND: "native_engine_matches_native_event_on_known_scenarios",
|
|
32
|
+
CertificationLevel.EXTERNAL_VALIDATION: "external_or_lower_timeframe_validation_available",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
INTRABAR_MARKERS = (
|
|
37
|
+
"exit_price",
|
|
38
|
+
"exit_type",
|
|
39
|
+
"stop_loss",
|
|
40
|
+
"stoploss",
|
|
41
|
+
"take_profit",
|
|
42
|
+
"takeprofit",
|
|
43
|
+
"trailing",
|
|
44
|
+
"trailing_stop",
|
|
45
|
+
"use_sl",
|
|
46
|
+
"use_tp",
|
|
47
|
+
"slpercent",
|
|
48
|
+
"tppercent",
|
|
49
|
+
"high[",
|
|
50
|
+
"low[",
|
|
51
|
+
)
|
|
52
|
+
FILL_REPLAY_MARKERS = ("fill_replay", "fills_df", "compact_fill", "bar_index", "sequence")
|
|
53
|
+
GRID_MARKERS = ("dca_ladder", "grid", "safety_order", "take_profit_price", "stop_loss_price")
|
|
54
|
+
NEXT_OPEN_MARKERS = ("next_open", "open[t+1]", "shift(1)", "open.shift")
|
|
55
|
+
CLOSE_TARGET_MARKERS = ("native_vectorized", "signal_notional", "pos_weight", "target_weight")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class AlphaExecutionClassification:
|
|
60
|
+
alpha_id: str
|
|
61
|
+
path: str
|
|
62
|
+
required_engine: str
|
|
63
|
+
current_backend: str
|
|
64
|
+
certification_status: str
|
|
65
|
+
certification_level: int
|
|
66
|
+
markers: tuple[str, ...] = ()
|
|
67
|
+
notes: tuple[str, ...] = ()
|
|
68
|
+
uses_intrabar_high_low: bool = False
|
|
69
|
+
uses_stop: bool = False
|
|
70
|
+
uses_take_profit: bool = False
|
|
71
|
+
uses_trailing: bool = False
|
|
72
|
+
uses_custom_exit_price: bool = False
|
|
73
|
+
uses_explicit_fills: bool = False
|
|
74
|
+
uses_grid_or_dca: bool = False
|
|
75
|
+
metadata: Dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> Dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def classify_alpha_source(source: str, *, alpha_id: str = "unknown", path: str = "") -> AlphaExecutionClassification:
|
|
82
|
+
text = source.lower()
|
|
83
|
+
markers = _matched_markers(text)
|
|
84
|
+
current_backend = _detect_current_backend(text)
|
|
85
|
+
uses_explicit_fills = any(marker in text for marker in FILL_REPLAY_MARKERS)
|
|
86
|
+
uses_grid_or_dca = any(marker in text for marker in GRID_MARKERS)
|
|
87
|
+
uses_stop = any(marker in text for marker in ("stop_loss", "stoploss", "slpercent", "use_sl"))
|
|
88
|
+
uses_take_profit = any(marker in text for marker in ("take_profit", "takeprofit", "tppercent", "use_tp"))
|
|
89
|
+
uses_trailing = "trailing" in text
|
|
90
|
+
uses_custom_exit_price = "exit_price" in text or "exit_type" in text
|
|
91
|
+
uses_intrabar_high_low = bool(re.search(r"\bhigh\s*\[|\blow\s*\[|df\s*\[\s*['\"]high|df\s*\[\s*['\"]low", text))
|
|
92
|
+
|
|
93
|
+
if uses_grid_or_dca:
|
|
94
|
+
required_engine = "event_lifecycle_v2"
|
|
95
|
+
level = CertificationLevel.LEGACY
|
|
96
|
+
status = "needs_specialized_event_or_nautilus_certification"
|
|
97
|
+
elif uses_explicit_fills and not (uses_stop or uses_take_profit or uses_trailing):
|
|
98
|
+
required_engine = "fill_replay_v1"
|
|
99
|
+
level = CertificationLevel.ACCOUNTING_REPLAY
|
|
100
|
+
status = "can_start_with_accounting_replay"
|
|
101
|
+
elif uses_stop or uses_take_profit or uses_trailing or uses_custom_exit_price or uses_intrabar_high_low:
|
|
102
|
+
required_engine = "intrabar_bracket_v1"
|
|
103
|
+
level = CertificationLevel.LEGACY
|
|
104
|
+
status = "requires_intrabar_migration"
|
|
105
|
+
elif any(marker in text for marker in NEXT_OPEN_MARKERS):
|
|
106
|
+
required_engine = "next_open_v1"
|
|
107
|
+
level = CertificationLevel.LEGACY
|
|
108
|
+
status = "requires_next_open_contract"
|
|
109
|
+
elif any(marker in text for marker in CLOSE_TARGET_MARKERS):
|
|
110
|
+
required_engine = "close_target_v2"
|
|
111
|
+
level = CertificationLevel.ENGINE_CAUSAL if current_backend in {"native_vectorized", "close_target_v2"} else CertificationLevel.LEGACY
|
|
112
|
+
status = "close_target_candidate"
|
|
113
|
+
else:
|
|
114
|
+
required_engine = "unknown"
|
|
115
|
+
level = CertificationLevel.LEGACY
|
|
116
|
+
status = "manual_review_required"
|
|
117
|
+
|
|
118
|
+
notes = _notes_for_classification(required_engine, current_backend, markers)
|
|
119
|
+
return AlphaExecutionClassification(
|
|
120
|
+
alpha_id=alpha_id,
|
|
121
|
+
path=path,
|
|
122
|
+
required_engine=required_engine,
|
|
123
|
+
current_backend=current_backend,
|
|
124
|
+
certification_status=status,
|
|
125
|
+
certification_level=int(level),
|
|
126
|
+
markers=tuple(markers),
|
|
127
|
+
notes=tuple(notes),
|
|
128
|
+
uses_intrabar_high_low=uses_intrabar_high_low,
|
|
129
|
+
uses_stop=uses_stop,
|
|
130
|
+
uses_take_profit=uses_take_profit,
|
|
131
|
+
uses_trailing=uses_trailing,
|
|
132
|
+
uses_custom_exit_price=uses_custom_exit_price,
|
|
133
|
+
uses_explicit_fills=uses_explicit_fills,
|
|
134
|
+
uses_grid_or_dca=uses_grid_or_dca,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def scan_alpha_directory(root: str | Path, *, suffixes: Sequence[str] = (".py", ".ipynb", ".md"), max_bytes: int = 2_000_000) -> List[AlphaExecutionClassification]:
|
|
139
|
+
base = Path(root)
|
|
140
|
+
if not base.exists():
|
|
141
|
+
raise FileNotFoundError(str(base))
|
|
142
|
+
out: list[AlphaExecutionClassification] = []
|
|
143
|
+
for path in sorted(p for p in base.rglob("*") if p.is_file() and p.suffix.lower() in suffixes):
|
|
144
|
+
if any(part.startswith(".") for part in path.relative_to(base).parts):
|
|
145
|
+
continue
|
|
146
|
+
if path.stat().st_size > max_bytes:
|
|
147
|
+
out.append(
|
|
148
|
+
AlphaExecutionClassification(
|
|
149
|
+
alpha_id=path.stem,
|
|
150
|
+
path=str(path),
|
|
151
|
+
required_engine="unknown",
|
|
152
|
+
current_backend="unknown",
|
|
153
|
+
certification_status="skipped_large_file",
|
|
154
|
+
certification_level=int(CertificationLevel.LEGACY),
|
|
155
|
+
notes=("file exceeds scanner max_bytes",),
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
continue
|
|
159
|
+
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
160
|
+
out.append(classify_alpha_source(text, alpha_id=path.stem, path=str(path)))
|
|
161
|
+
return out
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def certify_result_metadata(metadata: Dict) -> Dict:
|
|
165
|
+
engine = str(metadata.get("engine_id") or metadata.get("engine") or "").lower()
|
|
166
|
+
backend = str(metadata.get("backend") or metadata.get("backend_alias") or "").lower()
|
|
167
|
+
if engine == "fill_replay_v1":
|
|
168
|
+
level = CertificationLevel.ACCOUNTING_REPLAY
|
|
169
|
+
status = "accounting_certified"
|
|
170
|
+
elif engine == "intrabar_bracket_v1":
|
|
171
|
+
level = CertificationLevel.ENGINE_CAUSAL
|
|
172
|
+
status = "engine_causal_certified"
|
|
173
|
+
if metadata.get("cross_backend_parity_passed"):
|
|
174
|
+
level = CertificationLevel.CROSS_BACKEND
|
|
175
|
+
status = "cross_backend_certified"
|
|
176
|
+
elif backend == "nautilus" or "nautilus" in engine:
|
|
177
|
+
level = CertificationLevel.EXTERNAL_VALIDATION
|
|
178
|
+
status = "external_validation_route"
|
|
179
|
+
elif engine == "close_target_v2":
|
|
180
|
+
level = CertificationLevel.ENGINE_CAUSAL
|
|
181
|
+
status = "close_target_certified"
|
|
182
|
+
if str(metadata.get("certification_status", "")).startswith("uncertified"):
|
|
183
|
+
level = CertificationLevel.LEGACY
|
|
184
|
+
status = str(metadata.get("certification_status"))
|
|
185
|
+
else:
|
|
186
|
+
level = CertificationLevel.LEGACY
|
|
187
|
+
status = "uncertified_or_unknown"
|
|
188
|
+
return {
|
|
189
|
+
"engine_id": engine or "unknown",
|
|
190
|
+
"backend": backend or "unknown",
|
|
191
|
+
"certification_level": int(level),
|
|
192
|
+
"certification_label": f"LEVEL {int(level)}",
|
|
193
|
+
"certification_status": status,
|
|
194
|
+
"description": LEVEL_DESCRIPTIONS[level],
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def build_alpha_certification_report(items: Iterable[AlphaExecutionClassification]) -> Dict:
|
|
199
|
+
rows = [item.to_dict() for item in items]
|
|
200
|
+
by_engine: Dict[str, int] = {}
|
|
201
|
+
by_status: Dict[str, int] = {}
|
|
202
|
+
for row in rows:
|
|
203
|
+
by_engine[row["required_engine"]] = by_engine.get(row["required_engine"], 0) + 1
|
|
204
|
+
by_status[row["certification_status"]] = by_status.get(row["certification_status"], 0) + 1
|
|
205
|
+
return {
|
|
206
|
+
"total": len(rows),
|
|
207
|
+
"by_required_engine": by_engine,
|
|
208
|
+
"by_status": by_status,
|
|
209
|
+
"items": rows,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def alpha_report_markdown(report: Dict) -> str:
|
|
214
|
+
lines = [
|
|
215
|
+
"# Alpha Execution Certification Report",
|
|
216
|
+
"",
|
|
217
|
+
f"- Total files scanned: `{report['total']}`",
|
|
218
|
+
"",
|
|
219
|
+
"## By Required Engine",
|
|
220
|
+
"",
|
|
221
|
+
"| Engine | Count |",
|
|
222
|
+
"|---|---:|",
|
|
223
|
+
]
|
|
224
|
+
for engine, count in sorted(report["by_required_engine"].items()):
|
|
225
|
+
lines.append(f"| `{engine}` | {count} |")
|
|
226
|
+
lines.extend(["", "## By Status", "", "| Status | Count |", "|---|---:|"])
|
|
227
|
+
for status, count in sorted(report["by_status"].items()):
|
|
228
|
+
lines.append(f"| `{status}` | {count} |")
|
|
229
|
+
lines.extend(["", "## Files", "", "| Alpha | Required engine | Current backend | Status | Markers |", "|---|---|---|---|---|"])
|
|
230
|
+
for item in report["items"]:
|
|
231
|
+
markers = ", ".join(item["markers"][:8])
|
|
232
|
+
if len(item["markers"]) > 8:
|
|
233
|
+
markers += ", ..."
|
|
234
|
+
lines.append(
|
|
235
|
+
f"| `{item['alpha_id']}` | `{item['required_engine']}` | `{item['current_backend']}` | "
|
|
236
|
+
f"`{item['certification_status']}` | {markers or '-'} |"
|
|
237
|
+
)
|
|
238
|
+
return "\n".join(lines) + "\n"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _matched_markers(text: str) -> list[str]:
|
|
242
|
+
all_markers = sorted(set(INTRABAR_MARKERS + FILL_REPLAY_MARKERS + GRID_MARKERS + NEXT_OPEN_MARKERS + CLOSE_TARGET_MARKERS))
|
|
243
|
+
return [marker for marker in all_markers if marker in text]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _detect_current_backend(text: str) -> str:
|
|
247
|
+
if "nautilus_validation" in text or "backend=\"nautilus\"" in text or "backend='nautilus'" in text:
|
|
248
|
+
return "nautilus"
|
|
249
|
+
if "intrabar_bracket" in text:
|
|
250
|
+
return "native_intrabar"
|
|
251
|
+
if "fill_replay" in text:
|
|
252
|
+
return "fill_replay_v1"
|
|
253
|
+
if "native_event" in text:
|
|
254
|
+
return "native_event"
|
|
255
|
+
if "native_vectorized" in text:
|
|
256
|
+
return "native_vectorized"
|
|
257
|
+
if "%_equity" in text or "pct_equity" in text:
|
|
258
|
+
return "legacy_pct_equity"
|
|
259
|
+
if "backtestengine(" in text:
|
|
260
|
+
return "legacy"
|
|
261
|
+
return "unknown"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _notes_for_classification(required_engine: str, current_backend: str, markers: Sequence[str]) -> list[str]:
|
|
265
|
+
notes: list[str] = []
|
|
266
|
+
if required_engine == "intrabar_bracket_v1" and current_backend in {"native_vectorized", "legacy", "legacy_pct_equity"}:
|
|
267
|
+
notes.append("intrabar markers found on a close-target/legacy route; migrate to intrabar intent or fill replay")
|
|
268
|
+
if required_engine == "fill_replay_v1":
|
|
269
|
+
notes.append("accounting can be validated from explicit fills, but fill generation remains alpha-owned")
|
|
270
|
+
if required_engine == "event_lifecycle_v2":
|
|
271
|
+
notes.append("multi-order/grid/DCA behavior should stay on event lifecycle or Nautilus validation")
|
|
272
|
+
if not markers:
|
|
273
|
+
notes.append("no execution-sensitive markers detected; manual review still required before production certification")
|
|
274
|
+
return notes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Exchange/instrument quantity constraints shared by all backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, Optional, Sequence, Union
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .schema import InstrumentSpec
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NumberOrMap = Union[float, Dict[str, float], None]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class QuantityConstraints:
|
|
18
|
+
"""Per-symbol exchange quantity rules.
|
|
19
|
+
|
|
20
|
+
`contract_size` remains the PnL/notional multiplier. Fractional crypto
|
|
21
|
+
acceptance is controlled by `qty_step`/`lot_size`, `min_qty`, and
|
|
22
|
+
`min_notional`. QuantBT rounds target quantities down by default, matching
|
|
23
|
+
the conservative side of common exchange filters.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
symbols: tuple[str, ...]
|
|
27
|
+
qty_step: np.ndarray
|
|
28
|
+
min_qty: np.ndarray
|
|
29
|
+
min_notional: np.ndarray
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def enabled(self) -> bool:
|
|
33
|
+
return bool(
|
|
34
|
+
np.any(self.qty_step > 0.0)
|
|
35
|
+
or np.any(self.min_qty > 0.0)
|
|
36
|
+
or np.any(self.min_notional > 0.0)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def as_dict(self) -> Dict[str, Dict[str, float]]:
|
|
40
|
+
return {
|
|
41
|
+
symbol: {
|
|
42
|
+
"qty_step": float(self.qty_step[i]),
|
|
43
|
+
"lot_size": float(self.qty_step[i]),
|
|
44
|
+
"min_qty": float(self.min_qty[i]),
|
|
45
|
+
"min_notional": float(self.min_notional[i]),
|
|
46
|
+
}
|
|
47
|
+
for i, symbol in enumerate(self.symbols)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def build_quantity_constraints(
|
|
52
|
+
symbols: Sequence[str],
|
|
53
|
+
*,
|
|
54
|
+
instruments: Optional[Union[Dict[str, InstrumentSpec], Sequence[InstrumentSpec]]] = None,
|
|
55
|
+
qty_step: NumberOrMap = None,
|
|
56
|
+
lot_size: NumberOrMap = None,
|
|
57
|
+
slot_size: NumberOrMap = None,
|
|
58
|
+
min_qty: NumberOrMap = None,
|
|
59
|
+
min_notional: NumberOrMap = None,
|
|
60
|
+
) -> QuantityConstraints:
|
|
61
|
+
"""Resolve quantity constraints from explicit kwargs and InstrumentSpec.
|
|
62
|
+
|
|
63
|
+
Explicit kwargs override `InstrumentSpec`. `slot_size` is accepted as a
|
|
64
|
+
backward-compatible alias for `lot_size`.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
symbol_list = tuple(symbols)
|
|
68
|
+
inst_map = _instrument_map(instruments)
|
|
69
|
+
step_source = qty_step if qty_step is not None else (lot_size if lot_size is not None else slot_size)
|
|
70
|
+
|
|
71
|
+
steps = []
|
|
72
|
+
min_qtys = []
|
|
73
|
+
min_notionals = []
|
|
74
|
+
for symbol in symbol_list:
|
|
75
|
+
inst = inst_map.get(symbol)
|
|
76
|
+
default_step = 0.0 if inst is None else float(inst.lot_size)
|
|
77
|
+
default_min_qty = 0.0 if inst is None else float(inst.min_qty)
|
|
78
|
+
default_min_notional = 0.0 if inst is None else float(inst.min_notional)
|
|
79
|
+
steps.append(_value_for(step_source, symbol, default_step))
|
|
80
|
+
min_qtys.append(_value_for(min_qty, symbol, default_min_qty))
|
|
81
|
+
min_notionals.append(_value_for(min_notional, symbol, default_min_notional))
|
|
82
|
+
|
|
83
|
+
out = QuantityConstraints(
|
|
84
|
+
symbols=symbol_list,
|
|
85
|
+
qty_step=np.asarray(steps, dtype=np.float64),
|
|
86
|
+
min_qty=np.asarray(min_qtys, dtype=np.float64),
|
|
87
|
+
min_notional=np.asarray(min_notionals, dtype=np.float64),
|
|
88
|
+
)
|
|
89
|
+
if np.any(out.qty_step < 0.0) or np.any(out.min_qty < 0.0) or np.any(out.min_notional < 0.0):
|
|
90
|
+
raise ValueError("qty_step/lot_size, min_qty, and min_notional must be >= 0")
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def quantize_target_units_matrix(
|
|
95
|
+
target_units: np.ndarray,
|
|
96
|
+
prices: np.ndarray,
|
|
97
|
+
contract_sizes: np.ndarray,
|
|
98
|
+
constraints: QuantityConstraints,
|
|
99
|
+
) -> np.ndarray:
|
|
100
|
+
"""Round target-unit matrix down to exchange-acceptable quantities."""
|
|
101
|
+
|
|
102
|
+
if not constraints.enabled:
|
|
103
|
+
return np.ascontiguousarray(target_units, dtype=np.float64)
|
|
104
|
+
out = np.asarray(target_units, dtype=np.float64).copy(order="C")
|
|
105
|
+
prices_arr = np.asarray(prices, dtype=np.float64)
|
|
106
|
+
cs = np.asarray(contract_sizes, dtype=np.float64)
|
|
107
|
+
for j in range(out.shape[1]):
|
|
108
|
+
step = float(constraints.qty_step[j])
|
|
109
|
+
mnq = float(constraints.min_qty[j])
|
|
110
|
+
mnn = float(constraints.min_notional[j])
|
|
111
|
+
for i in range(out.shape[0]):
|
|
112
|
+
out[i, j] = quantize_signed_quantity(out[i, j], prices_arr[i, j], cs[j], step, mnq, mnn)
|
|
113
|
+
return np.ascontiguousarray(out, dtype=np.float64)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def quantize_signed_quantity(
|
|
117
|
+
qty: float,
|
|
118
|
+
price: float,
|
|
119
|
+
contract_size: float = 1.0,
|
|
120
|
+
qty_step: float = 0.0,
|
|
121
|
+
min_qty: float = 0.0,
|
|
122
|
+
min_notional: float = 0.0,
|
|
123
|
+
) -> float:
|
|
124
|
+
"""Round a signed quantity down and zero it if below exchange minima."""
|
|
125
|
+
|
|
126
|
+
q = float(qty)
|
|
127
|
+
if q == 0.0:
|
|
128
|
+
return 0.0
|
|
129
|
+
sign = 1.0 if q > 0.0 else -1.0
|
|
130
|
+
abs_q = abs(q)
|
|
131
|
+
if qty_step > 0.0:
|
|
132
|
+
abs_q = np.floor((abs_q / float(qty_step)) + 1e-12) * float(qty_step)
|
|
133
|
+
if abs_q <= 0.0:
|
|
134
|
+
return 0.0
|
|
135
|
+
if min_qty > 0.0 and abs_q + 1e-12 < min_qty:
|
|
136
|
+
return 0.0
|
|
137
|
+
if min_notional > 0.0 and abs_q * float(price) * float(contract_size) + 1e-12 < min_notional:
|
|
138
|
+
return 0.0
|
|
139
|
+
return sign * abs_q
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _instrument_map(instruments) -> Dict[str, InstrumentSpec]:
|
|
143
|
+
if instruments is None:
|
|
144
|
+
return {}
|
|
145
|
+
if isinstance(instruments, dict):
|
|
146
|
+
return {symbol: spec for symbol, spec in instruments.items() if spec is not None}
|
|
147
|
+
return {spec.symbol: spec for spec in instruments}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _value_for(value: NumberOrMap, symbol: str, default: float) -> float:
|
|
151
|
+
if value is None:
|
|
152
|
+
return float(default)
|
|
153
|
+
if isinstance(value, dict):
|
|
154
|
+
return float(value.get(symbol, default))
|
|
155
|
+
return float(value)
|