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,94 @@
1
+ """Domain-agnostic optimization API for QuantBT."""
2
+
3
+ from .callbacks import JsonlOptimizationLogger, SingleObjectiveEarlyStopping
4
+ from .candidate_selection import CandidateSelector, RobustSelectionConfig, SelectedCandidate, constraints_feasible
5
+ from .config import OptimizationConfig, SamplerConfig
6
+ from .constraints import CONSTRAINTS_USER_ATTR, constraints_from_trial, set_trial_constraints
7
+ from .evaluator import TrialEvaluator
8
+ from .evaluators import (
9
+ ArbitrageGenericEvaluator,
10
+ ArbitrageTrialOutput,
11
+ GenericEndpointEvaluator,
12
+ GridDCAGenericEvaluator,
13
+ GridDCATrialOutput,
14
+ OptionPackageGenericEvaluator,
15
+ OptionTrialOutput,
16
+ PreparedIntrabarEvaluator,
17
+ PreparedNativeEventStrategyEvaluator,
18
+ PreparedPortfolioEvaluator,
19
+ PreparedSignalEvaluator,
20
+ )
21
+ from .objectives import (
22
+ MissingOptimizationMetricError,
23
+ ReportMetricObjective,
24
+ SharpeObjective,
25
+ max_drawdown_constraint,
26
+ max_margin_utilization_constraint,
27
+ max_rejection_rate_constraint,
28
+ max_turnover_constraint,
29
+ metric_from_result,
30
+ metrics_from_result,
31
+ min_trades_constraint,
32
+ result_full_report,
33
+ )
34
+ from .optimizer import OptunaOptimizer
35
+ from .multiseed import MultiSeedOptimization
36
+ from .result import ObjectiveResult, OptimizationResult, OptimizationTrialRecord
37
+ from .samplers import build_sampler
38
+ from .space import (
39
+ SearchSpaceInfo,
40
+ build_grid_search_space,
41
+ search_space_info,
42
+ stable_params_key,
43
+ suggest_parameter,
44
+ suggest_params,
45
+ )
46
+
47
+ __all__ = [
48
+ "CONSTRAINTS_USER_ATTR",
49
+ "ArbitrageGenericEvaluator",
50
+ "ArbitrageTrialOutput",
51
+ "CandidateSelector",
52
+ "GenericEndpointEvaluator",
53
+ "GridDCAGenericEvaluator",
54
+ "GridDCATrialOutput",
55
+ "JsonlOptimizationLogger",
56
+ "MissingOptimizationMetricError",
57
+ "MultiSeedOptimization",
58
+ "ObjectiveResult",
59
+ "OptionPackageGenericEvaluator",
60
+ "OptionTrialOutput",
61
+ "OptimizationConfig",
62
+ "OptimizationResult",
63
+ "OptimizationTrialRecord",
64
+ "OptunaOptimizer",
65
+ "PreparedIntrabarEvaluator",
66
+ "PreparedNativeEventStrategyEvaluator",
67
+ "PreparedPortfolioEvaluator",
68
+ "PreparedSignalEvaluator",
69
+ "ReportMetricObjective",
70
+ "RobustSelectionConfig",
71
+ "SamplerConfig",
72
+ "SearchSpaceInfo",
73
+ "SelectedCandidate",
74
+ "SharpeObjective",
75
+ "SingleObjectiveEarlyStopping",
76
+ "TrialEvaluator",
77
+ "build_grid_search_space",
78
+ "build_sampler",
79
+ "constraints_feasible",
80
+ "constraints_from_trial",
81
+ "max_drawdown_constraint",
82
+ "max_margin_utilization_constraint",
83
+ "max_rejection_rate_constraint",
84
+ "max_turnover_constraint",
85
+ "metric_from_result",
86
+ "metrics_from_result",
87
+ "min_trades_constraint",
88
+ "search_space_info",
89
+ "set_trial_constraints",
90
+ "stable_params_key",
91
+ "suggest_parameter",
92
+ "suggest_params",
93
+ "result_full_report",
94
+ ]
@@ -0,0 +1,116 @@
1
+ """Callbacks shared by QuantBT optimization workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import time
8
+ from typing import Optional
9
+
10
+
11
+ class SingleObjectiveEarlyStopping:
12
+ """Stop a single-objective Optuna study after best-value stagnation."""
13
+
14
+ def __init__(self, patience: int, direction: str, min_delta: float = 1e-4, min_trials: int = 0):
15
+ if patience <= 0:
16
+ raise ValueError("patience must be positive")
17
+ direction = str(direction).lower().strip()
18
+ if direction not in {"maximize", "minimize"}:
19
+ raise ValueError("direction must be maximize or minimize")
20
+ if min_delta < 0.0:
21
+ raise ValueError("min_delta must be >= 0")
22
+ if min_trials < 0:
23
+ raise ValueError("min_trials must be >= 0")
24
+ self.patience = int(patience)
25
+ self.direction = direction
26
+ self.min_delta = float(min_delta)
27
+ self.min_trials = int(min_trials)
28
+ self._best: Optional[float] = None
29
+ self._stale = 0
30
+ self._completed = 0
31
+
32
+ def __call__(self, study, trial) -> None:
33
+ try:
34
+ import optuna
35
+ except Exception: # pragma: no cover - optuna import guard
36
+ optuna = None
37
+ if optuna is not None and trial.state is not optuna.trial.TrialState.COMPLETE:
38
+ return
39
+ try:
40
+ current = float(study.best_value)
41
+ except Exception:
42
+ return
43
+ self._completed += 1
44
+ if self._is_improved(current):
45
+ self._best = current
46
+ self._stale = 0
47
+ else:
48
+ self._stale += 1
49
+ if self._completed >= self.min_trials and self._stale >= self.patience:
50
+ study.stop()
51
+
52
+ def _is_improved(self, current: float) -> bool:
53
+ if self._best is None:
54
+ return True
55
+ if self.direction == "maximize":
56
+ return current > self._best + self.min_delta
57
+ return current < self._best - self.min_delta
58
+
59
+
60
+ class JsonlOptimizationLogger:
61
+ """Append parseable JSONL trial records.
62
+
63
+ Single-objective studies log when the best trial changes. Multi-objective
64
+ studies log every completed trial because there is no scalar best value.
65
+ """
66
+
67
+ def __init__(self, path, *, objective_count: int):
68
+ self.path = Path(path)
69
+ self.objective_count = int(objective_count)
70
+ self._previous_best_number: Optional[int] = None
71
+ self.path.parent.mkdir(parents=True, exist_ok=True)
72
+
73
+ def __call__(self, study, frozen_trial) -> None:
74
+ try:
75
+ import optuna
76
+ except Exception: # pragma: no cover - optuna import guard
77
+ optuna = None
78
+ if optuna is not None and frozen_trial.state is not optuna.trial.TrialState.COMPLETE:
79
+ return
80
+ if self.objective_count == 1:
81
+ try:
82
+ best_number = int(study.best_trial.number)
83
+ except Exception:
84
+ return
85
+ if best_number == self._previous_best_number:
86
+ return
87
+ self._previous_best_number = best_number
88
+ row = {
89
+ "trial": int(frozen_trial.number),
90
+ "state": str(frozen_trial.state.name),
91
+ "values": _trial_values(frozen_trial),
92
+ "params": dict(frozen_trial.user_attrs.get("quantbt_full_params", frozen_trial.params)),
93
+ "metrics": dict(frozen_trial.user_attrs.get("quantbt_metrics", {})),
94
+ "constraints": list(frozen_trial.user_attrs.get("quantbt_constraints", ())),
95
+ "metadata": dict(frozen_trial.user_attrs.get("quantbt_metadata", {})),
96
+ "duration_seconds": _duration_seconds(frozen_trial),
97
+ "logged_at_unix": time.time(),
98
+ }
99
+ with self.path.open("a", encoding="utf-8") as fh:
100
+ fh.write(json.dumps(row, sort_keys=True, default=str) + "\n")
101
+
102
+
103
+ def _trial_values(frozen_trial) -> list[float]:
104
+ if getattr(frozen_trial, "values", None) is not None:
105
+ return [float(value) for value in frozen_trial.values]
106
+ if getattr(frozen_trial, "value", None) is not None:
107
+ return [float(frozen_trial.value)]
108
+ return []
109
+
110
+
111
+ def _duration_seconds(frozen_trial) -> Optional[float]:
112
+ start = getattr(frozen_trial, "datetime_start", None)
113
+ complete = getattr(frozen_trial, "datetime_complete", None)
114
+ if start is None or complete is None:
115
+ return None
116
+ return float((complete - start).total_seconds())
@@ -0,0 +1,397 @@
1
+ """Candidate selection helpers for optimization results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass, field
7
+ from statistics import median
8
+ from typing import Any, Iterable, Optional
9
+
10
+ from .result import OptimizationResult, OptimizationTrialRecord
11
+
12
+
13
+ def constraints_feasible(constraints: tuple[float, ...]) -> bool:
14
+ """Return True when all Optuna formal constraints are feasible."""
15
+
16
+ return all(float(value) <= 0.0 for value in constraints)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class SelectedCandidate:
21
+ """Selected production candidate after feasibility/robustness filtering."""
22
+
23
+ params: dict[str, Any]
24
+ values: tuple[float, ...] = ()
25
+ metrics: dict[str, float] = field(default_factory=dict)
26
+ constraints: tuple[float, ...] = ()
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class RobustSelectionConfig:
32
+ """Configuration for plateau-based production candidate selection.
33
+
34
+ The selector is deliberately post-optimization: the sampler still learns
35
+ from the raw objective surface, while the final production params are
36
+ selected from a stable feasible neighborhood instead of a single spike.
37
+ """
38
+
39
+ top_quantile: float = 0.10
40
+ min_trades: Optional[float] = None
41
+ max_drawdown_pct: Optional[float] = None
42
+ neighborhood_radius: float = 0.10
43
+ min_neighbor_count: int = 3
44
+ seed_consensus: int = 1
45
+ instability_penalty: float = 0.25
46
+ worst_weight: float = 0.25
47
+ drawdown_penalty: float = 0.0
48
+ size_bonus: float = 0.01
49
+ ignore_params: tuple[str, ...] = ()
50
+
51
+ def __post_init__(self) -> None:
52
+ if not (0.0 < float(self.top_quantile) <= 1.0):
53
+ raise ValueError("top_quantile must be in (0, 1]")
54
+ if float(self.neighborhood_radius) < 0.0:
55
+ raise ValueError("neighborhood_radius must be non-negative")
56
+ if int(self.min_neighbor_count) <= 0:
57
+ raise ValueError("min_neighbor_count must be positive")
58
+ if int(self.seed_consensus) <= 0:
59
+ raise ValueError("seed_consensus must be positive")
60
+ object.__setattr__(self, "ignore_params", tuple(str(name) for name in self.ignore_params))
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class CandidateSelector:
65
+ """Small public selector interface.
66
+
67
+ This is intentionally conservative. Robust WFO plateau selectors can plug
68
+ into this interface later; Phase 32B provides best/feasible/Pareto policies
69
+ so Optuna's best trial is not silently treated as production params.
70
+ """
71
+
72
+ mode: str = "feasible_best"
73
+ objective_index: int = 0
74
+ config: Optional[RobustSelectionConfig] = None
75
+
76
+ def select(self, result: OptimizationResult) -> SelectedCandidate:
77
+ mode = str(self.mode).lower().strip()
78
+ if mode in {"best", "single_best"}:
79
+ return self._single_best(result, require_feasible=False)
80
+ if mode in {"feasible_best", "best_feasible"}:
81
+ return self._single_best(result, require_feasible=True)
82
+ if mode in {"pareto_first", "first_pareto"}:
83
+ return self._pareto_first(result)
84
+ if mode in {"robust_plateau", "plateau_robust"}:
85
+ return self._robust_plateau(result)
86
+ raise ValueError(f"unsupported candidate selector mode={self.mode!r}")
87
+
88
+ def _single_best(self, result: OptimizationResult, *, require_feasible: bool) -> SelectedCandidate:
89
+ direction = _direction(result, int(self.objective_index))
90
+ completed = [record for record in result.trials if record.state == "COMPLETE" and len(record.values) > int(self.objective_index)]
91
+ if require_feasible:
92
+ completed = [record for record in completed if constraints_feasible(record.constraints)]
93
+ if not completed:
94
+ raise ValueError("no completed feasible optimization trials")
95
+ reverse = direction == "maximize"
96
+ best = sorted(completed, key=lambda record: record.values[int(self.objective_index)], reverse=reverse)[0]
97
+ return _selected_from_record(
98
+ best,
99
+ metadata={
100
+ "selector": self.mode,
101
+ "objective_index": int(self.objective_index),
102
+ "feasibility_filter": bool(require_feasible),
103
+ },
104
+ )
105
+
106
+ def _pareto_first(self, result: OptimizationResult) -> SelectedCandidate:
107
+ pareto = [trial for trial in result.pareto_trials if constraints_feasible(tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())))]
108
+ if not pareto:
109
+ raise ValueError("optimization result has no Pareto trials")
110
+ trial = pareto[0]
111
+ params = dict(trial.user_attrs.get("quantbt_full_params", trial.params))
112
+ return SelectedCandidate(
113
+ params=params,
114
+ values=tuple(float(value) for value in (trial.values or ())),
115
+ metrics=dict(trial.user_attrs.get("quantbt_metrics", {})),
116
+ constraints=tuple(float(value) for value in trial.user_attrs.get("quantbt_constraints", ())),
117
+ metadata={
118
+ "selector": self.mode,
119
+ "trial_number": int(trial.number),
120
+ "pareto_count": int(len(result.pareto_trials)),
121
+ "feasible_pareto_count": int(len(pareto)),
122
+ },
123
+ )
124
+
125
+ def _robust_plateau(self, result: OptimizationResult) -> SelectedCandidate:
126
+ config = self.config or RobustSelectionConfig()
127
+ objective_index = int(self.objective_index)
128
+ direction = _direction(result, objective_index)
129
+ feasible = [
130
+ record
131
+ for record in result.trials
132
+ if _record_feasible_for_robust(record, objective_index=objective_index, config=config)
133
+ ]
134
+ if not feasible:
135
+ raise ValueError("no completed feasible optimization trials for robust plateau selection")
136
+
137
+ ranked = sorted(
138
+ feasible,
139
+ key=lambda record: _signed_objective(record, objective_index, direction),
140
+ reverse=True,
141
+ )
142
+ top_n = max(
143
+ 1,
144
+ int(math.ceil(len(ranked) * float(config.top_quantile))),
145
+ min(int(config.min_neighbor_count), len(ranked)),
146
+ )
147
+ top_n = min(top_n, len(ranked))
148
+ top = ranked[:top_n]
149
+ param_names = _param_names(feasible, ignore=config.ignore_params)
150
+ fallback_reasons: list[str] = []
151
+ scored: list[dict[str, Any]] = []
152
+ for record in top:
153
+ neighbors = [
154
+ neighbor
155
+ for neighbor in top
156
+ if _param_distance(record.params, neighbor.params, feasible, param_names) <= float(config.neighborhood_radius)
157
+ ]
158
+ if not neighbors:
159
+ neighbors = [record]
160
+ seed_count = _seed_consensus_count(neighbors)
161
+ meets_count = len(neighbors) >= int(config.min_neighbor_count)
162
+ meets_seed = seed_count >= int(config.seed_consensus)
163
+ if meets_count and meets_seed:
164
+ scored.append(_score_neighborhood(record, neighbors, objective_index, direction, config, feasible, param_names))
165
+
166
+ if not scored:
167
+ fallback_reasons.append("no_candidate_met_neighbor_or_seed_consensus")
168
+ for record in top:
169
+ neighbors = [
170
+ neighbor
171
+ for neighbor in top
172
+ if _param_distance(record.params, neighbor.params, feasible, param_names) <= float(config.neighborhood_radius)
173
+ ] or [record]
174
+ scored.append(_score_neighborhood(record, neighbors, objective_index, direction, config, feasible, param_names))
175
+
176
+ best_cluster = sorted(scored, key=lambda row: row["plateau_score"], reverse=True)[0]
177
+ selected_record = _medoid_record(best_cluster["neighbors"], feasible, param_names, objective_index, direction)
178
+ result.robust_candidates = [
179
+ {
180
+ "trial_number": int(row["center"].number),
181
+ "plateau_score": float(row["plateau_score"]),
182
+ "neighbor_count": int(len(row["neighbors"])),
183
+ "seed_consensus_count": int(row["seed_consensus_count"]),
184
+ "median_objective": float(row["median_objective"]),
185
+ "worst_objective": float(row["worst_objective"]),
186
+ "objective_std": float(row["objective_std"]),
187
+ "params": dict(row["center"].params),
188
+ }
189
+ for row in sorted(scored, key=lambda item: item["plateau_score"], reverse=True)
190
+ ]
191
+ metadata = {
192
+ "selector": self.mode,
193
+ "selected_by": "robust_plateau",
194
+ "objective_index": objective_index,
195
+ "top_quantile": float(config.top_quantile),
196
+ "top_trials": int(top_n),
197
+ "feasible_trials": int(len(feasible)),
198
+ "neighborhood_radius": float(config.neighborhood_radius),
199
+ "min_neighbor_count": int(config.min_neighbor_count),
200
+ "seed_consensus": int(config.seed_consensus),
201
+ "seed_consensus_count": int(best_cluster["seed_consensus_count"]),
202
+ "neighbor_count": int(len(best_cluster["neighbors"])),
203
+ "plateau_score": float(best_cluster["plateau_score"]),
204
+ "median_objective": float(best_cluster["median_objective"]),
205
+ "worst_objective": float(best_cluster["worst_objective"]),
206
+ "objective_std": float(best_cluster["objective_std"]),
207
+ "cluster_center_trial": int(best_cluster["center"].number),
208
+ "medoid_trial_number": int(selected_record.number),
209
+ "fallback_reasons": fallback_reasons,
210
+ "param_names": param_names,
211
+ }
212
+ return _selected_from_record(selected_record, metadata=metadata)
213
+
214
+
215
+ def _selected_from_record(record: OptimizationTrialRecord, *, metadata: Optional[dict[str, Any]] = None) -> SelectedCandidate:
216
+ merged_metadata = dict(record.metadata)
217
+ merged_metadata.update(metadata or {})
218
+ merged_metadata["trial_number"] = int(record.number)
219
+ return SelectedCandidate(
220
+ params=dict(record.params),
221
+ values=tuple(record.values),
222
+ metrics=dict(record.metrics),
223
+ constraints=tuple(record.constraints),
224
+ metadata=merged_metadata,
225
+ )
226
+
227
+
228
+ def _direction(result: OptimizationResult, objective_index: int) -> str:
229
+ try:
230
+ directions = tuple(str(direction.name).lower() for direction in result.study.directions)
231
+ except Exception:
232
+ directions = ("maximize",)
233
+ if objective_index < 0 or objective_index >= len(directions):
234
+ raise ValueError("objective_index out of range for optimization directions")
235
+ return directions[objective_index]
236
+
237
+
238
+ def _record_feasible_for_robust(
239
+ record: OptimizationTrialRecord,
240
+ *,
241
+ objective_index: int,
242
+ config: RobustSelectionConfig,
243
+ ) -> bool:
244
+ if record.state != "COMPLETE" or len(record.values) <= int(objective_index):
245
+ return False
246
+ if not constraints_feasible(record.constraints):
247
+ return False
248
+ if config.min_trades is not None:
249
+ trades = _metric(record, ("num_trades", "trades", "trade_count"))
250
+ if trades is None or float(trades) < float(config.min_trades):
251
+ return False
252
+ if config.max_drawdown_pct is not None:
253
+ mdd = _metric(record, ("max_drawdown_pct", "mdd_pct", "max_dd_pct"))
254
+ if mdd is None or float(mdd) > float(config.max_drawdown_pct):
255
+ return False
256
+ return True
257
+
258
+
259
+ def _metric(record: OptimizationTrialRecord, names: Iterable[str]) -> Optional[float]:
260
+ for name in names:
261
+ if name in record.metrics:
262
+ return float(record.metrics[name])
263
+ return None
264
+
265
+
266
+ def _signed_objective(record: OptimizationTrialRecord, objective_index: int, direction: str) -> float:
267
+ value = float(record.values[int(objective_index)])
268
+ if direction == "minimize":
269
+ return -value
270
+ return value
271
+
272
+
273
+ def _param_names(records: Iterable[OptimizationTrialRecord], *, ignore: tuple[str, ...]) -> list[str]:
274
+ ignored = set(ignore)
275
+ names: set[str] = set()
276
+ for record in records:
277
+ names.update(str(name) for name in record.params if str(name) not in ignored)
278
+ return sorted(names)
279
+
280
+
281
+ def _param_distance(
282
+ left: dict[str, Any],
283
+ right: dict[str, Any],
284
+ records: Iterable[OptimizationTrialRecord],
285
+ param_names: list[str],
286
+ ) -> float:
287
+ if not param_names:
288
+ return 0.0
289
+ total = 0.0
290
+ for name in param_names:
291
+ lv = left.get(name)
292
+ rv = right.get(name)
293
+ if _is_numeric(lv) and _is_numeric(rv):
294
+ span = _numeric_span(records, name)
295
+ diff = 0.0 if span <= 0.0 else abs(float(lv) - float(rv)) / span
296
+ else:
297
+ diff = 0.0 if lv == rv else 1.0
298
+ total += diff * diff
299
+ return math.sqrt(total / len(param_names))
300
+
301
+
302
+ def _numeric_span(records: Iterable[OptimizationTrialRecord], name: str) -> float:
303
+ values = [float(record.params[name]) for record in records if name in record.params and _is_numeric(record.params[name])]
304
+ if not values:
305
+ return 0.0
306
+ return float(max(values) - min(values))
307
+
308
+
309
+ def _is_numeric(value: Any) -> bool:
310
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(float(value))
311
+
312
+
313
+ def _seed_labels(records: Iterable[OptimizationTrialRecord]) -> set[str]:
314
+ labels = set()
315
+ for record in records:
316
+ for key in ("quantbt_seed", "seed"):
317
+ if key in record.metadata:
318
+ labels.add(str(record.metadata[key]))
319
+ break
320
+ return labels
321
+
322
+
323
+ def _seed_consensus_count(records: Iterable[OptimizationTrialRecord]) -> int:
324
+ records = list(records)
325
+ labels = _seed_labels(records)
326
+ if labels:
327
+ return len(labels)
328
+ return 1 if records else 0
329
+
330
+
331
+ def _score_neighborhood(
332
+ center: OptimizationTrialRecord,
333
+ neighbors: list[OptimizationTrialRecord],
334
+ objective_index: int,
335
+ direction: str,
336
+ config: RobustSelectionConfig,
337
+ all_records: list[OptimizationTrialRecord],
338
+ param_names: list[str],
339
+ ) -> dict[str, Any]:
340
+ signed = [_signed_objective(record, objective_index, direction) for record in neighbors]
341
+ med = float(median(signed))
342
+ worst = float(min(signed))
343
+ std = _std(signed)
344
+ mdds = [_metric(record, ("max_drawdown_pct", "mdd_pct", "max_dd_pct")) for record in neighbors]
345
+ mdd_penalty = float(median([float(value) for value in mdds if value is not None])) if any(value is not None for value in mdds) else 0.0
346
+ score = (
347
+ med
348
+ + float(config.worst_weight) * worst
349
+ - float(config.instability_penalty) * std
350
+ - float(config.drawdown_penalty) * mdd_penalty
351
+ + float(config.size_bonus) * math.log1p(len(neighbors))
352
+ )
353
+ return {
354
+ "center": center,
355
+ "neighbors": neighbors,
356
+ "plateau_score": float(score),
357
+ "median_objective": med,
358
+ "worst_objective": worst,
359
+ "objective_std": std,
360
+ "seed_consensus_count": _seed_consensus_count(neighbors),
361
+ "mean_distance": _mean_distance(center, neighbors, all_records, param_names),
362
+ }
363
+
364
+
365
+ def _std(values: list[float]) -> float:
366
+ if len(values) <= 1:
367
+ return 0.0
368
+ mean = sum(values) / len(values)
369
+ return math.sqrt(sum((value - mean) ** 2 for value in values) / len(values))
370
+
371
+
372
+ def _mean_distance(
373
+ center: OptimizationTrialRecord,
374
+ neighbors: list[OptimizationTrialRecord],
375
+ records: list[OptimizationTrialRecord],
376
+ param_names: list[str],
377
+ ) -> float:
378
+ if not neighbors:
379
+ return 0.0
380
+ return sum(_param_distance(center.params, record.params, records, param_names) for record in neighbors) / len(neighbors)
381
+
382
+
383
+ def _medoid_record(
384
+ neighbors: list[OptimizationTrialRecord],
385
+ all_records: list[OptimizationTrialRecord],
386
+ param_names: list[str],
387
+ objective_index: int,
388
+ direction: str,
389
+ ) -> OptimizationTrialRecord:
390
+ return sorted(
391
+ neighbors,
392
+ key=lambda record: (
393
+ _mean_distance(record, neighbors, all_records, param_names),
394
+ -_signed_objective(record, objective_index, direction),
395
+ int(record.number),
396
+ ),
397
+ )[0]
@@ -0,0 +1,84 @@
1
+ """Configuration objects for QuantBT domain-agnostic optimization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Optional, Tuple, Union
8
+
9
+
10
+ Direction = str
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class OptimizationConfig:
15
+ """Runtime configuration for :class:`OptunaOptimizer`.
16
+
17
+ The config intentionally avoids strategy/domain fields. Domain-specific
18
+ data, endpoints, prepared runners, and metric extraction belong in
19
+ evaluator adapters.
20
+ """
21
+
22
+ study_name: str
23
+ n_trials: int = 300
24
+ directions: Tuple[Direction, ...] = ("maximize",)
25
+ seed: Optional[int] = 42
26
+ n_jobs: int = 1
27
+ early_stopping_rounds: Optional[int] = None
28
+ early_stopping_min_trials: int = 0
29
+ early_stopping_min_delta: float = 1e-4
30
+ show_progress_bar: bool = True
31
+ storage: Optional[str] = None
32
+ load_if_exists: bool = True
33
+ log_path: Optional[Union[str, Path]] = None
34
+ duplicate_policy: str = "prune"
35
+ exception_policy: str = "raise"
36
+
37
+ def __post_init__(self) -> None:
38
+ if not str(self.study_name).strip():
39
+ raise ValueError("study_name must be non-empty")
40
+ if self.n_trials <= 0:
41
+ raise ValueError("n_trials must be positive")
42
+ if not self.directions:
43
+ raise ValueError("at least one direction is required")
44
+ directions = tuple(str(direction).lower().strip() for direction in self.directions)
45
+ invalid = set(directions) - {"maximize", "minimize"}
46
+ if invalid:
47
+ raise ValueError(f"invalid directions: {invalid}")
48
+ object.__setattr__(self, "directions", directions)
49
+ if self.n_jobs <= 0:
50
+ raise ValueError("n_jobs must be positive")
51
+ if self.early_stopping_rounds is not None and self.early_stopping_rounds <= 0:
52
+ raise ValueError("early_stopping_rounds must be positive when provided")
53
+ if self.early_stopping_min_trials < 0:
54
+ raise ValueError("early_stopping_min_trials must be >= 0")
55
+ if self.early_stopping_min_delta < 0.0:
56
+ raise ValueError("early_stopping_min_delta must be >= 0")
57
+ duplicate_policy = str(self.duplicate_policy).lower().strip()
58
+ if duplicate_policy not in {"allow", "prune", "raise"}:
59
+ raise ValueError("duplicate_policy must be allow, prune, or raise")
60
+ object.__setattr__(self, "duplicate_policy", duplicate_policy)
61
+ exception_policy = str(self.exception_policy).lower().strip()
62
+ if exception_policy not in {"raise", "fail_trial", "prune"}:
63
+ raise ValueError("exception_policy must be raise, fail_trial, or prune")
64
+ object.__setattr__(self, "exception_policy", exception_policy)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SamplerConfig:
69
+ """Optuna sampler selection and sampler-specific kwargs."""
70
+
71
+ name: str = "tpe"
72
+ kwargs: dict[str, Any] = field(default_factory=dict)
73
+ constraint_mode: str = "sampler"
74
+
75
+ def __post_init__(self) -> None:
76
+ name = str(self.name).lower().strip()
77
+ if not name:
78
+ raise ValueError("sampler name must be non-empty")
79
+ object.__setattr__(self, "name", name)
80
+ object.__setattr__(self, "kwargs", dict(self.kwargs or {}))
81
+ constraint_mode = str(self.constraint_mode).lower().strip()
82
+ if constraint_mode not in {"sampler", "post_filter"}:
83
+ raise ValueError("constraint_mode must be sampler or post_filter")
84
+ object.__setattr__(self, "constraint_mode", constraint_mode)