quantbt-engine 1.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quantbt/__init__.py +862 -0
- quantbt/adapters/__init__.py +5 -0
- quantbt/adapters/nautilus/__init__.py +42 -0
- quantbt/adapters/nautilus/_dependency.py +53 -0
- quantbt/adapters/nautilus/backend.py +639 -0
- quantbt/adapters/nautilus/instruments.py +275 -0
- quantbt/adapters/nautilus/options.py +465 -0
- quantbt/adapters/nautilus/reports.py +231 -0
- quantbt/backends/__init__.py +29 -0
- quantbt/backends/_native_event_rust.py +2168 -0
- quantbt/backends/native_event.py +5048 -0
- quantbt/backends/native_option.py +796 -0
- quantbt/backends/native_portfolio.py +928 -0
- quantbt/backends/native_vectorized.py +1299 -0
- quantbt/backtester.py +485 -0
- quantbt/benchmarks/__init__.py +1 -0
- quantbt/benchmarks/compare_phase9_parity.py +302 -0
- quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
- quantbt/benchmarks/profile_phase7.py +415 -0
- quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
- quantbt/benchmarks/run_optimization_overhead.py +195 -0
- quantbt/benchmarks/run_options_engine.py +272 -0
- quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
- quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
- quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
- quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
- quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
- quantbt/benchmarks/run_phase14_service_loop.py +694 -0
- quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
- quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
- quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
- quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
- quantbt/benchmarks/run_phase31_intrabar.py +441 -0
- quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
- quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
- quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
- quantbt/benchmarks/run_phase7.py +654 -0
- quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
- quantbt/core/__init__.py +323 -0
- quantbt/core/arbitrage.py +767 -0
- quantbt/core/basket.py +234 -0
- quantbt/core/certification.py +274 -0
- quantbt/core/constraints.py +155 -0
- quantbt/core/engine.py +1144 -0
- quantbt/core/event.py +869 -0
- quantbt/core/execution_contract.py +197 -0
- quantbt/core/execution_depth.py +613 -0
- quantbt/core/intrabar_kernel.py +1906 -0
- quantbt/core/intrabar_reference.py +907 -0
- quantbt/core/intrabar_session.py +156 -0
- quantbt/core/market_tape.py +480 -0
- quantbt/core/native_event_capabilities.py +121 -0
- quantbt/core/native_event_parity.py +336 -0
- quantbt/core/order_compiler.py +422 -0
- quantbt/core/orders.py +319 -0
- quantbt/core/portfolio.py +392 -0
- quantbt/core/preprocessor.py +256 -0
- quantbt/core/reactive.py +173 -0
- quantbt/core/results.py +341 -0
- quantbt/core/schema.py +211 -0
- quantbt/core/scopes.py +96 -0
- quantbt/core/structured_orders.py +378 -0
- quantbt/core/types.py +87 -0
- quantbt/core/vectorized.py +203 -0
- quantbt/endpoint.py +4465 -0
- quantbt/engines.py +1014 -0
- quantbt/metrics/__init__.py +45 -0
- quantbt/metrics/options_analytics.py +46 -0
- quantbt/metrics/performance.py +541 -0
- quantbt/optimization/__init__.py +94 -0
- quantbt/optimization/callbacks.py +116 -0
- quantbt/optimization/candidate_selection.py +397 -0
- quantbt/optimization/config.py +84 -0
- quantbt/optimization/constraints.py +22 -0
- quantbt/optimization/evaluator.py +21 -0
- quantbt/optimization/evaluators/__init__.py +32 -0
- quantbt/optimization/evaluators/arbitrage.py +22 -0
- quantbt/optimization/evaluators/generic.py +34 -0
- quantbt/optimization/evaluators/grid_dca.py +22 -0
- quantbt/optimization/evaluators/intrabar.py +54 -0
- quantbt/optimization/evaluators/native_event.py +51 -0
- quantbt/optimization/evaluators/options.py +22 -0
- quantbt/optimization/evaluators/portfolio.py +42 -0
- quantbt/optimization/evaluators/signal.py +43 -0
- quantbt/optimization/multiseed.py +176 -0
- quantbt/optimization/objectives.py +221 -0
- quantbt/optimization/optimizer.py +500 -0
- quantbt/optimization/result.py +80 -0
- quantbt/optimization/samplers.py +84 -0
- quantbt/optimization/space.py +223 -0
- quantbt/options/__init__.py +215 -0
- quantbt/options/cache.py +116 -0
- quantbt/options/conventions.py +158 -0
- quantbt/options/data.py +177 -0
- quantbt/options/execution.py +600 -0
- quantbt/options/fees.py +135 -0
- quantbt/options/greeks.py +173 -0
- quantbt/options/hedging.py +232 -0
- quantbt/options/iv.py +188 -0
- quantbt/options/ledger.py +263 -0
- quantbt/options/lifecycle.py +94 -0
- quantbt/options/margin.py +311 -0
- quantbt/options/packages.py +147 -0
- quantbt/options/pricing.py +191 -0
- quantbt/options/schema.py +227 -0
- quantbt/options/selectors.py +281 -0
- quantbt/options/strategy.py +249 -0
- quantbt/options/surface.py +142 -0
- quantbt/options/tape.py +226 -0
- quantbt/options/templates/__init__.py +33 -0
- quantbt/options/templates/packages.py +354 -0
- quantbt/portfolio.py +606 -0
- quantbt/py.typed +1 -0
- quantbt/reporting/__init__.py +38 -0
- quantbt/reporting/arbitrage_audit.py +211 -0
- quantbt/reporting/nautilus_bundle.py +758 -0
- quantbt/reporting/nautilus_certification.py +226 -0
- quantbt/reporting/nautilus_diagnostics.py +225 -0
- quantbt/reporting/parity.py +496 -0
- quantbt/reporting/portfolio_audit.py +206 -0
- quantbt/reporting/portfolio_nautilus.py +140 -0
- quantbt/sizing/__init__.py +3 -0
- quantbt/sizing/fast.py +67 -0
- quantbt/sizing/modes.py +162 -0
- quantbt/viz/__init__.py +4 -0
- quantbt/viz/plots.py +310 -0
- quantbt/viz/themes.py +102 -0
- quantbt/walkforward.py +3136 -0
- quantbt_engine-1.0.7.dist-info/METADATA +891 -0
- quantbt_engine-1.0.7.dist-info/RECORD +133 -0
- quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
- quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
- quantbt_engine-1.0.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"""Domain-agnostic Optuna optimizer core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
from .callbacks import JsonlOptimizationLogger, SingleObjectiveEarlyStopping
|
|
9
|
+
from .candidate_selection import constraints_feasible
|
|
10
|
+
from .config import OptimizationConfig, SamplerConfig
|
|
11
|
+
from .constraints import constraints_from_trial, set_trial_constraints
|
|
12
|
+
from .evaluator import TrialEvaluator
|
|
13
|
+
from .result import ObjectiveResult, OptimizationResult, OptimizationTrialRecord
|
|
14
|
+
from .samplers import build_sampler
|
|
15
|
+
from .space import search_space_info, stable_params_key, suggest_params
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OptunaOptimizer:
|
|
19
|
+
"""Generic Optuna orchestration over a domain-specific evaluator."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
evaluator: TrialEvaluator,
|
|
25
|
+
config: OptimizationConfig,
|
|
26
|
+
sampler_config: Optional[SamplerConfig] = None,
|
|
27
|
+
):
|
|
28
|
+
self.evaluator = evaluator
|
|
29
|
+
self.config = config
|
|
30
|
+
self.sampler_config = sampler_config or SamplerConfig()
|
|
31
|
+
self._seen_params: set[str] = set()
|
|
32
|
+
|
|
33
|
+
def optimize(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
param_ranges: Mapping[str, Any],
|
|
37
|
+
fixed_params: Optional[Mapping[str, Any]] = None,
|
|
38
|
+
initial_trials: Optional[Sequence[Mapping[str, Any]]] = None,
|
|
39
|
+
effective_params_builder: Optional[Callable[[Mapping[str, Any]], Mapping[str, Any]]] = None,
|
|
40
|
+
candidate_selector=None,
|
|
41
|
+
) -> OptimizationResult:
|
|
42
|
+
"""Run an Optuna study and return a QuantBT result schema."""
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
import optuna
|
|
46
|
+
except Exception as exc: # pragma: no cover - dependency guard
|
|
47
|
+
raise ImportError("QuantBT optimization requires optuna") from exc
|
|
48
|
+
if int(self.config.n_jobs) != 1:
|
|
49
|
+
raise NotImplementedError("parallel optimization is not certified")
|
|
50
|
+
|
|
51
|
+
objective_count = len(self.config.directions)
|
|
52
|
+
self._seen_params = set()
|
|
53
|
+
constraints_callback = (
|
|
54
|
+
constraints_from_trial
|
|
55
|
+
if self.sampler_config.name in {"tpe", "nsgaii"} and self.sampler_config.constraint_mode == "sampler"
|
|
56
|
+
else None
|
|
57
|
+
)
|
|
58
|
+
sampler = build_sampler(
|
|
59
|
+
self.sampler_config,
|
|
60
|
+
seed=self.config.seed,
|
|
61
|
+
search_space=param_ranges,
|
|
62
|
+
objective_count=objective_count,
|
|
63
|
+
constraints_func=constraints_callback,
|
|
64
|
+
)
|
|
65
|
+
study = optuna.create_study(
|
|
66
|
+
study_name=self.config.study_name,
|
|
67
|
+
directions=list(self.config.directions),
|
|
68
|
+
sampler=sampler,
|
|
69
|
+
storage=self.config.storage,
|
|
70
|
+
load_if_exists=bool(self.config.load_if_exists),
|
|
71
|
+
pruner=optuna.pruners.NopPruner(),
|
|
72
|
+
)
|
|
73
|
+
self._preload_seen_params(study)
|
|
74
|
+
self._enqueue_initial_trials(
|
|
75
|
+
study,
|
|
76
|
+
param_ranges=param_ranges,
|
|
77
|
+
fixed_params=fixed_params,
|
|
78
|
+
initial_trials=initial_trials,
|
|
79
|
+
)
|
|
80
|
+
callbacks = []
|
|
81
|
+
if self.config.early_stopping_rounds is not None:
|
|
82
|
+
if objective_count != 1:
|
|
83
|
+
raise ValueError("early stopping is supported for single-objective optimization only")
|
|
84
|
+
callbacks.append(
|
|
85
|
+
SingleObjectiveEarlyStopping(
|
|
86
|
+
self.config.early_stopping_rounds,
|
|
87
|
+
self.config.directions[0],
|
|
88
|
+
min_delta=float(self.config.early_stopping_min_delta),
|
|
89
|
+
min_trials=int(self.config.early_stopping_min_trials),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
if self.config.log_path is not None:
|
|
93
|
+
callbacks.append(JsonlOptimizationLogger(self.config.log_path, objective_count=objective_count))
|
|
94
|
+
|
|
95
|
+
catch = (Exception,) if self.config.exception_policy == "fail_trial" else ()
|
|
96
|
+
study.optimize(
|
|
97
|
+
lambda trial: self._objective(
|
|
98
|
+
trial,
|
|
99
|
+
param_ranges,
|
|
100
|
+
fixed_params,
|
|
101
|
+
objective_count,
|
|
102
|
+
effective_params_builder=effective_params_builder,
|
|
103
|
+
),
|
|
104
|
+
n_trials=int(self.config.n_trials),
|
|
105
|
+
n_jobs=int(self.config.n_jobs),
|
|
106
|
+
callbacks=callbacks,
|
|
107
|
+
show_progress_bar=bool(self.config.show_progress_bar),
|
|
108
|
+
catch=catch,
|
|
109
|
+
)
|
|
110
|
+
result = _build_result(study, objective_count)
|
|
111
|
+
result.baseline_trials = [
|
|
112
|
+
record
|
|
113
|
+
for record in result.trials
|
|
114
|
+
if record.metadata.get("quantbt_source") == "warm_start"
|
|
115
|
+
]
|
|
116
|
+
result.search_diagnostics = _search_diagnostics(
|
|
117
|
+
param_ranges=param_ranges,
|
|
118
|
+
fixed_params=fixed_params,
|
|
119
|
+
result=result,
|
|
120
|
+
objective_index=0,
|
|
121
|
+
)
|
|
122
|
+
if candidate_selector is not None:
|
|
123
|
+
selected = candidate_selector.select(result)
|
|
124
|
+
result.selected_params = dict(getattr(selected, "params", selected))
|
|
125
|
+
result.selection_metadata = dict(getattr(selected, "metadata", {}))
|
|
126
|
+
elif objective_count == 1:
|
|
127
|
+
if _result_has_constraints(result):
|
|
128
|
+
result.selected_params = None
|
|
129
|
+
result.selection_metadata = {"selected_by": None, "reason": "constraints_require_explicit_candidate_selector"}
|
|
130
|
+
else:
|
|
131
|
+
result.selected_params = dict(result.best_params or {})
|
|
132
|
+
_apply_baseline_floor(result)
|
|
133
|
+
return result
|
|
134
|
+
|
|
135
|
+
def _enqueue_initial_trials(self, study, *, param_ranges, fixed_params, initial_trials) -> None:
|
|
136
|
+
if not initial_trials:
|
|
137
|
+
return
|
|
138
|
+
fixed = dict(fixed_params or {})
|
|
139
|
+
for idx, payload in enumerate(initial_trials):
|
|
140
|
+
full_params = dict(payload or {})
|
|
141
|
+
full_params.update(fixed)
|
|
142
|
+
trial_params = _trial_params_for_enqueue(full_params, param_ranges, fixed)
|
|
143
|
+
study.enqueue_trial(
|
|
144
|
+
trial_params,
|
|
145
|
+
user_attrs={
|
|
146
|
+
"quantbt_source": "warm_start",
|
|
147
|
+
"quantbt_initial_trial_id": int(idx),
|
|
148
|
+
"quantbt_initial_full_params": dict(full_params),
|
|
149
|
+
},
|
|
150
|
+
skip_if_exists=True,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _preload_seen_params(self, study) -> None:
|
|
154
|
+
if not self.config.load_if_exists:
|
|
155
|
+
return
|
|
156
|
+
for trial in getattr(study, "trials", ()):
|
|
157
|
+
key = trial.user_attrs.get("quantbt_params_key")
|
|
158
|
+
if key is None:
|
|
159
|
+
params = trial.user_attrs.get("quantbt_full_params", trial.params)
|
|
160
|
+
if params:
|
|
161
|
+
key = stable_params_key(params)
|
|
162
|
+
if key:
|
|
163
|
+
self._seen_params.add(str(key))
|
|
164
|
+
|
|
165
|
+
def _objective(self, trial, param_ranges, fixed_params, objective_count: int, *, effective_params_builder=None):
|
|
166
|
+
try:
|
|
167
|
+
import optuna
|
|
168
|
+
except Exception as exc: # pragma: no cover
|
|
169
|
+
raise ImportError("QuantBT optimization requires optuna") from exc
|
|
170
|
+
params = suggest_params(trial, param_ranges, fixed_params=fixed_params)
|
|
171
|
+
source = str(trial.user_attrs.get("quantbt_source", "sampled"))
|
|
172
|
+
raw_params_key = stable_params_key(params)
|
|
173
|
+
effective_params = dict(effective_params_builder(params)) if effective_params_builder is not None else dict(params)
|
|
174
|
+
params_key = stable_params_key(effective_params)
|
|
175
|
+
trial.set_user_attr("quantbt_full_params", dict(params))
|
|
176
|
+
trial.set_user_attr("quantbt_source", source)
|
|
177
|
+
trial.set_user_attr("quantbt_params_key", params_key)
|
|
178
|
+
trial.set_user_attr("quantbt_raw_params_key", raw_params_key)
|
|
179
|
+
trial.set_user_attr("quantbt_effective_params", dict(effective_params))
|
|
180
|
+
if params_key in self._seen_params:
|
|
181
|
+
if self.config.duplicate_policy == "prune":
|
|
182
|
+
raise optuna.TrialPruned("duplicate parameter set")
|
|
183
|
+
if self.config.duplicate_policy == "raise":
|
|
184
|
+
raise ValueError(f"duplicate parameter set: {params_key}")
|
|
185
|
+
self._seen_params.add(params_key)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
objective = self.evaluator.evaluate(params)
|
|
189
|
+
except optuna.TrialPruned:
|
|
190
|
+
raise
|
|
191
|
+
except Exception as exc:
|
|
192
|
+
if self.config.exception_policy == "prune":
|
|
193
|
+
raise optuna.TrialPruned(str(exc)) from exc
|
|
194
|
+
raise
|
|
195
|
+
if not isinstance(objective, ObjectiveResult):
|
|
196
|
+
raise TypeError("TrialEvaluator.evaluate must return ObjectiveResult")
|
|
197
|
+
if objective.constraints and self.sampler_config.name not in {"tpe", "nsgaii"} and self.sampler_config.constraint_mode != "post_filter":
|
|
198
|
+
raise ValueError(
|
|
199
|
+
f"sampler {self.sampler_config.name!r} does not support formal constraints; "
|
|
200
|
+
"set SamplerConfig(..., constraint_mode='post_filter') to filter candidates after optimization"
|
|
201
|
+
)
|
|
202
|
+
if len(objective.values) != objective_count:
|
|
203
|
+
raise ValueError(f"objective returned {len(objective.values)} values but config has {objective_count} directions")
|
|
204
|
+
if not all(math.isfinite(float(value)) for value in objective.values):
|
|
205
|
+
raise optuna.TrialPruned("non-finite objective value")
|
|
206
|
+
|
|
207
|
+
trial.set_user_attr("quantbt_metrics", dict(objective.metrics))
|
|
208
|
+
metadata = dict(objective.metadata)
|
|
209
|
+
metadata.setdefault("quantbt_source", source)
|
|
210
|
+
metadata.setdefault("quantbt_params_key", params_key)
|
|
211
|
+
metadata.setdefault("quantbt_raw_params_key", raw_params_key)
|
|
212
|
+
trial.set_user_attr("quantbt_metadata", metadata)
|
|
213
|
+
set_trial_constraints(trial, objective.constraints)
|
|
214
|
+
|
|
215
|
+
if objective_count == 1:
|
|
216
|
+
return float(objective.values[0])
|
|
217
|
+
return tuple(float(value) for value in objective.values)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_result(study, objective_count: int) -> OptimizationResult:
|
|
221
|
+
trials = [_trial_record(trial) for trial in study.trials]
|
|
222
|
+
trials_frame = None
|
|
223
|
+
try:
|
|
224
|
+
trials_frame = study.trials_dataframe()
|
|
225
|
+
except Exception:
|
|
226
|
+
trials_frame = None
|
|
227
|
+
if objective_count == 1:
|
|
228
|
+
try:
|
|
229
|
+
best_params = dict(study.best_trial.user_attrs.get("quantbt_full_params", study.best_params))
|
|
230
|
+
best_values = (float(study.best_value),)
|
|
231
|
+
except Exception:
|
|
232
|
+
best_params = None
|
|
233
|
+
best_values = None
|
|
234
|
+
pareto_trials = []
|
|
235
|
+
else:
|
|
236
|
+
best_params = None
|
|
237
|
+
best_values = None
|
|
238
|
+
pareto_trials = list(study.best_trials)
|
|
239
|
+
return OptimizationResult(
|
|
240
|
+
study=study,
|
|
241
|
+
best_params=best_params,
|
|
242
|
+
best_values=best_values,
|
|
243
|
+
pareto_trials=pareto_trials,
|
|
244
|
+
trials=trials,
|
|
245
|
+
trials_frame=trials_frame,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _result_has_constraints(result: OptimizationResult) -> bool:
|
|
250
|
+
return any(len(record.constraints) > 0 for record in result.trials if record.state == "COMPLETE")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _trial_record(trial) -> OptimizationTrialRecord:
|
|
254
|
+
values = tuple(float(value) for value in (trial.values or ()))
|
|
255
|
+
metadata = dict(trial.user_attrs.get("quantbt_metadata", {}))
|
|
256
|
+
for key in (
|
|
257
|
+
"quantbt_source",
|
|
258
|
+
"quantbt_params_key",
|
|
259
|
+
"quantbt_raw_params_key",
|
|
260
|
+
"quantbt_initial_trial_id",
|
|
261
|
+
):
|
|
262
|
+
if key in trial.user_attrs:
|
|
263
|
+
metadata.setdefault(key, trial.user_attrs[key])
|
|
264
|
+
return OptimizationTrialRecord(
|
|
265
|
+
number=int(trial.number),
|
|
266
|
+
state=str(trial.state.name),
|
|
267
|
+
params=dict(trial.user_attrs.get("quantbt_full_params", trial.params)),
|
|
268
|
+
values=values,
|
|
269
|
+
metrics=dict(trial.user_attrs.get("quantbt_metrics", {})),
|
|
270
|
+
constraints=tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())),
|
|
271
|
+
metadata=metadata,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _trial_params_for_enqueue(params: Mapping[str, Any], param_ranges: Mapping[str, Any], fixed_params: Mapping[str, Any]) -> dict[str, Any]:
|
|
276
|
+
"""Return only Optuna-suggested params for `study.enqueue_trial`.
|
|
277
|
+
|
|
278
|
+
Scalar constants and fixed params are merged inside `suggest_params`, so
|
|
279
|
+
enqueuing them would create confusing Optuna distributions. Missing active
|
|
280
|
+
search params are rejected because a warm-start baseline must be evaluated
|
|
281
|
+
exactly, not partially sampled.
|
|
282
|
+
"""
|
|
283
|
+
|
|
284
|
+
queued: dict[str, Any] = {}
|
|
285
|
+
missing: list[str] = []
|
|
286
|
+
fixed = set(dict(fixed_params or {}))
|
|
287
|
+
for name, spec in dict(param_ranges or {}).items():
|
|
288
|
+
if name in fixed or not _is_suggested_spec(spec):
|
|
289
|
+
continue
|
|
290
|
+
if name not in params:
|
|
291
|
+
missing.append(str(name))
|
|
292
|
+
else:
|
|
293
|
+
queued[str(name)] = params[name]
|
|
294
|
+
if missing:
|
|
295
|
+
joined = ", ".join(missing[:10])
|
|
296
|
+
raise ValueError(f"initial trial is missing search params: {joined}")
|
|
297
|
+
return queued
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _is_suggested_spec(spec: Any) -> bool:
|
|
301
|
+
if isinstance(spec, range):
|
|
302
|
+
return True
|
|
303
|
+
if isinstance(spec, tuple) and len(spec) in (2, 3):
|
|
304
|
+
return True
|
|
305
|
+
if isinstance(spec, list):
|
|
306
|
+
return True
|
|
307
|
+
return False
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _apply_baseline_floor(result: OptimizationResult) -> None:
|
|
311
|
+
"""Keep the best feasible warm-start when selected candidate regresses."""
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
directions = tuple(str(direction.name).lower() for direction in result.study.directions)
|
|
315
|
+
except Exception:
|
|
316
|
+
directions = ("maximize",)
|
|
317
|
+
if len(directions) != 1:
|
|
318
|
+
return
|
|
319
|
+
baselines = [
|
|
320
|
+
record
|
|
321
|
+
for record in result.baseline_trials
|
|
322
|
+
if record.state == "COMPLETE" and record.values and constraints_feasible(record.constraints)
|
|
323
|
+
]
|
|
324
|
+
if not baselines:
|
|
325
|
+
result.search_regression = False
|
|
326
|
+
result.selection_metadata.setdefault("best_baseline_trial", None)
|
|
327
|
+
return
|
|
328
|
+
best_baseline = sorted(
|
|
329
|
+
baselines,
|
|
330
|
+
key=lambda record: record.values[0],
|
|
331
|
+
reverse=directions[0] == "maximize",
|
|
332
|
+
)[0]
|
|
333
|
+
selected = _selected_record(result)
|
|
334
|
+
if selected is None:
|
|
335
|
+
selected = best_baseline
|
|
336
|
+
selected_value = selected.values[0] if selected.values else float("-inf")
|
|
337
|
+
baseline_better = _is_better(best_baseline.values[0], selected_value, directions[0])
|
|
338
|
+
result.selection_metadata.setdefault(
|
|
339
|
+
"best_baseline_trial",
|
|
340
|
+
{
|
|
341
|
+
"trial_number": int(best_baseline.number),
|
|
342
|
+
"value": float(best_baseline.values[0]),
|
|
343
|
+
"params": dict(best_baseline.params),
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
if not baseline_better:
|
|
347
|
+
result.search_regression = False
|
|
348
|
+
result.selection_metadata.setdefault("search_regression", False)
|
|
349
|
+
return
|
|
350
|
+
result.selected_params = dict(best_baseline.params)
|
|
351
|
+
result.search_regression = True
|
|
352
|
+
result.selection_metadata.update(
|
|
353
|
+
{
|
|
354
|
+
"selected_by": "warm_start_baseline_floor",
|
|
355
|
+
"search_regression": True,
|
|
356
|
+
"previous_selected_trial": None if selected is None else int(selected.number),
|
|
357
|
+
"previous_selected_value": None if selected is None or not selected.values else float(selected.values[0]),
|
|
358
|
+
"trial_number": int(best_baseline.number),
|
|
359
|
+
"value": float(best_baseline.values[0]),
|
|
360
|
+
}
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _selected_record(result: OptimizationResult) -> Optional[OptimizationTrialRecord]:
|
|
365
|
+
trial_number = result.selection_metadata.get("trial_number")
|
|
366
|
+
if trial_number is not None:
|
|
367
|
+
for record in result.trials:
|
|
368
|
+
if int(record.number) == int(trial_number):
|
|
369
|
+
return record
|
|
370
|
+
if result.selected_params is not None:
|
|
371
|
+
selected_key = stable_params_key(result.selected_params)
|
|
372
|
+
for record in result.trials:
|
|
373
|
+
if stable_params_key(record.params) == selected_key and record.state == "COMPLETE":
|
|
374
|
+
return record
|
|
375
|
+
if result.best_params is not None:
|
|
376
|
+
best_key = stable_params_key(result.best_params)
|
|
377
|
+
for record in result.trials:
|
|
378
|
+
if stable_params_key(record.params) == best_key and record.state == "COMPLETE":
|
|
379
|
+
return record
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _is_better(candidate: float, incumbent: float, direction: str) -> bool:
|
|
384
|
+
if direction == "minimize":
|
|
385
|
+
return float(candidate) < float(incumbent)
|
|
386
|
+
return float(candidate) > float(incumbent)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _search_diagnostics(
|
|
390
|
+
*,
|
|
391
|
+
param_ranges: Mapping[str, Any],
|
|
392
|
+
fixed_params: Optional[Mapping[str, Any]],
|
|
393
|
+
result: OptimizationResult,
|
|
394
|
+
objective_index: int,
|
|
395
|
+
) -> dict[str, Any]:
|
|
396
|
+
info = search_space_info(param_ranges, fixed_params=fixed_params)
|
|
397
|
+
variable_names = list(info.variable_names)
|
|
398
|
+
completed = [
|
|
399
|
+
record
|
|
400
|
+
for record in result.trials
|
|
401
|
+
if record.state == "COMPLETE" and len(record.values) > int(objective_index)
|
|
402
|
+
]
|
|
403
|
+
try:
|
|
404
|
+
direction = str(result.study.directions[int(objective_index)].name).lower()
|
|
405
|
+
except Exception:
|
|
406
|
+
direction = "maximize"
|
|
407
|
+
ranked = sorted(
|
|
408
|
+
completed,
|
|
409
|
+
key=lambda record: record.values[int(objective_index)],
|
|
410
|
+
reverse=direction == "maximize",
|
|
411
|
+
)
|
|
412
|
+
top_n = max(1, int(math.ceil(len(ranked) * 0.10))) if ranked else 0
|
|
413
|
+
top = ranked[:top_n]
|
|
414
|
+
source_counts: dict[str, int] = {}
|
|
415
|
+
effective_keys: list[str] = []
|
|
416
|
+
for record in result.trials:
|
|
417
|
+
source = str(record.metadata.get("quantbt_source", "sampled"))
|
|
418
|
+
source_counts[source] = source_counts.get(source, 0) + 1
|
|
419
|
+
key = record.metadata.get("quantbt_params_key")
|
|
420
|
+
if key is not None:
|
|
421
|
+
effective_keys.append(str(key))
|
|
422
|
+
coverage = {
|
|
423
|
+
name: len({record.params.get(name) for record in completed if name in record.params})
|
|
424
|
+
for name in variable_names
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
"nominal_dimension": int(len(variable_names)),
|
|
428
|
+
"variable_names": variable_names,
|
|
429
|
+
"grid_size_estimate": info.grid_size,
|
|
430
|
+
"has_categorical": bool(info.has_categorical),
|
|
431
|
+
"has_continuous": bool(info.has_continuous),
|
|
432
|
+
"has_dynamic_float": bool(info.has_dynamic_float),
|
|
433
|
+
"param_kind_counts": _param_kind_counts(param_ranges, fixed_params),
|
|
434
|
+
"completed_trials": int(len(completed)),
|
|
435
|
+
"pruned_trials": int(sum(1 for record in result.trials if record.state == "PRUNED")),
|
|
436
|
+
"failed_trials": int(sum(1 for record in result.trials if record.state == "FAIL")),
|
|
437
|
+
"source_counts": source_counts,
|
|
438
|
+
"effective_duplicate_count": int(len(effective_keys) - len(set(effective_keys))),
|
|
439
|
+
"param_coverage": coverage,
|
|
440
|
+
"top_decile_size": int(top_n),
|
|
441
|
+
"top_decile_distributions": _top_distributions(top, variable_names),
|
|
442
|
+
"baseline_rank": _baseline_rank(ranked),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _param_kind_counts(param_ranges: Mapping[str, Any], fixed_params: Optional[Mapping[str, Any]]) -> dict[str, int]:
|
|
447
|
+
fixed = set(dict(fixed_params or {}))
|
|
448
|
+
counts = {"fixed": 0, "categorical": 0, "int": 0, "float": 0, "constant": 0}
|
|
449
|
+
for name, spec in dict(param_ranges or {}).items():
|
|
450
|
+
if name in fixed:
|
|
451
|
+
counts["fixed"] += 1
|
|
452
|
+
continue
|
|
453
|
+
if isinstance(spec, range) or isinstance(spec, list):
|
|
454
|
+
counts["categorical"] += 1
|
|
455
|
+
elif isinstance(spec, tuple) and len(spec) in (2, 3):
|
|
456
|
+
numeric = all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in spec)
|
|
457
|
+
looks_int = numeric and all(isinstance(value, int) and not isinstance(value, bool) for value in spec)
|
|
458
|
+
counts["int" if looks_int else "float"] += 1
|
|
459
|
+
else:
|
|
460
|
+
counts["constant"] += 1
|
|
461
|
+
return counts
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _top_distributions(records: Sequence[OptimizationTrialRecord], variable_names: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
465
|
+
distributions: dict[str, dict[str, Any]] = {}
|
|
466
|
+
for name in variable_names:
|
|
467
|
+
values = [record.params.get(name) for record in records if name in record.params]
|
|
468
|
+
counts: dict[str, int] = {}
|
|
469
|
+
numeric: list[float] = []
|
|
470
|
+
for value in values:
|
|
471
|
+
counts[str(value)] = counts.get(str(value), 0) + 1
|
|
472
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
473
|
+
numeric.append(float(value))
|
|
474
|
+
payload: dict[str, Any] = {"counts": counts}
|
|
475
|
+
if numeric:
|
|
476
|
+
payload.update(
|
|
477
|
+
{
|
|
478
|
+
"min": float(min(numeric)),
|
|
479
|
+
"max": float(max(numeric)),
|
|
480
|
+
"mean": float(sum(numeric) / len(numeric)),
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
distributions[str(name)] = payload
|
|
484
|
+
return distributions
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _baseline_rank(ranked: Sequence[OptimizationTrialRecord]) -> list[dict[str, Any]]:
|
|
488
|
+
rows = []
|
|
489
|
+
for rank, record in enumerate(ranked, start=1):
|
|
490
|
+
if record.metadata.get("quantbt_source") != "warm_start":
|
|
491
|
+
continue
|
|
492
|
+
rows.append(
|
|
493
|
+
{
|
|
494
|
+
"rank": int(rank),
|
|
495
|
+
"trial_number": int(record.number),
|
|
496
|
+
"value": None if not record.values else float(record.values[0]),
|
|
497
|
+
"params": dict(record.params),
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
return rows
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Result schemas for QuantBT optimization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Optional, Sequence, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ObjectiveResult:
|
|
11
|
+
"""Evaluator output consumed by the domain-agnostic optimizer.
|
|
12
|
+
|
|
13
|
+
`values` follows Optuna conventions: one value for single-objective
|
|
14
|
+
optimization and one value per configured direction for multi-objective
|
|
15
|
+
optimization. Formal constraints use Optuna's sign convention:
|
|
16
|
+
`<= 0` means feasible and `> 0` means violated.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
values: Tuple[float, ...]
|
|
20
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
21
|
+
constraints: Tuple[float, ...] = ()
|
|
22
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
def __post_init__(self) -> None:
|
|
25
|
+
values = tuple(float(value) for value in self.values)
|
|
26
|
+
if not values:
|
|
27
|
+
raise ValueError("ObjectiveResult.values must be non-empty")
|
|
28
|
+
constraints = tuple(float(value) for value in self.constraints)
|
|
29
|
+
metrics = {str(key): float(value) for key, value in dict(self.metrics or {}).items()}
|
|
30
|
+
object.__setattr__(self, "values", values)
|
|
31
|
+
object.__setattr__(self, "constraints", constraints)
|
|
32
|
+
object.__setattr__(self, "metrics", metrics)
|
|
33
|
+
object.__setattr__(self, "metadata", dict(self.metadata or {}))
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def scalar(
|
|
37
|
+
cls,
|
|
38
|
+
value: float,
|
|
39
|
+
*,
|
|
40
|
+
metrics: Optional[dict[str, float]] = None,
|
|
41
|
+
constraints: Sequence[float] = (),
|
|
42
|
+
metadata: Optional[dict[str, Any]] = None,
|
|
43
|
+
) -> "ObjectiveResult":
|
|
44
|
+
"""Build a single-objective result."""
|
|
45
|
+
|
|
46
|
+
return cls(values=(float(value),), metrics=dict(metrics or {}), constraints=tuple(constraints), metadata=dict(metadata or {}))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class OptimizationTrialRecord:
|
|
51
|
+
"""Compact, serializable record of one completed/pruned/failed trial."""
|
|
52
|
+
|
|
53
|
+
number: int
|
|
54
|
+
state: str
|
|
55
|
+
params: dict[str, Any]
|
|
56
|
+
values: Tuple[float, ...] = ()
|
|
57
|
+
metrics: dict[str, float] = field(default_factory=dict)
|
|
58
|
+
constraints: Tuple[float, ...] = ()
|
|
59
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class OptimizationResult:
|
|
64
|
+
"""Public result returned by :class:`OptunaOptimizer`."""
|
|
65
|
+
|
|
66
|
+
study: Any
|
|
67
|
+
best_params: Optional[dict[str, Any]]
|
|
68
|
+
best_values: Optional[Tuple[float, ...]]
|
|
69
|
+
pareto_trials: list[Any]
|
|
70
|
+
trials: list[OptimizationTrialRecord]
|
|
71
|
+
trials_frame: Any
|
|
72
|
+
selected_params: Optional[dict[str, Any]] = None
|
|
73
|
+
selection_metadata: dict[str, Any] = field(default_factory=dict)
|
|
74
|
+
baseline_trials: list[OptimizationTrialRecord] = field(default_factory=list)
|
|
75
|
+
phase_results: list[Any] = field(default_factory=list)
|
|
76
|
+
seed_results: list[Any] = field(default_factory=list)
|
|
77
|
+
robust_candidates: list[Any] = field(default_factory=list)
|
|
78
|
+
selected_validation: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
search_regression: bool = False
|
|
80
|
+
search_diagnostics: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Optuna sampler factory with QuantBT compatibility checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from typing import Any, Callable, Mapping, Optional
|
|
7
|
+
|
|
8
|
+
from .config import SamplerConfig
|
|
9
|
+
from .space import build_grid_search_space, search_space_info
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_sampler(
|
|
13
|
+
sampler_config: SamplerConfig,
|
|
14
|
+
*,
|
|
15
|
+
seed: Optional[int],
|
|
16
|
+
search_space: Mapping[str, Any],
|
|
17
|
+
objective_count: int,
|
|
18
|
+
constraints_func: Optional[Callable] = None,
|
|
19
|
+
):
|
|
20
|
+
"""Build an Optuna sampler and validate domain-agnostic compatibility."""
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
import optuna
|
|
24
|
+
except Exception as exc: # pragma: no cover - dependency guard
|
|
25
|
+
raise ImportError("QuantBT optimization requires optuna") from exc
|
|
26
|
+
|
|
27
|
+
cfg = sampler_config if isinstance(sampler_config, SamplerConfig) else SamplerConfig(**dict(sampler_config))
|
|
28
|
+
name = cfg.name
|
|
29
|
+
kwargs = dict(cfg.kwargs)
|
|
30
|
+
info = search_space_info(search_space)
|
|
31
|
+
|
|
32
|
+
if name == "tpe":
|
|
33
|
+
payload = {**kwargs}
|
|
34
|
+
if seed is not None:
|
|
35
|
+
payload.setdefault("seed", int(seed))
|
|
36
|
+
if constraints_func is not None and _accepts(optuna.samplers.TPESampler, "constraints_func"):
|
|
37
|
+
payload.setdefault("constraints_func", constraints_func)
|
|
38
|
+
return optuna.samplers.TPESampler(**payload)
|
|
39
|
+
|
|
40
|
+
if name == "random":
|
|
41
|
+
if constraints_func is not None:
|
|
42
|
+
raise ValueError("RandomSampler does not support formal constraints")
|
|
43
|
+
payload = {**kwargs}
|
|
44
|
+
if seed is not None:
|
|
45
|
+
payload.setdefault("seed", int(seed))
|
|
46
|
+
return optuna.samplers.RandomSampler(**payload)
|
|
47
|
+
|
|
48
|
+
if name == "grid":
|
|
49
|
+
if constraints_func is not None:
|
|
50
|
+
raise ValueError("GridSampler does not support formal constraints")
|
|
51
|
+
max_grid_size = int(kwargs.pop("max_grid_size", 100_000))
|
|
52
|
+
grid = build_grid_search_space(search_space, max_grid_size=max_grid_size)
|
|
53
|
+
payload = {**kwargs}
|
|
54
|
+
if seed is not None:
|
|
55
|
+
payload.setdefault("seed", int(seed))
|
|
56
|
+
return optuna.samplers.GridSampler(grid, **payload)
|
|
57
|
+
|
|
58
|
+
if name == "cmaes":
|
|
59
|
+
if constraints_func is not None:
|
|
60
|
+
raise ValueError("CmaEsSampler does not support formal constraints")
|
|
61
|
+
if info.has_categorical:
|
|
62
|
+
raise ValueError("CMA-ES requires a numeric continuous/int search space; categorical params are not supported")
|
|
63
|
+
if info.has_dynamic_float is False and not info.variable_names:
|
|
64
|
+
raise ValueError("CMA-ES requires at least one variable numeric parameter")
|
|
65
|
+
payload = {**kwargs}
|
|
66
|
+
if seed is not None:
|
|
67
|
+
payload.setdefault("seed", int(seed))
|
|
68
|
+
return optuna.samplers.CmaEsSampler(**payload)
|
|
69
|
+
|
|
70
|
+
if name == "nsgaii":
|
|
71
|
+
payload = {**kwargs}
|
|
72
|
+
if seed is not None:
|
|
73
|
+
payload.setdefault("seed", int(seed))
|
|
74
|
+
if constraints_func is not None and _accepts(optuna.samplers.NSGAIISampler, "constraints_func"):
|
|
75
|
+
payload.setdefault("constraints_func", constraints_func)
|
|
76
|
+
if objective_count < 1:
|
|
77
|
+
raise ValueError("objective_count must be positive")
|
|
78
|
+
return optuna.samplers.NSGAIISampler(**payload)
|
|
79
|
+
|
|
80
|
+
raise ValueError("sampler name must be one of: tpe, random, grid, cmaes, nsgaii")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _accepts(callable_obj, parameter: str) -> bool:
|
|
84
|
+
return parameter in inspect.signature(callable_obj).parameters
|