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,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)
|