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.
Files changed (133) hide show
  1. quantbt/__init__.py +862 -0
  2. quantbt/adapters/__init__.py +5 -0
  3. quantbt/adapters/nautilus/__init__.py +42 -0
  4. quantbt/adapters/nautilus/_dependency.py +53 -0
  5. quantbt/adapters/nautilus/backend.py +639 -0
  6. quantbt/adapters/nautilus/instruments.py +275 -0
  7. quantbt/adapters/nautilus/options.py +465 -0
  8. quantbt/adapters/nautilus/reports.py +231 -0
  9. quantbt/backends/__init__.py +29 -0
  10. quantbt/backends/_native_event_rust.py +2168 -0
  11. quantbt/backends/native_event.py +5048 -0
  12. quantbt/backends/native_option.py +796 -0
  13. quantbt/backends/native_portfolio.py +928 -0
  14. quantbt/backends/native_vectorized.py +1299 -0
  15. quantbt/backtester.py +485 -0
  16. quantbt/benchmarks/__init__.py +1 -0
  17. quantbt/benchmarks/compare_phase9_parity.py +302 -0
  18. quantbt/benchmarks/gamma_scalping_backtestsample.py +802 -0
  19. quantbt/benchmarks/profile_phase7.py +415 -0
  20. quantbt/benchmarks/run_arbitrage_phase_e.py +160 -0
  21. quantbt/benchmarks/run_optimization_overhead.py +195 -0
  22. quantbt/benchmarks/run_options_engine.py +272 -0
  23. quantbt/benchmarks/run_pct_equity_nautilus_smoke.py +227 -0
  24. quantbt/benchmarks/run_phase12_arbitrage_cert.py +402 -0
  25. quantbt/benchmarks/run_phase12_benchmark_nautilus_cert.py +430 -0
  26. quantbt/benchmarks/run_phase13_portfolio_report.py +97 -0
  27. quantbt/benchmarks/run_phase13_wfo_cache.py +162 -0
  28. quantbt/benchmarks/run_phase14_service_loop.py +694 -0
  29. quantbt/benchmarks/run_phase15a_nautilus_certification.py +514 -0
  30. quantbt/benchmarks/run_phase15b_synthetic_depth.py +175 -0
  31. quantbt/benchmarks/run_phase16_performance_debt.py +389 -0
  32. quantbt/benchmarks/run_phase30e_reactive_runner.py +161 -0
  33. quantbt/benchmarks/run_phase31_intrabar.py +441 -0
  34. quantbt/benchmarks/run_phase34a_native_event_memory.py +179 -0
  35. quantbt/benchmarks/run_phase34b_native_event_prepared_score.py +173 -0
  36. quantbt/benchmarks/run_phase34c_native_event_single_pass.py +176 -0
  37. quantbt/benchmarks/run_phase7.py +654 -0
  38. quantbt/benchmarks/run_portfolio_real_parity.py +537 -0
  39. quantbt/core/__init__.py +323 -0
  40. quantbt/core/arbitrage.py +767 -0
  41. quantbt/core/basket.py +234 -0
  42. quantbt/core/certification.py +274 -0
  43. quantbt/core/constraints.py +155 -0
  44. quantbt/core/engine.py +1144 -0
  45. quantbt/core/event.py +869 -0
  46. quantbt/core/execution_contract.py +197 -0
  47. quantbt/core/execution_depth.py +613 -0
  48. quantbt/core/intrabar_kernel.py +1906 -0
  49. quantbt/core/intrabar_reference.py +907 -0
  50. quantbt/core/intrabar_session.py +156 -0
  51. quantbt/core/market_tape.py +480 -0
  52. quantbt/core/native_event_capabilities.py +121 -0
  53. quantbt/core/native_event_parity.py +336 -0
  54. quantbt/core/order_compiler.py +422 -0
  55. quantbt/core/orders.py +319 -0
  56. quantbt/core/portfolio.py +392 -0
  57. quantbt/core/preprocessor.py +256 -0
  58. quantbt/core/reactive.py +173 -0
  59. quantbt/core/results.py +341 -0
  60. quantbt/core/schema.py +211 -0
  61. quantbt/core/scopes.py +96 -0
  62. quantbt/core/structured_orders.py +378 -0
  63. quantbt/core/types.py +87 -0
  64. quantbt/core/vectorized.py +203 -0
  65. quantbt/endpoint.py +4465 -0
  66. quantbt/engines.py +1014 -0
  67. quantbt/metrics/__init__.py +45 -0
  68. quantbt/metrics/options_analytics.py +46 -0
  69. quantbt/metrics/performance.py +541 -0
  70. quantbt/optimization/__init__.py +94 -0
  71. quantbt/optimization/callbacks.py +116 -0
  72. quantbt/optimization/candidate_selection.py +397 -0
  73. quantbt/optimization/config.py +84 -0
  74. quantbt/optimization/constraints.py +22 -0
  75. quantbt/optimization/evaluator.py +21 -0
  76. quantbt/optimization/evaluators/__init__.py +32 -0
  77. quantbt/optimization/evaluators/arbitrage.py +22 -0
  78. quantbt/optimization/evaluators/generic.py +34 -0
  79. quantbt/optimization/evaluators/grid_dca.py +22 -0
  80. quantbt/optimization/evaluators/intrabar.py +54 -0
  81. quantbt/optimization/evaluators/native_event.py +51 -0
  82. quantbt/optimization/evaluators/options.py +22 -0
  83. quantbt/optimization/evaluators/portfolio.py +42 -0
  84. quantbt/optimization/evaluators/signal.py +43 -0
  85. quantbt/optimization/multiseed.py +176 -0
  86. quantbt/optimization/objectives.py +221 -0
  87. quantbt/optimization/optimizer.py +500 -0
  88. quantbt/optimization/result.py +80 -0
  89. quantbt/optimization/samplers.py +84 -0
  90. quantbt/optimization/space.py +223 -0
  91. quantbt/options/__init__.py +215 -0
  92. quantbt/options/cache.py +116 -0
  93. quantbt/options/conventions.py +158 -0
  94. quantbt/options/data.py +177 -0
  95. quantbt/options/execution.py +600 -0
  96. quantbt/options/fees.py +135 -0
  97. quantbt/options/greeks.py +173 -0
  98. quantbt/options/hedging.py +232 -0
  99. quantbt/options/iv.py +188 -0
  100. quantbt/options/ledger.py +263 -0
  101. quantbt/options/lifecycle.py +94 -0
  102. quantbt/options/margin.py +311 -0
  103. quantbt/options/packages.py +147 -0
  104. quantbt/options/pricing.py +191 -0
  105. quantbt/options/schema.py +227 -0
  106. quantbt/options/selectors.py +281 -0
  107. quantbt/options/strategy.py +249 -0
  108. quantbt/options/surface.py +142 -0
  109. quantbt/options/tape.py +226 -0
  110. quantbt/options/templates/__init__.py +33 -0
  111. quantbt/options/templates/packages.py +354 -0
  112. quantbt/portfolio.py +606 -0
  113. quantbt/py.typed +1 -0
  114. quantbt/reporting/__init__.py +38 -0
  115. quantbt/reporting/arbitrage_audit.py +211 -0
  116. quantbt/reporting/nautilus_bundle.py +758 -0
  117. quantbt/reporting/nautilus_certification.py +226 -0
  118. quantbt/reporting/nautilus_diagnostics.py +225 -0
  119. quantbt/reporting/parity.py +496 -0
  120. quantbt/reporting/portfolio_audit.py +206 -0
  121. quantbt/reporting/portfolio_nautilus.py +140 -0
  122. quantbt/sizing/__init__.py +3 -0
  123. quantbt/sizing/fast.py +67 -0
  124. quantbt/sizing/modes.py +162 -0
  125. quantbt/viz/__init__.py +4 -0
  126. quantbt/viz/plots.py +310 -0
  127. quantbt/viz/themes.py +102 -0
  128. quantbt/walkforward.py +3136 -0
  129. quantbt_engine-1.0.7.dist-info/METADATA +891 -0
  130. quantbt_engine-1.0.7.dist-info/RECORD +133 -0
  131. quantbt_engine-1.0.7.dist-info/WHEEL +5 -0
  132. quantbt_engine-1.0.7.dist-info/licenses/LICENSE +21 -0
  133. 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