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/walkforward.py
ADDED
|
@@ -0,0 +1,3136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
quantbt.walkforward
|
|
3
|
+
-------------------
|
|
4
|
+
WalkForwardEngine foundation.
|
|
5
|
+
|
|
6
|
+
This module intentionally stays orchestration-focused. It builds time-safe
|
|
7
|
+
folds, calls a strategy adapter, stitches OOS signals/positions, and leaves the
|
|
8
|
+
final market simulation to existing QuantBT endpoints.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .core.preprocessor import validate_datetime
|
|
24
|
+
from .optimization.callbacks import SingleObjectiveEarlyStopping as _OptimizationEarlyStopping
|
|
25
|
+
from .optimization.space import stable_params_key, suggest_params as _optimization_suggest_params
|
|
26
|
+
|
|
27
|
+
try: # optional acceleration; Python/NumPy baseline remains available
|
|
28
|
+
from numba import njit
|
|
29
|
+
except Exception: # pragma: no cover - optional dependency guard
|
|
30
|
+
njit = None
|
|
31
|
+
|
|
32
|
+
_NUMBA_AVAILABLE = njit is not None
|
|
33
|
+
|
|
34
|
+
StrategyOutput = Union[pd.Series, pd.DataFrame, Dict[str, pd.Series]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class WalkForwardCompatibilityEntry:
|
|
39
|
+
"""One public walk-forward endpoint compatibility row."""
|
|
40
|
+
|
|
41
|
+
target_mode: str
|
|
42
|
+
expected_output: str
|
|
43
|
+
final_engine: str
|
|
44
|
+
status: str
|
|
45
|
+
notes: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class WalkForwardBenchmarkSnapshot:
|
|
50
|
+
"""Small deterministic kernel benchmark snapshot for audit/CI smoke tests."""
|
|
51
|
+
|
|
52
|
+
n_obs: int
|
|
53
|
+
n_samples: int
|
|
54
|
+
seed: int
|
|
55
|
+
numba_available: bool
|
|
56
|
+
numba_requested: bool
|
|
57
|
+
python_score_seconds: float
|
|
58
|
+
accelerated_score_seconds: float
|
|
59
|
+
python_bootstrap_seconds: float
|
|
60
|
+
accelerated_bootstrap_seconds: float
|
|
61
|
+
max_score_abs_diff: float
|
|
62
|
+
max_bootstrap_abs_diff: float
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
65
|
+
"""Return a JSON-serializable snapshot."""
|
|
66
|
+
return {
|
|
67
|
+
"n_obs": self.n_obs,
|
|
68
|
+
"n_samples": self.n_samples,
|
|
69
|
+
"seed": self.seed,
|
|
70
|
+
"numba_available": self.numba_available,
|
|
71
|
+
"numba_requested": self.numba_requested,
|
|
72
|
+
"python_score_seconds": self.python_score_seconds,
|
|
73
|
+
"accelerated_score_seconds": self.accelerated_score_seconds,
|
|
74
|
+
"python_bootstrap_seconds": self.python_bootstrap_seconds,
|
|
75
|
+
"accelerated_bootstrap_seconds": self.accelerated_bootstrap_seconds,
|
|
76
|
+
"max_score_abs_diff": self.max_score_abs_diff,
|
|
77
|
+
"max_bootstrap_abs_diff": self.max_bootstrap_abs_diff,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class WalkForwardFold:
|
|
83
|
+
"""One time-safe train/OOS fold."""
|
|
84
|
+
|
|
85
|
+
fold_id: int
|
|
86
|
+
train_start: pd.Timestamp
|
|
87
|
+
train_end: pd.Timestamp
|
|
88
|
+
test_start: pd.Timestamp
|
|
89
|
+
test_end: pd.Timestamp
|
|
90
|
+
train_index: pd.DatetimeIndex
|
|
91
|
+
test_index: pd.DatetimeIndex
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class WalkForwardConfig:
|
|
96
|
+
"""
|
|
97
|
+
Configuration for Phase 1 walk-forward splitting and stitching.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
split_mode:
|
|
102
|
+
String such as `walk_forward_2022`, an integer year, or a timestamp-like
|
|
103
|
+
value marking the first OOS period.
|
|
104
|
+
split_frequency:
|
|
105
|
+
`single`, `yearly`, `semi_yearly`, `quarterly`, `monthly`, or
|
|
106
|
+
`weekly`. `single` creates one train/test holdout fold.
|
|
107
|
+
window_mode:
|
|
108
|
+
`expanding` keeps the first train timestamp fixed. `rolling` uses
|
|
109
|
+
`train_window` as the train lookback.
|
|
110
|
+
train_window:
|
|
111
|
+
Optional pandas offset string such as `365D` or `730D`, required for
|
|
112
|
+
rolling mode.
|
|
113
|
+
min_train_bars:
|
|
114
|
+
Folds with fewer train bars are skipped.
|
|
115
|
+
min_test_bars:
|
|
116
|
+
Folds with fewer OOS bars are skipped.
|
|
117
|
+
target_mode:
|
|
118
|
+
Existing QuantBT route used for the final stitched backtest:
|
|
119
|
+
`signal_notional`, `pct_equity`, `dca_ladder`, `portfolio`, `basket`,
|
|
120
|
+
or `arbitrage`.
|
|
121
|
+
fill_value:
|
|
122
|
+
Value used outside OOS windows when constructing the stitched output.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
split_mode: Union[str, int, pd.Timestamp] = "walk_forward_2022"
|
|
126
|
+
split_frequency: str = "quarterly"
|
|
127
|
+
window_mode: str = "expanding"
|
|
128
|
+
train_window: Optional[str] = None
|
|
129
|
+
min_train_bars: int = 1
|
|
130
|
+
min_test_bars: int = 1
|
|
131
|
+
target_mode: str = "signal_notional"
|
|
132
|
+
fill_value: float = 0.0
|
|
133
|
+
optimization_mode: str = "none"
|
|
134
|
+
optuna_trials: int = 0
|
|
135
|
+
optuna_early_stopping: Optional[int] = None
|
|
136
|
+
random_seed: int = 42
|
|
137
|
+
decay_lambda: float = 0.5
|
|
138
|
+
decay_gamma: float = 0.5
|
|
139
|
+
top_is_fraction: float = 0.10
|
|
140
|
+
top_is_k: Optional[int] = None
|
|
141
|
+
candidate_selection_metric: str = "robust_decay"
|
|
142
|
+
candidate_decay_lambda: Optional[float] = None
|
|
143
|
+
candidate_decay_gamma: Optional[float] = None
|
|
144
|
+
sbb_samples: int = 256
|
|
145
|
+
sbb_block_length: int = 20
|
|
146
|
+
sbb_decay_lambda: float = 0.5
|
|
147
|
+
sbb_std_penalty: float = 0.1
|
|
148
|
+
sbb_simulation: str = "stationary"
|
|
149
|
+
regime_count: int = 3
|
|
150
|
+
regime_lookback: int = 20
|
|
151
|
+
regime_weights: Optional[Dict[Union[int, str], float]] = None
|
|
152
|
+
stress_vol_multiplier: float = 1.0
|
|
153
|
+
garch_p: int = 1
|
|
154
|
+
garch_q: int = 1
|
|
155
|
+
garch_dist: str = "t"
|
|
156
|
+
garch_vol_multiplier: float = 1.0
|
|
157
|
+
flat_top_fraction: float = 0.1
|
|
158
|
+
flat_eps: float = 0.15
|
|
159
|
+
flat_min_samples: int = 3
|
|
160
|
+
flat_selector: str = "medoid"
|
|
161
|
+
plateau_quantile: float = 0.25
|
|
162
|
+
plateau_median_weight: float = 0.25
|
|
163
|
+
plateau_std_penalty: float = 0.50
|
|
164
|
+
plateau_size_bonus: float = 0.01
|
|
165
|
+
is_subperiods: int = 6
|
|
166
|
+
q25_weight: float = 0.30
|
|
167
|
+
dispersion_penalty: float = 0.50
|
|
168
|
+
temporal_weight: float = 0.65
|
|
169
|
+
plateau_weight: float = 0.35
|
|
170
|
+
use_bootstrap_penalty: bool = False
|
|
171
|
+
use_complexity_penalty: bool = False
|
|
172
|
+
scoring_backend: str = "proxy"
|
|
173
|
+
scoring_trading_days: int = 365
|
|
174
|
+
min_trades_per_year: Optional[float] = None
|
|
175
|
+
trade_penalty_factor: Optional[float] = None
|
|
176
|
+
use_numba: bool = True
|
|
177
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
178
|
+
|
|
179
|
+
def __post_init__(self) -> None:
|
|
180
|
+
freq = self.split_frequency.lower().strip()
|
|
181
|
+
if freq not in {"single", "yearly", "semi_yearly", "quarterly", "monthly", "weekly"}:
|
|
182
|
+
raise ValueError("split_frequency must be single, yearly, semi_yearly, quarterly, monthly, or weekly")
|
|
183
|
+
object.__setattr__(self, "split_frequency", freq)
|
|
184
|
+
|
|
185
|
+
mode = self.window_mode.lower().strip()
|
|
186
|
+
if mode not in {"expanding", "rolling"}:
|
|
187
|
+
raise ValueError("window_mode must be expanding or rolling")
|
|
188
|
+
if mode == "rolling" and self.train_window is None:
|
|
189
|
+
raise ValueError("rolling window_mode requires train_window")
|
|
190
|
+
object.__setattr__(self, "window_mode", mode)
|
|
191
|
+
|
|
192
|
+
if self.min_train_bars <= 0 or self.min_test_bars <= 0:
|
|
193
|
+
raise ValueError("min_train_bars and min_test_bars must be > 0")
|
|
194
|
+
opt_mode = self.optimization_mode.lower().strip()
|
|
195
|
+
if opt_mode not in {
|
|
196
|
+
"none",
|
|
197
|
+
"mode_1_decay",
|
|
198
|
+
"mode_2_sbb",
|
|
199
|
+
"mode_3_flat_minima",
|
|
200
|
+
"mode_4_is_only_robust",
|
|
201
|
+
"mode_5_full_robust",
|
|
202
|
+
}:
|
|
203
|
+
raise NotImplementedError(
|
|
204
|
+
"optimization_mode must be one of: none, mode_1_decay, mode_2_sbb, mode_3_flat_minima, "
|
|
205
|
+
"mode_4_is_only_robust, mode_5_full_robust"
|
|
206
|
+
)
|
|
207
|
+
object.__setattr__(self, "optimization_mode", opt_mode)
|
|
208
|
+
if self.optuna_trials < 0:
|
|
209
|
+
raise ValueError("optuna_trials must be >= 0")
|
|
210
|
+
if self.optuna_early_stopping is not None and self.optuna_early_stopping <= 0:
|
|
211
|
+
raise ValueError("optuna_early_stopping must be > 0")
|
|
212
|
+
if not 0.0 < self.top_is_fraction <= 1.0:
|
|
213
|
+
raise ValueError("top_is_fraction must be in (0, 1]")
|
|
214
|
+
if self.top_is_k is not None and self.top_is_k <= 0:
|
|
215
|
+
raise ValueError("top_is_k must be > 0 when provided")
|
|
216
|
+
metric = self.candidate_selection_metric.lower().strip()
|
|
217
|
+
if opt_mode == "mode_4_is_only_robust" and metric == "robust_decay":
|
|
218
|
+
metric = "is_only_robust"
|
|
219
|
+
if opt_mode == "mode_5_full_robust" and metric == "robust_decay":
|
|
220
|
+
metric = "full_robust"
|
|
221
|
+
valid_metrics = {
|
|
222
|
+
"robust_decay",
|
|
223
|
+
"mean_oos_sharpe",
|
|
224
|
+
"mean_is_sharpe",
|
|
225
|
+
"is_plateau_robust",
|
|
226
|
+
"is_only_robust",
|
|
227
|
+
"full_robust",
|
|
228
|
+
"full_plateau_robust",
|
|
229
|
+
"full_temporal_robust",
|
|
230
|
+
"full_best",
|
|
231
|
+
}
|
|
232
|
+
if metric not in valid_metrics:
|
|
233
|
+
raise ValueError(
|
|
234
|
+
"candidate_selection_metric must be robust_decay, mean_oos_sharpe, "
|
|
235
|
+
"mean_is_sharpe, is_plateau_robust, is_only_robust, full_robust, "
|
|
236
|
+
"full_plateau_robust, full_temporal_robust, or full_best"
|
|
237
|
+
)
|
|
238
|
+
object.__setattr__(self, "candidate_selection_metric", metric)
|
|
239
|
+
if opt_mode == "mode_4_is_only_robust" and metric != "is_only_robust":
|
|
240
|
+
raise ValueError("mode_4_is_only_robust requires candidate_selection_metric='is_only_robust'")
|
|
241
|
+
if opt_mode == "mode_5_full_robust" and metric not in {
|
|
242
|
+
"full_robust",
|
|
243
|
+
"full_plateau_robust",
|
|
244
|
+
"full_temporal_robust",
|
|
245
|
+
"full_best",
|
|
246
|
+
}:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
"mode_5_full_robust requires candidate_selection_metric to be one of: "
|
|
249
|
+
"full_robust, full_plateau_robust, full_temporal_robust, full_best"
|
|
250
|
+
)
|
|
251
|
+
candidate_decay_lambda = None if self.candidate_decay_lambda is None else float(self.candidate_decay_lambda)
|
|
252
|
+
if candidate_decay_lambda is not None and candidate_decay_lambda < 0.0:
|
|
253
|
+
raise ValueError("candidate_decay_lambda must be >= 0 when provided")
|
|
254
|
+
object.__setattr__(self, "candidate_decay_lambda", candidate_decay_lambda)
|
|
255
|
+
candidate_decay_gamma = None if self.candidate_decay_gamma is None else float(self.candidate_decay_gamma)
|
|
256
|
+
if candidate_decay_gamma is not None and candidate_decay_gamma < 0.0:
|
|
257
|
+
raise ValueError("candidate_decay_gamma must be >= 0 when provided")
|
|
258
|
+
object.__setattr__(self, "candidate_decay_gamma", candidate_decay_gamma)
|
|
259
|
+
if self.sbb_samples <= 0:
|
|
260
|
+
raise ValueError("sbb_samples must be > 0")
|
|
261
|
+
if self.sbb_block_length <= 0:
|
|
262
|
+
raise ValueError("sbb_block_length must be > 0")
|
|
263
|
+
sim = self.sbb_simulation.lower().strip()
|
|
264
|
+
if sim not in {"stationary", "regime", "stress", "garch"}:
|
|
265
|
+
raise ValueError("sbb_simulation must be stationary, regime, stress, or garch")
|
|
266
|
+
object.__setattr__(self, "sbb_simulation", sim)
|
|
267
|
+
if self.regime_count < 2:
|
|
268
|
+
raise ValueError("regime_count must be >= 2")
|
|
269
|
+
if self.regime_lookback <= 0:
|
|
270
|
+
raise ValueError("regime_lookback must be > 0")
|
|
271
|
+
weights = None
|
|
272
|
+
if self.regime_weights is not None:
|
|
273
|
+
weights = _normalize_regime_weights(self.regime_weights, int(self.regime_count))
|
|
274
|
+
object.__setattr__(self, "regime_weights", weights)
|
|
275
|
+
if self.stress_vol_multiplier <= 0.0:
|
|
276
|
+
raise ValueError("stress_vol_multiplier must be > 0")
|
|
277
|
+
if self.garch_p <= 0 or self.garch_q <= 0:
|
|
278
|
+
raise ValueError("garch_p and garch_q must be > 0")
|
|
279
|
+
garch_dist = self.garch_dist.lower().strip()
|
|
280
|
+
if garch_dist not in {"normal", "gaussian", "t", "studentst"}:
|
|
281
|
+
raise ValueError("garch_dist must be normal, gaussian, t, or studentst")
|
|
282
|
+
object.__setattr__(self, "garch_dist", "normal" if garch_dist == "gaussian" else garch_dist)
|
|
283
|
+
if self.garch_vol_multiplier <= 0.0:
|
|
284
|
+
raise ValueError("garch_vol_multiplier must be > 0")
|
|
285
|
+
if not 0.0 < self.flat_top_fraction <= 1.0:
|
|
286
|
+
raise ValueError("flat_top_fraction must be in (0, 1]")
|
|
287
|
+
if self.flat_eps <= 0.0:
|
|
288
|
+
raise ValueError("flat_eps must be > 0")
|
|
289
|
+
if self.flat_min_samples <= 0:
|
|
290
|
+
raise ValueError("flat_min_samples must be > 0")
|
|
291
|
+
selector = self.flat_selector.lower().strip()
|
|
292
|
+
if selector not in {"medoid", "centroid"}:
|
|
293
|
+
raise ValueError("flat_selector must be medoid or centroid")
|
|
294
|
+
object.__setattr__(self, "flat_selector", selector)
|
|
295
|
+
if not 0.0 <= self.plateau_quantile <= 1.0:
|
|
296
|
+
raise ValueError("plateau_quantile must be in [0, 1]")
|
|
297
|
+
if self.plateau_median_weight < 0.0:
|
|
298
|
+
raise ValueError("plateau_median_weight must be >= 0")
|
|
299
|
+
if self.plateau_std_penalty < 0.0:
|
|
300
|
+
raise ValueError("plateau_std_penalty must be >= 0")
|
|
301
|
+
if self.is_subperiods <= 0:
|
|
302
|
+
raise ValueError("is_subperiods must be > 0")
|
|
303
|
+
if self.q25_weight < 0.0:
|
|
304
|
+
raise ValueError("q25_weight must be >= 0")
|
|
305
|
+
if self.dispersion_penalty < 0.0:
|
|
306
|
+
raise ValueError("dispersion_penalty must be >= 0")
|
|
307
|
+
if self.temporal_weight < 0.0 or self.plateau_weight < 0.0:
|
|
308
|
+
raise ValueError("temporal_weight and plateau_weight must be >= 0")
|
|
309
|
+
scoring_backend = self.scoring_backend.lower().strip()
|
|
310
|
+
if scoring_backend not in {"proxy", "endpoint"}:
|
|
311
|
+
raise ValueError("scoring_backend must be proxy or endpoint")
|
|
312
|
+
if opt_mode == "mode_2_sbb" and scoring_backend == "endpoint":
|
|
313
|
+
raise ValueError("mode_2_sbb requires scoring_backend='proxy' because it simulates train return paths")
|
|
314
|
+
object.__setattr__(self, "scoring_backend", scoring_backend)
|
|
315
|
+
try:
|
|
316
|
+
scoring_days = int(self.scoring_trading_days)
|
|
317
|
+
except (TypeError, ValueError) as exc:
|
|
318
|
+
raise ValueError("scoring_trading_days must be a positive integer") from exc
|
|
319
|
+
if scoring_days <= 0:
|
|
320
|
+
raise ValueError("scoring_trading_days must be > 0")
|
|
321
|
+
object.__setattr__(self, "scoring_trading_days", scoring_days)
|
|
322
|
+
min_trades = None if self.min_trades_per_year is None else float(self.min_trades_per_year)
|
|
323
|
+
if min_trades is not None and min_trades < 0.0:
|
|
324
|
+
raise ValueError("min_trades_per_year must be >= 0 when provided")
|
|
325
|
+
object.__setattr__(self, "min_trades_per_year", min_trades)
|
|
326
|
+
penalty_factor = None if self.trade_penalty_factor is None else float(self.trade_penalty_factor)
|
|
327
|
+
if penalty_factor is not None and penalty_factor < 0.0:
|
|
328
|
+
raise ValueError("trade_penalty_factor must be >= 0 when provided")
|
|
329
|
+
object.__setattr__(self, "trade_penalty_factor", penalty_factor)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@dataclass
|
|
333
|
+
class WalkForwardResult:
|
|
334
|
+
"""Phase 1 walk-forward artifact returned before/after final backtest."""
|
|
335
|
+
|
|
336
|
+
folds: List[WalkForwardFold]
|
|
337
|
+
oos_output: Optional[StrategyOutput]
|
|
338
|
+
fold_table: pd.DataFrame
|
|
339
|
+
params: Dict[str, Any]
|
|
340
|
+
backtest_result: Any = None
|
|
341
|
+
trial_table: pd.DataFrame = field(default_factory=pd.DataFrame)
|
|
342
|
+
candidate_table: pd.DataFrame = field(default_factory=pd.DataFrame)
|
|
343
|
+
best_trial: Optional[Dict[str, Any]] = None
|
|
344
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def oos_positions(self) -> Optional[StrategyOutput]:
|
|
348
|
+
"""Alias for `oos_output` used by portfolio-style callers."""
|
|
349
|
+
return self.oos_output
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@dataclass(frozen=True)
|
|
353
|
+
class WalkForwardTrialRecord:
|
|
354
|
+
"""Audit row for one parameter trial."""
|
|
355
|
+
|
|
356
|
+
trial_id: int
|
|
357
|
+
params: Dict[str, Any]
|
|
358
|
+
objective: float
|
|
359
|
+
mean_is_sharpe: float
|
|
360
|
+
mean_oos_sharpe: float
|
|
361
|
+
mean_decay: float
|
|
362
|
+
std_decay: float
|
|
363
|
+
fold_metrics: List[Dict[str, Any]]
|
|
364
|
+
pruned: bool = False
|
|
365
|
+
selection_metadata: Dict[str, Any] = field(default_factory=dict)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class EarlyStoppingCallback(_OptimizationEarlyStopping):
|
|
369
|
+
"""Stop Optuna if best value does not improve after N trials."""
|
|
370
|
+
|
|
371
|
+
def __init__(self, early_stopping_rounds: int, direction: str = "maximize"):
|
|
372
|
+
super().__init__(patience=int(early_stopping_rounds), direction=direction, min_delta=0.0)
|
|
373
|
+
self.early_stopping_rounds = int(early_stopping_rounds)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
class DuplicatePruner:
|
|
377
|
+
"""Optuna pruner that avoids running duplicate parameter sets."""
|
|
378
|
+
|
|
379
|
+
def __init__(self):
|
|
380
|
+
self.trial_params = set()
|
|
381
|
+
|
|
382
|
+
def prune(self, study, trial) -> bool:
|
|
383
|
+
params_key = stable_params_key(trial.params)
|
|
384
|
+
if params_key in self.trial_params:
|
|
385
|
+
return True
|
|
386
|
+
self.trial_params.add(params_key)
|
|
387
|
+
return False
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def logging_callback(study, frozen_trial) -> None:
|
|
391
|
+
"""Record previous best value when Optuna improves."""
|
|
392
|
+
previous_best_value = study.user_attrs.get("previous_best_value", None)
|
|
393
|
+
if previous_best_value != study.best_value:
|
|
394
|
+
study.set_user_attr("previous_best_value", study.best_value)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def walkforward_support_matrix(as_dataframe: bool = True):
|
|
398
|
+
"""
|
|
399
|
+
Return the current walk-forward compatibility matrix.
|
|
400
|
+
|
|
401
|
+
This is intentionally public so notebooks/services can validate a route
|
|
402
|
+
before wiring a strategy into `QuantBTEndpoint.walk_forward(...)`.
|
|
403
|
+
"""
|
|
404
|
+
entries = [
|
|
405
|
+
WalkForwardCompatibilityEntry(
|
|
406
|
+
target_mode="signal_notional",
|
|
407
|
+
expected_output="pd.Series scalar signal",
|
|
408
|
+
final_engine="native_vectorized or native_event",
|
|
409
|
+
status="supported",
|
|
410
|
+
notes="Recommended default for single-symbol systematic alpha.",
|
|
411
|
+
),
|
|
412
|
+
WalkForwardCompatibilityEntry(
|
|
413
|
+
target_mode="notional",
|
|
414
|
+
expected_output="pd.Series scalar target",
|
|
415
|
+
final_engine="native_vectorized or native_event",
|
|
416
|
+
status="supported",
|
|
417
|
+
notes="Explicit notional sizing route.",
|
|
418
|
+
),
|
|
419
|
+
WalkForwardCompatibilityEntry(
|
|
420
|
+
target_mode="unit",
|
|
421
|
+
expected_output="pd.Series scalar target",
|
|
422
|
+
final_engine="native_vectorized or native_event",
|
|
423
|
+
status="supported",
|
|
424
|
+
notes="Explicit unit sizing route.",
|
|
425
|
+
),
|
|
426
|
+
WalkForwardCompatibilityEntry(
|
|
427
|
+
target_mode="pct_equity",
|
|
428
|
+
expected_output="pd.Series scalar weight",
|
|
429
|
+
final_engine="legacy BacktestEngine",
|
|
430
|
+
status="supported",
|
|
431
|
+
notes="Legacy `%_equity` accounting route.",
|
|
432
|
+
),
|
|
433
|
+
WalkForwardCompatibilityEntry(
|
|
434
|
+
target_mode="dca_ladder",
|
|
435
|
+
expected_output="pd.Series structural ladder level",
|
|
436
|
+
final_engine="legacy BacktestEngine",
|
|
437
|
+
status="supported",
|
|
438
|
+
notes="Requires high/low data for intrabar ladder fills.",
|
|
439
|
+
),
|
|
440
|
+
WalkForwardCompatibilityEntry(
|
|
441
|
+
target_mode="portfolio",
|
|
442
|
+
expected_output="pd.DataFrame or dict[str, pd.Series]",
|
|
443
|
+
final_engine="PortfolioBacktestEngine",
|
|
444
|
+
status="supported",
|
|
445
|
+
notes="Multi-symbol portfolio positions stitched across OOS folds.",
|
|
446
|
+
),
|
|
447
|
+
WalkForwardCompatibilityEntry(
|
|
448
|
+
target_mode="basket",
|
|
449
|
+
expected_output="pd.Series scalar basket signal",
|
|
450
|
+
final_engine="native_event basket route",
|
|
451
|
+
status="supported",
|
|
452
|
+
notes="Requires BasketSpec on the endpoint.",
|
|
453
|
+
),
|
|
454
|
+
WalkForwardCompatibilityEntry(
|
|
455
|
+
target_mode="arbitrage",
|
|
456
|
+
expected_output="pd.Series scalar package signal",
|
|
457
|
+
final_engine="supported arbitrage package route",
|
|
458
|
+
status="partial",
|
|
459
|
+
notes="Current supported arbitrage specs only; future specialized engines reserved.",
|
|
460
|
+
),
|
|
461
|
+
WalkForwardCompatibilityEntry(
|
|
462
|
+
target_mode="nautilus_validation",
|
|
463
|
+
expected_output="pd.Series scalar signal",
|
|
464
|
+
final_engine="Nautilus adapter",
|
|
465
|
+
status="reserved",
|
|
466
|
+
notes="Reserved for future WFO parity validation, not routed by walk-forward today.",
|
|
467
|
+
),
|
|
468
|
+
]
|
|
469
|
+
rows = [entry.__dict__ for entry in entries]
|
|
470
|
+
if as_dataframe:
|
|
471
|
+
return pd.DataFrame(rows)
|
|
472
|
+
return rows
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class WalkForwardEngine:
|
|
476
|
+
"""
|
|
477
|
+
Time-safe walk-forward splitter and OOS stitcher.
|
|
478
|
+
|
|
479
|
+
The engine can use fixed `params`, Optuna decay search, SBB robustness, or
|
|
480
|
+
flat-minima selection. The final output is always stitched OOS only;
|
|
481
|
+
endpoint simulation is still delegated to QuantBT's normal backtest routes.
|
|
482
|
+
"""
|
|
483
|
+
|
|
484
|
+
def __init__(
|
|
485
|
+
self,
|
|
486
|
+
strategy: Any,
|
|
487
|
+
config: Optional[WalkForwardConfig] = None,
|
|
488
|
+
scorer: Optional[Callable[..., Dict[str, float]]] = None,
|
|
489
|
+
):
|
|
490
|
+
if strategy is None:
|
|
491
|
+
raise ValueError("WalkForwardEngine requires a strategy callable or strategy class/object")
|
|
492
|
+
self.strategy = strategy
|
|
493
|
+
self.config = config or WalkForwardConfig()
|
|
494
|
+
self.scorer = scorer
|
|
495
|
+
if self.config.scoring_backend == "endpoint" and self.scorer is None:
|
|
496
|
+
raise ValueError("scoring_backend='endpoint' requires a scorer callback")
|
|
497
|
+
|
|
498
|
+
def run(
|
|
499
|
+
self,
|
|
500
|
+
data,
|
|
501
|
+
params: Optional[Dict[str, Any]] = None,
|
|
502
|
+
param_ranges: Optional[Dict[str, Any]] = None,
|
|
503
|
+
datetime_index: Optional[Union[pd.DatetimeIndex, pd.Series]] = None,
|
|
504
|
+
) -> WalkForwardResult:
|
|
505
|
+
"""Build folds, call the strategy per fold, and stitch OOS output."""
|
|
506
|
+
idx = _infer_datetime_index(data, datetime_index)
|
|
507
|
+
data_for_strategy = _align_data_to_datetime_index(data, idx)
|
|
508
|
+
folds = self.build_folds(idx)
|
|
509
|
+
trial_records: List[WalkForwardTrialRecord] = []
|
|
510
|
+
candidate_records: List[WalkForwardTrialRecord] = []
|
|
511
|
+
if params is not None:
|
|
512
|
+
chosen_params = dict(params)
|
|
513
|
+
selected_record = self.evaluate_params(data=data_for_strategy, folds=folds, params=chosen_params, trial_id=0)
|
|
514
|
+
trial_records.append(selected_record)
|
|
515
|
+
elif self.config.optimization_mode in {
|
|
516
|
+
"mode_1_decay",
|
|
517
|
+
"mode_2_sbb",
|
|
518
|
+
"mode_3_flat_minima",
|
|
519
|
+
"mode_4_is_only_robust",
|
|
520
|
+
"mode_5_full_robust",
|
|
521
|
+
} and self.config.optuna_trials > 0:
|
|
522
|
+
selected_record, trial_records, candidate_records = self.optimize_params(
|
|
523
|
+
data=data_for_strategy,
|
|
524
|
+
folds=folds,
|
|
525
|
+
param_ranges=param_ranges or {},
|
|
526
|
+
)
|
|
527
|
+
chosen_params = dict(selected_record.params)
|
|
528
|
+
else:
|
|
529
|
+
chosen_params = dict(_default_params_from_ranges(param_ranges or {}))
|
|
530
|
+
selected_record = self.evaluate_params(data=data_for_strategy, folds=folds, params=chosen_params, trial_id=0)
|
|
531
|
+
trial_records.append(selected_record)
|
|
532
|
+
|
|
533
|
+
outputs: List[StrategyOutput] = []
|
|
534
|
+
|
|
535
|
+
for fold in folds:
|
|
536
|
+
out = self._call_strategy(data=data_for_strategy, params=chosen_params, fold=fold)
|
|
537
|
+
outputs.append(_slice_output_to_test(out, fold.test_index))
|
|
538
|
+
|
|
539
|
+
stitched = stitch_oos_outputs(
|
|
540
|
+
outputs=outputs,
|
|
541
|
+
folds=folds,
|
|
542
|
+
full_index=idx,
|
|
543
|
+
fill_value=self.config.fill_value,
|
|
544
|
+
)
|
|
545
|
+
fold_table = _fold_table(folds)
|
|
546
|
+
return WalkForwardResult(
|
|
547
|
+
folds=folds,
|
|
548
|
+
oos_output=stitched,
|
|
549
|
+
fold_table=fold_table,
|
|
550
|
+
params=chosen_params,
|
|
551
|
+
trial_table=_trial_table(trial_records),
|
|
552
|
+
candidate_table=_trial_table(candidate_records),
|
|
553
|
+
best_trial=_trial_to_dict(selected_record),
|
|
554
|
+
metadata={
|
|
555
|
+
"engine": "walk_forward_phase4",
|
|
556
|
+
"split_mode": str(self.config.split_mode),
|
|
557
|
+
"split_frequency": self.config.split_frequency,
|
|
558
|
+
"window_mode": self.config.window_mode,
|
|
559
|
+
"target_mode": self.config.target_mode,
|
|
560
|
+
"optimization_mode": self.config.optimization_mode,
|
|
561
|
+
"validation_claim": (
|
|
562
|
+
"none_full_sample_calibration"
|
|
563
|
+
if self.config.optimization_mode == "mode_5_full_robust"
|
|
564
|
+
else "walk_forward_oos"
|
|
565
|
+
),
|
|
566
|
+
"full_sample_used_for_selection": self.config.optimization_mode == "mode_5_full_robust",
|
|
567
|
+
"oos_used_for_selection": self.config.optimization_mode not in {
|
|
568
|
+
"mode_2_sbb",
|
|
569
|
+
"mode_4_is_only_robust",
|
|
570
|
+
"mode_5_full_robust",
|
|
571
|
+
}
|
|
572
|
+
and self.config.candidate_selection_metric not in {
|
|
573
|
+
"is_plateau_robust",
|
|
574
|
+
"is_only_robust",
|
|
575
|
+
"full_robust",
|
|
576
|
+
"full_plateau_robust",
|
|
577
|
+
"full_temporal_robust",
|
|
578
|
+
"full_best",
|
|
579
|
+
},
|
|
580
|
+
"n_folds": len(folds),
|
|
581
|
+
"n_trials": len(trial_records),
|
|
582
|
+
"n_candidates": len(candidate_records),
|
|
583
|
+
"top_is_fraction": self.config.top_is_fraction,
|
|
584
|
+
"top_is_k": self.config.top_is_k,
|
|
585
|
+
"candidate_selection_metric": self.config.candidate_selection_metric,
|
|
586
|
+
"data_hash": _data_hash(data_for_strategy),
|
|
587
|
+
"config_hash": _config_hash(self.config),
|
|
588
|
+
"random_seed": self.config.random_seed,
|
|
589
|
+
"scoring_trading_days": self.config.scoring_trading_days,
|
|
590
|
+
"min_trades_per_year": self.config.min_trades_per_year,
|
|
591
|
+
"trade_penalty_factor": self.config.trade_penalty_factor,
|
|
592
|
+
"sbb_simulation": self.config.sbb_simulation,
|
|
593
|
+
"sbb_samples": self.config.sbb_samples,
|
|
594
|
+
"sbb_block_length": self.config.sbb_block_length,
|
|
595
|
+
"regime_count": self.config.regime_count,
|
|
596
|
+
"regime_lookback": self.config.regime_lookback,
|
|
597
|
+
"regime_weights": self.config.regime_weights,
|
|
598
|
+
"stress_vol_multiplier": self.config.stress_vol_multiplier,
|
|
599
|
+
"garch_p": self.config.garch_p,
|
|
600
|
+
"garch_q": self.config.garch_q,
|
|
601
|
+
"garch_dist": self.config.garch_dist,
|
|
602
|
+
"garch_vol_multiplier": self.config.garch_vol_multiplier,
|
|
603
|
+
"numba_enabled": bool(self.config.use_numba and _NUMBA_AVAILABLE),
|
|
604
|
+
"plateau_quantile": self.config.plateau_quantile,
|
|
605
|
+
"plateau_median_weight": self.config.plateau_median_weight,
|
|
606
|
+
"plateau_std_penalty": self.config.plateau_std_penalty,
|
|
607
|
+
"plateau_size_bonus": self.config.plateau_size_bonus,
|
|
608
|
+
"is_subperiods": self.config.is_subperiods,
|
|
609
|
+
"q25_weight": self.config.q25_weight,
|
|
610
|
+
"dispersion_penalty": self.config.dispersion_penalty,
|
|
611
|
+
"temporal_weight": self.config.temporal_weight,
|
|
612
|
+
"plateau_weight": self.config.plateau_weight,
|
|
613
|
+
"use_bootstrap_penalty": self.config.use_bootstrap_penalty,
|
|
614
|
+
"use_complexity_penalty": self.config.use_complexity_penalty,
|
|
615
|
+
"scoring_backend": self.config.scoring_backend,
|
|
616
|
+
**self.config.metadata,
|
|
617
|
+
},
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def optimize_params(
|
|
621
|
+
self,
|
|
622
|
+
data,
|
|
623
|
+
folds: Sequence[WalkForwardFold],
|
|
624
|
+
param_ranges: Dict[str, Any],
|
|
625
|
+
) -> tuple[WalkForwardTrialRecord, List[WalkForwardTrialRecord], List[WalkForwardTrialRecord]]:
|
|
626
|
+
"""Run anti-leakage two-stage optimization and return selected params plus ledgers."""
|
|
627
|
+
if not param_ranges:
|
|
628
|
+
raise ValueError(f"{self.config.optimization_mode} optimization requires param_ranges")
|
|
629
|
+
validate_param_ranges(param_ranges, context=self.config.optimization_mode)
|
|
630
|
+
try:
|
|
631
|
+
import optuna
|
|
632
|
+
except ImportError as exc: # pragma: no cover - environment guard
|
|
633
|
+
raise ImportError("WalkForwardEngine optimization requires optuna") from exc
|
|
634
|
+
|
|
635
|
+
records: List[WalkForwardTrialRecord] = []
|
|
636
|
+
seen_params = set()
|
|
637
|
+
|
|
638
|
+
def objective(trial):
|
|
639
|
+
params = _sample_params(trial, param_ranges)
|
|
640
|
+
params_key = stable_params_key(params)
|
|
641
|
+
if params_key in seen_params:
|
|
642
|
+
record = WalkForwardTrialRecord(
|
|
643
|
+
trial_id=int(trial.number),
|
|
644
|
+
params=dict(params),
|
|
645
|
+
objective=-np.inf,
|
|
646
|
+
mean_is_sharpe=0.0,
|
|
647
|
+
mean_oos_sharpe=0.0,
|
|
648
|
+
mean_decay=0.0,
|
|
649
|
+
std_decay=0.0,
|
|
650
|
+
fold_metrics=[],
|
|
651
|
+
pruned=True,
|
|
652
|
+
)
|
|
653
|
+
records.append(record)
|
|
654
|
+
raise optuna.TrialPruned("duplicate parameter set")
|
|
655
|
+
seen_params.add(params_key)
|
|
656
|
+
if self.config.optimization_mode == "mode_2_sbb":
|
|
657
|
+
record = self.evaluate_params_sbb(data=data, folds=folds, params=params, trial_id=trial.number)
|
|
658
|
+
else:
|
|
659
|
+
record = self.evaluate_params_is(data=data, folds=folds, params=params, trial_id=trial.number)
|
|
660
|
+
records.append(record)
|
|
661
|
+
trial.set_user_attr("fold_metrics", record.fold_metrics)
|
|
662
|
+
trial.set_user_attr("params", record.params)
|
|
663
|
+
trial.set_user_attr("mean_is_sharpe", record.mean_is_sharpe)
|
|
664
|
+
trial.set_user_attr("mean_oos_sharpe", record.mean_oos_sharpe)
|
|
665
|
+
trial.set_user_attr("mean_decay", record.mean_decay)
|
|
666
|
+
trial.set_user_attr("std_decay", record.std_decay)
|
|
667
|
+
return record.objective
|
|
668
|
+
|
|
669
|
+
sampler = optuna.samplers.TPESampler(seed=int(self.config.random_seed))
|
|
670
|
+
pruner = DuplicatePruner()
|
|
671
|
+
study = optuna.create_study(direction="maximize", sampler=sampler, pruner=pruner)
|
|
672
|
+
callbacks = [logging_callback]
|
|
673
|
+
if self.config.optuna_early_stopping is not None:
|
|
674
|
+
callbacks.append(EarlyStoppingCallback(self.config.optuna_early_stopping))
|
|
675
|
+
study.optimize(
|
|
676
|
+
objective,
|
|
677
|
+
n_trials=int(self.config.optuna_trials),
|
|
678
|
+
callbacks=callbacks,
|
|
679
|
+
show_progress_bar=False,
|
|
680
|
+
)
|
|
681
|
+
candidates = _select_is_candidate_records(records, param_ranges, self.config)
|
|
682
|
+
if self.config.optimization_mode == "mode_5_full_robust":
|
|
683
|
+
if not candidates:
|
|
684
|
+
raise ValueError("full-sample robust optimization produced no candidates")
|
|
685
|
+
selected = _with_selection_metadata(
|
|
686
|
+
candidates[0],
|
|
687
|
+
{
|
|
688
|
+
**candidates[0].selection_metadata,
|
|
689
|
+
"stage": "full_sample_candidate_selection",
|
|
690
|
+
"candidate_selection_complete": True,
|
|
691
|
+
"oos_seen_by_optuna": False,
|
|
692
|
+
"oos_used_for_selection": False,
|
|
693
|
+
"full_sample_used_for_selection": True,
|
|
694
|
+
"validation_claim": "none_full_sample_calibration",
|
|
695
|
+
"intended_use": "production_calibration",
|
|
696
|
+
},
|
|
697
|
+
)
|
|
698
|
+
records.extend(candidates)
|
|
699
|
+
return selected, records, list(candidates)
|
|
700
|
+
candidate_records = []
|
|
701
|
+
seen_candidate_params = set()
|
|
702
|
+
for candidate_id, candidate in enumerate(candidates):
|
|
703
|
+
params_key = tuple(sorted(candidate.params.items()))
|
|
704
|
+
if params_key in seen_candidate_params:
|
|
705
|
+
continue
|
|
706
|
+
seen_candidate_params.add(params_key)
|
|
707
|
+
evaluated = self.evaluate_params(
|
|
708
|
+
data=data,
|
|
709
|
+
folds=folds,
|
|
710
|
+
params=dict(candidate.params),
|
|
711
|
+
trial_id=int(candidate.trial_id),
|
|
712
|
+
)
|
|
713
|
+
evaluated = _with_selection_metadata(
|
|
714
|
+
evaluated,
|
|
715
|
+
{
|
|
716
|
+
**candidate.selection_metadata,
|
|
717
|
+
"stage": "oos_candidate_selection",
|
|
718
|
+
"candidate_id": int(candidate_id),
|
|
719
|
+
"source_trial_id": int(candidate.trial_id),
|
|
720
|
+
"source_is_objective": float(candidate.objective),
|
|
721
|
+
"oos_seen_by_optuna": False,
|
|
722
|
+
},
|
|
723
|
+
)
|
|
724
|
+
candidate_records.append(evaluated)
|
|
725
|
+
if not candidate_records:
|
|
726
|
+
raise ValueError("anti-leakage optimization produced no OOS candidates")
|
|
727
|
+
best = _select_oos_candidate_record(candidate_records, self.config)
|
|
728
|
+
records.extend(candidate_records)
|
|
729
|
+
return best, records, candidate_records
|
|
730
|
+
|
|
731
|
+
def evaluate_params_is(
|
|
732
|
+
self,
|
|
733
|
+
data,
|
|
734
|
+
folds: Sequence[WalkForwardFold],
|
|
735
|
+
params: Dict[str, Any],
|
|
736
|
+
trial_id: int = 0,
|
|
737
|
+
) -> WalkForwardTrialRecord:
|
|
738
|
+
"""Score params on in-sample folds only for anti-leakage Optuna search."""
|
|
739
|
+
fold_metrics = []
|
|
740
|
+
is_scores = []
|
|
741
|
+
|
|
742
|
+
for fold in folds:
|
|
743
|
+
is_output = self._call_strategy_for_indices(
|
|
744
|
+
data=data,
|
|
745
|
+
params=params,
|
|
746
|
+
train_index=fold.train_index,
|
|
747
|
+
test_index=fold.train_index,
|
|
748
|
+
fold=fold,
|
|
749
|
+
context="anti-leakage in-sample search",
|
|
750
|
+
)
|
|
751
|
+
is_metrics = self._score_strategy_output(
|
|
752
|
+
data,
|
|
753
|
+
is_output,
|
|
754
|
+
fold.train_index,
|
|
755
|
+
fold=fold,
|
|
756
|
+
params=params,
|
|
757
|
+
context="anti-leakage in-sample search",
|
|
758
|
+
)
|
|
759
|
+
required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year)
|
|
760
|
+
factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor)
|
|
761
|
+
penalty = trade_frequency_penalty(is_metrics["trade_count"], required_trades, factor)
|
|
762
|
+
is_sharpe = is_metrics["sharpe"] - penalty
|
|
763
|
+
shard_stats = self._score_is_subperiods(
|
|
764
|
+
data=data,
|
|
765
|
+
is_output=is_output,
|
|
766
|
+
train_index=fold.train_index,
|
|
767
|
+
fold=fold,
|
|
768
|
+
params=params,
|
|
769
|
+
)
|
|
770
|
+
is_scores.append(is_sharpe)
|
|
771
|
+
fold_metrics.append(
|
|
772
|
+
{
|
|
773
|
+
"fold_id": fold.fold_id,
|
|
774
|
+
"train_start": fold.train_start,
|
|
775
|
+
"train_end": fold.train_end,
|
|
776
|
+
"test_start": fold.test_start,
|
|
777
|
+
"test_end": fold.test_end,
|
|
778
|
+
"is_sharpe": is_sharpe,
|
|
779
|
+
"is_sharpe_raw": is_metrics["sharpe"],
|
|
780
|
+
"is_turnover": is_metrics["turnover"],
|
|
781
|
+
"is_trade_count": is_metrics["trade_count"],
|
|
782
|
+
"is_required_trades": required_trades,
|
|
783
|
+
"is_trade_penalty": penalty,
|
|
784
|
+
"oos_evaluated": False,
|
|
785
|
+
**shard_stats,
|
|
786
|
+
}
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
mean_is = float(np.mean(is_scores)) if is_scores else 0.0
|
|
790
|
+
shard_values = _collect_subperiod_sharpes(fold_metrics)
|
|
791
|
+
temporal_stats = _temporal_robustness_stats(
|
|
792
|
+
shard_values,
|
|
793
|
+
q25_weight=float(self.config.q25_weight),
|
|
794
|
+
dispersion_penalty=float(self.config.dispersion_penalty),
|
|
795
|
+
fallback=mean_is,
|
|
796
|
+
)
|
|
797
|
+
temporal_stats["is_subperiod_count"] = temporal_stats["temporal_count"]
|
|
798
|
+
return WalkForwardTrialRecord(
|
|
799
|
+
trial_id=int(trial_id),
|
|
800
|
+
params=dict(params),
|
|
801
|
+
objective=mean_is,
|
|
802
|
+
mean_is_sharpe=mean_is,
|
|
803
|
+
mean_oos_sharpe=0.0,
|
|
804
|
+
mean_decay=0.0,
|
|
805
|
+
std_decay=0.0,
|
|
806
|
+
fold_metrics=fold_metrics,
|
|
807
|
+
selection_metadata={
|
|
808
|
+
"stage": "is_search",
|
|
809
|
+
"objective_mode": self.config.optimization_mode,
|
|
810
|
+
"oos_seen_by_optuna": False,
|
|
811
|
+
**temporal_stats,
|
|
812
|
+
},
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
def _score_is_subperiods(
|
|
816
|
+
self,
|
|
817
|
+
data,
|
|
818
|
+
is_output: StrategyOutput,
|
|
819
|
+
train_index: pd.DatetimeIndex,
|
|
820
|
+
fold: WalkForwardFold,
|
|
821
|
+
params: Dict[str, Any],
|
|
822
|
+
) -> Dict[str, Any]:
|
|
823
|
+
if self.config.optimization_mode not in {"mode_4_is_only_robust", "mode_5_full_robust"}:
|
|
824
|
+
return {}
|
|
825
|
+
shards = _split_index_into_subperiods(train_index, int(self.config.is_subperiods))
|
|
826
|
+
scores = []
|
|
827
|
+
raw_scores = []
|
|
828
|
+
trade_counts = []
|
|
829
|
+
factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor)
|
|
830
|
+
for shard_id, shard_index in enumerate(shards):
|
|
831
|
+
if len(shard_index) < 2:
|
|
832
|
+
continue
|
|
833
|
+
shard_output = _slice_output_to_test(is_output, shard_index)
|
|
834
|
+
metrics = self._score_strategy_output(
|
|
835
|
+
data,
|
|
836
|
+
shard_output,
|
|
837
|
+
shard_index,
|
|
838
|
+
fold=fold,
|
|
839
|
+
params=params,
|
|
840
|
+
context=f"is-only robustness subperiod {shard_id}",
|
|
841
|
+
)
|
|
842
|
+
required = _required_trades_for_index(shard_index, self.config.min_trades_per_year)
|
|
843
|
+
penalty = trade_frequency_penalty(metrics["trade_count"], required, factor)
|
|
844
|
+
raw = float(metrics["sharpe"])
|
|
845
|
+
score = raw - penalty
|
|
846
|
+
raw_scores.append(raw)
|
|
847
|
+
scores.append(float(score))
|
|
848
|
+
trade_counts.append(float(metrics["trade_count"]))
|
|
849
|
+
stats = _temporal_robustness_stats(
|
|
850
|
+
scores,
|
|
851
|
+
q25_weight=float(self.config.q25_weight),
|
|
852
|
+
dispersion_penalty=float(self.config.dispersion_penalty),
|
|
853
|
+
fallback=0.0,
|
|
854
|
+
)
|
|
855
|
+
return {
|
|
856
|
+
"is_subperiod_sharpes": [float(x) for x in scores],
|
|
857
|
+
"is_subperiod_sharpes_raw": [float(x) for x in raw_scores],
|
|
858
|
+
"is_subperiod_trade_counts": [float(x) for x in trade_counts],
|
|
859
|
+
"is_subperiod_count": int(len(scores)),
|
|
860
|
+
"is_subperiod_median": stats["temporal_median"],
|
|
861
|
+
"is_subperiod_q25": stats["temporal_q25"],
|
|
862
|
+
"is_subperiod_mad": stats["temporal_mad"],
|
|
863
|
+
"is_temporal_score": stats["temporal_score"],
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
def evaluate_params(
|
|
867
|
+
self,
|
|
868
|
+
data,
|
|
869
|
+
folds: Sequence[WalkForwardFold],
|
|
870
|
+
params: Dict[str, Any],
|
|
871
|
+
trial_id: int = 0,
|
|
872
|
+
) -> WalkForwardTrialRecord:
|
|
873
|
+
"""Score params with mode_1_decay return-proxy metrics."""
|
|
874
|
+
fold_metrics = []
|
|
875
|
+
is_scores = []
|
|
876
|
+
oos_scores = []
|
|
877
|
+
decay = []
|
|
878
|
+
|
|
879
|
+
for fold in folds:
|
|
880
|
+
is_output = self._call_strategy_for_indices(
|
|
881
|
+
data=data,
|
|
882
|
+
params=params,
|
|
883
|
+
train_index=fold.train_index,
|
|
884
|
+
test_index=fold.train_index,
|
|
885
|
+
fold=fold,
|
|
886
|
+
context="in-sample scoring",
|
|
887
|
+
)
|
|
888
|
+
oos_output = self._call_strategy_for_indices(
|
|
889
|
+
data=data,
|
|
890
|
+
params=params,
|
|
891
|
+
train_index=fold.train_index,
|
|
892
|
+
test_index=fold.test_index,
|
|
893
|
+
fold=fold,
|
|
894
|
+
context="out-of-sample scoring",
|
|
895
|
+
)
|
|
896
|
+
is_metrics = self._score_strategy_output(
|
|
897
|
+
data,
|
|
898
|
+
is_output,
|
|
899
|
+
fold.train_index,
|
|
900
|
+
fold=fold,
|
|
901
|
+
params=params,
|
|
902
|
+
context="in-sample scoring",
|
|
903
|
+
)
|
|
904
|
+
oos_metrics = self._score_strategy_output(
|
|
905
|
+
data,
|
|
906
|
+
oos_output,
|
|
907
|
+
fold.test_index,
|
|
908
|
+
fold=fold,
|
|
909
|
+
params=params,
|
|
910
|
+
context="out-of-sample scoring",
|
|
911
|
+
)
|
|
912
|
+
is_required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year)
|
|
913
|
+
oos_required_trades = _required_trades_for_index(fold.test_index, self.config.min_trades_per_year)
|
|
914
|
+
factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor)
|
|
915
|
+
is_penalty = trade_frequency_penalty(is_metrics["trade_count"], is_required_trades, factor)
|
|
916
|
+
oos_penalty = trade_frequency_penalty(oos_metrics["trade_count"], oos_required_trades, factor)
|
|
917
|
+
is_sharpe = is_metrics["sharpe"] - is_penalty
|
|
918
|
+
oos_sharpe = oos_metrics["sharpe"] - oos_penalty
|
|
919
|
+
d = is_sharpe - oos_sharpe
|
|
920
|
+
is_scores.append(is_sharpe)
|
|
921
|
+
oos_scores.append(oos_sharpe)
|
|
922
|
+
decay.append(d)
|
|
923
|
+
fold_metrics.append(
|
|
924
|
+
{
|
|
925
|
+
"fold_id": fold.fold_id,
|
|
926
|
+
"train_start": fold.train_start,
|
|
927
|
+
"train_end": fold.train_end,
|
|
928
|
+
"test_start": fold.test_start,
|
|
929
|
+
"test_end": fold.test_end,
|
|
930
|
+
"is_sharpe": is_sharpe,
|
|
931
|
+
"oos_sharpe": oos_sharpe,
|
|
932
|
+
"is_sharpe_raw": is_metrics["sharpe"],
|
|
933
|
+
"oos_sharpe_raw": oos_metrics["sharpe"],
|
|
934
|
+
"decay": d,
|
|
935
|
+
"is_turnover": is_metrics["turnover"],
|
|
936
|
+
"oos_turnover": oos_metrics["turnover"],
|
|
937
|
+
"is_trade_count": is_metrics["trade_count"],
|
|
938
|
+
"oos_trade_count": oos_metrics["trade_count"],
|
|
939
|
+
"is_required_trades": is_required_trades,
|
|
940
|
+
"oos_required_trades": oos_required_trades,
|
|
941
|
+
"is_trade_penalty": is_penalty,
|
|
942
|
+
"oos_trade_penalty": oos_penalty,
|
|
943
|
+
}
|
|
944
|
+
)
|
|
945
|
+
|
|
946
|
+
mean_oos = float(np.mean(oos_scores)) if oos_scores else 0.0
|
|
947
|
+
mean_is = float(np.mean(is_scores)) if is_scores else 0.0
|
|
948
|
+
mean_decay = float(np.mean(decay)) if decay else 0.0
|
|
949
|
+
std_decay = float(np.std(decay, ddof=1)) if len(decay) > 1 else 0.0
|
|
950
|
+
decay_lambda = self.config.decay_lambda if self.config.candidate_decay_lambda is None else self.config.candidate_decay_lambda
|
|
951
|
+
decay_gamma = self.config.decay_gamma if self.config.candidate_decay_gamma is None else self.config.candidate_decay_gamma
|
|
952
|
+
objective = (
|
|
953
|
+
mean_oos
|
|
954
|
+
- float(decay_lambda) * std_decay
|
|
955
|
+
- float(decay_gamma) * max(0.0, mean_decay)
|
|
956
|
+
)
|
|
957
|
+
return WalkForwardTrialRecord(
|
|
958
|
+
trial_id=int(trial_id),
|
|
959
|
+
params=dict(params),
|
|
960
|
+
objective=float(objective),
|
|
961
|
+
mean_is_sharpe=mean_is,
|
|
962
|
+
mean_oos_sharpe=mean_oos,
|
|
963
|
+
mean_decay=mean_decay,
|
|
964
|
+
std_decay=std_decay,
|
|
965
|
+
fold_metrics=fold_metrics,
|
|
966
|
+
)
|
|
967
|
+
|
|
968
|
+
def evaluate_params_sbb(
|
|
969
|
+
self,
|
|
970
|
+
data,
|
|
971
|
+
folds: Sequence[WalkForwardFold],
|
|
972
|
+
params: Dict[str, Any],
|
|
973
|
+
trial_id: int = 0,
|
|
974
|
+
) -> WalkForwardTrialRecord:
|
|
975
|
+
"""
|
|
976
|
+
Score params with train-only synthetic OOS robustness.
|
|
977
|
+
|
|
978
|
+
The strategy is evaluated on each train fold, then its train return
|
|
979
|
+
proxy is simulated with the selected Mode 2 generator. The selected
|
|
980
|
+
objective rewards high synthetic Sharpe and penalizes estimated decay
|
|
981
|
+
from original IS Sharpe to synthetic Sharpe. OOS bars are not evaluated
|
|
982
|
+
inside the Optuna objective.
|
|
983
|
+
"""
|
|
984
|
+
fold_metrics = []
|
|
985
|
+
is_scores = []
|
|
986
|
+
synthetic_scores = []
|
|
987
|
+
synthetic_stds = []
|
|
988
|
+
decay = []
|
|
989
|
+
|
|
990
|
+
for fold in folds:
|
|
991
|
+
is_output = self._call_strategy_for_indices(
|
|
992
|
+
data=data,
|
|
993
|
+
params=params,
|
|
994
|
+
train_index=fold.train_index,
|
|
995
|
+
test_index=fold.train_index,
|
|
996
|
+
fold=fold,
|
|
997
|
+
context="sbb train scoring",
|
|
998
|
+
)
|
|
999
|
+
is_metrics = self._score_strategy_output(
|
|
1000
|
+
data,
|
|
1001
|
+
is_output,
|
|
1002
|
+
fold.train_index,
|
|
1003
|
+
fold=fold,
|
|
1004
|
+
params=params,
|
|
1005
|
+
context="sbb train scoring",
|
|
1006
|
+
)
|
|
1007
|
+
returns = strategy_return_series(
|
|
1008
|
+
data,
|
|
1009
|
+
is_output,
|
|
1010
|
+
fold.train_index,
|
|
1011
|
+
).to_numpy(dtype=np.float64)
|
|
1012
|
+
seed = int(self.config.random_seed) + int(trial_id) * 100_003 + int(fold.fold_id) * 9_176
|
|
1013
|
+
boot = synthetic_walkforward_sharpes(
|
|
1014
|
+
returns=returns,
|
|
1015
|
+
n_samples=int(self.config.sbb_samples),
|
|
1016
|
+
block_length=int(self.config.sbb_block_length),
|
|
1017
|
+
seed=seed,
|
|
1018
|
+
trading_days=int(self.config.scoring_trading_days),
|
|
1019
|
+
use_numba=bool(self.config.use_numba),
|
|
1020
|
+
simulation=self.config.sbb_simulation,
|
|
1021
|
+
regime_count=int(self.config.regime_count),
|
|
1022
|
+
regime_lookback=int(self.config.regime_lookback),
|
|
1023
|
+
regime_weights=self.config.regime_weights,
|
|
1024
|
+
stress_vol_multiplier=float(self.config.stress_vol_multiplier),
|
|
1025
|
+
garch_p=int(self.config.garch_p),
|
|
1026
|
+
garch_q=int(self.config.garch_q),
|
|
1027
|
+
garch_dist=self.config.garch_dist,
|
|
1028
|
+
garch_vol_multiplier=float(self.config.garch_vol_multiplier),
|
|
1029
|
+
)
|
|
1030
|
+
synthetic_mean = float(np.mean(boot)) if len(boot) else 0.0
|
|
1031
|
+
synthetic_std = float(np.std(boot, ddof=1)) if len(boot) > 1 else 0.0
|
|
1032
|
+
required_trades = _required_trades_for_index(fold.train_index, self.config.min_trades_per_year)
|
|
1033
|
+
factor = 1.0 if self.config.trade_penalty_factor is None else float(self.config.trade_penalty_factor)
|
|
1034
|
+
penalty = trade_frequency_penalty(is_metrics["trade_count"], required_trades, factor)
|
|
1035
|
+
is_sharpe = is_metrics["sharpe"] - penalty
|
|
1036
|
+
synthetic_sharpe = synthetic_mean - penalty
|
|
1037
|
+
d = float(is_sharpe - synthetic_sharpe)
|
|
1038
|
+
fold_objective = (
|
|
1039
|
+
synthetic_sharpe
|
|
1040
|
+
- float(self.config.sbb_decay_lambda) * max(0.0, d)
|
|
1041
|
+
- float(self.config.sbb_std_penalty) * synthetic_std
|
|
1042
|
+
)
|
|
1043
|
+
is_scores.append(is_sharpe)
|
|
1044
|
+
synthetic_scores.append(synthetic_sharpe)
|
|
1045
|
+
synthetic_stds.append(synthetic_std)
|
|
1046
|
+
decay.append(d)
|
|
1047
|
+
fold_metrics.append(
|
|
1048
|
+
{
|
|
1049
|
+
"fold_id": fold.fold_id,
|
|
1050
|
+
"train_start": fold.train_start,
|
|
1051
|
+
"train_end": fold.train_end,
|
|
1052
|
+
"test_start": fold.test_start,
|
|
1053
|
+
"test_end": fold.test_end,
|
|
1054
|
+
"is_sharpe": is_sharpe,
|
|
1055
|
+
"synthetic_oos_sharpe": synthetic_sharpe,
|
|
1056
|
+
"is_sharpe_raw": is_metrics["sharpe"],
|
|
1057
|
+
"synthetic_oos_sharpe_raw": synthetic_mean,
|
|
1058
|
+
"synthetic_oos_std": synthetic_std,
|
|
1059
|
+
"decay": d,
|
|
1060
|
+
"sbb_objective": float(fold_objective),
|
|
1061
|
+
"sbb_samples": int(self.config.sbb_samples),
|
|
1062
|
+
"sbb_block_length": int(self.config.sbb_block_length),
|
|
1063
|
+
"sbb_simulation": self.config.sbb_simulation,
|
|
1064
|
+
"regime_count": int(self.config.regime_count),
|
|
1065
|
+
"regime_lookback": int(self.config.regime_lookback),
|
|
1066
|
+
"regime_weights": self.config.regime_weights,
|
|
1067
|
+
"stress_vol_multiplier": float(self.config.stress_vol_multiplier),
|
|
1068
|
+
"garch_p": int(self.config.garch_p),
|
|
1069
|
+
"garch_q": int(self.config.garch_q),
|
|
1070
|
+
"garch_dist": self.config.garch_dist,
|
|
1071
|
+
"garch_vol_multiplier": float(self.config.garch_vol_multiplier),
|
|
1072
|
+
"is_turnover": is_metrics["turnover"],
|
|
1073
|
+
"is_trade_count": is_metrics["trade_count"],
|
|
1074
|
+
"is_required_trades": required_trades,
|
|
1075
|
+
"is_trade_penalty": penalty,
|
|
1076
|
+
}
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
mean_is = float(np.mean(is_scores)) if is_scores else 0.0
|
|
1080
|
+
mean_synthetic = float(np.mean(synthetic_scores)) if synthetic_scores else 0.0
|
|
1081
|
+
mean_synthetic_std = float(np.mean(synthetic_stds)) if synthetic_stds else 0.0
|
|
1082
|
+
mean_decay = float(np.mean(decay)) if decay else 0.0
|
|
1083
|
+
std_decay = float(np.std(decay, ddof=1)) if len(decay) > 1 else 0.0
|
|
1084
|
+
objective = (
|
|
1085
|
+
mean_synthetic
|
|
1086
|
+
- float(self.config.sbb_decay_lambda) * max(0.0, mean_decay)
|
|
1087
|
+
- float(self.config.sbb_std_penalty) * mean_synthetic_std
|
|
1088
|
+
)
|
|
1089
|
+
return WalkForwardTrialRecord(
|
|
1090
|
+
trial_id=int(trial_id),
|
|
1091
|
+
params=dict(params),
|
|
1092
|
+
objective=float(objective),
|
|
1093
|
+
mean_is_sharpe=mean_is,
|
|
1094
|
+
mean_oos_sharpe=mean_synthetic,
|
|
1095
|
+
mean_decay=mean_decay,
|
|
1096
|
+
std_decay=std_decay,
|
|
1097
|
+
fold_metrics=fold_metrics,
|
|
1098
|
+
selection_metadata={
|
|
1099
|
+
"stage": "is_search",
|
|
1100
|
+
"objective_mode": "mode_2_sbb",
|
|
1101
|
+
"sbb_samples": int(self.config.sbb_samples),
|
|
1102
|
+
"sbb_block_length": int(self.config.sbb_block_length),
|
|
1103
|
+
"sbb_simulation": self.config.sbb_simulation,
|
|
1104
|
+
"regime_count": int(self.config.regime_count),
|
|
1105
|
+
"regime_lookback": int(self.config.regime_lookback),
|
|
1106
|
+
"regime_weights": self.config.regime_weights,
|
|
1107
|
+
"stress_vol_multiplier": float(self.config.stress_vol_multiplier),
|
|
1108
|
+
"garch_p": int(self.config.garch_p),
|
|
1109
|
+
"garch_q": int(self.config.garch_q),
|
|
1110
|
+
"garch_dist": self.config.garch_dist,
|
|
1111
|
+
"garch_vol_multiplier": float(self.config.garch_vol_multiplier),
|
|
1112
|
+
"oos_seen_by_optuna": False,
|
|
1113
|
+
},
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
def build_folds(self, idx: pd.DatetimeIndex) -> List[WalkForwardFold]:
|
|
1117
|
+
"""Return chronological train/OOS folds without lookahead."""
|
|
1118
|
+
idx = validate_datetime(idx)
|
|
1119
|
+
if len(idx) == 0:
|
|
1120
|
+
raise ValueError("walk-forward datetime index is empty")
|
|
1121
|
+
|
|
1122
|
+
if self.config.optimization_mode == "mode_5_full_robust":
|
|
1123
|
+
if len(idx) < self.config.min_train_bars:
|
|
1124
|
+
raise ValueError("full-sample robust calibration produced too few bars")
|
|
1125
|
+
return [
|
|
1126
|
+
WalkForwardFold(
|
|
1127
|
+
fold_id=0,
|
|
1128
|
+
train_start=idx[0],
|
|
1129
|
+
train_end=idx[-1],
|
|
1130
|
+
test_start=idx[0],
|
|
1131
|
+
test_end=idx[-1],
|
|
1132
|
+
train_index=idx,
|
|
1133
|
+
test_index=idx,
|
|
1134
|
+
)
|
|
1135
|
+
]
|
|
1136
|
+
|
|
1137
|
+
first_oos = _first_oos_timestamp(self.config.split_mode)
|
|
1138
|
+
if first_oos <= idx[0]:
|
|
1139
|
+
raise ValueError("first OOS timestamp must be after the first data timestamp")
|
|
1140
|
+
if first_oos > idx[-1]:
|
|
1141
|
+
raise ValueError("first OOS timestamp is after the available data")
|
|
1142
|
+
|
|
1143
|
+
if self.config.split_frequency == "single":
|
|
1144
|
+
train_start = idx[0] if self.config.window_mode == "expanding" else first_oos - pd.Timedelta(self.config.train_window)
|
|
1145
|
+
train_index = idx[(idx >= train_start) & (idx < first_oos)]
|
|
1146
|
+
test_index = idx[idx >= first_oos]
|
|
1147
|
+
if len(train_index) < self.config.min_train_bars:
|
|
1148
|
+
raise ValueError("train/test split produced too few train bars")
|
|
1149
|
+
if len(test_index) < self.config.min_test_bars:
|
|
1150
|
+
raise ValueError("train/test split produced too few test bars")
|
|
1151
|
+
return [
|
|
1152
|
+
WalkForwardFold(
|
|
1153
|
+
fold_id=0,
|
|
1154
|
+
train_start=train_index[0],
|
|
1155
|
+
train_end=train_index[-1],
|
|
1156
|
+
test_start=test_index[0],
|
|
1157
|
+
test_end=test_index[-1],
|
|
1158
|
+
train_index=train_index,
|
|
1159
|
+
test_index=test_index,
|
|
1160
|
+
)
|
|
1161
|
+
]
|
|
1162
|
+
|
|
1163
|
+
step = _frequency_offset(self.config.split_frequency)
|
|
1164
|
+
folds: List[WalkForwardFold] = []
|
|
1165
|
+
test_start = first_oos
|
|
1166
|
+
fold_id = 0
|
|
1167
|
+
while test_start <= idx[-1]:
|
|
1168
|
+
test_stop = test_start + step
|
|
1169
|
+
test_mask = (idx >= test_start) & (idx < test_stop)
|
|
1170
|
+
test_index = idx[test_mask]
|
|
1171
|
+
if len(test_index) < self.config.min_test_bars:
|
|
1172
|
+
test_start = test_stop
|
|
1173
|
+
continue
|
|
1174
|
+
|
|
1175
|
+
if self.config.window_mode == "expanding":
|
|
1176
|
+
train_start = idx[0]
|
|
1177
|
+
else:
|
|
1178
|
+
train_start = test_start - pd.Timedelta(self.config.train_window)
|
|
1179
|
+
train_mask = (idx >= train_start) & (idx < test_start)
|
|
1180
|
+
train_index = idx[train_mask]
|
|
1181
|
+
if len(train_index) < self.config.min_train_bars:
|
|
1182
|
+
test_start = test_stop
|
|
1183
|
+
continue
|
|
1184
|
+
|
|
1185
|
+
folds.append(
|
|
1186
|
+
WalkForwardFold(
|
|
1187
|
+
fold_id=fold_id,
|
|
1188
|
+
train_start=train_index[0],
|
|
1189
|
+
train_end=train_index[-1],
|
|
1190
|
+
test_start=test_index[0],
|
|
1191
|
+
test_end=test_index[-1],
|
|
1192
|
+
train_index=train_index,
|
|
1193
|
+
test_index=test_index,
|
|
1194
|
+
)
|
|
1195
|
+
)
|
|
1196
|
+
fold_id += 1
|
|
1197
|
+
test_start = test_stop
|
|
1198
|
+
|
|
1199
|
+
if not folds:
|
|
1200
|
+
raise ValueError("walk-forward split produced no folds")
|
|
1201
|
+
return folds
|
|
1202
|
+
|
|
1203
|
+
def _score_strategy_output(
|
|
1204
|
+
self,
|
|
1205
|
+
data,
|
|
1206
|
+
output: StrategyOutput,
|
|
1207
|
+
index: pd.DatetimeIndex,
|
|
1208
|
+
fold: WalkForwardFold,
|
|
1209
|
+
params: Dict[str, Any],
|
|
1210
|
+
context: str,
|
|
1211
|
+
) -> Dict[str, float]:
|
|
1212
|
+
if self.config.scoring_backend == "endpoint":
|
|
1213
|
+
assert self.scorer is not None
|
|
1214
|
+
return self.scorer(
|
|
1215
|
+
data=data,
|
|
1216
|
+
output=output,
|
|
1217
|
+
index=index,
|
|
1218
|
+
fold=fold,
|
|
1219
|
+
params=params,
|
|
1220
|
+
context=context,
|
|
1221
|
+
trading_days=int(self.config.scoring_trading_days),
|
|
1222
|
+
)
|
|
1223
|
+
return score_strategy_output(
|
|
1224
|
+
data,
|
|
1225
|
+
output,
|
|
1226
|
+
index,
|
|
1227
|
+
trading_days=int(self.config.scoring_trading_days),
|
|
1228
|
+
use_numba=bool(self.config.use_numba),
|
|
1229
|
+
)
|
|
1230
|
+
|
|
1231
|
+
def _call_strategy(self, data, params: Dict[str, Any], fold: WalkForwardFold) -> StrategyOutput:
|
|
1232
|
+
return self._call_strategy_for_indices(
|
|
1233
|
+
data=data,
|
|
1234
|
+
params=params,
|
|
1235
|
+
train_index=fold.train_index,
|
|
1236
|
+
test_index=fold.test_index,
|
|
1237
|
+
fold=fold,
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
def _call_strategy_for_indices(
|
|
1241
|
+
self,
|
|
1242
|
+
data,
|
|
1243
|
+
params: Dict[str, Any],
|
|
1244
|
+
train_index: pd.DatetimeIndex,
|
|
1245
|
+
test_index: pd.DatetimeIndex,
|
|
1246
|
+
fold: WalkForwardFold,
|
|
1247
|
+
context: str = "out-of-sample generation",
|
|
1248
|
+
) -> StrategyOutput:
|
|
1249
|
+
strategy = self.strategy() if isinstance(self.strategy, type) else self.strategy
|
|
1250
|
+
try:
|
|
1251
|
+
if hasattr(strategy, "build_signal"):
|
|
1252
|
+
output = strategy.build_signal(
|
|
1253
|
+
data=data,
|
|
1254
|
+
params=params,
|
|
1255
|
+
train_index=train_index,
|
|
1256
|
+
test_index=test_index,
|
|
1257
|
+
fold=fold,
|
|
1258
|
+
)
|
|
1259
|
+
elif hasattr(strategy, "generate_signal"):
|
|
1260
|
+
output = strategy.generate_signal(
|
|
1261
|
+
data=data,
|
|
1262
|
+
params=params,
|
|
1263
|
+
train_index=train_index,
|
|
1264
|
+
test_index=test_index,
|
|
1265
|
+
fold=fold,
|
|
1266
|
+
)
|
|
1267
|
+
elif callable(strategy):
|
|
1268
|
+
output = strategy(
|
|
1269
|
+
data=data,
|
|
1270
|
+
params=params,
|
|
1271
|
+
train_index=train_index,
|
|
1272
|
+
test_index=test_index,
|
|
1273
|
+
fold=fold,
|
|
1274
|
+
)
|
|
1275
|
+
else:
|
|
1276
|
+
raise TypeError("strategy must be callable or expose build_signal/generate_signal")
|
|
1277
|
+
except Exception as exc:
|
|
1278
|
+
raise RuntimeError(
|
|
1279
|
+
"walk-forward strategy failed during "
|
|
1280
|
+
f"{context} for fold_id={fold.fold_id}, "
|
|
1281
|
+
f"train=[{fold.train_start}, {fold.train_end}], "
|
|
1282
|
+
f"test=[{test_index[0]}, {test_index[-1]}]"
|
|
1283
|
+
) from exc
|
|
1284
|
+
return validate_walkforward_strategy_output(
|
|
1285
|
+
output,
|
|
1286
|
+
expected_index=test_index,
|
|
1287
|
+
context=f"{context} fold_id={fold.fold_id}",
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def validate_walkforward_strategy_output(
|
|
1292
|
+
output: StrategyOutput,
|
|
1293
|
+
expected_index: pd.DatetimeIndex,
|
|
1294
|
+
context: str = "walk-forward strategy output",
|
|
1295
|
+
) -> StrategyOutput:
|
|
1296
|
+
"""
|
|
1297
|
+
Validate strategy output before slicing/stitching.
|
|
1298
|
+
|
|
1299
|
+
Walk-forward output must be timestamp-indexed. Accepting RangeIndex or
|
|
1300
|
+
array-like output would silently reindex to all zeros, which is dangerous in
|
|
1301
|
+
production research.
|
|
1302
|
+
"""
|
|
1303
|
+
idx = validate_datetime(expected_index)
|
|
1304
|
+
if len(idx) == 0:
|
|
1305
|
+
raise ValueError(f"{context}: expected_index is empty")
|
|
1306
|
+
|
|
1307
|
+
if isinstance(output, pd.Series):
|
|
1308
|
+
_validate_timestamped_index(output.index, context=context)
|
|
1309
|
+
_validate_index_coverage(output.index, idx, context=context)
|
|
1310
|
+
return output
|
|
1311
|
+
|
|
1312
|
+
if isinstance(output, pd.DataFrame):
|
|
1313
|
+
if len(output.columns) == 0:
|
|
1314
|
+
raise ValueError(f"{context}: DataFrame output must have at least one column")
|
|
1315
|
+
_validate_timestamped_index(output.index, context=context)
|
|
1316
|
+
_validate_index_coverage(output.index, idx, context=context)
|
|
1317
|
+
return output
|
|
1318
|
+
|
|
1319
|
+
if isinstance(output, dict):
|
|
1320
|
+
if not output:
|
|
1321
|
+
raise ValueError(f"{context}: dict output must contain at least one symbol")
|
|
1322
|
+
for symbol, series in output.items():
|
|
1323
|
+
if not isinstance(symbol, str) or not symbol:
|
|
1324
|
+
raise ValueError(f"{context}: dict output keys must be non-empty symbol strings")
|
|
1325
|
+
if not isinstance(series, pd.Series):
|
|
1326
|
+
raise TypeError(f"{context}: dict output for {symbol!r} must be a pandas Series")
|
|
1327
|
+
_validate_timestamped_index(series.index, context=f"{context} symbol={symbol}")
|
|
1328
|
+
_validate_index_coverage(series.index, idx, context=f"{context} symbol={symbol}")
|
|
1329
|
+
return output
|
|
1330
|
+
|
|
1331
|
+
raise TypeError(
|
|
1332
|
+
f"{context}: strategy output must be pd.Series, pd.DataFrame, or dict[str, pd.Series]; "
|
|
1333
|
+
f"got {type(output).__name__}"
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def validate_param_ranges(param_ranges: Dict[str, Any], context: str = "walk-forward optimization") -> Dict[str, Any]:
|
|
1338
|
+
"""Validate Optuna/default parameter ranges and return the original mapping."""
|
|
1339
|
+
if not isinstance(param_ranges, dict):
|
|
1340
|
+
raise TypeError(f"{context}: param_ranges must be a dict, got {type(param_ranges).__name__}")
|
|
1341
|
+
if not param_ranges:
|
|
1342
|
+
raise ValueError(f"{context}: param_ranges must not be empty")
|
|
1343
|
+
for name, spec in param_ranges.items():
|
|
1344
|
+
if not isinstance(name, str) or not name:
|
|
1345
|
+
raise ValueError(f"{context}: parameter names must be non-empty strings")
|
|
1346
|
+
if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec):
|
|
1347
|
+
low = float(spec[0])
|
|
1348
|
+
high = float(spec[1])
|
|
1349
|
+
if high < low:
|
|
1350
|
+
raise ValueError(f"{context}: param_ranges[{name!r}] high must be >= low")
|
|
1351
|
+
if len(spec) == 3 and float(spec[2]) <= 0.0:
|
|
1352
|
+
raise ValueError(f"{context}: param_ranges[{name!r}] step must be > 0")
|
|
1353
|
+
elif isinstance(spec, (list, tuple)):
|
|
1354
|
+
if not spec:
|
|
1355
|
+
raise ValueError(f"{context}: param_ranges[{name!r}] categorical choices must not be empty")
|
|
1356
|
+
elif spec is None:
|
|
1357
|
+
raise ValueError(f"{context}: param_ranges[{name!r}] fixed value must not be None")
|
|
1358
|
+
return param_ranges
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
def trade_frequency_penalty(
|
|
1362
|
+
actual_trades: float,
|
|
1363
|
+
required_trades: float,
|
|
1364
|
+
penalty_factor: Optional[float],
|
|
1365
|
+
) -> float:
|
|
1366
|
+
"""
|
|
1367
|
+
Smooth normalized linear penalty for under-trading.
|
|
1368
|
+
|
|
1369
|
+
Returns zero when disabled, when required trades are non-positive, or when
|
|
1370
|
+
actual trades meet/exceed the required count.
|
|
1371
|
+
"""
|
|
1372
|
+
if penalty_factor is None or penalty_factor <= 0.0 or required_trades <= 0.0:
|
|
1373
|
+
return 0.0
|
|
1374
|
+
actual = max(0.0, float(actual_trades))
|
|
1375
|
+
required = max(0.0, float(required_trades))
|
|
1376
|
+
return float(penalty_factor) * max(0.0, 1.0 - actual / required)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _required_trades_for_index(index: pd.DatetimeIndex, min_trades_per_year: Optional[float]) -> float:
|
|
1380
|
+
if min_trades_per_year is None or min_trades_per_year <= 0.0 or len(index) == 0:
|
|
1381
|
+
return 0.0
|
|
1382
|
+
idx = validate_datetime(index)
|
|
1383
|
+
if len(idx) <= 1:
|
|
1384
|
+
duration_days = 1.0 / 365.0
|
|
1385
|
+
else:
|
|
1386
|
+
duration_days = max((idx[-1] - idx[0]).total_seconds() / 86_400.0, 1.0 / 365.0)
|
|
1387
|
+
return float(min_trades_per_year) * (duration_days / 365.0)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _validate_timestamped_index(index, context: str) -> None:
|
|
1391
|
+
if not isinstance(index, pd.DatetimeIndex):
|
|
1392
|
+
raise TypeError(f"{context}: output must use a pandas DatetimeIndex, got {type(index).__name__}")
|
|
1393
|
+
if len(index) == 0:
|
|
1394
|
+
raise ValueError(f"{context}: output index is empty")
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
def _validate_index_coverage(index: pd.DatetimeIndex, expected_index: pd.DatetimeIndex, context: str) -> None:
|
|
1398
|
+
output_index = validate_datetime(index)
|
|
1399
|
+
missing = expected_index.difference(output_index)
|
|
1400
|
+
if len(missing) > 0:
|
|
1401
|
+
sample = ", ".join(str(ts) for ts in missing[:3])
|
|
1402
|
+
raise ValueError(
|
|
1403
|
+
f"{context}: output index must cover every expected fold timestamp; "
|
|
1404
|
+
f"missing {len(missing)} of {len(expected_index)} timestamps, first missing: {sample}"
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
def score_strategy_output(
|
|
1409
|
+
data,
|
|
1410
|
+
output: StrategyOutput,
|
|
1411
|
+
index: pd.DatetimeIndex,
|
|
1412
|
+
trading_days: int = 365,
|
|
1413
|
+
use_numba: bool = True,
|
|
1414
|
+
) -> Dict[str, float]:
|
|
1415
|
+
"""
|
|
1416
|
+
Score strategy output with a transparent return proxy.
|
|
1417
|
+
|
|
1418
|
+
This is an optimization-time metric, not the final accounting simulation.
|
|
1419
|
+
Final PnL/fees/slippage/margin still come from the endpoint backtest after
|
|
1420
|
+
OOS stitching.
|
|
1421
|
+
"""
|
|
1422
|
+
idx = validate_datetime(index)
|
|
1423
|
+
if len(idx) < 2:
|
|
1424
|
+
return {"sharpe": 0.0, "turnover": 0.0, "mean_return": 0.0, "volatility": 0.0}
|
|
1425
|
+
strat_returns = strategy_return_series(data, output, idx)
|
|
1426
|
+
position_matrix = strategy_position_frame(output, idx)
|
|
1427
|
+
returns_arr = strat_returns.to_numpy(dtype=np.float64)
|
|
1428
|
+
pos_arr = position_matrix.to_numpy(dtype=np.float64)
|
|
1429
|
+
if bool(use_numba) and _NUMBA_AVAILABLE:
|
|
1430
|
+
mean, sd, sharpe, turnover, trade_count = _score_returns_positions_numba(returns_arr, pos_arr, float(trading_days))
|
|
1431
|
+
else:
|
|
1432
|
+
mean, sd, sharpe, turnover, trade_count = _score_returns_positions_python(returns_arr, pos_arr, float(trading_days))
|
|
1433
|
+
return {
|
|
1434
|
+
"sharpe": float(sharpe),
|
|
1435
|
+
"turnover": float(turnover),
|
|
1436
|
+
"trade_count": float(trade_count),
|
|
1437
|
+
"mean_return": float(mean),
|
|
1438
|
+
"volatility": float(sd),
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def strategy_position_frame(output: StrategyOutput, index: pd.DatetimeIndex) -> pd.DataFrame:
|
|
1443
|
+
"""Return strategy output as a float position DataFrame on `index`."""
|
|
1444
|
+
idx = validate_datetime(index)
|
|
1445
|
+
if isinstance(output, pd.DataFrame):
|
|
1446
|
+
return _normalize_frame_output(output).reindex(idx).fillna(0.0)
|
|
1447
|
+
if isinstance(output, dict):
|
|
1448
|
+
return pd.DataFrame(
|
|
1449
|
+
{symbol: _normalize_series_output(series).reindex(idx).fillna(0.0) for symbol, series in output.items()},
|
|
1450
|
+
index=idx,
|
|
1451
|
+
)
|
|
1452
|
+
return pd.DataFrame({"DEFAULT": _normalize_series_output(output).reindex(idx).fillna(0.0)}, index=idx)
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def strategy_return_series(data, output: StrategyOutput, index: pd.DatetimeIndex) -> pd.Series:
|
|
1456
|
+
"""Return the transparent position return proxy used by WFO scoring."""
|
|
1457
|
+
idx = validate_datetime(index)
|
|
1458
|
+
if len(idx) == 0:
|
|
1459
|
+
return pd.Series(dtype=float, index=idx)
|
|
1460
|
+
close_map = _close_map_from_data(data)
|
|
1461
|
+
if isinstance(output, pd.DataFrame):
|
|
1462
|
+
symbols = list(output.columns)
|
|
1463
|
+
pos = _normalize_frame_output(output, symbols).reindex(idx).fillna(0.0)
|
|
1464
|
+
returns = pd.DataFrame({s: close_map[s].reindex(idx).pct_change().fillna(0.0) for s in symbols})
|
|
1465
|
+
strat_returns = (pos * returns).mean(axis=1)
|
|
1466
|
+
elif isinstance(output, dict):
|
|
1467
|
+
symbols = list(output.keys())
|
|
1468
|
+
pos = pd.DataFrame({s: _normalize_series_output(output[s]).reindex(idx).fillna(0.0) for s in symbols})
|
|
1469
|
+
returns = pd.DataFrame({s: close_map[s].reindex(idx).pct_change().fillna(0.0) for s in symbols})
|
|
1470
|
+
strat_returns = (pos * returns).mean(axis=1)
|
|
1471
|
+
else:
|
|
1472
|
+
series = _normalize_series_output(output).reindex(idx).fillna(0.0)
|
|
1473
|
+
close = next(iter(close_map.values())).reindex(idx)
|
|
1474
|
+
strat_returns = series * close.pct_change().fillna(0.0)
|
|
1475
|
+
return strat_returns.fillna(0.0).astype(float)
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def stationary_bootstrap_sharpes(
|
|
1479
|
+
returns: np.ndarray,
|
|
1480
|
+
n_samples: int,
|
|
1481
|
+
block_length: int,
|
|
1482
|
+
seed: int,
|
|
1483
|
+
trading_days: int = 365,
|
|
1484
|
+
use_numba: bool = True,
|
|
1485
|
+
) -> np.ndarray:
|
|
1486
|
+
"""
|
|
1487
|
+
Generate Sharpe values from stationary block bootstrap samples.
|
|
1488
|
+
|
|
1489
|
+
Random index generation stays in NumPy for transparent seeding. The repeated
|
|
1490
|
+
sample scoring loop is numba-accelerated when numba is available.
|
|
1491
|
+
"""
|
|
1492
|
+
clean = np.asarray(returns, dtype=np.float64)
|
|
1493
|
+
clean = clean[np.isfinite(clean)]
|
|
1494
|
+
if clean.size < 2:
|
|
1495
|
+
return np.zeros(int(n_samples), dtype=np.float64)
|
|
1496
|
+
indices = _stationary_bootstrap_indices(
|
|
1497
|
+
n_obs=int(clean.size),
|
|
1498
|
+
n_samples=int(n_samples),
|
|
1499
|
+
block_length=int(block_length),
|
|
1500
|
+
seed=int(seed),
|
|
1501
|
+
)
|
|
1502
|
+
if bool(use_numba) and _NUMBA_AVAILABLE:
|
|
1503
|
+
return _bootstrap_sharpes_numba(clean, indices, float(trading_days))
|
|
1504
|
+
return _bootstrap_sharpes_python(clean, indices, float(trading_days))
|
|
1505
|
+
|
|
1506
|
+
|
|
1507
|
+
def synthetic_walkforward_sharpes(
|
|
1508
|
+
returns: np.ndarray,
|
|
1509
|
+
n_samples: int,
|
|
1510
|
+
block_length: int,
|
|
1511
|
+
seed: int,
|
|
1512
|
+
trading_days: int = 365,
|
|
1513
|
+
use_numba: bool = True,
|
|
1514
|
+
simulation: str = "stationary",
|
|
1515
|
+
regime_count: int = 3,
|
|
1516
|
+
regime_lookback: int = 20,
|
|
1517
|
+
regime_weights: Optional[Dict[Union[int, str], float]] = None,
|
|
1518
|
+
stress_vol_multiplier: float = 1.0,
|
|
1519
|
+
garch_p: int = 1,
|
|
1520
|
+
garch_q: int = 1,
|
|
1521
|
+
garch_dist: str = "t",
|
|
1522
|
+
garch_vol_multiplier: float = 1.0,
|
|
1523
|
+
) -> np.ndarray:
|
|
1524
|
+
"""
|
|
1525
|
+
Generate train-only synthetic Sharpe samples for Mode 2 WFO scoring.
|
|
1526
|
+
|
|
1527
|
+
`stationary` preserves the legacy SBB behavior. `regime` bootstraps blocks
|
|
1528
|
+
from volatility regimes estimated on the IS return proxy. `stress` keeps the
|
|
1529
|
+
SBB dependence model but scales demeaned returns before sampling. `garch`
|
|
1530
|
+
fits a GARCH(p, q) model on IS returns and simulates volatility-clustered
|
|
1531
|
+
paths with a deterministic seed.
|
|
1532
|
+
"""
|
|
1533
|
+
sim = str(simulation).lower().strip()
|
|
1534
|
+
clean = np.asarray(returns, dtype=np.float64)
|
|
1535
|
+
clean = clean[np.isfinite(clean)]
|
|
1536
|
+
if clean.size < 2:
|
|
1537
|
+
return np.zeros(int(n_samples), dtype=np.float64)
|
|
1538
|
+
if sim == "stationary":
|
|
1539
|
+
return stationary_bootstrap_sharpes(clean, n_samples, block_length, seed, trading_days, use_numba)
|
|
1540
|
+
if sim == "stress":
|
|
1541
|
+
stressed = _stress_returns(clean, float(stress_vol_multiplier))
|
|
1542
|
+
return stationary_bootstrap_sharpes(stressed, n_samples, block_length, seed, trading_days, use_numba)
|
|
1543
|
+
if sim == "regime":
|
|
1544
|
+
labels = volatility_regime_labels(clean, regime_count=int(regime_count), lookback=int(regime_lookback))
|
|
1545
|
+
weights = _normalize_regime_weights(regime_weights, int(regime_count)) if regime_weights is not None else None
|
|
1546
|
+
indices = _regime_bootstrap_indices(
|
|
1547
|
+
labels=labels,
|
|
1548
|
+
n_samples=int(n_samples),
|
|
1549
|
+
block_length=int(block_length),
|
|
1550
|
+
seed=int(seed),
|
|
1551
|
+
regime_weights=weights,
|
|
1552
|
+
regime_count=int(regime_count),
|
|
1553
|
+
)
|
|
1554
|
+
if bool(use_numba) and _NUMBA_AVAILABLE:
|
|
1555
|
+
return _bootstrap_sharpes_numba(clean, indices, float(trading_days))
|
|
1556
|
+
return _bootstrap_sharpes_python(clean, indices, float(trading_days))
|
|
1557
|
+
if sim == "garch":
|
|
1558
|
+
paths = _garch_simulated_paths(
|
|
1559
|
+
clean,
|
|
1560
|
+
n_samples=int(n_samples),
|
|
1561
|
+
seed=int(seed),
|
|
1562
|
+
p=int(garch_p),
|
|
1563
|
+
q=int(garch_q),
|
|
1564
|
+
dist=str(garch_dist),
|
|
1565
|
+
vol_multiplier=float(garch_vol_multiplier),
|
|
1566
|
+
)
|
|
1567
|
+
if bool(use_numba) and _NUMBA_AVAILABLE:
|
|
1568
|
+
return _path_sharpes_numba(paths, float(trading_days))
|
|
1569
|
+
return _path_sharpes_python(paths, float(trading_days))
|
|
1570
|
+
raise ValueError("simulation must be stationary, regime, stress, or garch")
|
|
1571
|
+
|
|
1572
|
+
|
|
1573
|
+
def volatility_regime_labels(returns: np.ndarray, regime_count: int = 3, lookback: int = 20) -> np.ndarray:
|
|
1574
|
+
"""
|
|
1575
|
+
Assign trailing-volatility regime labels from 0 (low vol) to N-1 (high vol).
|
|
1576
|
+
|
|
1577
|
+
The function uses only the in-sample return proxy passed by the caller. It
|
|
1578
|
+
does not inspect future OOS bars, so it is safe inside the WFO objective.
|
|
1579
|
+
"""
|
|
1580
|
+
clean = np.asarray(returns, dtype=np.float64)
|
|
1581
|
+
clean = clean[np.isfinite(clean)]
|
|
1582
|
+
if clean.size == 0:
|
|
1583
|
+
return np.zeros(0, dtype=np.int64)
|
|
1584
|
+
n_regimes = max(2, int(regime_count))
|
|
1585
|
+
window = max(1, int(lookback))
|
|
1586
|
+
trailing_vol = np.empty(clean.size, dtype=np.float64)
|
|
1587
|
+
abs_ret = np.abs(clean)
|
|
1588
|
+
cumsum = np.concatenate(([0.0], np.cumsum(abs_ret)))
|
|
1589
|
+
for i in range(clean.size):
|
|
1590
|
+
start = max(0, i + 1 - window)
|
|
1591
|
+
trailing_vol[i] = (cumsum[i + 1] - cumsum[start]) / float(i + 1 - start)
|
|
1592
|
+
quantiles = np.linspace(0.0, 1.0, n_regimes + 1)[1:-1]
|
|
1593
|
+
cuts = np.quantile(trailing_vol, quantiles) if quantiles.size else np.array([], dtype=np.float64)
|
|
1594
|
+
labels = np.searchsorted(cuts, trailing_vol, side="right").astype(np.int64)
|
|
1595
|
+
return np.minimum(labels, n_regimes - 1)
|
|
1596
|
+
|
|
1597
|
+
|
|
1598
|
+
def benchmark_walkforward_kernels(
|
|
1599
|
+
n_obs: int = 2_000,
|
|
1600
|
+
n_samples: int = 128,
|
|
1601
|
+
seed: int = 42,
|
|
1602
|
+
use_numba: bool = True,
|
|
1603
|
+
) -> WalkForwardBenchmarkSnapshot:
|
|
1604
|
+
"""
|
|
1605
|
+
Run a deterministic lightweight benchmark for WFO numeric kernels.
|
|
1606
|
+
|
|
1607
|
+
The snapshot is intended for smoke/performance-regression tracking. Unit
|
|
1608
|
+
tests should assert finite timings and numerical equivalence, not hard wall
|
|
1609
|
+
clock thresholds.
|
|
1610
|
+
"""
|
|
1611
|
+
if n_obs < 2:
|
|
1612
|
+
raise ValueError("n_obs must be >= 2")
|
|
1613
|
+
if n_samples < 1:
|
|
1614
|
+
raise ValueError("n_samples must be >= 1")
|
|
1615
|
+
rng = np.random.default_rng(int(seed))
|
|
1616
|
+
returns = rng.normal(loc=0.0002, scale=0.01, size=int(n_obs)).astype(np.float64)
|
|
1617
|
+
positions = rng.choice(np.array([-1.0, 0.0, 1.0], dtype=np.float64), size=(int(n_obs), 3))
|
|
1618
|
+
indices = _stationary_bootstrap_indices(
|
|
1619
|
+
n_obs=int(n_obs),
|
|
1620
|
+
n_samples=int(n_samples),
|
|
1621
|
+
block_length=max(2, int(np.sqrt(n_obs))),
|
|
1622
|
+
seed=int(seed),
|
|
1623
|
+
)
|
|
1624
|
+
|
|
1625
|
+
start = time.perf_counter()
|
|
1626
|
+
py_score = _score_returns_positions_python(returns, positions, 365.0)
|
|
1627
|
+
python_score_seconds = time.perf_counter() - start
|
|
1628
|
+
|
|
1629
|
+
start = time.perf_counter()
|
|
1630
|
+
accelerated_score = (
|
|
1631
|
+
_score_returns_positions_numba(returns, positions, 365.0)
|
|
1632
|
+
if bool(use_numba) and _NUMBA_AVAILABLE
|
|
1633
|
+
else _score_returns_positions_python(returns, positions, 365.0)
|
|
1634
|
+
)
|
|
1635
|
+
accelerated_score_seconds = time.perf_counter() - start
|
|
1636
|
+
|
|
1637
|
+
start = time.perf_counter()
|
|
1638
|
+
py_boot = _bootstrap_sharpes_python(returns, indices, 365.0)
|
|
1639
|
+
python_bootstrap_seconds = time.perf_counter() - start
|
|
1640
|
+
|
|
1641
|
+
start = time.perf_counter()
|
|
1642
|
+
accelerated_boot = (
|
|
1643
|
+
_bootstrap_sharpes_numba(returns, indices, 365.0)
|
|
1644
|
+
if bool(use_numba) and _NUMBA_AVAILABLE
|
|
1645
|
+
else _bootstrap_sharpes_python(returns, indices, 365.0)
|
|
1646
|
+
)
|
|
1647
|
+
accelerated_bootstrap_seconds = time.perf_counter() - start
|
|
1648
|
+
|
|
1649
|
+
return WalkForwardBenchmarkSnapshot(
|
|
1650
|
+
n_obs=int(n_obs),
|
|
1651
|
+
n_samples=int(n_samples),
|
|
1652
|
+
seed=int(seed),
|
|
1653
|
+
numba_available=bool(_NUMBA_AVAILABLE),
|
|
1654
|
+
numba_requested=bool(use_numba),
|
|
1655
|
+
python_score_seconds=float(python_score_seconds),
|
|
1656
|
+
accelerated_score_seconds=float(accelerated_score_seconds),
|
|
1657
|
+
python_bootstrap_seconds=float(python_bootstrap_seconds),
|
|
1658
|
+
accelerated_bootstrap_seconds=float(accelerated_bootstrap_seconds),
|
|
1659
|
+
max_score_abs_diff=float(np.max(np.abs(np.asarray(py_score) - np.asarray(accelerated_score)))),
|
|
1660
|
+
max_bootstrap_abs_diff=float(np.max(np.abs(py_boot - accelerated_boot))),
|
|
1661
|
+
)
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def _stationary_bootstrap_indices(n_obs: int, n_samples: int, block_length: int, seed: int) -> np.ndarray:
|
|
1665
|
+
if n_obs <= 0:
|
|
1666
|
+
raise ValueError("n_obs must be > 0")
|
|
1667
|
+
rng = np.random.default_rng(int(seed))
|
|
1668
|
+
p = 1.0 / max(1.0, float(block_length))
|
|
1669
|
+
indices = np.empty((int(n_samples), int(n_obs)), dtype=np.int64)
|
|
1670
|
+
for sample in range(int(n_samples)):
|
|
1671
|
+
current = int(rng.integers(0, n_obs))
|
|
1672
|
+
indices[sample, 0] = current
|
|
1673
|
+
for i in range(1, n_obs):
|
|
1674
|
+
if rng.random() < p:
|
|
1675
|
+
current = int(rng.integers(0, n_obs))
|
|
1676
|
+
else:
|
|
1677
|
+
current = (current + 1) % n_obs
|
|
1678
|
+
indices[sample, i] = current
|
|
1679
|
+
return indices
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def _regime_bootstrap_indices(
|
|
1683
|
+
labels: np.ndarray,
|
|
1684
|
+
n_samples: int,
|
|
1685
|
+
block_length: int,
|
|
1686
|
+
seed: int,
|
|
1687
|
+
regime_weights: Optional[Dict[Union[int, str], float]] = None,
|
|
1688
|
+
regime_count: Optional[int] = None,
|
|
1689
|
+
) -> np.ndarray:
|
|
1690
|
+
labels = np.asarray(labels, dtype=np.int64)
|
|
1691
|
+
if labels.size <= 0:
|
|
1692
|
+
raise ValueError("labels must not be empty")
|
|
1693
|
+
n_obs = int(labels.size)
|
|
1694
|
+
n_regimes = max(int(np.max(labels)) + 1, 2 if regime_count is None else int(regime_count))
|
|
1695
|
+
rng = np.random.default_rng(int(seed))
|
|
1696
|
+
p = 1.0 / max(1.0, float(block_length))
|
|
1697
|
+
if regime_weights is None:
|
|
1698
|
+
counts = np.bincount(labels, minlength=n_regimes).astype(np.float64)
|
|
1699
|
+
probs = counts / counts.sum()
|
|
1700
|
+
else:
|
|
1701
|
+
probs = np.zeros(n_regimes, dtype=np.float64)
|
|
1702
|
+
for key, value in regime_weights.items():
|
|
1703
|
+
idx = _regime_key_to_index(key, n_regimes)
|
|
1704
|
+
probs[idx] = float(value)
|
|
1705
|
+
total = float(probs.sum())
|
|
1706
|
+
if total <= 0.0:
|
|
1707
|
+
raise ValueError("regime_weights must sum to a positive value")
|
|
1708
|
+
probs = probs / total
|
|
1709
|
+
|
|
1710
|
+
starts_by_regime = [np.flatnonzero(labels == regime) for regime in range(n_regimes)]
|
|
1711
|
+
all_starts = np.arange(n_obs, dtype=np.int64)
|
|
1712
|
+
indices = np.empty((int(n_samples), n_obs), dtype=np.int64)
|
|
1713
|
+
for sample in range(int(n_samples)):
|
|
1714
|
+
current_regime = int(rng.choice(n_regimes, p=probs))
|
|
1715
|
+
choices = starts_by_regime[current_regime]
|
|
1716
|
+
if choices.size == 0:
|
|
1717
|
+
choices = all_starts
|
|
1718
|
+
current = int(rng.choice(choices))
|
|
1719
|
+
indices[sample, 0] = current
|
|
1720
|
+
for i in range(1, n_obs):
|
|
1721
|
+
next_current = (current + 1) % n_obs
|
|
1722
|
+
if rng.random() < p or labels[next_current] != current_regime:
|
|
1723
|
+
current_regime = int(rng.choice(n_regimes, p=probs))
|
|
1724
|
+
choices = starts_by_regime[current_regime]
|
|
1725
|
+
if choices.size == 0:
|
|
1726
|
+
choices = all_starts
|
|
1727
|
+
current = int(rng.choice(choices))
|
|
1728
|
+
else:
|
|
1729
|
+
current = next_current
|
|
1730
|
+
indices[sample, i] = current
|
|
1731
|
+
return indices
|
|
1732
|
+
|
|
1733
|
+
|
|
1734
|
+
def _stress_returns(returns: np.ndarray, vol_multiplier: float) -> np.ndarray:
|
|
1735
|
+
clean = np.asarray(returns, dtype=np.float64)
|
|
1736
|
+
mean = float(np.mean(clean)) if clean.size else 0.0
|
|
1737
|
+
return mean + (clean - mean) * float(vol_multiplier)
|
|
1738
|
+
|
|
1739
|
+
|
|
1740
|
+
def _normalize_regime_weights(
|
|
1741
|
+
weights: Optional[Dict[Union[int, str], float]],
|
|
1742
|
+
regime_count: int,
|
|
1743
|
+
) -> Optional[Dict[int, float]]:
|
|
1744
|
+
if weights is None:
|
|
1745
|
+
return None
|
|
1746
|
+
n_regimes = max(2, int(regime_count))
|
|
1747
|
+
out: Dict[int, float] = {}
|
|
1748
|
+
for key, value in weights.items():
|
|
1749
|
+
idx = _regime_key_to_index(key, n_regimes)
|
|
1750
|
+
val = float(value)
|
|
1751
|
+
if val < 0.0:
|
|
1752
|
+
raise ValueError("regime_weights values must be >= 0")
|
|
1753
|
+
out[idx] = out.get(idx, 0.0) + val
|
|
1754
|
+
total = sum(out.values())
|
|
1755
|
+
if total <= 0.0:
|
|
1756
|
+
raise ValueError("regime_weights must sum to a positive value")
|
|
1757
|
+
return {key: value / total for key, value in out.items()}
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
def _regime_key_to_index(key: Union[int, str], regime_count: int) -> int:
|
|
1761
|
+
n_regimes = max(2, int(regime_count))
|
|
1762
|
+
if isinstance(key, (int, np.integer)):
|
|
1763
|
+
idx = int(key)
|
|
1764
|
+
else:
|
|
1765
|
+
raw = str(key).lower().strip()
|
|
1766
|
+
aliases = {
|
|
1767
|
+
"low": 0,
|
|
1768
|
+
"low_vol": 0,
|
|
1769
|
+
"calm": 0,
|
|
1770
|
+
"mid": n_regimes // 2,
|
|
1771
|
+
"medium": n_regimes // 2,
|
|
1772
|
+
"normal": n_regimes // 2,
|
|
1773
|
+
"high": n_regimes - 1,
|
|
1774
|
+
"high_vol": n_regimes - 1,
|
|
1775
|
+
"crash": n_regimes - 1,
|
|
1776
|
+
"stress": n_regimes - 1,
|
|
1777
|
+
}
|
|
1778
|
+
idx = aliases[raw] if raw in aliases else int(raw)
|
|
1779
|
+
if idx < 0 or idx >= n_regimes:
|
|
1780
|
+
raise ValueError(f"regime key {key!r} is outside [0, {n_regimes - 1}]")
|
|
1781
|
+
return idx
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def _garch_simulated_paths(
|
|
1785
|
+
returns: np.ndarray,
|
|
1786
|
+
n_samples: int,
|
|
1787
|
+
seed: int,
|
|
1788
|
+
p: int,
|
|
1789
|
+
q: int,
|
|
1790
|
+
dist: str,
|
|
1791
|
+
vol_multiplier: float,
|
|
1792
|
+
) -> np.ndarray:
|
|
1793
|
+
clean = np.asarray(returns, dtype=np.float64)
|
|
1794
|
+
clean = clean[np.isfinite(clean)]
|
|
1795
|
+
min_obs = max(30, (int(p) + int(q)) * 12)
|
|
1796
|
+
if clean.size < min_obs:
|
|
1797
|
+
raise ValueError(f"garch simulation requires at least {min_obs} finite IS returns")
|
|
1798
|
+
try:
|
|
1799
|
+
from arch import arch_model
|
|
1800
|
+
except Exception as exc: # pragma: no cover - optional dependency guard
|
|
1801
|
+
raise ImportError("sbb_simulation='garch' requires the optional arch package") from exc
|
|
1802
|
+
|
|
1803
|
+
scaled = clean * 100.0
|
|
1804
|
+
with warnings.catch_warnings():
|
|
1805
|
+
warnings.simplefilter("ignore")
|
|
1806
|
+
model = arch_model(
|
|
1807
|
+
scaled,
|
|
1808
|
+
mean="Constant",
|
|
1809
|
+
vol="GARCH",
|
|
1810
|
+
p=int(p),
|
|
1811
|
+
q=int(q),
|
|
1812
|
+
dist=str(dist),
|
|
1813
|
+
rescale=False,
|
|
1814
|
+
)
|
|
1815
|
+
result = model.fit(disp="off", show_warning=False)
|
|
1816
|
+
|
|
1817
|
+
params = result.params
|
|
1818
|
+
mu = float(params.get("mu", 0.0))
|
|
1819
|
+
omega = max(float(params.get("omega", np.var(scaled) * 0.01)), 1e-12)
|
|
1820
|
+
alphas = np.array([max(float(params.get(f"alpha[{i}]", 0.0)), 0.0) for i in range(1, int(p) + 1)])
|
|
1821
|
+
betas = np.array([max(float(params.get(f"beta[{i}]", 0.0)), 0.0) for i in range(1, int(q) + 1)])
|
|
1822
|
+
total_persistence = float(alphas.sum() + betas.sum())
|
|
1823
|
+
unconditional_var = float(np.var(scaled, ddof=1))
|
|
1824
|
+
if total_persistence < 0.999:
|
|
1825
|
+
unconditional_var = max(omega / max(1e-12, 1.0 - total_persistence), 1e-12)
|
|
1826
|
+
rng = np.random.default_rng(int(seed))
|
|
1827
|
+
paths_pct = np.empty((int(n_samples), clean.size), dtype=np.float64)
|
|
1828
|
+
max_lag = max(int(p), int(q), 1)
|
|
1829
|
+
nu = max(float(params.get("nu", 8.0)), 2.1)
|
|
1830
|
+
for sample_id in range(int(n_samples)):
|
|
1831
|
+
eps = np.zeros(clean.size + max_lag, dtype=np.float64)
|
|
1832
|
+
sigma2 = np.full(clean.size + max_lag, unconditional_var, dtype=np.float64)
|
|
1833
|
+
for t in range(max_lag, clean.size + max_lag):
|
|
1834
|
+
var_t = omega
|
|
1835
|
+
for i, alpha in enumerate(alphas, start=1):
|
|
1836
|
+
var_t += float(alpha) * eps[t - i] * eps[t - i]
|
|
1837
|
+
for j, beta in enumerate(betas, start=1):
|
|
1838
|
+
var_t += float(beta) * sigma2[t - j]
|
|
1839
|
+
sigma2[t] = max(var_t, 1e-12)
|
|
1840
|
+
if str(dist).lower() in {"t", "studentst"}:
|
|
1841
|
+
shock = float(rng.standard_t(nu)) * float(np.sqrt((nu - 2.0) / nu))
|
|
1842
|
+
else:
|
|
1843
|
+
shock = float(rng.normal())
|
|
1844
|
+
eps[t] = float(np.sqrt(sigma2[t])) * shock
|
|
1845
|
+
paths_pct[sample_id, t - max_lag] = mu + eps[t]
|
|
1846
|
+
paths = paths_pct / 100.0
|
|
1847
|
+
return _stress_paths(paths, float(vol_multiplier))
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
def _stress_paths(paths: np.ndarray, vol_multiplier: float) -> np.ndarray:
|
|
1851
|
+
arr = np.asarray(paths, dtype=np.float64)
|
|
1852
|
+
means = np.mean(arr, axis=1, keepdims=True)
|
|
1853
|
+
return means + (arr - means) * float(vol_multiplier)
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
def _score_returns_positions_python(
|
|
1857
|
+
returns: np.ndarray,
|
|
1858
|
+
positions: np.ndarray,
|
|
1859
|
+
trading_days: float,
|
|
1860
|
+
) -> Tuple[float, float, float, float, float]:
|
|
1861
|
+
returns = np.asarray(returns, dtype=np.float64)
|
|
1862
|
+
positions = np.asarray(positions, dtype=np.float64)
|
|
1863
|
+
if returns.size == 0:
|
|
1864
|
+
return 0.0, 0.0, 0.0, 0.0, 0.0
|
|
1865
|
+
mean = float(np.mean(returns))
|
|
1866
|
+
sd = float(np.std(returns, ddof=1)) if returns.size > 1 else 0.0
|
|
1867
|
+
sharpe = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0
|
|
1868
|
+
turnover = 0.0
|
|
1869
|
+
trade_count = 0.0
|
|
1870
|
+
if positions.ndim == 1:
|
|
1871
|
+
positions = positions.reshape((-1, 1))
|
|
1872
|
+
if positions.shape[0] > 0:
|
|
1873
|
+
trade_count += float(np.count_nonzero(np.abs(positions[0, :]) > 0.0))
|
|
1874
|
+
if positions.shape[0] > 1:
|
|
1875
|
+
diffs = np.diff(positions, axis=0)
|
|
1876
|
+
turnover = float(np.abs(diffs).sum())
|
|
1877
|
+
trade_count = float(np.count_nonzero(np.abs(diffs) > 0.0))
|
|
1878
|
+
trade_count += float(np.count_nonzero(np.abs(positions[0, :]) > 0.0))
|
|
1879
|
+
return mean, sd, sharpe, turnover, trade_count
|
|
1880
|
+
|
|
1881
|
+
|
|
1882
|
+
def _bootstrap_sharpes_python(returns: np.ndarray, indices: np.ndarray, trading_days: float) -> np.ndarray:
|
|
1883
|
+
out = np.empty(indices.shape[0], dtype=np.float64)
|
|
1884
|
+
for i in range(indices.shape[0]):
|
|
1885
|
+
sample = returns[indices[i]]
|
|
1886
|
+
mean = float(np.mean(sample))
|
|
1887
|
+
sd = float(np.std(sample, ddof=1)) if sample.size > 1 else 0.0
|
|
1888
|
+
out[i] = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0
|
|
1889
|
+
return out
|
|
1890
|
+
|
|
1891
|
+
|
|
1892
|
+
def _path_sharpes_python(paths: np.ndarray, trading_days: float) -> np.ndarray:
|
|
1893
|
+
arr = np.asarray(paths, dtype=np.float64)
|
|
1894
|
+
out = np.empty(arr.shape[0], dtype=np.float64)
|
|
1895
|
+
for i in range(arr.shape[0]):
|
|
1896
|
+
sample = arr[i]
|
|
1897
|
+
mean = float(np.mean(sample))
|
|
1898
|
+
sd = float(np.std(sample, ddof=1)) if sample.size > 1 else 0.0
|
|
1899
|
+
out[i] = (mean / sd) * float(np.sqrt(trading_days)) if sd > 0.0 else 0.0
|
|
1900
|
+
return out
|
|
1901
|
+
|
|
1902
|
+
|
|
1903
|
+
if _NUMBA_AVAILABLE:
|
|
1904
|
+
|
|
1905
|
+
@njit(cache=True)
|
|
1906
|
+
def _score_returns_positions_numba(returns, positions, trading_days): # pragma: no cover - compared via tests
|
|
1907
|
+
n = returns.shape[0]
|
|
1908
|
+
if n == 0:
|
|
1909
|
+
return 0.0, 0.0, 0.0, 0.0, 0.0
|
|
1910
|
+
total = 0.0
|
|
1911
|
+
for i in range(n):
|
|
1912
|
+
total += returns[i]
|
|
1913
|
+
mean = total / n
|
|
1914
|
+
sd = 0.0
|
|
1915
|
+
if n > 1:
|
|
1916
|
+
var = 0.0
|
|
1917
|
+
for i in range(n):
|
|
1918
|
+
diff = returns[i] - mean
|
|
1919
|
+
var += diff * diff
|
|
1920
|
+
sd = (var / (n - 1)) ** 0.5
|
|
1921
|
+
sharpe = 0.0
|
|
1922
|
+
if sd > 0.0:
|
|
1923
|
+
sharpe = (mean / sd) * (trading_days ** 0.5)
|
|
1924
|
+
turnover = 0.0
|
|
1925
|
+
trade_count = 0.0
|
|
1926
|
+
if positions.shape[0] > 0:
|
|
1927
|
+
for j in range(positions.shape[1]):
|
|
1928
|
+
if abs(positions[0, j]) > 0.0:
|
|
1929
|
+
trade_count += 1.0
|
|
1930
|
+
if positions.shape[0] > 1:
|
|
1931
|
+
for i in range(1, positions.shape[0]):
|
|
1932
|
+
for j in range(positions.shape[1]):
|
|
1933
|
+
diff = positions[i, j] - positions[i - 1, j]
|
|
1934
|
+
turnover += abs(diff)
|
|
1935
|
+
if abs(diff) > 0.0:
|
|
1936
|
+
trade_count += 1.0
|
|
1937
|
+
return mean, sd, sharpe, turnover, trade_count
|
|
1938
|
+
|
|
1939
|
+
@njit(cache=True)
|
|
1940
|
+
def _bootstrap_sharpes_numba(returns, indices, trading_days): # pragma: no cover - compared via tests
|
|
1941
|
+
n_samples = indices.shape[0]
|
|
1942
|
+
n_obs = indices.shape[1]
|
|
1943
|
+
out = np.empty(n_samples, dtype=np.float64)
|
|
1944
|
+
for sample_id in range(n_samples):
|
|
1945
|
+
total = 0.0
|
|
1946
|
+
for i in range(n_obs):
|
|
1947
|
+
total += returns[indices[sample_id, i]]
|
|
1948
|
+
mean = total / n_obs
|
|
1949
|
+
sd = 0.0
|
|
1950
|
+
if n_obs > 1:
|
|
1951
|
+
var = 0.0
|
|
1952
|
+
for i in range(n_obs):
|
|
1953
|
+
diff = returns[indices[sample_id, i]] - mean
|
|
1954
|
+
var += diff * diff
|
|
1955
|
+
sd = (var / (n_obs - 1)) ** 0.5
|
|
1956
|
+
if sd > 0.0:
|
|
1957
|
+
out[sample_id] = (mean / sd) * (trading_days ** 0.5)
|
|
1958
|
+
else:
|
|
1959
|
+
out[sample_id] = 0.0
|
|
1960
|
+
return out
|
|
1961
|
+
|
|
1962
|
+
@njit(cache=True)
|
|
1963
|
+
def _path_sharpes_numba(paths, trading_days): # pragma: no cover - compared via tests
|
|
1964
|
+
n_samples = paths.shape[0]
|
|
1965
|
+
n_obs = paths.shape[1]
|
|
1966
|
+
out = np.empty(n_samples, dtype=np.float64)
|
|
1967
|
+
for sample_id in range(n_samples):
|
|
1968
|
+
total = 0.0
|
|
1969
|
+
for i in range(n_obs):
|
|
1970
|
+
total += paths[sample_id, i]
|
|
1971
|
+
mean = total / n_obs
|
|
1972
|
+
sd = 0.0
|
|
1973
|
+
if n_obs > 1:
|
|
1974
|
+
var = 0.0
|
|
1975
|
+
for i in range(n_obs):
|
|
1976
|
+
diff = paths[sample_id, i] - mean
|
|
1977
|
+
var += diff * diff
|
|
1978
|
+
sd = (var / (n_obs - 1)) ** 0.5
|
|
1979
|
+
if sd > 0.0:
|
|
1980
|
+
out[sample_id] = (mean / sd) * (trading_days ** 0.5)
|
|
1981
|
+
else:
|
|
1982
|
+
out[sample_id] = 0.0
|
|
1983
|
+
return out
|
|
1984
|
+
|
|
1985
|
+
else:
|
|
1986
|
+
|
|
1987
|
+
def _score_returns_positions_numba(returns, positions, trading_days): # pragma: no cover - fallback alias
|
|
1988
|
+
return _score_returns_positions_python(returns, positions, trading_days)
|
|
1989
|
+
|
|
1990
|
+
def _bootstrap_sharpes_numba(returns, indices, trading_days): # pragma: no cover - fallback alias
|
|
1991
|
+
return _bootstrap_sharpes_python(returns, indices, trading_days)
|
|
1992
|
+
|
|
1993
|
+
def _path_sharpes_numba(paths, trading_days): # pragma: no cover - fallback alias
|
|
1994
|
+
return _path_sharpes_python(paths, trading_days)
|
|
1995
|
+
|
|
1996
|
+
|
|
1997
|
+
def select_flat_minima_record(
|
|
1998
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
1999
|
+
param_ranges: Dict[str, Any],
|
|
2000
|
+
config: WalkForwardConfig,
|
|
2001
|
+
) -> WalkForwardTrialRecord:
|
|
2002
|
+
"""
|
|
2003
|
+
Select a robust top-trial cluster member instead of a sharp isolated peak.
|
|
2004
|
+
|
|
2005
|
+
This implements the Phase 3 flat-minima selector with a small deterministic
|
|
2006
|
+
DBSCAN-style clustering pass over normalized parameter coordinates.
|
|
2007
|
+
"""
|
|
2008
|
+
candidates = [r for r in records if not r.pruned and np.isfinite(r.objective)]
|
|
2009
|
+
if not candidates:
|
|
2010
|
+
raise ValueError("flat-minima selection received no completed trials")
|
|
2011
|
+
ranked = sorted(candidates, key=lambda record: record.objective, reverse=True)
|
|
2012
|
+
top_n = max(1, int(np.ceil(len(ranked) * float(config.flat_top_fraction))))
|
|
2013
|
+
top_n = min(len(ranked), max(top_n, int(config.flat_min_samples)))
|
|
2014
|
+
top = ranked[:top_n]
|
|
2015
|
+
matrix, names = _param_matrix(top, param_ranges)
|
|
2016
|
+
if matrix.shape[0] == 1 or matrix.shape[1] == 0:
|
|
2017
|
+
return _with_selection_metadata(
|
|
2018
|
+
top[0],
|
|
2019
|
+
{
|
|
2020
|
+
"objective_mode": "mode_3_flat_minima",
|
|
2021
|
+
"selector": "fallback_best",
|
|
2022
|
+
"reason": "insufficient_cluster_points",
|
|
2023
|
+
"top_trials": int(top_n),
|
|
2024
|
+
},
|
|
2025
|
+
)
|
|
2026
|
+
|
|
2027
|
+
labels, cluster_method = _dbscan_cluster_labels(
|
|
2028
|
+
matrix,
|
|
2029
|
+
eps=float(config.flat_eps),
|
|
2030
|
+
min_samples=int(config.flat_min_samples),
|
|
2031
|
+
)
|
|
2032
|
+
cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0)
|
|
2033
|
+
if not cluster_ids:
|
|
2034
|
+
return _with_selection_metadata(
|
|
2035
|
+
top[0],
|
|
2036
|
+
{
|
|
2037
|
+
"objective_mode": "mode_3_flat_minima",
|
|
2038
|
+
"selector": "fallback_best",
|
|
2039
|
+
"reason": "no_dense_cluster",
|
|
2040
|
+
"top_trials": int(top_n),
|
|
2041
|
+
"eps": float(config.flat_eps),
|
|
2042
|
+
"min_samples": int(config.flat_min_samples),
|
|
2043
|
+
"cluster_method": cluster_method,
|
|
2044
|
+
},
|
|
2045
|
+
)
|
|
2046
|
+
|
|
2047
|
+
best_cluster = None
|
|
2048
|
+
best_key = None
|
|
2049
|
+
for cluster_id in cluster_ids:
|
|
2050
|
+
member_idx = np.flatnonzero(labels == cluster_id)
|
|
2051
|
+
member_objectives = np.array([top[i].objective for i in member_idx], dtype=np.float64)
|
|
2052
|
+
key = (len(member_idx), float(np.mean(member_objectives)), float(np.max(member_objectives)))
|
|
2053
|
+
if best_key is None or key > best_key:
|
|
2054
|
+
best_key = key
|
|
2055
|
+
best_cluster = member_idx
|
|
2056
|
+
assert best_cluster is not None
|
|
2057
|
+
centroid = np.mean(matrix[best_cluster], axis=0)
|
|
2058
|
+
distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1))
|
|
2059
|
+
selected_idx = int(best_cluster[int(np.argmin(distances))])
|
|
2060
|
+
medoid = top[selected_idx]
|
|
2061
|
+
centroid_params = _centroid_params(
|
|
2062
|
+
centroid=centroid,
|
|
2063
|
+
names=names,
|
|
2064
|
+
param_ranges=param_ranges,
|
|
2065
|
+
base_params=medoid.params,
|
|
2066
|
+
)
|
|
2067
|
+
selected = medoid
|
|
2068
|
+
requires_evaluation = False
|
|
2069
|
+
if config.flat_selector == "centroid":
|
|
2070
|
+
selected = WalkForwardTrialRecord(
|
|
2071
|
+
trial_id=-1,
|
|
2072
|
+
params=centroid_params,
|
|
2073
|
+
objective=float(np.mean([top[i].objective for i in best_cluster])),
|
|
2074
|
+
mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])),
|
|
2075
|
+
mean_oos_sharpe=float(np.mean([top[i].mean_oos_sharpe for i in best_cluster])),
|
|
2076
|
+
mean_decay=float(np.mean([top[i].mean_decay for i in best_cluster])),
|
|
2077
|
+
std_decay=float(np.mean([top[i].std_decay for i in best_cluster])),
|
|
2078
|
+
fold_metrics=[],
|
|
2079
|
+
)
|
|
2080
|
+
requires_evaluation = True
|
|
2081
|
+
return _with_selection_metadata(
|
|
2082
|
+
selected,
|
|
2083
|
+
{
|
|
2084
|
+
"objective_mode": "mode_3_flat_minima",
|
|
2085
|
+
"selector": str(config.flat_selector),
|
|
2086
|
+
"param_names": names,
|
|
2087
|
+
"selected_trial_id": int(selected.trial_id),
|
|
2088
|
+
"medoid_trial_id": int(medoid.trial_id),
|
|
2089
|
+
"medoid_params": dict(medoid.params),
|
|
2090
|
+
"centroid_params": centroid_params,
|
|
2091
|
+
"centroid_normalized": [float(x) for x in centroid.tolist()],
|
|
2092
|
+
"requires_evaluation": requires_evaluation,
|
|
2093
|
+
"cluster_size": int(len(best_cluster)),
|
|
2094
|
+
"cluster_mean_objective": float(np.mean([top[i].objective for i in best_cluster])),
|
|
2095
|
+
"cluster_best_objective": float(np.max([top[i].objective for i in best_cluster])),
|
|
2096
|
+
"top_trials": int(top_n),
|
|
2097
|
+
"eps": float(config.flat_eps),
|
|
2098
|
+
"min_samples": int(config.flat_min_samples),
|
|
2099
|
+
"cluster_method": cluster_method,
|
|
2100
|
+
},
|
|
2101
|
+
)
|
|
2102
|
+
|
|
2103
|
+
|
|
2104
|
+
def select_is_plateau_robust_record(
|
|
2105
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2106
|
+
param_ranges: Dict[str, Any],
|
|
2107
|
+
config: WalkForwardConfig,
|
|
2108
|
+
) -> WalkForwardTrialRecord:
|
|
2109
|
+
"""
|
|
2110
|
+
Select robust train-only params from the top IS/search trial plateau.
|
|
2111
|
+
|
|
2112
|
+
The selector first takes the top `top_is_fraction`/`top_is_k` trials by the
|
|
2113
|
+
train-side objective. Inside that candidate pool it prefers dense parameter
|
|
2114
|
+
regions whose lower-tail and median scores remain strong while penalizing
|
|
2115
|
+
noisy, isolated peaks. OOS metrics are intentionally not used.
|
|
2116
|
+
"""
|
|
2117
|
+
completed = [record for record in records if not record.pruned and np.isfinite(record.objective)]
|
|
2118
|
+
if not completed:
|
|
2119
|
+
raise ValueError("is_plateau_robust selection received no completed trials")
|
|
2120
|
+
ranked = sorted(completed, key=lambda record: record.objective, reverse=True)
|
|
2121
|
+
top_n = _candidate_count(len(ranked), config)
|
|
2122
|
+
top = ranked[:top_n]
|
|
2123
|
+
matrix, names = _param_matrix(top, param_ranges)
|
|
2124
|
+
if matrix.shape[0] == 1 or matrix.shape[1] == 0:
|
|
2125
|
+
return _with_selection_metadata(
|
|
2126
|
+
top[0],
|
|
2127
|
+
{
|
|
2128
|
+
"objective_mode": config.optimization_mode,
|
|
2129
|
+
"selector": "fallback_best_train_objective",
|
|
2130
|
+
"selected_by": "is_plateau_robust",
|
|
2131
|
+
"oos_used_for_selection": False,
|
|
2132
|
+
"reason": "insufficient_cluster_points",
|
|
2133
|
+
"top_trials": int(top_n),
|
|
2134
|
+
},
|
|
2135
|
+
)
|
|
2136
|
+
|
|
2137
|
+
labels, cluster_method = _dbscan_cluster_labels(
|
|
2138
|
+
matrix,
|
|
2139
|
+
eps=float(config.flat_eps),
|
|
2140
|
+
min_samples=int(config.flat_min_samples),
|
|
2141
|
+
)
|
|
2142
|
+
cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0)
|
|
2143
|
+
if not cluster_ids:
|
|
2144
|
+
return _with_selection_metadata(
|
|
2145
|
+
top[0],
|
|
2146
|
+
{
|
|
2147
|
+
"objective_mode": config.optimization_mode,
|
|
2148
|
+
"selector": "fallback_best_train_objective",
|
|
2149
|
+
"selected_by": "is_plateau_robust",
|
|
2150
|
+
"oos_used_for_selection": False,
|
|
2151
|
+
"reason": "no_dense_train_plateau",
|
|
2152
|
+
"top_trials": int(top_n),
|
|
2153
|
+
"eps": float(config.flat_eps),
|
|
2154
|
+
"min_samples": int(config.flat_min_samples),
|
|
2155
|
+
"cluster_method": cluster_method,
|
|
2156
|
+
},
|
|
2157
|
+
)
|
|
2158
|
+
|
|
2159
|
+
best_cluster = None
|
|
2160
|
+
best_key = None
|
|
2161
|
+
best_cluster_stats = None
|
|
2162
|
+
for cluster_id in cluster_ids:
|
|
2163
|
+
member_idx = np.flatnonzero(labels == cluster_id)
|
|
2164
|
+
values = np.array([top[i].objective for i in member_idx], dtype=np.float64)
|
|
2165
|
+
q = float(np.quantile(values, float(config.plateau_quantile)))
|
|
2166
|
+
median = float(np.median(values))
|
|
2167
|
+
std = float(np.std(values, ddof=1)) if len(values) > 1 else 0.0
|
|
2168
|
+
cluster_score = (
|
|
2169
|
+
q
|
|
2170
|
+
+ float(config.plateau_median_weight) * median
|
|
2171
|
+
- float(config.plateau_std_penalty) * std
|
|
2172
|
+
+ float(config.plateau_size_bonus) * float(np.log1p(len(member_idx)))
|
|
2173
|
+
)
|
|
2174
|
+
key = (cluster_score, q, median, len(member_idx), float(np.max(values)))
|
|
2175
|
+
if best_key is None or key > best_key:
|
|
2176
|
+
best_key = key
|
|
2177
|
+
best_cluster = member_idx
|
|
2178
|
+
best_cluster_stats = {
|
|
2179
|
+
"plateau_score": float(cluster_score),
|
|
2180
|
+
"plateau_quantile_score": q,
|
|
2181
|
+
"plateau_median_score": median,
|
|
2182
|
+
"plateau_std_score": std,
|
|
2183
|
+
"cluster_best_objective": float(np.max(values)),
|
|
2184
|
+
}
|
|
2185
|
+
assert best_cluster is not None and best_cluster_stats is not None
|
|
2186
|
+
|
|
2187
|
+
centroid = np.mean(matrix[best_cluster], axis=0)
|
|
2188
|
+
distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1))
|
|
2189
|
+
selected_idx = int(best_cluster[int(np.argmin(distances))])
|
|
2190
|
+
medoid = top[selected_idx]
|
|
2191
|
+
centroid_params = _centroid_params(
|
|
2192
|
+
centroid=centroid,
|
|
2193
|
+
names=names,
|
|
2194
|
+
param_ranges=param_ranges,
|
|
2195
|
+
base_params=medoid.params,
|
|
2196
|
+
)
|
|
2197
|
+
selected = medoid
|
|
2198
|
+
requires_evaluation = False
|
|
2199
|
+
if config.flat_selector == "centroid":
|
|
2200
|
+
selected = WalkForwardTrialRecord(
|
|
2201
|
+
trial_id=-1,
|
|
2202
|
+
params=centroid_params,
|
|
2203
|
+
objective=float(best_cluster_stats["plateau_score"]),
|
|
2204
|
+
mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])),
|
|
2205
|
+
mean_oos_sharpe=0.0,
|
|
2206
|
+
mean_decay=0.0,
|
|
2207
|
+
std_decay=0.0,
|
|
2208
|
+
fold_metrics=[],
|
|
2209
|
+
)
|
|
2210
|
+
requires_evaluation = True
|
|
2211
|
+
|
|
2212
|
+
return _with_selection_metadata(
|
|
2213
|
+
selected,
|
|
2214
|
+
{
|
|
2215
|
+
**best_cluster_stats,
|
|
2216
|
+
"objective_mode": config.optimization_mode,
|
|
2217
|
+
"selector": str(config.flat_selector),
|
|
2218
|
+
"selected_by": "is_plateau_robust",
|
|
2219
|
+
"oos_used_for_selection": False,
|
|
2220
|
+
"param_names": names,
|
|
2221
|
+
"selected_trial_id": int(selected.trial_id),
|
|
2222
|
+
"medoid_trial_id": int(medoid.trial_id),
|
|
2223
|
+
"medoid_params": dict(medoid.params),
|
|
2224
|
+
"centroid_params": centroid_params,
|
|
2225
|
+
"centroid_normalized": [float(x) for x in centroid.tolist()],
|
|
2226
|
+
"requires_evaluation": requires_evaluation,
|
|
2227
|
+
"cluster_size": int(len(best_cluster)),
|
|
2228
|
+
"top_trials": int(top_n),
|
|
2229
|
+
"eps": float(config.flat_eps),
|
|
2230
|
+
"min_samples": int(config.flat_min_samples),
|
|
2231
|
+
"plateau_quantile": float(config.plateau_quantile),
|
|
2232
|
+
"plateau_median_weight": float(config.plateau_median_weight),
|
|
2233
|
+
"plateau_std_penalty": float(config.plateau_std_penalty),
|
|
2234
|
+
"plateau_size_bonus": float(config.plateau_size_bonus),
|
|
2235
|
+
"cluster_method": cluster_method,
|
|
2236
|
+
},
|
|
2237
|
+
)
|
|
2238
|
+
|
|
2239
|
+
|
|
2240
|
+
def select_is_only_robust_record(
|
|
2241
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2242
|
+
param_ranges: Dict[str, Any],
|
|
2243
|
+
config: WalkForwardConfig,
|
|
2244
|
+
) -> WalkForwardTrialRecord:
|
|
2245
|
+
"""
|
|
2246
|
+
Select strict train-only robust params from IS temporal stability + plateau.
|
|
2247
|
+
|
|
2248
|
+
This selector is designed for `mode_4_is_only_robust`. It never reads OOS
|
|
2249
|
+
metrics. It combines two IS-only robustness signals:
|
|
2250
|
+
|
|
2251
|
+
* temporal robustness across train subperiod shards;
|
|
2252
|
+
* plateau robustness across dense top-trial parameter regions.
|
|
2253
|
+
"""
|
|
2254
|
+
completed = [record for record in records if not record.pruned and np.isfinite(record.objective)]
|
|
2255
|
+
if not completed:
|
|
2256
|
+
raise ValueError("is_only_robust selection received no completed trials")
|
|
2257
|
+
ranked = sorted(completed, key=lambda record: record.objective, reverse=True)
|
|
2258
|
+
top_n = _candidate_count(len(ranked), config)
|
|
2259
|
+
top = ranked[:top_n]
|
|
2260
|
+
matrix, names = _param_matrix(top, param_ranges)
|
|
2261
|
+
if matrix.shape[0] == 1 or matrix.shape[1] == 0:
|
|
2262
|
+
selected = _best_temporal_record(top)
|
|
2263
|
+
return _with_selection_metadata(
|
|
2264
|
+
selected,
|
|
2265
|
+
{
|
|
2266
|
+
**selected.selection_metadata,
|
|
2267
|
+
"objective_mode": config.optimization_mode,
|
|
2268
|
+
"selector": "fallback_best_is_temporal",
|
|
2269
|
+
"selected_by": "is_only_robust",
|
|
2270
|
+
"oos_used_for_selection": False,
|
|
2271
|
+
"reason": "insufficient_cluster_points",
|
|
2272
|
+
"top_trials": int(top_n),
|
|
2273
|
+
"candidate_selection_complete": True,
|
|
2274
|
+
},
|
|
2275
|
+
)
|
|
2276
|
+
|
|
2277
|
+
labels, cluster_method = _dbscan_cluster_labels(
|
|
2278
|
+
matrix,
|
|
2279
|
+
eps=float(config.flat_eps),
|
|
2280
|
+
min_samples=int(config.flat_min_samples),
|
|
2281
|
+
)
|
|
2282
|
+
cluster_ids = sorted(label for label in set(labels.tolist()) if label >= 0)
|
|
2283
|
+
if not cluster_ids:
|
|
2284
|
+
selected = _best_temporal_record(top)
|
|
2285
|
+
return _with_selection_metadata(
|
|
2286
|
+
selected,
|
|
2287
|
+
{
|
|
2288
|
+
**selected.selection_metadata,
|
|
2289
|
+
"objective_mode": config.optimization_mode,
|
|
2290
|
+
"selector": "fallback_best_is_temporal",
|
|
2291
|
+
"selected_by": "is_only_robust",
|
|
2292
|
+
"oos_used_for_selection": False,
|
|
2293
|
+
"reason": "no_dense_train_plateau",
|
|
2294
|
+
"top_trials": int(top_n),
|
|
2295
|
+
"eps": float(config.flat_eps),
|
|
2296
|
+
"min_samples": int(config.flat_min_samples),
|
|
2297
|
+
"cluster_method": cluster_method,
|
|
2298
|
+
"candidate_selection_complete": True,
|
|
2299
|
+
},
|
|
2300
|
+
)
|
|
2301
|
+
|
|
2302
|
+
best_cluster = None
|
|
2303
|
+
best_key = None
|
|
2304
|
+
best_cluster_stats = None
|
|
2305
|
+
for cluster_id in cluster_ids:
|
|
2306
|
+
member_idx = np.flatnonzero(labels == cluster_id)
|
|
2307
|
+
objective_values = np.array([top[i].objective for i in member_idx], dtype=np.float64)
|
|
2308
|
+
temporal_values = np.array(
|
|
2309
|
+
[float(top[i].selection_metadata.get("temporal_score", top[i].mean_is_sharpe)) for i in member_idx],
|
|
2310
|
+
dtype=np.float64,
|
|
2311
|
+
)
|
|
2312
|
+
q = float(np.quantile(objective_values, float(config.plateau_quantile)))
|
|
2313
|
+
median = float(np.median(objective_values))
|
|
2314
|
+
std = float(np.std(objective_values, ddof=1)) if len(objective_values) > 1 else 0.0
|
|
2315
|
+
plateau_score = (
|
|
2316
|
+
q
|
|
2317
|
+
+ float(config.plateau_median_weight) * median
|
|
2318
|
+
- float(config.plateau_std_penalty) * std
|
|
2319
|
+
+ float(config.plateau_size_bonus) * float(np.log1p(len(member_idx)))
|
|
2320
|
+
)
|
|
2321
|
+
temporal_stats = _temporal_robustness_stats(
|
|
2322
|
+
temporal_values,
|
|
2323
|
+
q25_weight=float(config.q25_weight),
|
|
2324
|
+
dispersion_penalty=float(config.dispersion_penalty),
|
|
2325
|
+
fallback=float(np.mean(temporal_values)) if len(temporal_values) else 0.0,
|
|
2326
|
+
)
|
|
2327
|
+
bootstrap_penalty = 0.0
|
|
2328
|
+
complexity_penalty = 0.0
|
|
2329
|
+
final_score = (
|
|
2330
|
+
float(config.temporal_weight) * float(temporal_stats["temporal_score"])
|
|
2331
|
+
+ float(config.plateau_weight) * float(plateau_score)
|
|
2332
|
+
- bootstrap_penalty
|
|
2333
|
+
- complexity_penalty
|
|
2334
|
+
)
|
|
2335
|
+
key = (
|
|
2336
|
+
final_score,
|
|
2337
|
+
float(temporal_stats["temporal_q25"]),
|
|
2338
|
+
float(temporal_stats["temporal_median"]),
|
|
2339
|
+
plateau_score,
|
|
2340
|
+
len(member_idx),
|
|
2341
|
+
float(np.max(objective_values)),
|
|
2342
|
+
)
|
|
2343
|
+
if best_key is None or key > best_key:
|
|
2344
|
+
best_key = key
|
|
2345
|
+
best_cluster = member_idx
|
|
2346
|
+
best_cluster_stats = {
|
|
2347
|
+
"is_only_robust_score": float(final_score),
|
|
2348
|
+
"temporal_score": float(temporal_stats["temporal_score"]),
|
|
2349
|
+
"temporal_median": float(temporal_stats["temporal_median"]),
|
|
2350
|
+
"temporal_q25": float(temporal_stats["temporal_q25"]),
|
|
2351
|
+
"temporal_mad": float(temporal_stats["temporal_mad"]),
|
|
2352
|
+
"plateau_score": float(plateau_score),
|
|
2353
|
+
"plateau_quantile_score": q,
|
|
2354
|
+
"plateau_median_score": median,
|
|
2355
|
+
"plateau_std_score": std,
|
|
2356
|
+
"cluster_best_objective": float(np.max(objective_values)),
|
|
2357
|
+
"bootstrap_penalty": bootstrap_penalty,
|
|
2358
|
+
"complexity_penalty": complexity_penalty,
|
|
2359
|
+
}
|
|
2360
|
+
assert best_cluster is not None and best_cluster_stats is not None
|
|
2361
|
+
|
|
2362
|
+
centroid = np.mean(matrix[best_cluster], axis=0)
|
|
2363
|
+
distances = np.sqrt(((matrix[best_cluster] - centroid) ** 2).sum(axis=1))
|
|
2364
|
+
selected_idx = int(best_cluster[int(np.argmin(distances))])
|
|
2365
|
+
medoid = top[selected_idx]
|
|
2366
|
+
centroid_params = _centroid_params(
|
|
2367
|
+
centroid=centroid,
|
|
2368
|
+
names=names,
|
|
2369
|
+
param_ranges=param_ranges,
|
|
2370
|
+
base_params=medoid.params,
|
|
2371
|
+
)
|
|
2372
|
+
selected = medoid
|
|
2373
|
+
requires_evaluation = False
|
|
2374
|
+
if config.flat_selector == "centroid":
|
|
2375
|
+
selected = WalkForwardTrialRecord(
|
|
2376
|
+
trial_id=-1,
|
|
2377
|
+
params=centroid_params,
|
|
2378
|
+
objective=float(best_cluster_stats["is_only_robust_score"]),
|
|
2379
|
+
mean_is_sharpe=float(np.mean([top[i].mean_is_sharpe for i in best_cluster])),
|
|
2380
|
+
mean_oos_sharpe=0.0,
|
|
2381
|
+
mean_decay=0.0,
|
|
2382
|
+
std_decay=0.0,
|
|
2383
|
+
fold_metrics=[],
|
|
2384
|
+
)
|
|
2385
|
+
requires_evaluation = True
|
|
2386
|
+
|
|
2387
|
+
return _with_selection_metadata(
|
|
2388
|
+
selected,
|
|
2389
|
+
{
|
|
2390
|
+
**selected.selection_metadata,
|
|
2391
|
+
**best_cluster_stats,
|
|
2392
|
+
"objective_mode": config.optimization_mode,
|
|
2393
|
+
"selector": str(config.flat_selector),
|
|
2394
|
+
"selected_by": "is_only_robust",
|
|
2395
|
+
"oos_used_for_selection": False,
|
|
2396
|
+
"param_names": names,
|
|
2397
|
+
"selected_trial_id": int(selected.trial_id),
|
|
2398
|
+
"medoid_trial_id": int(medoid.trial_id),
|
|
2399
|
+
"medoid_params": dict(medoid.params),
|
|
2400
|
+
"centroid_params": centroid_params,
|
|
2401
|
+
"centroid_normalized": [float(x) for x in centroid.tolist()],
|
|
2402
|
+
"requires_evaluation": requires_evaluation,
|
|
2403
|
+
"cluster_size": int(len(best_cluster)),
|
|
2404
|
+
"top_trials": int(top_n),
|
|
2405
|
+
"eps": float(config.flat_eps),
|
|
2406
|
+
"min_samples": int(config.flat_min_samples),
|
|
2407
|
+
"plateau_quantile": float(config.plateau_quantile),
|
|
2408
|
+
"plateau_median_weight": float(config.plateau_median_weight),
|
|
2409
|
+
"plateau_std_penalty": float(config.plateau_std_penalty),
|
|
2410
|
+
"plateau_size_bonus": float(config.plateau_size_bonus),
|
|
2411
|
+
"is_subperiods": int(config.is_subperiods),
|
|
2412
|
+
"q25_weight": float(config.q25_weight),
|
|
2413
|
+
"dispersion_penalty": float(config.dispersion_penalty),
|
|
2414
|
+
"temporal_weight": float(config.temporal_weight),
|
|
2415
|
+
"plateau_weight": float(config.plateau_weight),
|
|
2416
|
+
"use_bootstrap_penalty": bool(config.use_bootstrap_penalty),
|
|
2417
|
+
"use_complexity_penalty": bool(config.use_complexity_penalty),
|
|
2418
|
+
"cluster_method": cluster_method,
|
|
2419
|
+
},
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def select_full_sample_robust_record(
|
|
2424
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2425
|
+
param_ranges: Dict[str, Any],
|
|
2426
|
+
config: WalkForwardConfig,
|
|
2427
|
+
) -> WalkForwardTrialRecord:
|
|
2428
|
+
"""
|
|
2429
|
+
Select params for full-sample robust calibration.
|
|
2430
|
+
|
|
2431
|
+
This is not an OOS validation selector. The whole supplied history is
|
|
2432
|
+
treated as one calibration sample, then top trials are filtered by temporal
|
|
2433
|
+
subperiod robustness and parameter-surface plateau robustness.
|
|
2434
|
+
"""
|
|
2435
|
+
metric = config.candidate_selection_metric
|
|
2436
|
+
completed = [record for record in records if not record.pruned and np.isfinite(record.objective)]
|
|
2437
|
+
if not completed:
|
|
2438
|
+
raise ValueError("full-sample robust selection received no completed trials")
|
|
2439
|
+
ranked = sorted(completed, key=lambda record: record.objective, reverse=True)
|
|
2440
|
+
|
|
2441
|
+
if metric == "full_best":
|
|
2442
|
+
return _with_selection_metadata(
|
|
2443
|
+
ranked[0],
|
|
2444
|
+
{
|
|
2445
|
+
**ranked[0].selection_metadata,
|
|
2446
|
+
"objective_mode": config.optimization_mode,
|
|
2447
|
+
"selected_by": "full_best",
|
|
2448
|
+
"selector": "best_full_sample_objective",
|
|
2449
|
+
"oos_used_for_selection": False,
|
|
2450
|
+
"full_sample_used_for_selection": True,
|
|
2451
|
+
"validation_claim": "none_full_sample_calibration",
|
|
2452
|
+
"candidate_selection_complete": True,
|
|
2453
|
+
},
|
|
2454
|
+
)
|
|
2455
|
+
|
|
2456
|
+
if metric == "full_temporal_robust":
|
|
2457
|
+
top_n = _candidate_count(len(ranked), config)
|
|
2458
|
+
top = ranked[:top_n]
|
|
2459
|
+
selected = _best_temporal_record(top)
|
|
2460
|
+
return _with_selection_metadata(
|
|
2461
|
+
selected,
|
|
2462
|
+
{
|
|
2463
|
+
**selected.selection_metadata,
|
|
2464
|
+
"objective_mode": config.optimization_mode,
|
|
2465
|
+
"selected_by": "full_temporal_robust",
|
|
2466
|
+
"selector": "best_full_sample_temporal_score",
|
|
2467
|
+
"oos_used_for_selection": False,
|
|
2468
|
+
"full_sample_used_for_selection": True,
|
|
2469
|
+
"validation_claim": "none_full_sample_calibration",
|
|
2470
|
+
"top_trials": int(top_n),
|
|
2471
|
+
"candidate_selection_complete": True,
|
|
2472
|
+
},
|
|
2473
|
+
)
|
|
2474
|
+
|
|
2475
|
+
if metric == "full_plateau_robust":
|
|
2476
|
+
selected = select_is_plateau_robust_record(completed, param_ranges, config=config)
|
|
2477
|
+
selected_by = "full_plateau_robust"
|
|
2478
|
+
else:
|
|
2479
|
+
selected = select_is_only_robust_record(completed, param_ranges, config=config)
|
|
2480
|
+
selected_by = "full_robust"
|
|
2481
|
+
|
|
2482
|
+
return _with_selection_metadata(
|
|
2483
|
+
selected,
|
|
2484
|
+
{
|
|
2485
|
+
**selected.selection_metadata,
|
|
2486
|
+
"objective_mode": config.optimization_mode,
|
|
2487
|
+
"selected_by": selected_by,
|
|
2488
|
+
"oos_used_for_selection": False,
|
|
2489
|
+
"full_sample_used_for_selection": True,
|
|
2490
|
+
"validation_claim": "none_full_sample_calibration",
|
|
2491
|
+
"candidate_selection_complete": True,
|
|
2492
|
+
},
|
|
2493
|
+
)
|
|
2494
|
+
|
|
2495
|
+
|
|
2496
|
+
def _select_is_candidate_records(
|
|
2497
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2498
|
+
param_ranges: Dict[str, Any],
|
|
2499
|
+
config: WalkForwardConfig,
|
|
2500
|
+
) -> List[WalkForwardTrialRecord]:
|
|
2501
|
+
completed = [record for record in records if not record.pruned and np.isfinite(record.objective)]
|
|
2502
|
+
if not completed:
|
|
2503
|
+
raise ValueError("anti-leakage optimization completed no valid in-sample trials")
|
|
2504
|
+
ranked = sorted(completed, key=lambda record: record.objective, reverse=True)
|
|
2505
|
+
top_n = _candidate_count(len(ranked), config)
|
|
2506
|
+
top = ranked[:top_n]
|
|
2507
|
+
if config.optimization_mode == "mode_5_full_robust":
|
|
2508
|
+
full = select_full_sample_robust_record(completed, param_ranges, config=config)
|
|
2509
|
+
return [full, *top]
|
|
2510
|
+
if config.candidate_selection_metric == "is_only_robust" or config.optimization_mode == "mode_4_is_only_robust":
|
|
2511
|
+
robust = select_is_only_robust_record(completed, param_ranges, config=config)
|
|
2512
|
+
return [robust, *top]
|
|
2513
|
+
if config.candidate_selection_metric == "is_plateau_robust":
|
|
2514
|
+
plateau = select_is_plateau_robust_record(completed, param_ranges, config=config)
|
|
2515
|
+
return [plateau, *top]
|
|
2516
|
+
if config.optimization_mode == "mode_3_flat_minima":
|
|
2517
|
+
flat = select_flat_minima_record(completed, param_ranges, config=config)
|
|
2518
|
+
return [flat, *top]
|
|
2519
|
+
return top
|
|
2520
|
+
|
|
2521
|
+
|
|
2522
|
+
def _candidate_count(n_records: int, config: WalkForwardConfig) -> int:
|
|
2523
|
+
if n_records <= 0:
|
|
2524
|
+
return 0
|
|
2525
|
+
if config.top_is_k is not None:
|
|
2526
|
+
return max(1, min(n_records, int(config.top_is_k)))
|
|
2527
|
+
return max(1, min(n_records, int(np.ceil(n_records * float(config.top_is_fraction)))))
|
|
2528
|
+
|
|
2529
|
+
|
|
2530
|
+
def _best_temporal_record(records: Sequence[WalkForwardTrialRecord]) -> WalkForwardTrialRecord:
|
|
2531
|
+
return max(
|
|
2532
|
+
records,
|
|
2533
|
+
key=lambda record: (
|
|
2534
|
+
float(record.selection_metadata.get("temporal_score", record.mean_is_sharpe)),
|
|
2535
|
+
float(record.selection_metadata.get("temporal_q25", record.mean_is_sharpe)),
|
|
2536
|
+
float(record.objective),
|
|
2537
|
+
),
|
|
2538
|
+
)
|
|
2539
|
+
|
|
2540
|
+
|
|
2541
|
+
def _split_index_into_subperiods(index: pd.DatetimeIndex, n_parts: int) -> List[pd.DatetimeIndex]:
|
|
2542
|
+
idx = validate_datetime(index)
|
|
2543
|
+
if len(idx) == 0:
|
|
2544
|
+
return []
|
|
2545
|
+
n = max(1, min(int(n_parts), len(idx)))
|
|
2546
|
+
return [pd.DatetimeIndex(part) for part in np.array_split(idx, n) if len(part) > 0]
|
|
2547
|
+
|
|
2548
|
+
|
|
2549
|
+
def _collect_subperiod_sharpes(fold_metrics: Sequence[Dict[str, Any]]) -> List[float]:
|
|
2550
|
+
values: List[float] = []
|
|
2551
|
+
for metrics in fold_metrics:
|
|
2552
|
+
for value in metrics.get("is_subperiod_sharpes", []) or []:
|
|
2553
|
+
try:
|
|
2554
|
+
numeric = float(value)
|
|
2555
|
+
except (TypeError, ValueError):
|
|
2556
|
+
continue
|
|
2557
|
+
if np.isfinite(numeric):
|
|
2558
|
+
values.append(numeric)
|
|
2559
|
+
return values
|
|
2560
|
+
|
|
2561
|
+
|
|
2562
|
+
def _temporal_robustness_stats(
|
|
2563
|
+
values,
|
|
2564
|
+
q25_weight: float,
|
|
2565
|
+
dispersion_penalty: float,
|
|
2566
|
+
fallback: float,
|
|
2567
|
+
) -> Dict[str, float]:
|
|
2568
|
+
arr = np.asarray(list(values), dtype=np.float64)
|
|
2569
|
+
arr = arr[np.isfinite(arr)]
|
|
2570
|
+
if arr.size == 0:
|
|
2571
|
+
fallback_value = float(fallback)
|
|
2572
|
+
return {
|
|
2573
|
+
"temporal_score": fallback_value,
|
|
2574
|
+
"temporal_median": fallback_value,
|
|
2575
|
+
"temporal_q25": fallback_value,
|
|
2576
|
+
"temporal_mad": 0.0,
|
|
2577
|
+
"temporal_count": 0.0,
|
|
2578
|
+
}
|
|
2579
|
+
median = float(np.median(arr))
|
|
2580
|
+
q25 = float(np.quantile(arr, 0.25))
|
|
2581
|
+
mad = float(np.median(np.abs(arr - median)))
|
|
2582
|
+
score = median + float(q25_weight) * q25 - float(dispersion_penalty) * mad
|
|
2583
|
+
return {
|
|
2584
|
+
"temporal_score": float(score),
|
|
2585
|
+
"temporal_median": median,
|
|
2586
|
+
"temporal_q25": q25,
|
|
2587
|
+
"temporal_mad": mad,
|
|
2588
|
+
"temporal_count": float(arr.size),
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
|
|
2592
|
+
def _select_oos_candidate_record(
|
|
2593
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2594
|
+
config: WalkForwardConfig,
|
|
2595
|
+
) -> WalkForwardTrialRecord:
|
|
2596
|
+
metric = config.candidate_selection_metric
|
|
2597
|
+
if metric == "robust_decay":
|
|
2598
|
+
key = lambda record: record.objective
|
|
2599
|
+
elif metric == "mean_oos_sharpe":
|
|
2600
|
+
key = lambda record: record.mean_oos_sharpe
|
|
2601
|
+
elif metric == "mean_is_sharpe":
|
|
2602
|
+
key = lambda record: record.mean_is_sharpe
|
|
2603
|
+
elif metric == "is_plateau_robust":
|
|
2604
|
+
selected = next(
|
|
2605
|
+
(
|
|
2606
|
+
record
|
|
2607
|
+
for record in records
|
|
2608
|
+
if record.selection_metadata.get("selected_by") == "is_plateau_robust"
|
|
2609
|
+
),
|
|
2610
|
+
None,
|
|
2611
|
+
)
|
|
2612
|
+
if selected is None:
|
|
2613
|
+
key = lambda record: record.selection_metadata.get("plateau_score", record.mean_is_sharpe)
|
|
2614
|
+
selected = max(records, key=key)
|
|
2615
|
+
return _with_selection_metadata(
|
|
2616
|
+
selected,
|
|
2617
|
+
{
|
|
2618
|
+
**selected.selection_metadata,
|
|
2619
|
+
"selected_by": metric,
|
|
2620
|
+
"candidate_selection_complete": True,
|
|
2621
|
+
"oos_seen_by_optuna": False,
|
|
2622
|
+
"oos_used_for_selection": False,
|
|
2623
|
+
},
|
|
2624
|
+
)
|
|
2625
|
+
elif metric == "is_only_robust":
|
|
2626
|
+
selected = next(
|
|
2627
|
+
(
|
|
2628
|
+
record
|
|
2629
|
+
for record in records
|
|
2630
|
+
if record.selection_metadata.get("selected_by") == "is_only_robust"
|
|
2631
|
+
),
|
|
2632
|
+
None,
|
|
2633
|
+
)
|
|
2634
|
+
if selected is None:
|
|
2635
|
+
key = lambda record: record.selection_metadata.get("is_only_robust_score", record.selection_metadata.get("temporal_score", record.mean_is_sharpe))
|
|
2636
|
+
selected = max(records, key=key)
|
|
2637
|
+
return _with_selection_metadata(
|
|
2638
|
+
selected,
|
|
2639
|
+
{
|
|
2640
|
+
**selected.selection_metadata,
|
|
2641
|
+
"selected_by": metric,
|
|
2642
|
+
"candidate_selection_complete": True,
|
|
2643
|
+
"oos_seen_by_optuna": False,
|
|
2644
|
+
"oos_used_for_selection": False,
|
|
2645
|
+
},
|
|
2646
|
+
)
|
|
2647
|
+
else: # pragma: no cover - validated in config
|
|
2648
|
+
raise ValueError(f"unsupported candidate_selection_metric: {metric}")
|
|
2649
|
+
selected = max(records, key=key)
|
|
2650
|
+
return _with_selection_metadata(
|
|
2651
|
+
selected,
|
|
2652
|
+
{
|
|
2653
|
+
**selected.selection_metadata,
|
|
2654
|
+
"selected_by": metric,
|
|
2655
|
+
"candidate_selection_complete": True,
|
|
2656
|
+
"oos_seen_by_optuna": False,
|
|
2657
|
+
},
|
|
2658
|
+
)
|
|
2659
|
+
|
|
2660
|
+
|
|
2661
|
+
def _with_selection_metadata(record: WalkForwardTrialRecord, metadata: Dict[str, Any]) -> WalkForwardTrialRecord:
|
|
2662
|
+
return WalkForwardTrialRecord(
|
|
2663
|
+
trial_id=record.trial_id,
|
|
2664
|
+
params=dict(record.params),
|
|
2665
|
+
objective=record.objective,
|
|
2666
|
+
mean_is_sharpe=record.mean_is_sharpe,
|
|
2667
|
+
mean_oos_sharpe=record.mean_oos_sharpe,
|
|
2668
|
+
mean_decay=record.mean_decay,
|
|
2669
|
+
std_decay=record.std_decay,
|
|
2670
|
+
fold_metrics=list(record.fold_metrics),
|
|
2671
|
+
pruned=record.pruned,
|
|
2672
|
+
selection_metadata=dict(metadata),
|
|
2673
|
+
)
|
|
2674
|
+
|
|
2675
|
+
|
|
2676
|
+
def _param_matrix(
|
|
2677
|
+
records: Sequence[WalkForwardTrialRecord],
|
|
2678
|
+
param_ranges: Dict[str, Any],
|
|
2679
|
+
) -> Tuple[np.ndarray, List[str]]:
|
|
2680
|
+
names = [name for name in param_ranges.keys() if _is_clusterable_param(name, param_ranges[name], records)]
|
|
2681
|
+
if not names:
|
|
2682
|
+
return np.zeros((len(records), 0), dtype=np.float64), []
|
|
2683
|
+
matrix = np.zeros((len(records), len(names)), dtype=np.float64)
|
|
2684
|
+
for col, name in enumerate(names):
|
|
2685
|
+
spec = param_ranges[name]
|
|
2686
|
+
values = [record.params.get(name) for record in records]
|
|
2687
|
+
matrix[:, col] = _normalize_param_values(values, spec)
|
|
2688
|
+
return matrix, names
|
|
2689
|
+
|
|
2690
|
+
|
|
2691
|
+
def _is_clusterable_param(name: str, spec: Any, records: Sequence[WalkForwardTrialRecord]) -> bool:
|
|
2692
|
+
values = [record.params.get(name) for record in records]
|
|
2693
|
+
return any(value is not None for value in values) and len(set(map(str, values))) > 1
|
|
2694
|
+
|
|
2695
|
+
|
|
2696
|
+
def _normalize_param_values(values: Sequence[Any], spec: Any) -> np.ndarray:
|
|
2697
|
+
if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec):
|
|
2698
|
+
low = float(spec[0])
|
|
2699
|
+
high = float(spec[1])
|
|
2700
|
+
denom = high - low
|
|
2701
|
+
if denom == 0.0:
|
|
2702
|
+
return np.zeros(len(values), dtype=np.float64)
|
|
2703
|
+
return np.array([(float(value) - low) / denom for value in values], dtype=np.float64)
|
|
2704
|
+
if isinstance(spec, (list, tuple)):
|
|
2705
|
+
choices = list(spec)
|
|
2706
|
+
denom = max(1, len(choices) - 1)
|
|
2707
|
+
encoded = []
|
|
2708
|
+
for value in values:
|
|
2709
|
+
try:
|
|
2710
|
+
encoded.append(float(choices.index(value)) / float(denom))
|
|
2711
|
+
except ValueError:
|
|
2712
|
+
encoded.append(0.0)
|
|
2713
|
+
return np.array(encoded, dtype=np.float64)
|
|
2714
|
+
numeric = np.array([float(value) if _is_number(value) else 0.0 for value in values], dtype=np.float64)
|
|
2715
|
+
span = float(np.max(numeric) - np.min(numeric))
|
|
2716
|
+
if span == 0.0:
|
|
2717
|
+
return np.zeros(len(values), dtype=np.float64)
|
|
2718
|
+
return (numeric - float(np.min(numeric))) / span
|
|
2719
|
+
|
|
2720
|
+
|
|
2721
|
+
def _centroid_params(
|
|
2722
|
+
centroid: np.ndarray,
|
|
2723
|
+
names: Sequence[str],
|
|
2724
|
+
param_ranges: Dict[str, Any],
|
|
2725
|
+
base_params: Dict[str, Any],
|
|
2726
|
+
) -> Dict[str, Any]:
|
|
2727
|
+
params = dict(base_params)
|
|
2728
|
+
for value, name in zip(centroid, names):
|
|
2729
|
+
params[name] = _denormalize_param_value(float(value), param_ranges[name])
|
|
2730
|
+
return params
|
|
2731
|
+
|
|
2732
|
+
|
|
2733
|
+
def _denormalize_param_value(value: float, spec: Any) -> Any:
|
|
2734
|
+
clipped = min(1.0, max(0.0, float(value)))
|
|
2735
|
+
if isinstance(spec, tuple) and len(spec) in (2, 3) and all(_is_number(x) for x in spec):
|
|
2736
|
+
low = float(spec[0])
|
|
2737
|
+
high = float(spec[1])
|
|
2738
|
+
raw = low + clipped * (high - low)
|
|
2739
|
+
step = spec[2] if len(spec) == 3 else None
|
|
2740
|
+
if step is not None:
|
|
2741
|
+
step_f = float(step)
|
|
2742
|
+
if step_f > 0.0:
|
|
2743
|
+
raw = low + round((raw - low) / step_f) * step_f
|
|
2744
|
+
raw = min(high, max(low, raw))
|
|
2745
|
+
if _looks_int(spec[0]) and _looks_int(spec[1]) and (step is None or _looks_int(step)):
|
|
2746
|
+
return int(round(raw))
|
|
2747
|
+
return float(raw)
|
|
2748
|
+
if isinstance(spec, (list, tuple)):
|
|
2749
|
+
choices = list(spec)
|
|
2750
|
+
if not choices:
|
|
2751
|
+
raise ValueError("cannot denormalize an empty categorical parameter range")
|
|
2752
|
+
idx = int(round(clipped * (len(choices) - 1)))
|
|
2753
|
+
return choices[min(len(choices) - 1, max(0, idx))]
|
|
2754
|
+
return spec
|
|
2755
|
+
|
|
2756
|
+
|
|
2757
|
+
def _dbscan_cluster_labels(matrix: np.ndarray, eps: float, min_samples: int) -> Tuple[np.ndarray, str]:
|
|
2758
|
+
try:
|
|
2759
|
+
from sklearn.cluster import DBSCAN
|
|
2760
|
+
|
|
2761
|
+
labels = DBSCAN(eps=float(eps), min_samples=int(min_samples), metric="euclidean").fit_predict(matrix)
|
|
2762
|
+
return labels.astype(np.int64), "sklearn.DBSCAN"
|
|
2763
|
+
except Exception:
|
|
2764
|
+
return _density_cluster_labels(matrix, eps=float(eps), min_samples=int(min_samples)), "numpy_dbscan_fallback"
|
|
2765
|
+
|
|
2766
|
+
|
|
2767
|
+
def _density_cluster_labels(matrix: np.ndarray, eps: float, min_samples: int) -> np.ndarray:
|
|
2768
|
+
n = matrix.shape[0]
|
|
2769
|
+
labels = np.full(n, -1, dtype=np.int64)
|
|
2770
|
+
visited = np.zeros(n, dtype=bool)
|
|
2771
|
+
cluster_id = 0
|
|
2772
|
+
for point in range(n):
|
|
2773
|
+
if visited[point]:
|
|
2774
|
+
continue
|
|
2775
|
+
visited[point] = True
|
|
2776
|
+
neighbors = _region_query(matrix, point, eps)
|
|
2777
|
+
if len(neighbors) < min_samples:
|
|
2778
|
+
continue
|
|
2779
|
+
labels[point] = cluster_id
|
|
2780
|
+
seeds = list(neighbors)
|
|
2781
|
+
cursor = 0
|
|
2782
|
+
while cursor < len(seeds):
|
|
2783
|
+
neighbor = seeds[cursor]
|
|
2784
|
+
if not visited[neighbor]:
|
|
2785
|
+
visited[neighbor] = True
|
|
2786
|
+
neighbor_neighbors = _region_query(matrix, int(neighbor), eps)
|
|
2787
|
+
if len(neighbor_neighbors) >= min_samples:
|
|
2788
|
+
for candidate in neighbor_neighbors:
|
|
2789
|
+
if int(candidate) not in seeds:
|
|
2790
|
+
seeds.append(int(candidate))
|
|
2791
|
+
if labels[neighbor] < 0:
|
|
2792
|
+
labels[neighbor] = cluster_id
|
|
2793
|
+
cursor += 1
|
|
2794
|
+
cluster_id += 1
|
|
2795
|
+
return labels
|
|
2796
|
+
|
|
2797
|
+
|
|
2798
|
+
def _region_query(matrix: np.ndarray, point: int, eps: float) -> List[int]:
|
|
2799
|
+
diff = matrix - matrix[int(point)]
|
|
2800
|
+
distances = np.sqrt((diff * diff).sum(axis=1))
|
|
2801
|
+
return [int(i) for i in np.flatnonzero(distances <= eps)]
|
|
2802
|
+
|
|
2803
|
+
|
|
2804
|
+
def stitch_oos_outputs(
|
|
2805
|
+
outputs: Sequence[StrategyOutput],
|
|
2806
|
+
folds: Sequence[WalkForwardFold],
|
|
2807
|
+
full_index: Union[pd.DatetimeIndex, pd.Series],
|
|
2808
|
+
fill_value: float = 0.0,
|
|
2809
|
+
) -> Optional[StrategyOutput]:
|
|
2810
|
+
"""Stitch per-fold OOS strategy output into one full-index object."""
|
|
2811
|
+
idx = validate_datetime(full_index)
|
|
2812
|
+
if len(outputs) != len(folds):
|
|
2813
|
+
raise ValueError("outputs and folds must have the same length")
|
|
2814
|
+
if not outputs:
|
|
2815
|
+
return None
|
|
2816
|
+
|
|
2817
|
+
first = outputs[0]
|
|
2818
|
+
if isinstance(first, pd.DataFrame):
|
|
2819
|
+
columns = list(first.columns)
|
|
2820
|
+
stitched = pd.DataFrame(fill_value, index=idx, columns=columns, dtype=float)
|
|
2821
|
+
for out, fold in zip(outputs, folds):
|
|
2822
|
+
frame = _normalize_frame_output(out, columns)
|
|
2823
|
+
stitched.loc[fold.test_index, columns] = frame.reindex(fold.test_index).fillna(fill_value).values
|
|
2824
|
+
return stitched
|
|
2825
|
+
|
|
2826
|
+
if isinstance(first, dict):
|
|
2827
|
+
symbols = list(first.keys())
|
|
2828
|
+
stitched = {symbol: pd.Series(fill_value, index=idx, dtype=float) for symbol in symbols}
|
|
2829
|
+
for out, fold in zip(outputs, folds):
|
|
2830
|
+
if not isinstance(out, dict) or set(out.keys()) != set(symbols):
|
|
2831
|
+
raise TypeError("all walk-forward dict outputs must have the same symbol keys")
|
|
2832
|
+
for symbol in symbols:
|
|
2833
|
+
series = _normalize_series_output(out[symbol])
|
|
2834
|
+
stitched[symbol].loc[fold.test_index] = series.reindex(fold.test_index).fillna(fill_value).values
|
|
2835
|
+
return stitched
|
|
2836
|
+
|
|
2837
|
+
stitched = pd.Series(fill_value, index=idx, dtype=float)
|
|
2838
|
+
for out, fold in zip(outputs, folds):
|
|
2839
|
+
series = _normalize_series_output(out)
|
|
2840
|
+
stitched.loc[fold.test_index] = series.reindex(fold.test_index).fillna(fill_value).values
|
|
2841
|
+
return stitched
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
def _infer_datetime_index(data, datetime_index) -> pd.DatetimeIndex:
|
|
2845
|
+
if datetime_index is not None:
|
|
2846
|
+
return validate_datetime(datetime_index)
|
|
2847
|
+
if isinstance(data, pd.DataFrame):
|
|
2848
|
+
return validate_datetime(data.index)
|
|
2849
|
+
if isinstance(data, dict):
|
|
2850
|
+
if not data:
|
|
2851
|
+
raise ValueError("walk-forward data dict is empty")
|
|
2852
|
+
first = next(iter(data.values()))
|
|
2853
|
+
if isinstance(first, pd.DataFrame) or isinstance(first, pd.Series):
|
|
2854
|
+
return validate_datetime(first.index)
|
|
2855
|
+
raise ValueError("datetime_index is required when data has no DatetimeIndex")
|
|
2856
|
+
|
|
2857
|
+
|
|
2858
|
+
def _align_data_to_datetime_index(data, idx: pd.DatetimeIndex):
|
|
2859
|
+
"""
|
|
2860
|
+
Return a data view/copy whose timestamp index matches WFO fold indices.
|
|
2861
|
+
|
|
2862
|
+
`validate_datetime` normalizes fold indices to UTC. Real research frames
|
|
2863
|
+
are often tz-naive; passing them unchanged into a strategy makes common
|
|
2864
|
+
code like `series.reindex(test_index)` silently return all NaN. Alignment is
|
|
2865
|
+
length-preserving and does not inspect future values.
|
|
2866
|
+
"""
|
|
2867
|
+
if isinstance(data, pd.DataFrame):
|
|
2868
|
+
if len(data) != len(idx):
|
|
2869
|
+
return data
|
|
2870
|
+
out = data.copy()
|
|
2871
|
+
out.index = idx
|
|
2872
|
+
return out
|
|
2873
|
+
if isinstance(data, pd.Series):
|
|
2874
|
+
if len(data) != len(idx):
|
|
2875
|
+
return data
|
|
2876
|
+
out = data.copy()
|
|
2877
|
+
out.index = idx
|
|
2878
|
+
return out
|
|
2879
|
+
if isinstance(data, dict):
|
|
2880
|
+
out = {}
|
|
2881
|
+
for key, value in data.items():
|
|
2882
|
+
if isinstance(value, pd.DataFrame) and len(value) == len(idx):
|
|
2883
|
+
item = value.copy()
|
|
2884
|
+
item.index = idx
|
|
2885
|
+
out[key] = item
|
|
2886
|
+
elif isinstance(value, pd.Series) and len(value) == len(idx):
|
|
2887
|
+
item = value.copy()
|
|
2888
|
+
item.index = idx
|
|
2889
|
+
out[key] = item
|
|
2890
|
+
else:
|
|
2891
|
+
out[key] = value
|
|
2892
|
+
return out
|
|
2893
|
+
return data
|
|
2894
|
+
|
|
2895
|
+
|
|
2896
|
+
def _first_oos_timestamp(split_mode) -> pd.Timestamp:
|
|
2897
|
+
if isinstance(split_mode, int):
|
|
2898
|
+
ts = pd.Timestamp(year=int(split_mode), month=1, day=1, tz="UTC")
|
|
2899
|
+
else:
|
|
2900
|
+
raw = str(split_mode)
|
|
2901
|
+
if raw.startswith("walk_forward_"):
|
|
2902
|
+
raw = raw.replace("walk_forward_", "", 1)
|
|
2903
|
+
if raw.isdigit() and len(raw) == 4:
|
|
2904
|
+
ts = pd.Timestamp(year=int(raw), month=1, day=1, tz="UTC")
|
|
2905
|
+
else:
|
|
2906
|
+
ts = pd.Timestamp(raw)
|
|
2907
|
+
if ts.tz is None:
|
|
2908
|
+
return ts.tz_localize("UTC")
|
|
2909
|
+
return ts.tz_convert("UTC")
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
def _frequency_offset(split_frequency: str) -> pd.DateOffset:
|
|
2913
|
+
if split_frequency == "yearly":
|
|
2914
|
+
return pd.DateOffset(years=1)
|
|
2915
|
+
if split_frequency == "semi_yearly":
|
|
2916
|
+
return pd.DateOffset(months=6)
|
|
2917
|
+
if split_frequency == "quarterly":
|
|
2918
|
+
return pd.DateOffset(months=3)
|
|
2919
|
+
if split_frequency == "monthly":
|
|
2920
|
+
return pd.DateOffset(months=1)
|
|
2921
|
+
if split_frequency == "weekly":
|
|
2922
|
+
return pd.DateOffset(weeks=1)
|
|
2923
|
+
raise ValueError("unsupported split_frequency")
|
|
2924
|
+
|
|
2925
|
+
|
|
2926
|
+
def _default_params_from_ranges(param_ranges: Dict[str, Any]) -> Dict[str, Any]:
|
|
2927
|
+
params: Dict[str, Any] = {}
|
|
2928
|
+
for key, value in param_ranges.items():
|
|
2929
|
+
if isinstance(value, (list, tuple)):
|
|
2930
|
+
if len(value) == 0:
|
|
2931
|
+
raise ValueError(f"param_ranges[{key!r}] is empty")
|
|
2932
|
+
params[key] = value[0]
|
|
2933
|
+
else:
|
|
2934
|
+
params[key] = value
|
|
2935
|
+
return params
|
|
2936
|
+
|
|
2937
|
+
|
|
2938
|
+
def _slice_output_to_test(output: StrategyOutput, test_index: pd.DatetimeIndex) -> StrategyOutput:
|
|
2939
|
+
if isinstance(output, pd.DataFrame):
|
|
2940
|
+
return _normalize_frame_output(output).reindex(test_index).fillna(0.0)
|
|
2941
|
+
if isinstance(output, dict):
|
|
2942
|
+
return {key: _normalize_series_output(value).reindex(test_index).fillna(0.0) for key, value in output.items()}
|
|
2943
|
+
return _normalize_series_output(output).reindex(test_index).fillna(0.0)
|
|
2944
|
+
|
|
2945
|
+
|
|
2946
|
+
def _normalize_series_output(output) -> pd.Series:
|
|
2947
|
+
if not isinstance(output, pd.Series):
|
|
2948
|
+
output = pd.Series(output)
|
|
2949
|
+
series = output.copy()
|
|
2950
|
+
if isinstance(series.index, pd.DatetimeIndex):
|
|
2951
|
+
series.index = series.index.tz_localize("UTC") if series.index.tz is None else series.index.tz_convert("UTC")
|
|
2952
|
+
return series[~series.index.duplicated(keep="first")].astype(float)
|
|
2953
|
+
|
|
2954
|
+
|
|
2955
|
+
def _normalize_frame_output(output, columns: Optional[List[str]] = None) -> pd.DataFrame:
|
|
2956
|
+
if not isinstance(output, pd.DataFrame):
|
|
2957
|
+
raise TypeError("walk-forward output must be a pandas DataFrame")
|
|
2958
|
+
frame = output.copy()
|
|
2959
|
+
if isinstance(frame.index, pd.DatetimeIndex):
|
|
2960
|
+
frame.index = frame.index.tz_localize("UTC") if frame.index.tz is None else frame.index.tz_convert("UTC")
|
|
2961
|
+
frame = frame[~frame.index.duplicated(keep="first")]
|
|
2962
|
+
if columns is not None:
|
|
2963
|
+
missing = set(columns) - set(frame.columns)
|
|
2964
|
+
if missing:
|
|
2965
|
+
raise ValueError(f"walk-forward output missing columns: {sorted(missing)}")
|
|
2966
|
+
frame = frame[columns]
|
|
2967
|
+
return frame.astype(float)
|
|
2968
|
+
|
|
2969
|
+
|
|
2970
|
+
def _fold_table(folds: Sequence[WalkForwardFold]) -> pd.DataFrame:
|
|
2971
|
+
return pd.DataFrame(
|
|
2972
|
+
[
|
|
2973
|
+
{
|
|
2974
|
+
"fold_id": fold.fold_id,
|
|
2975
|
+
"train_start": fold.train_start,
|
|
2976
|
+
"train_end": fold.train_end,
|
|
2977
|
+
"test_start": fold.test_start,
|
|
2978
|
+
"test_end": fold.test_end,
|
|
2979
|
+
"train_bars": len(fold.train_index),
|
|
2980
|
+
"test_bars": len(fold.test_index),
|
|
2981
|
+
}
|
|
2982
|
+
for fold in folds
|
|
2983
|
+
]
|
|
2984
|
+
)
|
|
2985
|
+
|
|
2986
|
+
|
|
2987
|
+
def _sample_params(trial, param_ranges: Dict[str, Any]) -> Dict[str, Any]:
|
|
2988
|
+
return _optimization_suggest_params(trial, param_ranges)
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def _looks_int(value: Any) -> bool:
|
|
2992
|
+
return isinstance(value, (int, np.integer)) or (isinstance(value, float) and float(value).is_integer())
|
|
2993
|
+
|
|
2994
|
+
|
|
2995
|
+
def _is_number(value: Any) -> bool:
|
|
2996
|
+
return isinstance(value, (int, float, np.integer, np.floating))
|
|
2997
|
+
|
|
2998
|
+
|
|
2999
|
+
def _trial_table(records: Sequence[WalkForwardTrialRecord]) -> pd.DataFrame:
|
|
3000
|
+
return pd.DataFrame([_trial_to_dict(record, include_fold_metrics=False) for record in records])
|
|
3001
|
+
|
|
3002
|
+
|
|
3003
|
+
def _trial_to_dict(record: WalkForwardTrialRecord, include_fold_metrics: bool = True) -> Dict[str, Any]:
|
|
3004
|
+
out = {
|
|
3005
|
+
"trial_id": record.trial_id,
|
|
3006
|
+
"params": record.params,
|
|
3007
|
+
"objective": record.objective,
|
|
3008
|
+
"mean_is_sharpe": record.mean_is_sharpe,
|
|
3009
|
+
"mean_oos_sharpe": record.mean_oos_sharpe,
|
|
3010
|
+
"mean_decay": record.mean_decay,
|
|
3011
|
+
"std_decay": record.std_decay,
|
|
3012
|
+
"pruned": record.pruned,
|
|
3013
|
+
}
|
|
3014
|
+
if include_fold_metrics:
|
|
3015
|
+
out["fold_metrics"] = record.fold_metrics
|
|
3016
|
+
if record.selection_metadata:
|
|
3017
|
+
out["selection_metadata"] = record.selection_metadata
|
|
3018
|
+
for key in (
|
|
3019
|
+
"temporal_score",
|
|
3020
|
+
"temporal_median",
|
|
3021
|
+
"temporal_q25",
|
|
3022
|
+
"temporal_mad",
|
|
3023
|
+
"temporal_count",
|
|
3024
|
+
"is_subperiod_count",
|
|
3025
|
+
"is_only_robust_score",
|
|
3026
|
+
"plateau_score",
|
|
3027
|
+
):
|
|
3028
|
+
if key in record.selection_metadata:
|
|
3029
|
+
out[key] = record.selection_metadata[key]
|
|
3030
|
+
return out
|
|
3031
|
+
|
|
3032
|
+
|
|
3033
|
+
def _close_map_from_data(data) -> Dict[str, pd.Series]:
|
|
3034
|
+
if isinstance(data, pd.DataFrame):
|
|
3035
|
+
if "close" not in data.columns:
|
|
3036
|
+
raise ValueError("walk-forward scoring requires a close column")
|
|
3037
|
+
return {"DEFAULT": _series_utc(data["close"])}
|
|
3038
|
+
if isinstance(data, dict):
|
|
3039
|
+
out: Dict[str, pd.Series] = {}
|
|
3040
|
+
for key, value in data.items():
|
|
3041
|
+
if isinstance(value, pd.DataFrame):
|
|
3042
|
+
if "close" not in value.columns:
|
|
3043
|
+
raise ValueError(f"walk-forward scoring data[{key!r}] requires a close column")
|
|
3044
|
+
out[key] = _series_utc(value["close"])
|
|
3045
|
+
elif isinstance(value, pd.Series):
|
|
3046
|
+
out[key] = _series_utc(value)
|
|
3047
|
+
else:
|
|
3048
|
+
raise TypeError("walk-forward scoring dict values must be DataFrame or Series")
|
|
3049
|
+
if not out:
|
|
3050
|
+
raise ValueError("walk-forward scoring data dict is empty")
|
|
3051
|
+
return out
|
|
3052
|
+
raise TypeError("walk-forward scoring requires DataFrame or dict data")
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
def _series_utc(series: pd.Series) -> pd.Series:
|
|
3056
|
+
out = series.copy()
|
|
3057
|
+
if isinstance(out.index, pd.DatetimeIndex):
|
|
3058
|
+
out.index = out.index.tz_localize("UTC") if out.index.tz is None else out.index.tz_convert("UTC")
|
|
3059
|
+
return out[~out.index.duplicated(keep="first")].astype(float)
|
|
3060
|
+
|
|
3061
|
+
|
|
3062
|
+
def _data_hash(data) -> str:
|
|
3063
|
+
try:
|
|
3064
|
+
if isinstance(data, pd.DataFrame):
|
|
3065
|
+
idx = validate_datetime(data.index)
|
|
3066
|
+
payload = {"kind": "frame", "rows": len(data), "start": str(idx[0]), "end": str(idx[-1]), "columns": list(data.columns)}
|
|
3067
|
+
elif isinstance(data, dict):
|
|
3068
|
+
payload = {"kind": "dict", "keys": sorted(data.keys())}
|
|
3069
|
+
spans = {}
|
|
3070
|
+
for key, value in data.items():
|
|
3071
|
+
if isinstance(value, (pd.DataFrame, pd.Series)):
|
|
3072
|
+
idx = validate_datetime(value.index)
|
|
3073
|
+
spans[key] = {"rows": len(value), "start": str(idx[0]), "end": str(idx[-1])}
|
|
3074
|
+
payload["spans"] = spans
|
|
3075
|
+
else:
|
|
3076
|
+
payload = {"kind": type(data).__name__}
|
|
3077
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|
|
3078
|
+
except Exception:
|
|
3079
|
+
return "unavailable"
|
|
3080
|
+
|
|
3081
|
+
|
|
3082
|
+
def _config_hash(config: WalkForwardConfig) -> str:
|
|
3083
|
+
payload = {
|
|
3084
|
+
"split_mode": str(config.split_mode),
|
|
3085
|
+
"split_frequency": config.split_frequency,
|
|
3086
|
+
"window_mode": config.window_mode,
|
|
3087
|
+
"train_window": config.train_window,
|
|
3088
|
+
"min_train_bars": config.min_train_bars,
|
|
3089
|
+
"min_test_bars": config.min_test_bars,
|
|
3090
|
+
"target_mode": config.target_mode,
|
|
3091
|
+
"optimization_mode": config.optimization_mode,
|
|
3092
|
+
"optuna_trials": config.optuna_trials,
|
|
3093
|
+
"optuna_early_stopping": config.optuna_early_stopping,
|
|
3094
|
+
"random_seed": config.random_seed,
|
|
3095
|
+
"decay_lambda": config.decay_lambda,
|
|
3096
|
+
"decay_gamma": config.decay_gamma,
|
|
3097
|
+
"top_is_fraction": config.top_is_fraction,
|
|
3098
|
+
"top_is_k": config.top_is_k,
|
|
3099
|
+
"candidate_selection_metric": config.candidate_selection_metric,
|
|
3100
|
+
"candidate_decay_lambda": config.candidate_decay_lambda,
|
|
3101
|
+
"candidate_decay_gamma": config.candidate_decay_gamma,
|
|
3102
|
+
"sbb_samples": config.sbb_samples,
|
|
3103
|
+
"sbb_block_length": config.sbb_block_length,
|
|
3104
|
+
"sbb_decay_lambda": config.sbb_decay_lambda,
|
|
3105
|
+
"sbb_std_penalty": config.sbb_std_penalty,
|
|
3106
|
+
"sbb_simulation": config.sbb_simulation,
|
|
3107
|
+
"regime_count": config.regime_count,
|
|
3108
|
+
"regime_lookback": config.regime_lookback,
|
|
3109
|
+
"regime_weights": config.regime_weights,
|
|
3110
|
+
"stress_vol_multiplier": config.stress_vol_multiplier,
|
|
3111
|
+
"garch_p": config.garch_p,
|
|
3112
|
+
"garch_q": config.garch_q,
|
|
3113
|
+
"garch_dist": config.garch_dist,
|
|
3114
|
+
"garch_vol_multiplier": config.garch_vol_multiplier,
|
|
3115
|
+
"flat_top_fraction": config.flat_top_fraction,
|
|
3116
|
+
"flat_eps": config.flat_eps,
|
|
3117
|
+
"flat_min_samples": config.flat_min_samples,
|
|
3118
|
+
"flat_selector": config.flat_selector,
|
|
3119
|
+
"plateau_quantile": config.plateau_quantile,
|
|
3120
|
+
"plateau_median_weight": config.plateau_median_weight,
|
|
3121
|
+
"plateau_std_penalty": config.plateau_std_penalty,
|
|
3122
|
+
"plateau_size_bonus": config.plateau_size_bonus,
|
|
3123
|
+
"is_subperiods": config.is_subperiods,
|
|
3124
|
+
"q25_weight": config.q25_weight,
|
|
3125
|
+
"dispersion_penalty": config.dispersion_penalty,
|
|
3126
|
+
"temporal_weight": config.temporal_weight,
|
|
3127
|
+
"plateau_weight": config.plateau_weight,
|
|
3128
|
+
"use_bootstrap_penalty": config.use_bootstrap_penalty,
|
|
3129
|
+
"use_complexity_penalty": config.use_complexity_penalty,
|
|
3130
|
+
"scoring_backend": config.scoring_backend,
|
|
3131
|
+
"scoring_trading_days": config.scoring_trading_days,
|
|
3132
|
+
"min_trades_per_year": config.min_trades_per_year,
|
|
3133
|
+
"trade_penalty_factor": config.trade_penalty_factor,
|
|
3134
|
+
"use_numba": config.use_numba,
|
|
3135
|
+
}
|
|
3136
|
+
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode("utf-8")).hexdigest()
|