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,345 @@
|
|
|
1
|
+
"""Concurrent trial execution: workers evaluate, the parent records.
|
|
2
|
+
|
|
3
|
+
Candidate evaluation is embarrassingly parallel — each candidate is an independent
|
|
4
|
+
backtest over the same read-only bars — and the machines this runs on have cores to
|
|
5
|
+
spare. What made parallelism dangerous was never the compute; it was that the trial
|
|
6
|
+
store is built on a **single-writer** assumption: one process appends the journal,
|
|
7
|
+
one process updates the index, and memoization assumes there are no in-flight
|
|
8
|
+
duplicates. Parallelism done naively breaks the store's core promise (an accurate,
|
|
9
|
+
deduplicated campaign trial count) in exactly the silent way this project fights.
|
|
10
|
+
|
|
11
|
+
The resolution is that the framework already separates *execution* from *recording*.
|
|
12
|
+
Workers never write anything: they receive a fully-described candidate, run it, and
|
|
13
|
+
return numbers. The parent alone journals, updates the index, and answers
|
|
14
|
+
memoization lookups. **Concurrency of execution does not require concurrency of
|
|
15
|
+
journaling**, so nothing about storage changes — no schema, no locking, no
|
|
16
|
+
interleaved journal.
|
|
17
|
+
|
|
18
|
+
Three properties this module exists to preserve, in order of importance:
|
|
19
|
+
|
|
20
|
+
1. **The campaign trial count stays exact.** In-flight dedup means a candidate
|
|
21
|
+
already dispatched is never dispatched twice, and a crashed candidate is still a
|
|
22
|
+
candidate that was tried. A search that runs faster but miscounts trials is a net
|
|
23
|
+
loss — it corrupts the deflated Sharpe silently, at scale.
|
|
24
|
+
2. **Results do not depend on scheduling.** Each candidate's seed derives from its
|
|
25
|
+
own identity, not from dispatch order, and results are returned in submission
|
|
26
|
+
order regardless of completion order. A parallel run and a sequential run of the
|
|
27
|
+
same search produce the same set of trials and the same winner.
|
|
28
|
+
3. **Nothing is shared mutably.** Workers build their own strategy, engine, and data
|
|
29
|
+
client from a picklable description. There are no live clients crossing the
|
|
30
|
+
process boundary, which is also what makes this work under spawn (macOS,
|
|
31
|
+
Windows) rather than only under fork.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import concurrent.futures as futures
|
|
35
|
+
import hashlib
|
|
36
|
+
import json
|
|
37
|
+
import logging
|
|
38
|
+
import os
|
|
39
|
+
from dataclasses import dataclass, field
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
42
|
+
|
|
43
|
+
logger = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
#: Conservative by default. Each worker holds its own copy of the bar frames, so
|
|
46
|
+
#: memory scales with workers x per-worker footprint — a wide universe times many
|
|
47
|
+
#: workers can dwarf the sequential run. Raising this is a deliberate act.
|
|
48
|
+
DEFAULT_MAX_WORKERS = 4
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def resolve_workers(requested: Optional[int]) -> int:
|
|
52
|
+
"""How many workers to actually use.
|
|
53
|
+
|
|
54
|
+
``None`` or anything below 2 means sequential — and sequential is not "a pool of
|
|
55
|
+
one", it is the original code path, byte for byte. Nobody should pay pickling and
|
|
56
|
+
process-spawn costs to run one thing at a time.
|
|
57
|
+
"""
|
|
58
|
+
if not requested or requested < 2:
|
|
59
|
+
return 1
|
|
60
|
+
cores = os.cpu_count() or 1
|
|
61
|
+
return max(1, min(int(requested), cores))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# --------------------------------------------------------------------------- #
|
|
65
|
+
# Picklable descriptions (nothing live crosses the process boundary)
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class DataSpec:
|
|
69
|
+
"""A recipe for building a data client, rather than a client itself.
|
|
70
|
+
|
|
71
|
+
A live market-data client holds sockets and vendor SDK objects and cannot be
|
|
72
|
+
pickled to a spawned process. Passing the *recipe* keeps workers independent and
|
|
73
|
+
keeps this module free of any vendor import.
|
|
74
|
+
|
|
75
|
+
``kind="synthetic"`` builds the deterministic keyless feed — the same one the
|
|
76
|
+
offline demo uses — which makes a genuinely parallel run reproducible with no
|
|
77
|
+
network and no credentials.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
kind: str = "cache"
|
|
81
|
+
seed: int = 42
|
|
82
|
+
cache_dir: Optional[str] = None
|
|
83
|
+
offline: bool = False
|
|
84
|
+
|
|
85
|
+
def build(self):
|
|
86
|
+
"""Construct this worker's own data client."""
|
|
87
|
+
from quantred.marketdata.client import MarketDataClient
|
|
88
|
+
|
|
89
|
+
if self.kind == "synthetic":
|
|
90
|
+
from quantred.marketdata.synthetic import SyntheticMarketData
|
|
91
|
+
|
|
92
|
+
return MarketDataClient(SyntheticMarketData(seed=self.seed))
|
|
93
|
+
|
|
94
|
+
from quantred.services.data import build_data_client
|
|
95
|
+
|
|
96
|
+
# Cache-first: the parent warmed the ranges before dispatch, so workers read
|
|
97
|
+
# local Parquet instead of N processes stampeding the vendor API.
|
|
98
|
+
return build_data_client(cache=True, offline=self.offline, cache_dir=self.cache_dir)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class EvalRequest:
|
|
103
|
+
"""One candidate, described completely enough to run anywhere.
|
|
104
|
+
|
|
105
|
+
``key`` is the candidate's dedup identity. It does triple duty: it is what
|
|
106
|
+
in-flight dedup compares, what the per-candidate seed derives from, and the
|
|
107
|
+
tie-breaker that makes ranking a total order. All three have to agree, so there
|
|
108
|
+
is one field rather than three.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
key: str
|
|
112
|
+
strategy: str
|
|
113
|
+
params: Dict[str, Any]
|
|
114
|
+
symbols: Tuple[str, ...]
|
|
115
|
+
start: datetime
|
|
116
|
+
end: datetime
|
|
117
|
+
capital: float = 100_000.0
|
|
118
|
+
cost: Optional[Dict[str, Any]] = None
|
|
119
|
+
data: DataSpec = field(default_factory=DataSpec)
|
|
120
|
+
base_seed: int = 42
|
|
121
|
+
#: The book each candidate is evaluated at. Not a tunable param, so a worker that
|
|
122
|
+
#: built the strategy from ``params`` alone evaluated the class default instead.
|
|
123
|
+
position_limits: Optional[Dict[str, Any]] = None
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def seed(self) -> int:
|
|
127
|
+
"""This candidate's seed, derived from its identity and never from its
|
|
128
|
+
position in the queue — so the same candidate simulates identically whether
|
|
129
|
+
it ran first, last, or in the sequential path."""
|
|
130
|
+
digest = hashlib.sha256(f"{self.base_seed}:{self.key}".encode()).hexdigest()
|
|
131
|
+
return int(digest[:8], 16)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def candidate_key(strategy: str, params: Dict[str, Any], symbols: Sequence[str], start, end) -> str:
|
|
135
|
+
"""The stable identity of one candidate.
|
|
136
|
+
|
|
137
|
+
Deliberately reuses the trial store's own hashing, so "the same candidate" means
|
|
138
|
+
exactly what it means everywhere else — dedup, memoization, and the campaign
|
|
139
|
+
count. A second definition here would eventually disagree with the store, and
|
|
140
|
+
the disagreement would show up as a miscounted campaign rather than an error.
|
|
141
|
+
"""
|
|
142
|
+
from quantred.store.trials import params_hash, universe_hash
|
|
143
|
+
|
|
144
|
+
window = f"{_iso(start)}|{_iso(end)}"
|
|
145
|
+
return f"{strategy}|{universe_hash(symbols)}|{params_hash(params)}|{window}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _iso(value: Any) -> str:
|
|
149
|
+
iso = getattr(value, "isoformat", None)
|
|
150
|
+
return iso() if callable(iso) else str(value)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# The worker side
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
def evaluate(request: EvalRequest) -> Dict[str, Any]:
|
|
157
|
+
"""Run one candidate and return its numbers. Top-level and picklable by design.
|
|
158
|
+
|
|
159
|
+
This is the entire worker contract. It constructs its own strategy, cost model,
|
|
160
|
+
data client, and engine; it never touches the journal, the trial-store index, or
|
|
161
|
+
stdout. A failure is *returned*, not raised: one candidate that cannot run must
|
|
162
|
+
not take the campaign with it, and a crashed candidate is still a configuration
|
|
163
|
+
that was tried.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
from quantred.engine.backtest import BacktestEngine
|
|
167
|
+
from quantred.services.registry import resolve_strategy_class
|
|
168
|
+
from quantred.strategies.base import build_with_limits
|
|
169
|
+
|
|
170
|
+
strategy = build_with_limits(
|
|
171
|
+
resolve_strategy_class(request.strategy), dict(request.params), request.position_limits
|
|
172
|
+
)
|
|
173
|
+
result = BacktestEngine(
|
|
174
|
+
strategy,
|
|
175
|
+
request.data.build(),
|
|
176
|
+
cost_model=_build_cost_model(request.cost),
|
|
177
|
+
).run(list(request.symbols), request.start, request.end, request.capital)
|
|
178
|
+
return {
|
|
179
|
+
"key": request.key,
|
|
180
|
+
"params": dict(request.params),
|
|
181
|
+
"metrics": dict(result.metrics),
|
|
182
|
+
"error": None,
|
|
183
|
+
}
|
|
184
|
+
except Exception as exc: # noqa: BLE001 - a worker failure is data, not a crash
|
|
185
|
+
return {
|
|
186
|
+
"key": request.key,
|
|
187
|
+
"params": dict(request.params),
|
|
188
|
+
"metrics": {},
|
|
189
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _build_cost_model(spec: Optional[Dict[str, Any]]):
|
|
194
|
+
"""A cost model from its picklable kwargs; ``None`` means a gross run."""
|
|
195
|
+
if spec is None:
|
|
196
|
+
return None
|
|
197
|
+
from quantred.costs import ParametricCostModel
|
|
198
|
+
|
|
199
|
+
return ParametricCostModel(**spec)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def cost_spec(cost_model) -> Optional[Dict[str, Any]]:
|
|
203
|
+
"""A live cost model as the kwargs a worker rebuilds it from.
|
|
204
|
+
|
|
205
|
+
Reads the model's own attributes back out rather than asking callers to pass the
|
|
206
|
+
construction arguments twice — two descriptions of one cost model would drift,
|
|
207
|
+
and the drift would show up as a parallel run pricing trades differently from
|
|
208
|
+
the sequential one.
|
|
209
|
+
"""
|
|
210
|
+
if cost_model is None:
|
|
211
|
+
return None
|
|
212
|
+
return {
|
|
213
|
+
"commission_bps": getattr(cost_model, "commission_rate", 0.0) * 1e4,
|
|
214
|
+
"default_spread_bps": getattr(cost_model, "default_spread", 0.0) * 1e4,
|
|
215
|
+
"impact_eta": getattr(cost_model, "impact_eta", 0.3),
|
|
216
|
+
"participation_cap": getattr(cost_model, "participation_cap", 0.10),
|
|
217
|
+
"annual_borrow_bps": getattr(cost_model, "annual_borrow_rate", 0.0) * 1e4,
|
|
218
|
+
"linear_impact": bool(getattr(cost_model, "linear_impact", False)),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------- #
|
|
223
|
+
# The parent side
|
|
224
|
+
# --------------------------------------------------------------------------- #
|
|
225
|
+
@dataclass
|
|
226
|
+
class PoolReport:
|
|
227
|
+
"""What a dispatch produced, including what it deliberately did not run."""
|
|
228
|
+
|
|
229
|
+
results: List[Dict[str, Any]] = field(default_factory=list)
|
|
230
|
+
duplicates: int = 0
|
|
231
|
+
failures: int = 0
|
|
232
|
+
interrupted: bool = False
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def completed(self) -> int:
|
|
236
|
+
return len(self.results)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def run_pool(
|
|
240
|
+
requests: Sequence[EvalRequest],
|
|
241
|
+
workers: int,
|
|
242
|
+
*,
|
|
243
|
+
on_result: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
244
|
+
) -> PoolReport:
|
|
245
|
+
"""Evaluate ``requests`` across a process pool; return results in **submission
|
|
246
|
+
order**.
|
|
247
|
+
|
|
248
|
+
Submission order, not completion order, is the point: it makes downstream
|
|
249
|
+
aggregation independent of how the scheduler happened to interleave the work.
|
|
250
|
+
Combined with per-candidate seeds derived from identity, that is what lets a
|
|
251
|
+
parallel run and a sequential run produce the same answer.
|
|
252
|
+
|
|
253
|
+
Duplicates are dropped before dispatch rather than deduplicated afterwards. Two
|
|
254
|
+
identical candidates are one trial — running both would inflate the campaign's
|
|
255
|
+
multiple-testing total with work that produced no new information, which is the
|
|
256
|
+
exact accounting the trial store exists to keep honest.
|
|
257
|
+
|
|
258
|
+
Ctrl-C cancels what has not started, keeps what has already finished (that
|
|
259
|
+
compute was really spent), and reports the run as partial rather than pretending
|
|
260
|
+
it completed.
|
|
261
|
+
"""
|
|
262
|
+
unique: List[EvalRequest] = []
|
|
263
|
+
seen: Dict[str, int] = {}
|
|
264
|
+
duplicates = 0
|
|
265
|
+
for request in requests:
|
|
266
|
+
if request.key in seen:
|
|
267
|
+
duplicates += 1
|
|
268
|
+
continue
|
|
269
|
+
seen[request.key] = len(unique)
|
|
270
|
+
unique.append(request)
|
|
271
|
+
|
|
272
|
+
report = PoolReport(duplicates=duplicates)
|
|
273
|
+
if not unique:
|
|
274
|
+
return report
|
|
275
|
+
|
|
276
|
+
ordered: List[Optional[Dict[str, Any]]] = [None] * len(unique)
|
|
277
|
+
try:
|
|
278
|
+
with futures.ProcessPoolExecutor(max_workers=workers) as pool:
|
|
279
|
+
pending = {pool.submit(evaluate, request): i for i, request in enumerate(unique)}
|
|
280
|
+
try:
|
|
281
|
+
for future in futures.as_completed(pending):
|
|
282
|
+
index = pending[future]
|
|
283
|
+
ordered[index] = future.result()
|
|
284
|
+
if on_result is not None:
|
|
285
|
+
# Progress printing happens HERE, in the parent. Workers never
|
|
286
|
+
# write to stdout, so N workers' chatter cannot interleave.
|
|
287
|
+
on_result(ordered[index])
|
|
288
|
+
except KeyboardInterrupt:
|
|
289
|
+
report.interrupted = True
|
|
290
|
+
for future in pending:
|
|
291
|
+
future.cancel()
|
|
292
|
+
logger.warning("Interrupted; keeping %d completed evaluation(s)", _count(ordered))
|
|
293
|
+
except KeyboardInterrupt: # raised while the pool is shutting down
|
|
294
|
+
report.interrupted = True
|
|
295
|
+
|
|
296
|
+
report.results = [row for row in ordered if row is not None]
|
|
297
|
+
report.failures = sum(1 for row in report.results if row.get("error"))
|
|
298
|
+
return report
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _count(rows: Sequence[Optional[Dict[str, Any]]]) -> int:
|
|
302
|
+
return sum(1 for row in rows if row is not None)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def warm_for(data_spec: DataSpec, symbols: Sequence[str], timeframe: str, start, end) -> None:
|
|
306
|
+
"""Pre-warm the bar cache over the union of what the workers will read.
|
|
307
|
+
|
|
308
|
+
Done once, in the parent, before any dispatch. Without it, N workers hitting a
|
|
309
|
+
cold cache stampede the vendor API for the same ranges simultaneously — N times
|
|
310
|
+
the requests, N times the rate-limit exposure, for one range of bars. A failure
|
|
311
|
+
here is logged and swallowed: warming is an optimization, and a worker that has
|
|
312
|
+
to fetch its own bars still produces the right answer.
|
|
313
|
+
"""
|
|
314
|
+
if data_spec.kind != "cache":
|
|
315
|
+
return
|
|
316
|
+
try:
|
|
317
|
+
from quantred.services.data import build_data_client
|
|
318
|
+
from quantred.store.bars import CachedMarketData
|
|
319
|
+
|
|
320
|
+
provider = build_data_client(cache=True, cache_dir=data_spec.cache_dir).provider
|
|
321
|
+
if isinstance(provider, CachedMarketData):
|
|
322
|
+
provider.warm(list(symbols), timeframe, start, end)
|
|
323
|
+
except Exception: # noqa: BLE001 - warming never blocks the run
|
|
324
|
+
logger.warning("Cache pre-warm failed; workers will fetch their own bars", exc_info=True)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def summarize(report: PoolReport) -> str:
|
|
328
|
+
"""A one-line account of a dispatch, including what it dropped.
|
|
329
|
+
|
|
330
|
+
Silence about skipped or failed work reads as "everything ran", so the counts
|
|
331
|
+
are always stated even when they are zero-worthy.
|
|
332
|
+
"""
|
|
333
|
+
parts = [f"{report.completed} evaluated"]
|
|
334
|
+
if report.duplicates:
|
|
335
|
+
parts.append(f"{report.duplicates} duplicate candidate(s) skipped")
|
|
336
|
+
if report.failures:
|
|
337
|
+
parts.append(f"{report.failures} failed")
|
|
338
|
+
if report.interrupted:
|
|
339
|
+
parts.append("INTERRUPTED — partial run")
|
|
340
|
+
return ", ".join(parts)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def as_json(value: Any) -> str:
|
|
344
|
+
"""Stable JSON for logging a candidate's params in a deterministic order."""
|
|
345
|
+
return json.dumps(value, sort_keys=True, default=str)
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Parameter search space derived from a ``PARAM_RANGES`` declaration.
|
|
2
|
+
|
|
3
|
+
Turns the ``{name: {min, max, step, default, type}}`` spec that strategies and
|
|
4
|
+
scanners already declare into the concrete things an optimizer needs:
|
|
5
|
+
|
|
6
|
+
* full **grid** of step-aligned combinations,
|
|
7
|
+
* **random** samples,
|
|
8
|
+
* **normalize/denormalize** to a ``[0, 1]`` vector (for surrogate models).
|
|
9
|
+
|
|
10
|
+
Only parameters that declare ``min``/``max``/``step`` are searched; everything
|
|
11
|
+
else is held at its default, so a search is always a complete, valid config.
|
|
12
|
+
|
|
13
|
+
A space may also declare **constraints** between parameters - ``exit_period``
|
|
14
|
+
below ``entry_period``, say. They are enforced by construction rather than by
|
|
15
|
+
rejection: the grid never contains an invalid point and the random sampler never
|
|
16
|
+
draws one, because an evaluated invalid combination is a journaled trial, and a
|
|
17
|
+
journaled trial permanently raises the deflation bar for every future candidate in
|
|
18
|
+
its family. Wasted trials are not just wasted compute.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import operator
|
|
23
|
+
from itertools import product
|
|
24
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from quantred.utils.numeric import step_decimals
|
|
29
|
+
|
|
30
|
+
#: The comparisons a declarative constraint may use. Deliberately a fixed table
|
|
31
|
+
#: rather than an expression to evaluate: a space is data a sampler introspects, and
|
|
32
|
+
#: something that has to be executed to be understood cannot be introspected at all.
|
|
33
|
+
_OPERATORS = {
|
|
34
|
+
"<": operator.lt,
|
|
35
|
+
"<=": operator.le,
|
|
36
|
+
">": operator.gt,
|
|
37
|
+
">=": operator.ge,
|
|
38
|
+
"==": operator.eq,
|
|
39
|
+
"!=": operator.ne,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
#: One declared constraint: ``(left, op, right)``, where each side is either the name
|
|
43
|
+
#: of a parameter in the same space or a literal number.
|
|
44
|
+
Constraint = Tuple[str, str, Any]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParameterConstraints:
|
|
48
|
+
"""The declared relationships between parameters in one space.
|
|
49
|
+
|
|
50
|
+
Two questions, and they are not the same one: ``holds`` asks whether a complete
|
|
51
|
+
assignment is valid, and ``feasible`` asks whether a *partial* one can still be
|
|
52
|
+
completed. The sampler needs the second - that is what lets it narrow each
|
|
53
|
+
parameter's candidate values as it goes, instead of drawing a whole point and
|
|
54
|
+
throwing it away.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(self, declared: Sequence[Constraint], names: Sequence[str]):
|
|
58
|
+
self.declared: Tuple[Constraint, ...] = tuple(declared)
|
|
59
|
+
for left, op, right in self.declared:
|
|
60
|
+
if op not in _OPERATORS:
|
|
61
|
+
raise ValueError(f"Unknown constraint operator {op!r}; expected one of {sorted(_OPERATORS)}")
|
|
62
|
+
for side in (left, right):
|
|
63
|
+
if isinstance(side, str) and side not in names:
|
|
64
|
+
raise ValueError(f"Constraint references unknown parameter {side!r}")
|
|
65
|
+
|
|
66
|
+
def __bool__(self) -> bool:
|
|
67
|
+
return bool(self.declared)
|
|
68
|
+
|
|
69
|
+
def _resolve(self, side: Any, params: Dict[str, Any]) -> Any:
|
|
70
|
+
"""A side's value, or ``None`` when it names a parameter not yet assigned."""
|
|
71
|
+
if isinstance(side, str):
|
|
72
|
+
return params.get(side)
|
|
73
|
+
return side
|
|
74
|
+
|
|
75
|
+
def holds(self, params: Dict[str, Any]) -> bool:
|
|
76
|
+
"""Whether a complete assignment satisfies every declared constraint."""
|
|
77
|
+
return self.violations(params) == ()
|
|
78
|
+
|
|
79
|
+
def violations(self, params: Dict[str, Any]) -> Tuple[str, ...]:
|
|
80
|
+
"""Every constraint this assignment breaks, rendered for a human.
|
|
81
|
+
|
|
82
|
+
Returned rather than raised: a caller screening a user-supplied config wants
|
|
83
|
+
to report all of them at once, and a caller sampling wants a boolean.
|
|
84
|
+
"""
|
|
85
|
+
broken = []
|
|
86
|
+
for left, op, right in self.declared:
|
|
87
|
+
lhs, rhs = self._resolve(left, params), self._resolve(right, params)
|
|
88
|
+
if lhs is None or rhs is None:
|
|
89
|
+
continue # not fully assigned; `feasible` is the question for that
|
|
90
|
+
if not _OPERATORS[op](lhs, rhs):
|
|
91
|
+
broken.append(f"{left} {op} {right} (got {lhs} {op} {rhs})")
|
|
92
|
+
return tuple(broken)
|
|
93
|
+
|
|
94
|
+
def feasible(self, params: Dict[str, Any]) -> bool:
|
|
95
|
+
"""Whether a partial assignment still violates nothing it has decided.
|
|
96
|
+
|
|
97
|
+
A constraint with an unassigned side is not yet judged: it may still be
|
|
98
|
+
satisfiable, and refusing it here would prune valid regions of the space.
|
|
99
|
+
"""
|
|
100
|
+
return self.violations(params) == ()
|
|
101
|
+
|
|
102
|
+
def describe(self) -> Tuple[str, ...]:
|
|
103
|
+
return tuple(f"{left} {op} {right}" for left, op, right in self.declared)
|
|
104
|
+
|
|
105
|
+
def can_bind(self, extremes: Dict[str, Tuple[Any, Any]]) -> bool:
|
|
106
|
+
"""Whether any declared constraint could exclude a point in these ranges.
|
|
107
|
+
|
|
108
|
+
``extremes`` maps a parameter to its ``(lowest, highest)`` declared value. A
|
|
109
|
+
constraint that every combination of the extremes already satisfies cannot
|
|
110
|
+
remove anything, and saying so is worth the few lines: it lets a space whose
|
|
111
|
+
constraints are decorative keep drawing exactly the points it drew before one
|
|
112
|
+
was declared. A declaration that changes nothing should change nothing —
|
|
113
|
+
including which configs a seeded random search happens to visit.
|
|
114
|
+
|
|
115
|
+
Conservative by construction: equality operators, and anything it cannot
|
|
116
|
+
bound, answer "yes, it could".
|
|
117
|
+
"""
|
|
118
|
+
for left, op, right in self.declared:
|
|
119
|
+
lo_l, hi_l = self._extreme(left, extremes)
|
|
120
|
+
lo_r, hi_r = self._extreme(right, extremes)
|
|
121
|
+
if None in (lo_l, hi_l, lo_r, hi_r):
|
|
122
|
+
return True
|
|
123
|
+
# The worst case for each ordering: the combination most likely to fail.
|
|
124
|
+
if op == "<" and hi_l < lo_r:
|
|
125
|
+
continue
|
|
126
|
+
if op == "<=" and hi_l <= lo_r:
|
|
127
|
+
continue
|
|
128
|
+
if op == ">" and lo_l > hi_r:
|
|
129
|
+
continue
|
|
130
|
+
if op == ">=" and lo_l >= hi_r:
|
|
131
|
+
continue
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _extreme(side: Any, extremes: Dict[str, Tuple[Any, Any]]) -> Tuple[Any, Any]:
|
|
137
|
+
if isinstance(side, str):
|
|
138
|
+
return extremes.get(side, (None, None))
|
|
139
|
+
return (side, side)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ParameterSpace:
|
|
143
|
+
"""A searchable view over a ``PARAM_RANGES`` mapping."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, param_ranges: Dict[str, Dict[str, Any]], constraints: Sequence[Constraint] = ()):
|
|
146
|
+
self.param_ranges = param_ranges
|
|
147
|
+
self.searchable: List[str] = [
|
|
148
|
+
name for name, spec in param_ranges.items() if all(k in spec for k in ("min", "max", "step"))
|
|
149
|
+
]
|
|
150
|
+
self.defaults: Dict[str, Any] = {
|
|
151
|
+
name: spec["default"] for name, spec in param_ranges.items() if "default" in spec
|
|
152
|
+
}
|
|
153
|
+
self.constraints = ParameterConstraints(constraints, list(param_ranges))
|
|
154
|
+
|
|
155
|
+
@classmethod
|
|
156
|
+
def for_class(cls, strategy_class) -> "ParameterSpace":
|
|
157
|
+
"""The space a strategy (or scanner) class declares, constraints included.
|
|
158
|
+
|
|
159
|
+
One construction path, so a class that declares constraints gets them applied
|
|
160
|
+
wherever its space is built. Building ``ParameterSpace(cls.PARAM_RANGES)``
|
|
161
|
+
directly is what silently drops them, which is the failure this exists to
|
|
162
|
+
prevent - an invalid combination that gets evaluated is a journaled trial.
|
|
163
|
+
"""
|
|
164
|
+
return cls(
|
|
165
|
+
getattr(strategy_class, "PARAM_RANGES", {}) or {},
|
|
166
|
+
getattr(strategy_class, "PARAM_CONSTRAINTS", ()) or (),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def _values_for(self, name: str) -> List[Any]:
|
|
170
|
+
spec = self.param_ranges[name]
|
|
171
|
+
decimals = step_decimals(spec["step"])
|
|
172
|
+
n_steps = int(round((spec["max"] - spec["min"]) / spec["step"])) + 1
|
|
173
|
+
values = []
|
|
174
|
+
for i in range(n_steps):
|
|
175
|
+
value = round(spec["min"] + i * spec["step"], decimals)
|
|
176
|
+
value = min(spec["max"], max(spec["min"], value))
|
|
177
|
+
value = int(value) if spec["type"] == "int" else value
|
|
178
|
+
if value not in values:
|
|
179
|
+
values.append(value)
|
|
180
|
+
return values
|
|
181
|
+
|
|
182
|
+
def unconstrained_grid_size(self) -> int:
|
|
183
|
+
"""Points in the full Cartesian product, before constraints remove any."""
|
|
184
|
+
return math.prod(len(self._values_for(name)) for name in self.searchable) if self.searchable else 0
|
|
185
|
+
|
|
186
|
+
def grid_size(self) -> int:
|
|
187
|
+
"""Number of *valid* points in the full grid.
|
|
188
|
+
|
|
189
|
+
Computed without materializing the product when nothing is constrained. With
|
|
190
|
+
constraints it enumerates, because the count a caller budgets against has to
|
|
191
|
+
be the count it will actually get - reporting the unconstrained size and then
|
|
192
|
+
yielding fewer points is how a cap ends up meaning something different from
|
|
193
|
+
what it said.
|
|
194
|
+
"""
|
|
195
|
+
if not self._constraints_can_bind():
|
|
196
|
+
return self.unconstrained_grid_size()
|
|
197
|
+
return len(self.grid())
|
|
198
|
+
|
|
199
|
+
def grid(self) -> List[Dict[str, Any]]:
|
|
200
|
+
"""Every step-aligned, constraint-satisfying combination of searchable params.
|
|
201
|
+
|
|
202
|
+
Note: this is the full Cartesian product and can be enormous for
|
|
203
|
+
many-parameter spaces. Callers that cap the number of evaluations should
|
|
204
|
+
check :meth:`grid_size` first and fall back to :meth:`random_samples`.
|
|
205
|
+
"""
|
|
206
|
+
axes = [self._values_for(name) for name in self.searchable]
|
|
207
|
+
points = [{**self.defaults, **dict(zip(self.searchable, combo))} for combo in product(*axes)]
|
|
208
|
+
if not self._constraints_can_bind():
|
|
209
|
+
return points
|
|
210
|
+
return [point for point in points if self.constraints.holds(point)]
|
|
211
|
+
|
|
212
|
+
def random_samples(self, n: int, rng: np.random.Generator) -> List[Dict[str, Any]]:
|
|
213
|
+
"""``n`` random step-aligned configs, none of which violate a constraint.
|
|
214
|
+
|
|
215
|
+
Drawn parameter by parameter, each one restricted to the values still
|
|
216
|
+
consistent with what has already been decided - so an invalid region is
|
|
217
|
+
unreachable rather than reached and discarded. A draw that paints itself into
|
|
218
|
+
a corner (no value left for a later parameter) is restarted rather than
|
|
219
|
+
returned half-built; the restart budget is bounded so a space whose
|
|
220
|
+
constraints admit nothing fails loudly instead of hanging.
|
|
221
|
+
"""
|
|
222
|
+
if not self._constraints_can_bind():
|
|
223
|
+
samples = []
|
|
224
|
+
for _ in range(n):
|
|
225
|
+
params = dict(self.defaults)
|
|
226
|
+
for name in self.searchable:
|
|
227
|
+
params[name] = rng.choice(self._values_for(name)).item()
|
|
228
|
+
samples.append(params)
|
|
229
|
+
return samples
|
|
230
|
+
|
|
231
|
+
samples = []
|
|
232
|
+
attempts = 0
|
|
233
|
+
budget = max(20 * n, 100)
|
|
234
|
+
while len(samples) < n and attempts < budget:
|
|
235
|
+
attempts += 1
|
|
236
|
+
drawn = self._draw_constrained(rng)
|
|
237
|
+
if drawn is not None:
|
|
238
|
+
samples.append(drawn)
|
|
239
|
+
if len(samples) < n:
|
|
240
|
+
raise ValueError(
|
|
241
|
+
f"Could only draw {len(samples)} of {n} configs satisfying "
|
|
242
|
+
f"{', '.join(self.constraints.describe())} — the constraints may admit "
|
|
243
|
+
f"little or none of the declared ranges"
|
|
244
|
+
)
|
|
245
|
+
return samples
|
|
246
|
+
|
|
247
|
+
def _draw_constrained(self, rng: np.random.Generator) -> Optional[Dict[str, Any]]:
|
|
248
|
+
"""One constraint-satisfying draw, or ``None`` if this attempt dead-ended."""
|
|
249
|
+
params = dict(self.defaults)
|
|
250
|
+
for name in self.searchable:
|
|
251
|
+
allowed = [v for v in self._values_for(name) if self.constraints.feasible({**params, name: v})]
|
|
252
|
+
if not allowed:
|
|
253
|
+
return None
|
|
254
|
+
params[name] = allowed[int(rng.integers(len(allowed)))]
|
|
255
|
+
if isinstance(params[name], np.generic):
|
|
256
|
+
params[name] = params[name].item()
|
|
257
|
+
return params if self.constraints.holds(params) else None
|
|
258
|
+
|
|
259
|
+
# --- normalization, for surrogate-model optimizers -------------------- #
|
|
260
|
+
def to_unit_vector(self, params: Dict[str, Any]) -> np.ndarray:
|
|
261
|
+
"""Map a config to a ``[0, 1]`` vector over the searchable parameters."""
|
|
262
|
+
vec = []
|
|
263
|
+
for name in self.searchable:
|
|
264
|
+
spec = self.param_ranges[name]
|
|
265
|
+
vec.append((params[name] - spec["min"]) / (spec["max"] - spec["min"]))
|
|
266
|
+
return np.array(vec, dtype="float64")
|
|
267
|
+
|
|
268
|
+
def from_unit_vector(self, vec: np.ndarray) -> Dict[str, Any]:
|
|
269
|
+
"""Inverse of :meth:`to_unit_vector`, snapped to the step grid.
|
|
270
|
+
|
|
271
|
+
The one path constraints cannot be enforced *by construction*: a surrogate
|
|
272
|
+
proposes a point in a continuous box and snapping it can land outside the
|
|
273
|
+
feasible region. The caller checks :meth:`is_valid` and drops such a proposal
|
|
274
|
+
before evaluating it, so it still costs no trial — but this is why
|
|
275
|
+
reject-after-draw is not the design anywhere it can be avoided.
|
|
276
|
+
"""
|
|
277
|
+
params = dict(self.defaults)
|
|
278
|
+
for i, name in enumerate(self.searchable):
|
|
279
|
+
spec = self.param_ranges[name]
|
|
280
|
+
raw = vec[i] * (spec["max"] - spec["min"]) + spec["min"]
|
|
281
|
+
snapped = round(raw / spec["step"]) * spec["step"]
|
|
282
|
+
snapped = min(spec["max"], max(spec["min"], round(snapped, step_decimals(spec["step"]))))
|
|
283
|
+
params[name] = int(snapped) if spec["type"] == "int" else snapped
|
|
284
|
+
return params
|
|
285
|
+
|
|
286
|
+
def _constraints_can_bind(self) -> bool:
|
|
287
|
+
"""Whether the declared constraints can exclude anything in this space."""
|
|
288
|
+
if not self.constraints:
|
|
289
|
+
return False
|
|
290
|
+
extremes = {}
|
|
291
|
+
for name in self.searchable:
|
|
292
|
+
values = self._values_for(name)
|
|
293
|
+
if values:
|
|
294
|
+
extremes[name] = (min(values), max(values))
|
|
295
|
+
for name, value in self.defaults.items():
|
|
296
|
+
extremes.setdefault(name, (value, value))
|
|
297
|
+
return self.constraints.can_bind(extremes)
|
|
298
|
+
|
|
299
|
+
def is_valid(self, params: Dict[str, Any]) -> bool:
|
|
300
|
+
"""Whether a complete assignment satisfies every declared constraint."""
|
|
301
|
+
return self.constraints.holds(params)
|
|
302
|
+
|
|
303
|
+
def violations(self, params: Dict[str, Any]) -> Tuple[str, ...]:
|
|
304
|
+
"""Every constraint ``params`` breaks, for a caller that has to explain why."""
|
|
305
|
+
return self.constraints.violations(params)
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def dimensions(self) -> int:
|
|
309
|
+
return len(self.searchable)
|