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