quantred 2.4.0__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 (117) hide show
  1. quantred/__init__.py +24 -0
  2. quantred/alphas/__init__.py +53 -0
  3. quantred/alphas/base.py +221 -0
  4. quantred/alphas/combine.py +260 -0
  5. quantred/alphas/horizon.py +153 -0
  6. quantred/alphas/refine.py +300 -0
  7. quantred/alphas/scorers.py +87 -0
  8. quantred/analytics/attribution.py +354 -0
  9. quantred/analytics/bootstrap.py +353 -0
  10. quantred/analytics/charts.py +393 -0
  11. quantred/analytics/excursion.py +185 -0
  12. quantred/analytics/execution_quality.py +180 -0
  13. quantred/analytics/exposure.py +167 -0
  14. quantred/analytics/htmlreport.py +1208 -0
  15. quantred/analytics/information.py +197 -0
  16. quantred/analytics/metrics.py +502 -0
  17. quantred/analytics/performance.py +639 -0
  18. quantred/analytics/reporting.py +1672 -0
  19. quantred/analytics/screening.py +190 -0
  20. quantred/analytics/series_comparison.py +303 -0
  21. quantred/analytics/trade_analytics.py +353 -0
  22. quantred/brokers/alpaca/broker.py +415 -0
  23. quantred/brokers/alpaca/factory.py +33 -0
  24. quantred/brokers/alpaca/market_data.py +166 -0
  25. quantred/brokers/base.py +220 -0
  26. quantred/brokers/dryrun.py +164 -0
  27. quantred/brokers/errors.py +70 -0
  28. quantred/cli.py +8619 -0
  29. quantred/costs/__init__.py +11 -0
  30. quantred/costs/base.py +126 -0
  31. quantred/costs/parametric.py +131 -0
  32. quantred/data/__init__.py +29 -0
  33. quantred/data/compute.py +332 -0
  34. quantred/data/edges.py +81 -0
  35. quantred/data/features.py +120 -0
  36. quantred/data/panel.py +61 -0
  37. quantred/data/scan.py +85 -0
  38. quantred/data/store.py +269 -0
  39. quantred/demo/__init__.py +22 -0
  40. quantred/demo/scanners.py +104 -0
  41. quantred/demo/strategies.py +125 -0
  42. quantred/engine/backtest.py +1206 -0
  43. quantred/engine/barcheck.py +228 -0
  44. quantred/engine/live.py +539 -0
  45. quantred/execution/decision.py +192 -0
  46. quantred/execution/flatten.py +302 -0
  47. quantred/execution/halt.py +165 -0
  48. quantred/execution/ledger.py +758 -0
  49. quantred/execution/live_trader.py +587 -0
  50. quantred/execution/order_id.py +83 -0
  51. quantred/execution/sizing.py +114 -0
  52. quantred/indicators/indicators.py +158 -0
  53. quantred/marketdata/base.py +62 -0
  54. quantred/marketdata/client.py +59 -0
  55. quantred/marketdata/session.py +112 -0
  56. quantred/marketdata/synthetic.py +74 -0
  57. quantred/marketdata/timeframe.py +125 -0
  58. quantred/mcp/__init__.py +6 -0
  59. quantred/mcp/selftest.py +139 -0
  60. quantred/mcp/server.py +2419 -0
  61. quantred/optimization/causality.py +607 -0
  62. quantred/optimization/config_store.py +300 -0
  63. quantred/optimization/optimizer.py +431 -0
  64. quantred/optimization/parallel.py +345 -0
  65. quantred/optimization/param_space.py +309 -0
  66. quantred/optimization/walk_forward.py +1254 -0
  67. quantred/portfolio/allocator.py +135 -0
  68. quantred/portfolio/benchmark.py +109 -0
  69. quantred/portfolio/optimizer.py +786 -0
  70. quantred/portfolio/policy.py +335 -0
  71. quantred/portfolio/posterior.py +208 -0
  72. quantred/research/__init__.py +30 -0
  73. quantred/research/agent.py +868 -0
  74. quantred/research/demo_proposals.py +222 -0
  75. quantred/research/llm.py +311 -0
  76. quantred/research/proposer.py +234 -0
  77. quantred/research/provider_errors.py +242 -0
  78. quantred/research/sandbox.py +340 -0
  79. quantred/risk/__init__.py +60 -0
  80. quantred/risk/base.py +218 -0
  81. quantred/risk/conditional.py +474 -0
  82. quantred/risk/exposures.py +88 -0
  83. quantred/risk/factor.py +119 -0
  84. quantred/risk/sample.py +89 -0
  85. quantred/risk/streaming.py +214 -0
  86. quantred/scanners/base.py +141 -0
  87. quantred/scanners/symbol_scanner.py +148 -0
  88. quantred/services/__init__.py +51 -0
  89. quantred/services/analysis.py +6082 -0
  90. quantred/services/audit.py +574 -0
  91. quantred/services/campaign.py +381 -0
  92. quantred/services/configs.py +282 -0
  93. quantred/services/data.py +172 -0
  94. quantred/services/dryrun.py +206 -0
  95. quantred/services/glossary.py +130 -0
  96. quantred/services/maintenance.py +314 -0
  97. quantred/services/registry.py +351 -0
  98. quantred/services/setup.py +1023 -0
  99. quantred/services/sizing.py +150 -0
  100. quantred/services/smallreal.py +450 -0
  101. quantred/settings.py +571 -0
  102. quantred/store/__init__.py +0 -0
  103. quantred/store/bars.py +660 -0
  104. quantred/store/trials.py +1804 -0
  105. quantred/strategies/base.py +643 -0
  106. quantred/strategies/signals.py +28 -0
  107. quantred/utils/errors.py +48 -0
  108. quantred/utils/extras.py +330 -0
  109. quantred/utils/logging_config.py +33 -0
  110. quantred/utils/numeric.py +46 -0
  111. quantred/utils/streaming.py +474 -0
  112. quantred/utils/timeutils.py +148 -0
  113. quantred-2.4.0.dist-info/METADATA +632 -0
  114. quantred-2.4.0.dist-info/RECORD +117 -0
  115. quantred-2.4.0.dist-info/WHEEL +4 -0
  116. quantred-2.4.0.dist-info/entry_points.txt +8 -0
  117. quantred-2.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,431 @@
1
+ """Parameter optimization for strategies - excellent at finding the config that
2
+ would have made you rich last year. Whether it works *next* year is what
3
+ walk-forward validation is for.
4
+
5
+ Tunes a strategy's parameters by backtesting candidate configs and ranking them
6
+ by an objective metric. Three search methods, increasing in sophistication:
7
+
8
+ * :meth:`grid_search` - exhaustive (or capped) sweep of the step grid
9
+ * :meth:`random_search` - random step-aligned sampling
10
+ * :meth:`optimize_bayesian` - trains a Gaussian-Process *surrogate model* of the
11
+ objective and proposes promising configs (the "train a model to align params"
12
+ approach). Requires scikit-learn (optional ``optimize`` extra).
13
+
14
+ Runs serially for determinism and simplicity; each evaluation is an independent
15
+ :class:`BacktestEngine` run, so this is trivially parallelizable later.
16
+ """
17
+
18
+ import logging
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from typing import Any, Dict, List, Optional, Type
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from quantred.costs.base import CostModel
27
+ from quantred.engine.backtest import BacktestEngine
28
+ from quantred.marketdata.client import MarketDataClient
29
+ from quantred.optimization.param_space import ParameterSpace
30
+ from quantred.strategies.base import Strategy, build_with_limits
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ # Stand-in for +inf objective values (e.g. profit_factor with no losses) so
35
+ # ranking and the surrogate model stay numerically well-behaved.
36
+ _INF_SENTINEL = 1e6
37
+
38
+
39
+ def _plain(value):
40
+ """A DataFrame cell as a plain Python scalar.
41
+
42
+ A row read off a DataFrame carries numpy scalars, and under NumPy 2 those repr as
43
+ ``np.int64(50)`` - which is what the CLI printed back as the chosen config, and
44
+ what every other consumer then had to re-normalize. Converting once here keeps a
45
+ search result an ordinary dict.
46
+ """
47
+ return value.item() if hasattr(value, "item") else value
48
+
49
+
50
+ @dataclass
51
+ class OptimizationResult:
52
+ """Outcome of a parameter search."""
53
+
54
+ best_params: Dict[str, Any]
55
+ best_score: float
56
+ objective: str
57
+ results: pd.DataFrame # one row per evaluated config (searched params + metrics)
58
+
59
+
60
+ class ParameterOptimizer:
61
+ """Searches a strategy's parameter space for the best backtest objective."""
62
+
63
+ def __init__(
64
+ self,
65
+ strategy_class: Type[Strategy],
66
+ data_client: MarketDataClient,
67
+ initial_capital: float = 100_000.0,
68
+ seed: int = 42,
69
+ cost_model: Optional[CostModel] = None,
70
+ *,
71
+ trial_store: Optional[Any] = None,
72
+ strategy_name: Optional[str] = None,
73
+ cost_key: Optional[Dict[str, Any]] = None,
74
+ accounting: Optional[int] = None,
75
+ force: bool = False,
76
+ workers: Optional[int] = None,
77
+ data_spec: Optional[Any] = None,
78
+ space: Optional[ParameterSpace] = None,
79
+ position_limits: Optional[Dict[str, Any]] = None,
80
+ ):
81
+ self.strategy_class = strategy_class
82
+ self.data_client = data_client
83
+ self.initial_capital = initial_capital
84
+ #: Charged on every simulated fill. ``None`` searches gross returns, which
85
+ #: reliably favors the highest-turnover config in the space.
86
+ self.cost_model = cost_model
87
+ #: Overridable so a caller can search a *narrowed* space - the same strategy,
88
+ #: fewer values per axis - without a second sampler. Defaults to everything the
89
+ #: class declares, constraints included.
90
+ self.space = space if space is not None else ParameterSpace.for_class(strategy_class)
91
+ #: The book each candidate is evaluated against. `position_limits` is not a
92
+ #: tunable param, so building a candidate from params alone drops it and
93
+ #: evaluates a different strategy from the one the config describes.
94
+ self.position_limits = position_limits
95
+ self._rng = np.random.default_rng(seed)
96
+ #: Optional per-candidate memoization: a
97
+ #: search that re-evaluates a config already scored this campaign - a
98
+ #: real possibility with random sampling, or a resumed/extended search -
99
+ #: is served from the trial store instead of re-simulated. All-or-nothing:
100
+ #: `trial_store` is `None` unless the caller opts in (CLI/MCP wiring), so
101
+ #: every other caller (tests, ad hoc scripts) is unaffected.
102
+ self.trial_store = trial_store
103
+ self.strategy_name = strategy_name
104
+ self.cost_key = cost_key or {}
105
+ self.accounting = accounting
106
+ self.force = force
107
+ #: Candidate evaluation is embarrassingly parallel, but only *execution*
108
+ #: parallelizes: memoization is still checked here, in the parent, before
109
+ #: anything is dispatched, and nothing a worker returns is written by the
110
+ #: worker. ``workers <= 1`` runs the original sequential path untouched.
111
+ from quantred.optimization.parallel import resolve_workers
112
+
113
+ self.workers = resolve_workers(workers)
114
+ #: How a worker builds its own data client (a live one cannot be pickled to
115
+ #: a spawned process). Required for parallel execution; without it, a
116
+ #: parallel request degrades to sequential rather than guessing.
117
+ self.data_spec = data_spec
118
+ self.seed = seed
119
+
120
+ # ------------------------------------------------------------------ #
121
+ # Search methods
122
+ # ------------------------------------------------------------------ #
123
+ def grid_search(
124
+ self, symbols, start, end, objective="sharpe_ratio", max_evals=None
125
+ ) -> OptimizationResult:
126
+ total = self.space.grid_size()
127
+ # The full grid is combinatorial: for many-parameter spaces it can be
128
+ # billions of points, so never materialize it when we're going to cap.
129
+ # When the grid exceeds the budget, randomly sample the grid instead.
130
+ if max_evals and total > max_evals:
131
+ logger.info("Grid has %d configs; randomly sampling %d", total, max_evals)
132
+ combos = self.space.random_samples(max_evals, self._rng)
133
+ else:
134
+ combos = self.space.grid()
135
+ return self._evaluate_all(combos, symbols, start, end, objective)
136
+
137
+ def random_search(
138
+ self, symbols, start, end, objective="sharpe_ratio", n_samples=50
139
+ ) -> OptimizationResult:
140
+ combos = self.space.random_samples(n_samples, self._rng)
141
+ return self._evaluate_all(combos, symbols, start, end, objective)
142
+
143
+ def optimize_bayesian(
144
+ self,
145
+ symbols,
146
+ start,
147
+ end,
148
+ objective="sharpe_ratio",
149
+ n_initial=8,
150
+ n_iterations=20,
151
+ exploration=0.1,
152
+ n_candidates=512,
153
+ ) -> OptimizationResult:
154
+ """Bayesian optimization with a Gaussian-Process surrogate (UCB acquisition)."""
155
+ gp = self._make_gp() # raises a friendly error if scikit-learn is missing
156
+
157
+ rows: List[Dict[str, Any]] = []
158
+ x_obs: List[np.ndarray] = []
159
+ y_obs: List[float] = []
160
+
161
+ for params in self.space.random_samples(n_initial, self._rng):
162
+ score, row = self._score_config(params, symbols, start, end, objective)
163
+ if row is not None:
164
+ rows.append(row)
165
+ x_obs.append(self.space.to_unit_vector(params))
166
+ y_obs.append(score)
167
+
168
+ if not x_obs:
169
+ logger.warning("No successful initial evaluations; aborting Bayesian search")
170
+ return self._build_result(rows, objective)
171
+
172
+ # The surrogate proposes one point at a time by design, so parallelism comes
173
+ # from asking it for a *batch* per round: propose `workers` points, evaluate
174
+ # them together, refit, repeat. Same evaluation budget, fewer rounds.
175
+ batch = self.workers if self._parallel_available() else 1
176
+ remaining = n_iterations
177
+ round_index = 0
178
+ while remaining > 0:
179
+ gp.fit(np.vstack(x_obs), self._standardize(y_obs))
180
+ picks = self._propose_batch(gp, min(batch, remaining), exploration, n_candidates)
181
+ round_index += 1
182
+
183
+ if len(picks) == 1:
184
+ params = self.space.from_unit_vector(picks[0])
185
+ score, row = self._score_config(params, symbols, start, end, objective)
186
+ logger.info("Bayesian round %d -> %s=%.4f", round_index, objective, score)
187
+ if row is not None:
188
+ rows.append(row)
189
+ x_obs.append(picks[0])
190
+ y_obs.append(score)
191
+ else:
192
+ combos = [self.space.from_unit_vector(pick) for pick in picks]
193
+ batch_rows = self._evaluate_parallel(combos, symbols, start, end, objective)
194
+ logger.info("Bayesian round %d evaluated %d candidates", round_index, len(batch_rows))
195
+ for pick, row in zip(picks, batch_rows):
196
+ rows.append(row)
197
+ x_obs.append(pick)
198
+ y_obs.append(self._finite(row.get(objective, float("-inf"))))
199
+ remaining -= len(picks)
200
+
201
+ return self._build_result(rows, objective)
202
+
203
+ def _propose_batch(self, gp, size: int, exploration: float, n_candidates: int) -> List[np.ndarray]:
204
+ """Propose ``size`` distinct points from one surrogate fit.
205
+
206
+ The acquisition function has no memory of what it just proposed, so asking it
207
+ `size` times returns the same point `size` times. This applies the standard
208
+ constant-liar trick: after picking a point, penalize its neighborhood so the
209
+ next pick explores elsewhere, exactly as a sequential run would once that
210
+ point's (unknown) result came back.
211
+ """
212
+ candidates = self._rng.random((n_candidates, self.space.dimensions))
213
+ mean, std = gp.predict(candidates, return_std=True)
214
+ acquisition = mean + exploration * std
215
+ picks: List[np.ndarray] = []
216
+ for _ in range(size):
217
+ index = int(np.argmax(acquisition))
218
+ picks.append(candidates[index])
219
+ # The lie: assume this point comes back unremarkable, and suppress the
220
+ # region around it so the batch spreads rather than clustering.
221
+ distance = np.linalg.norm(candidates - candidates[index], axis=1)
222
+ acquisition = acquisition - np.exp(-((distance / 0.1) ** 2)) * (acquisition.max() + 1.0)
223
+ return picks
224
+
225
+ # ------------------------------------------------------------------ #
226
+ # Evaluation helpers
227
+ # ------------------------------------------------------------------ #
228
+ def _evaluate_all(self, combos, symbols, start, end, objective) -> OptimizationResult:
229
+ logger.info("Evaluating %d parameter configs on %d symbols", len(combos), len(symbols))
230
+ if self._parallel_available():
231
+ return self._build_result(
232
+ self._evaluate_parallel(combos, symbols, start, end, objective), objective
233
+ )
234
+ rows: List[Dict[str, Any]] = []
235
+ for i, params in enumerate(combos, 1):
236
+ _, row = self._score_config(params, symbols, start, end, objective)
237
+ if row is not None:
238
+ rows.append(row)
239
+ if i % 10 == 0 or i == len(combos):
240
+ logger.info(" ... %d/%d evaluated", i, len(combos))
241
+ return self._build_result(rows, objective)
242
+
243
+ def _parallel_available(self) -> bool:
244
+ """Whether this search can actually run in parallel.
245
+
246
+ A worker builds its own data client from a picklable recipe, so without one
247
+ there is nothing to build. Falling back to sequential is the right failure:
248
+ the answer is identical, only slower, and silently guessing at how to
249
+ reconstruct someone's data client is how a parallel run ends up reading
250
+ different bars than the sequential one did.
251
+ """
252
+ return self.workers > 1 and self.data_spec is not None
253
+
254
+ def _evaluate_parallel(self, combos, symbols, start, end, objective) -> List[Dict[str, Any]]:
255
+ """Dispatch candidates to workers; keep every write here in the parent.
256
+
257
+ Memoization is resolved *before* dispatch, so a candidate already scored this
258
+ campaign costs no compute and no worker slot. Workers only ever run the
259
+ candidates that genuinely need running.
260
+ """
261
+ from quantred.optimization.parallel import EvalRequest, candidate_key, cost_spec, run_pool, summarize
262
+
263
+ rows: List[Dict[str, Any]] = []
264
+ requests: List[EvalRequest] = []
265
+ spec = cost_spec(self.cost_model)
266
+ for params in combos:
267
+ cached = self._find_cached(params, symbols, start, end, objective)
268
+ if cached is not None:
269
+ rows.append({**{k: params[k] for k in self.space.searchable}, **cached})
270
+ continue
271
+ requests.append(
272
+ EvalRequest(
273
+ key=candidate_key(self.strategy_name or "", params, symbols, start, end),
274
+ strategy=self.strategy_name or "",
275
+ params=dict(params),
276
+ symbols=tuple(symbols),
277
+ start=start,
278
+ end=end,
279
+ capital=self.initial_capital,
280
+ cost=spec,
281
+ data=self.data_spec,
282
+ base_seed=self.seed,
283
+ position_limits=dict(self.position_limits) if self.position_limits else None,
284
+ )
285
+ )
286
+
287
+ done = [0]
288
+
289
+ def _progress(_result) -> None:
290
+ done[0] += 1
291
+ if done[0] % 10 == 0 or done[0] == len(requests):
292
+ logger.info(" ... %d/%d evaluated (%d workers)", done[0], len(requests), self.workers)
293
+
294
+ report = run_pool(requests, self.workers, on_result=_progress)
295
+ logger.info("Parallel evaluation: %s", summarize(report))
296
+ for result in report.results:
297
+ if result.get("error"):
298
+ # A crashed candidate is still a configuration that was tried; it is
299
+ # reported, not hidden, and it does not stop the campaign.
300
+ logger.error("Evaluation failed for %s: %s", result["params"], result["error"])
301
+ continue
302
+ searched = {k: result["params"][k] for k in self.space.searchable if k in result["params"]}
303
+ rows.append({**searched, **result["metrics"]})
304
+ return rows
305
+
306
+ def _score_config(self, params, symbols, start, end, objective):
307
+ """Backtest one config; return (objective_score, results_row) or (-inf, None)."""
308
+ try:
309
+ metrics = self._backtest(params, symbols, start, end, objective)
310
+ except Exception as exc: # noqa: BLE001
311
+ logger.error("Evaluation failed for %s: %s", params, exc)
312
+ return float("-inf"), None
313
+ row = {**{k: params[k] for k in self.space.searchable}, **metrics}
314
+ return self._finite(metrics.get(objective, float("-inf"))), row
315
+
316
+ def _backtest(
317
+ self, params: Dict[str, Any], symbols, start: datetime, end: datetime, objective: Optional[str] = None
318
+ ) -> Dict[str, float]:
319
+ cached = self._find_cached(params, symbols, start, end, objective)
320
+ if cached is not None:
321
+ return cached
322
+ strategy = build_with_limits(self.strategy_class, params, self.position_limits)
323
+ result = BacktestEngine(strategy, self.data_client, cost_model=self.cost_model).run(
324
+ symbols, start, end, self.initial_capital
325
+ )
326
+ return result.metrics
327
+
328
+ def candidate_identity(self) -> Dict[str, Any]:
329
+ """What a candidate's result depends on besides its params, under reserved keys.
330
+
331
+ One definition for the lookup in :meth:`_find_cached` and for the journal line a
332
+ caller writes after the search, because a memo is only ever found by the key it
333
+ was recorded under. The key held the params and the cost model; the capital and
334
+ the book were missing, and both change the result (whole-share rounding scales
335
+ with capital, and a one-position book is a different strategy from an
336
+ eight-position one), so a search at one capital was answered from a search at
337
+ another. The shapes are the ones backtests and walk-forwards already key by.
338
+ """
339
+ # Lazy: services sit above this layer, and these two are the shared definitions.
340
+ from quantred.services.analysis import backtest_run_key, limits_key
341
+ from quantred.strategies.base import resolve_book
342
+
343
+ return {
344
+ "_cost": self.cost_key,
345
+ **limits_key(resolve_book(self.strategy_class, self.position_limits)),
346
+ **backtest_run_key(self.initial_capital, None, False),
347
+ }
348
+
349
+ def _find_cached(
350
+ self, params: Dict[str, Any], symbols, start, end, objective: Optional[str]
351
+ ) -> Optional[Dict[str, float]]:
352
+ """A stored metrics dict for this exact candidate, or ``None``.
353
+
354
+ Only serves a memo when the requested ``objective`` is one of the trial
355
+ store's denormalized headline fields - the store only ever persists a
356
+ fixed subset (see ``quantred.store.trials``), so silently serving a memo
357
+ missing the requested objective would make a real candidate look like
358
+ ``-inf`` and drop out of the ranking. An objective the memo can't answer
359
+ is treated as a miss (a real run), never a wrong answer.
360
+ """
361
+ if self.trial_store is None or self.force or not self.strategy_name:
362
+ return None
363
+ from quantred.optimization.config_store import current_git_sha
364
+
365
+ found = self.trial_store.find(
366
+ strategy=self.strategy_name,
367
+ params={**params, **self.candidate_identity()},
368
+ symbols=symbols,
369
+ window_start=start,
370
+ window_end=end,
371
+ accounting=self.accounting,
372
+ git_sha=current_git_sha(),
373
+ )
374
+ if found is None:
375
+ return None
376
+ import json
377
+
378
+ metrics = dict(json.loads(found["metrics_json"] or "{}"))
379
+ if not metrics or (objective is not None and objective not in metrics):
380
+ return None
381
+ metrics["_memoized_from"] = found["id"]
382
+ metrics["_memoized_ts"] = found["ts"]
383
+ return metrics
384
+
385
+ def _build_result(self, rows: List[Dict[str, Any]], objective: str) -> OptimizationResult:
386
+ df = pd.DataFrame(rows)
387
+ if df.empty or objective not in df.columns:
388
+ return OptimizationResult({}, float("-inf"), objective, df)
389
+ # A total order, not just a sort by score. Ranking on the objective alone
390
+ # leaves tied candidates in whatever order they were evaluated, so a parallel
391
+ # run completing in a different order could pick a different "best" config
392
+ # from an identical set of results. Breaking ties on the searched parameter
393
+ # values makes the winner a property of the results, not of the schedule.
394
+ tie_break = [k for k in self.space.searchable if k in df.columns]
395
+ df = df.sort_values(
396
+ [objective, *tie_break],
397
+ ascending=[False, *[True] * len(tie_break)],
398
+ kind="mergesort",
399
+ ).reset_index(drop=True)
400
+ best = df.iloc[0]
401
+ # Layer the searched values over the full defaults: callers construct a
402
+ # strategy straight from best_params, so dropping *pinned* params (declared
403
+ # with a default but no min/max/step) would yield an unconstructable config.
404
+ searched = {k: _plain(best[k]) for k in self.space.searchable if k in df.columns}
405
+ best_params = {**self.space.defaults, **searched}
406
+ logger.info("Best %s = %.4f with %s", objective, best[objective], searched)
407
+ return OptimizationResult(best_params, float(best[objective]), objective, df)
408
+
409
+ @staticmethod
410
+ def _finite(value: float) -> float:
411
+ if value == float("inf"):
412
+ return _INF_SENTINEL
413
+ if value == float("-inf"):
414
+ return -_INF_SENTINEL
415
+ return float(value)
416
+
417
+ def _standardize(self, y: List[float]) -> np.ndarray:
418
+ arr = np.array([self._finite(v) for v in y], dtype="float64")
419
+ std = arr.std()
420
+ return (arr - arr.mean()) / std if std > 0 else arr - arr.mean()
421
+
422
+ @staticmethod
423
+ def _make_gp():
424
+ try:
425
+ from sklearn.gaussian_process import GaussianProcessRegressor
426
+ from sklearn.gaussian_process.kernels import Matern
427
+ except ImportError as exc: # pragma: no cover - depends on optional extra
428
+ from quantred.utils.extras import missing_extra_message
429
+
430
+ raise RuntimeError(missing_extra_message("Bayesian optimization", "optimize")) from exc
431
+ return GaussianProcessRegressor(kernel=Matern(nu=2.5), n_restarts_optimizer=3, random_state=42)