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.
- quantred/__init__.py +24 -0
- quantred/alphas/__init__.py +53 -0
- quantred/alphas/base.py +221 -0
- quantred/alphas/combine.py +260 -0
- quantred/alphas/horizon.py +153 -0
- quantred/alphas/refine.py +300 -0
- quantred/alphas/scorers.py +87 -0
- quantred/analytics/attribution.py +354 -0
- quantred/analytics/bootstrap.py +353 -0
- quantred/analytics/charts.py +393 -0
- quantred/analytics/excursion.py +185 -0
- quantred/analytics/execution_quality.py +180 -0
- quantred/analytics/exposure.py +167 -0
- quantred/analytics/htmlreport.py +1208 -0
- quantred/analytics/information.py +197 -0
- quantred/analytics/metrics.py +502 -0
- quantred/analytics/performance.py +639 -0
- quantred/analytics/reporting.py +1672 -0
- quantred/analytics/screening.py +190 -0
- quantred/analytics/series_comparison.py +303 -0
- quantred/analytics/trade_analytics.py +353 -0
- quantred/brokers/alpaca/broker.py +415 -0
- quantred/brokers/alpaca/factory.py +33 -0
- quantred/brokers/alpaca/market_data.py +166 -0
- quantred/brokers/base.py +220 -0
- quantred/brokers/dryrun.py +164 -0
- quantred/brokers/errors.py +70 -0
- quantred/cli.py +8619 -0
- quantred/costs/__init__.py +11 -0
- quantred/costs/base.py +126 -0
- quantred/costs/parametric.py +131 -0
- quantred/data/__init__.py +29 -0
- quantred/data/compute.py +332 -0
- quantred/data/edges.py +81 -0
- quantred/data/features.py +120 -0
- quantred/data/panel.py +61 -0
- quantred/data/scan.py +85 -0
- quantred/data/store.py +269 -0
- quantred/demo/__init__.py +22 -0
- quantred/demo/scanners.py +104 -0
- quantred/demo/strategies.py +125 -0
- quantred/engine/backtest.py +1206 -0
- quantred/engine/barcheck.py +228 -0
- quantred/engine/live.py +539 -0
- quantred/execution/decision.py +192 -0
- quantred/execution/flatten.py +302 -0
- quantred/execution/halt.py +165 -0
- quantred/execution/ledger.py +758 -0
- quantred/execution/live_trader.py +587 -0
- quantred/execution/order_id.py +83 -0
- quantred/execution/sizing.py +114 -0
- quantred/indicators/indicators.py +158 -0
- quantred/marketdata/base.py +62 -0
- quantred/marketdata/client.py +59 -0
- quantred/marketdata/session.py +112 -0
- quantred/marketdata/synthetic.py +74 -0
- quantred/marketdata/timeframe.py +125 -0
- quantred/mcp/__init__.py +6 -0
- quantred/mcp/selftest.py +139 -0
- quantred/mcp/server.py +2419 -0
- quantred/optimization/causality.py +607 -0
- quantred/optimization/config_store.py +300 -0
- quantred/optimization/optimizer.py +431 -0
- quantred/optimization/parallel.py +345 -0
- quantred/optimization/param_space.py +309 -0
- quantred/optimization/walk_forward.py +1254 -0
- quantred/portfolio/allocator.py +135 -0
- quantred/portfolio/benchmark.py +109 -0
- quantred/portfolio/optimizer.py +786 -0
- quantred/portfolio/policy.py +335 -0
- quantred/portfolio/posterior.py +208 -0
- quantred/research/__init__.py +30 -0
- quantred/research/agent.py +868 -0
- quantred/research/demo_proposals.py +222 -0
- quantred/research/llm.py +311 -0
- quantred/research/proposer.py +234 -0
- quantred/research/provider_errors.py +242 -0
- quantred/research/sandbox.py +340 -0
- quantred/risk/__init__.py +60 -0
- quantred/risk/base.py +218 -0
- quantred/risk/conditional.py +474 -0
- quantred/risk/exposures.py +88 -0
- quantred/risk/factor.py +119 -0
- quantred/risk/sample.py +89 -0
- quantred/risk/streaming.py +214 -0
- quantred/scanners/base.py +141 -0
- quantred/scanners/symbol_scanner.py +148 -0
- quantred/services/__init__.py +51 -0
- quantred/services/analysis.py +6082 -0
- quantred/services/audit.py +574 -0
- quantred/services/campaign.py +381 -0
- quantred/services/configs.py +282 -0
- quantred/services/data.py +172 -0
- quantred/services/dryrun.py +206 -0
- quantred/services/glossary.py +130 -0
- quantred/services/maintenance.py +314 -0
- quantred/services/registry.py +351 -0
- quantred/services/setup.py +1023 -0
- quantred/services/sizing.py +150 -0
- quantred/services/smallreal.py +450 -0
- quantred/settings.py +571 -0
- quantred/store/__init__.py +0 -0
- quantred/store/bars.py +660 -0
- quantred/store/trials.py +1804 -0
- quantred/strategies/base.py +643 -0
- quantred/strategies/signals.py +28 -0
- quantred/utils/errors.py +48 -0
- quantred/utils/extras.py +330 -0
- quantred/utils/logging_config.py +33 -0
- quantred/utils/numeric.py +46 -0
- quantred/utils/streaming.py +474 -0
- quantred/utils/timeutils.py +148 -0
- quantred-2.4.0.dist-info/METADATA +632 -0
- quantred-2.4.0.dist-info/RECORD +117 -0
- quantred-2.4.0.dist-info/WHEEL +4 -0
- quantred-2.4.0.dist-info/entry_points.txt +8 -0
- 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)
|