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,381 @@
1
+ """What validated a config, assembled from what was already recorded.
2
+
3
+ Promoting the winning row of a walk-forward captures the parameters and loses the
4
+ recipe. But a walk-forward is not one row — it is a *validation recipe* (the folds, the
5
+ embargo, the objective, the search method, the book it searched at) plus a chosen
6
+ parameter set plus the universe that was actually resolved. Without the recipe the
7
+ config cannot say what validated it, and "this passed a walk-forward" is not a fact
8
+ anybody can check.
9
+
10
+ None of this is re-derived and none of it is re-run. The recipe is in the journal,
11
+ where it was written as the trial's dedup identity; the universe is in the journal, in
12
+ full, because the store keeps only its hash. This reads both back.
13
+
14
+ **Three kinds of thing, labelled as such, because they age differently.**
15
+
16
+ ``recipe`` is *how it was validated* — reusable, still meaningful next year, and the
17
+ thing you would repeat to check the claim.
18
+
19
+ ``evidence`` is *what was measured* — scoped to an accounting era and never valid
20
+ outside it. When ``ACCOUNTING_VERSION`` moves, the recipe survives unchanged and every
21
+ number here becomes a historical curiosity. Marking the boundary in the artifact is
22
+ what stops the two being read as one.
23
+
24
+ ``metadata`` is *about the record itself* — where it came from, what is still
25
+ recoverable, what was never captured.
26
+
27
+ A section that could not be assembled says so with a reason. Absent is never rendered
28
+ as empty, and an artifact that quietly omits a section it failed to build claims more
29
+ completeness than it has.
30
+ """
31
+
32
+ from typing import Any, Dict, List, Optional
33
+
34
+ #: The label on each block. Kept as data rather than prose so a reader — or an agent —
35
+ #: can filter on it: after an accounting bump, everything marked ``evidence`` is stale
36
+ #: and everything marked ``recipe`` is not.
37
+ RECIPE = "recipe"
38
+ EVIDENCE = "evidence"
39
+ METADATA = "metadata"
40
+
41
+ #: Reserved keys inside a recorded params/recipe dict. They are the dedup identity's
42
+ #: own bookkeeping, not knobs anybody set.
43
+ _RESERVED_PREFIX = "_"
44
+
45
+
46
+ def _split_recipe(recorded: Optional[Dict[str, Any]]) -> Dict[str, Any]:
47
+ """A recorded dedup recipe split into the knobs and the folded keys.
48
+
49
+ The cost model and the book limits are folded into the recipe as reserved keys
50
+ because they change what a validation *means*, but they are not search settings and
51
+ reading them as such would be misleading.
52
+ """
53
+ recorded = dict(recorded or {})
54
+ folded = {k: v for k, v in recorded.items() if k.startswith(_RESERVED_PREFIX)}
55
+ knobs = {k: v for k, v in recorded.items() if not k.startswith(_RESERVED_PREFIX)}
56
+ return {"settings": knobs, "folded": folded}
57
+
58
+
59
+ def campaign_material(store, trial_id: str, *, journal_path: Optional[Any] = None) -> Dict[str, Any]:
60
+ """Everything recorded about what validated one trial, as one labelled block.
61
+
62
+ Takes an open store rather than opening one — the CLI and the MCP server both reach
63
+ this through the handle they already have, and a fourth way of opening the trial
64
+ store is the last thing this codebase needs.
65
+ """
66
+ from quantred.engine.backtest import ACCOUNTING_VERSION
67
+ from quantred.services.audit import journal_record_for_trial, research_record_for_trial
68
+
69
+ material: Dict[str, Any] = {"available": False, "reason": "", "trial_id": trial_id}
70
+
71
+ from quantred.store.trials import AmbiguousTrialId
72
+
73
+ try:
74
+ resolved = store.resolve_id(trial_id)
75
+ except AmbiguousTrialId as exc:
76
+ material["reason"] = str(exc)
77
+ return material
78
+ row = store.row_for(resolved) if resolved else None
79
+ if row is None:
80
+ material["not_indexed"] = True
81
+ material["reason"] = (
82
+ f"no trial with id {trial_id!r} is indexed. "
83
+ "Its journal line may still exist — "
84
+ "`trials rebuild` reindexes from the journal."
85
+ )
86
+ return material
87
+
88
+ trial_id = material["trial_id"] = resolved
89
+ # `audit_log` flattens a trial's extra fields onto the record rather than nesting
90
+ # them under a key, so `dedup_params`, `returns` and `trades` sit at the top level
91
+ # beside `inputs`. Printed once rather than inferred from the writer's signature:
92
+ # assuming a nested `extra` compiled, passed, and reported every recipe as absent.
93
+ record = journal_record_for_trial(trial_id, journal_path)
94
+ if record is None and row.get("kind") == "research":
95
+ record = research_record_for_trial(trial_id, journal_path)
96
+ inputs = (record or {}).get("inputs") or {}
97
+
98
+ accounting = row.get("accounting")
99
+ material.update(
100
+ {
101
+ "available": True,
102
+ "kind": row.get("kind"),
103
+ "strategy": row.get("strategy"),
104
+ RECIPE: _recipe_section(row, inputs, record),
105
+ EVIDENCE: _evidence_section(
106
+ store, row, inputs, trial_id, accounting, ACCOUNTING_VERSION, record=record
107
+ ),
108
+ METADATA: _metadata_section(row, inputs, record, trial_id),
109
+ }
110
+ )
111
+ return material
112
+
113
+
114
+ #: Context facts that describe how a run was set up, in the order a reader wants them.
115
+ _CONTEXT_FACTS = ("capital", "scanner", "scan_as_of", "cache")
116
+
117
+
118
+ def _context_section(inputs: Dict[str, Any], record: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
119
+ """The recorded run context, with every fact carrying whether it was recorded.
120
+
121
+ Each fact reports ``recorded`` rather than being present-or-missing, so a reader -
122
+ or an agent - can tell "this run used no scanner" from "nobody wrote down whether it
123
+ did". Every trial predating this block reads as the second, which is what it is: the
124
+ fact was never captured, and defaulting it would put a capital or a cache policy in
125
+ the record that nobody chose.
126
+
127
+ A verdict trial keeps its scanner in its identity (``_verdict.scanner``) rather than
128
+ in the context block, so that is read as the recorded fact: reporting such a trial's
129
+ scanner as not recorded sent a reader to guess at a value the journal holds.
130
+ """
131
+ context = dict(inputs.get("context") or {})
132
+ if "scanner" not in context:
133
+ identity = (record or {}).get("dedup_params") or (record or {}).get("resolved_config") or {}
134
+ scanner = (identity.get("_verdict") or {}).get("scanner")
135
+ if scanner:
136
+ context["scanner"] = scanner
137
+ return {fact: {"recorded": fact in context, "value": context.get(fact)} for fact in _CONTEXT_FACTS}
138
+
139
+
140
+ def _recorded(inputs: Dict[str, Any], key: str) -> Dict[str, Any]:
141
+ """One context fact off a trial's journal inputs, with its recorded status.
142
+
143
+ Reads the *journal* rather than the store row: the context is not indexed, because
144
+ nothing queries on it and duplicating it into a column would be a second copy of a
145
+ fact the journal already holds.
146
+ """
147
+ context = (inputs or {}).get("context") or {}
148
+ return {"recorded": key in context, "value": context.get(key)}
149
+
150
+
151
+ def _recipe_section(row, inputs, record) -> Dict[str, Any]:
152
+ """How it was validated. Survives an accounting bump unchanged."""
153
+ section: Dict[str, Any] = {
154
+ "kind": RECIPE,
155
+ "note": "how this was validated — reusable, and unaffected by an accounting bump",
156
+ "window": inputs.get("window") or {"start": row.get("window_start"), "end": row.get("window_end")},
157
+ "objective": inputs.get("objective") or None,
158
+ # How the run was set up, for the parts that are *not* its identity: the capital
159
+ # it ran at, the scanner and as-of clock its universe came through, the cache
160
+ # policy its bars were read under. Labelled separately from `validation` because
161
+ # nothing here is hashed - two runs differing only in these keys are one trial to
162
+ # the memo, which a reader comparing them needs to know.
163
+ "context": _context_section(inputs, record),
164
+ # The universe that was actually resolved, and the list it was resolved *from*.
165
+ # The store keeps only a hash of the first and nothing of the second, so this is
166
+ # the only place a reader gets them back - and without it the module's own
167
+ # claim to read the universe back was false, and a config's symbols could
168
+ # contradict the campaign with nothing able to notice.
169
+ "universe": {
170
+ "symbols": list(inputs.get("symbols") or []) or None,
171
+ "candidate_symbols": list(inputs.get("candidate_symbols") or []) or None,
172
+ },
173
+ }
174
+ if record is None:
175
+ # The store knows the trial happened and the journal line is where the recipe
176
+ # lives. Saying which half is missing beats an empty recipe that reads as "this
177
+ # was validated with no settings".
178
+ section["available"] = False
179
+ section["reason"] = (
180
+ "the journal line for this trial could not be read, and the recipe is only "
181
+ "recorded there — the store keeps a hash of it, not the recipe itself"
182
+ )
183
+ return section
184
+
185
+ if row.get("kind") == "research":
186
+ # The line was found; what it lacks is a per-trial recipe, which a research round
187
+ # does not have - its validation settings belong to the session, journaled once on
188
+ # the `research:session_start` line. "Could not be read" would send a reader
189
+ # looking for a corrupt journal.
190
+ section["available"] = False
191
+ section["reason"] = (
192
+ "a research round has no validation recipe of its own: the settings it was "
193
+ "validated under are the session's, journaled on its `research:session_start` "
194
+ f"line (session {inputs.get('session_id') or row.get('session_id') or 'unknown'}). "
195
+ "This round's line records its params, gate report and out-of-sample aggregate"
196
+ )
197
+ return section
198
+
199
+ dedup = record.get("dedup_params")
200
+ if dedup is None:
201
+ # Most kinds omit it because `params` already serves both roles; a backtest's
202
+ # identity *is* its parameters. Only a search has a recipe distinct from them.
203
+ section["available"] = False
204
+ section["reason"] = (
205
+ f"a {row.get('kind')!r} trial records no separate validation recipe — its "
206
+ "identity is its parameters, which are the config's own `params`"
207
+ )
208
+ # The cost model and book folded into those parameters are still named, in the
209
+ # same place a recipe names them: without this a config written from this block
210
+ # over MCP carried no `cost` for a backtest, where `trials promote` did.
211
+ params = _recorded_params(row, record)
212
+ if params:
213
+ folded = _split_recipe(params)["folded"]
214
+ if folded:
215
+ section["folded_into_identity"] = folded
216
+ return section
217
+
218
+ split = _split_recipe(dedup)
219
+ section.update(
220
+ {
221
+ "available": True,
222
+ "validation": split["settings"],
223
+ # Named rather than dropped: these are why two validations with identical
224
+ # settings can still be different validations.
225
+ "folded_into_identity": split["folded"],
226
+ }
227
+ )
228
+ return section
229
+
230
+
231
+ def _recorded_params(row, record: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
232
+ """A trial's recorded params: the journal line's, else the index row's."""
233
+ import json
234
+
235
+ params = (record or {}).get("params")
236
+ if isinstance(params, dict):
237
+ return params
238
+ try:
239
+ parsed = json.loads(row.get("params_json") or "null")
240
+ except (TypeError, ValueError):
241
+ return None
242
+ return parsed if isinstance(parsed, dict) else None
243
+
244
+
245
+ def _parse_metrics(row) -> Optional[Dict[str, Any]]:
246
+ """A row's stored metric block, parsed. ``None`` when there is none - never ``{}``,
247
+ which would read as a trial that measured nothing."""
248
+ import json
249
+
250
+ raw = row.get("metrics")
251
+ if isinstance(raw, dict):
252
+ return raw or None
253
+ try:
254
+ parsed = json.loads(row.get("metrics_json") or "{}")
255
+ except (TypeError, ValueError):
256
+ return None
257
+ return parsed or None
258
+
259
+
260
+ def _evidence_section(store, row, inputs, trial_id, accounting, current, record=None) -> Dict[str, Any]:
261
+ """What was measured. Scoped to one accounting era and worthless outside it."""
262
+ from quantred.services.audit import holdout_from_record
263
+
264
+ strategy, universe_hash = row.get("strategy"), row.get("universe_hash")
265
+ family = (
266
+ store.family_count_by_hash(strategy, universe_hash, accounting)
267
+ if strategy and universe_hash and accounting is not None
268
+ else None
269
+ )
270
+ section: Dict[str, Any] = {
271
+ "kind": EVIDENCE,
272
+ "note": (
273
+ "what was measured, under accounting "
274
+ f"v{accounting} — not comparable with a run on a different version"
275
+ ),
276
+ "accounting": accounting,
277
+ "current_accounting": current,
278
+ "comparable_with_current_engine": accounting == current,
279
+ "trial_ids": [trial_id],
280
+ # The raw row carries `metrics_json`, a string. `get_trial` parses it; this
281
+ # reads the light row and has to do the same, or every materialised config
282
+ # records `metrics: null` for a trial that measured plenty.
283
+ "metrics": _parse_metrics(row),
284
+ # SQLite has no boolean, so the column is 0/1/NULL. A JSON artifact that says
285
+ # `"promotable": 1` invites a reader to treat the count-looking value as one.
286
+ "promotable": None if row.get("promotable") is None else bool(row.get("promotable")),
287
+ # Which probes ran and what they said. A probe absent here did not run, which is
288
+ # not the same as running and passing - the distinction the probe suite itself
289
+ # is built around, carried into the record.
290
+ "probes": _recorded(inputs, "probes"),
291
+ "family_n_trials": family,
292
+ # A walk-forward's holdout is evidence the index never kept; it lives only in the
293
+ # journal line. Other kinds have no such concept, so they carry no key at all.
294
+ **({"holdout": holdout_from_record(record)} if row.get("kind") == "walkforward" else {}),
295
+ "quarantined": bool(row.get("contaminated_at")),
296
+ "quarantine_reason": row.get("contamination_reason"),
297
+ }
298
+ if accounting != current:
299
+ section["staleness"] = (
300
+ f"recorded under accounting v{accounting}; this engine is v{current}. The "
301
+ "recipe above still applies — every number in this section does not"
302
+ )
303
+ # Later trials served from this one are part of the same evidence: the number was
304
+ # reused, and a reader tracing a config back deserves the whole chain.
305
+ reused = store.reused_by(row) or []
306
+ if reused:
307
+ section["trial_ids"].extend(r["id"] for r in reused)
308
+ section["reused_by"] = reused
309
+ return section
310
+
311
+
312
+ def _seed(row, record) -> Dict[str, Any]:
313
+ """The seed this run used, and whether anybody wrote it down.
314
+
315
+ Read from the validation recipe first, because that is where it actually lives. The
316
+ ``seed`` column exists but only the research agent's session records ever populate
317
+ it — ``journal_trial`` has no seed parameter at all — so reading the column alone
318
+ reported ``None`` for every walk-forward driven from the CLI or MCP *while the same
319
+ block carried the real value two sections above*. A record that contradicts itself
320
+ about a fact it holds is worse than one that admits the fact is missing.
321
+
322
+ The column is still consulted, for the research-agent trials that do populate it and
323
+ may carry no recipe. Absent stays absent: a run whose seed nobody recorded reads as
324
+ unrecorded rather than as some default, which is the same rule every other fact in
325
+ this block follows.
326
+ """
327
+ recipe_seed = ((record or {}).get("dedup_params") or {}).get("seed")
328
+ stored = row.get("seed")
329
+
330
+ if recipe_seed is not None and stored is not None and recipe_seed != stored:
331
+ # Two records of one fact, disagreeing. The recipe is what defines the run, so
332
+ # it is the value — but reporting only the winner would hide that the record is
333
+ # internally inconsistent, and this block exists to make provenance checkable.
334
+ # Nothing here can tell which is the mistake, so it says both and neither is
335
+ # quietly dropped.
336
+ return {
337
+ "recorded": True,
338
+ "value": recipe_seed,
339
+ "from": "validation recipe",
340
+ "disagrees_with": {"value": stored, "from": "session record"},
341
+ }
342
+ if recipe_seed is not None:
343
+ return {"recorded": True, "value": recipe_seed, "from": "validation recipe"}
344
+ if stored is not None:
345
+ return {"recorded": True, "value": stored, "from": "session record"}
346
+ return {"recorded": False, "value": None, "from": None}
347
+
348
+
349
+ def _metadata_section(row, inputs, record, trial_id) -> Dict[str, Any]:
350
+ """About the record: where it came from, and what is still recoverable.
351
+
352
+ Artifacts are named by the command that reads them rather than by a path. A config
353
+ is a portable thing and the reader may be running an installed copy with a state
354
+ root somewhere else entirely, so a filesystem path is the one form of this that is
355
+ guaranteed wrong for somebody.
356
+ """
357
+ from quantred.services.setup import invocation
358
+
359
+ stored: List[Dict[str, Any]] = []
360
+ for name, key, how in (
361
+ ("return series", "returns", invocation(f"trials compare {trial_id} <other-trial-id>")),
362
+ ("trade table", "trades", invocation(f"trials analyze {trial_id}")),
363
+ ("proposed book", "weights", invocation(f"trials show {trial_id}")),
364
+ ):
365
+ present = record is not None and record.get(key) is not None
366
+ if record is not None and key == "returns" and not present:
367
+ # A research round keeps its series under `inputs`, not at the top level.
368
+ present = (record.get("inputs") or {}).get("oos_returns") is not None
369
+ stored.append({"artifact": name, "recorded": present, "read_with": how if present else None})
370
+
371
+ return {
372
+ "kind": METADATA,
373
+ "note": "about this record — not a claim about the strategy",
374
+ "trial_id": trial_id,
375
+ "recorded_at": row.get("ts"),
376
+ "git_sha": row.get("git_sha"),
377
+ "seed": _seed(row, record),
378
+ "journal_line_found": record is not None,
379
+ "notes": _recorded(inputs, "notes"),
380
+ "artifacts": stored,
381
+ }
@@ -0,0 +1,282 @@
1
+ """Config persistence service.
2
+
3
+ Thin wrappers over :mod:`quantred.optimization.config_store` that return JSON-able
4
+ results. Saving a config writes a file a human chooses to use; it **never**
5
+ affects any running process.
6
+ """
7
+
8
+ import dataclasses
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ from quantred.optimization import config_store
13
+
14
+ #: Device names Windows refuses as file names whatever the extension.
15
+ _RESERVED_WINDOWS_NAMES = {
16
+ "CON",
17
+ "PRN",
18
+ "AUX",
19
+ "NUL",
20
+ *(f"COM{i}" for i in range(1, 10)),
21
+ *(f"LPT{i}" for i in range(1, 10)),
22
+ }
23
+
24
+
25
+ def config_filename(name: Any) -> str:
26
+ """``name`` as the file it names inside the configs directory, or raise.
27
+
28
+ A name is a plain file name and nothing else: this is reachable from an agent, and
29
+ ``save_config(name="../x")`` wrote outside the configs directory (and a read of an
30
+ absolute path returned any JSON file on the machine). A directory component of any
31
+ kind, an absolute path, ``.`` / ``..`` and an empty stem are all refused, so every
32
+ config lives directly under :data:`~quantred.optimization.config_store.DEFAULT_CONFIG_DIR`.
33
+ """
34
+ text = name if isinstance(name, str) else ""
35
+ stem = text[: -len(".json")] if text.endswith(".json") else text
36
+ plain = (
37
+ bool(stem.strip())
38
+ and stem not in (".", "..")
39
+ and "/" not in text
40
+ and "\\" not in text
41
+ and ":" not in text # a drive letter, or an alternate data stream on Windows
42
+ and all(ord(ch) >= 32 for ch in text)
43
+ and stem.split(".")[0].upper() not in _RESERVED_WINDOWS_NAMES
44
+ and Path(text).name == text
45
+ and not Path(text).is_absolute()
46
+ )
47
+ if not plain:
48
+ raise ValueError(
49
+ f"Config name {name!r} is not a plain file name. Give a short name such as "
50
+ "'momentum_v2' - no directories, no '..', no absolute path; it is written under "
51
+ "the configs directory."
52
+ )
53
+ return f"{stem}.json"
54
+
55
+
56
+ def _validate_candidate(
57
+ strategy: str,
58
+ params: Dict[str, Any],
59
+ scanner: Optional[str],
60
+ position_limits: Optional[Dict[str, Any]],
61
+ provenance: Optional[Dict[str, Any]],
62
+ ) -> None:
63
+ """Refuse a config that could not be run, before anything is written.
64
+
65
+ A saved config is what a person later promotes, so one that names a strategy that
66
+ does not exist, a parameter the strategy does not declare, a value its range check
67
+ rejects, or a book limit nothing enforces is a file that fails (or worse, runs at
68
+ the defaults) only when someone relies on it.
69
+ """
70
+ from quantred.services.registry import resolve_strategy_class, scanner_refusal
71
+ from quantred.strategies.base import build_with_limits, refuse_unknown_limits, refuse_unknown_params
72
+
73
+ cls = resolve_strategy_class(strategy)
74
+ if scanner is not None and (refusal := scanner_refusal(scanner)):
75
+ raise ValueError(refusal)
76
+ refuse_unknown_params(cls, params)
77
+ refuse_unknown_limits(position_limits)
78
+ # A config states only what it overrides; the run fills in the declared defaults, so
79
+ # the candidate is built the same way to check that its overrides construct.
80
+ candidate = {name: spec["default"] for name, spec in cls.PARAM_RANGES.items()}
81
+ candidate.update({k: v for k, v in (params or {}).items() if not k.startswith("_")})
82
+ try:
83
+ build_with_limits(cls, candidate, position_limits)
84
+ except (ValueError, TypeError, KeyError) as exc:
85
+ raise ValueError(f"These params do not construct {strategy!r}: {exc}") from exc
86
+ if isinstance(provenance, dict):
87
+ accepted = [f.name for f in dataclasses.fields(config_store.Provenance)]
88
+ unknown = [key for key in provenance if key not in accepted]
89
+ if unknown:
90
+ raise ValueError(
91
+ f"provenance has no field {', '.join(map(repr, unknown))}. Accepted fields: "
92
+ f"{', '.join(accepted)}. Put a trial id in `notes` or in `campaign` "
93
+ "(the get_campaign_material block)."
94
+ )
95
+
96
+
97
+ def save_config(
98
+ name: str,
99
+ *,
100
+ strategy: str,
101
+ params: Dict[str, Any],
102
+ scanner: Optional[str] = None,
103
+ symbols: Optional[Any] = None,
104
+ capital: Optional[float] = None,
105
+ position_limits: Optional[Dict[str, Any]] = None,
106
+ provenance: Optional[Dict[str, Any]] = None,
107
+ overwrite: bool = False,
108
+ ) -> Dict[str, Any]:
109
+ """Persist a candidate config to ``configs/<name>.json``; return the path.
110
+
111
+ Does not affect any running process - a human promotes a config to live.
112
+
113
+ ``position_limits``, ``symbols`` and ``capital`` are here because without them this
114
+ surface could not write a *runnable* config at all: the file omits the book, and a
115
+ run from it inherits the strategy class's default of one position rather than the
116
+ book that was validated. An agent holding campaign material knows that book -
117
+ ``recorded_book`` reads it out - and had no parameter to put it in.
118
+
119
+ Omitted values stay omitted rather than being defaulted, so a config that never knew
120
+ its book is distinguishable from one saved at the class default.
121
+ """
122
+ filename = config_filename(name)
123
+ _refuse_replacing(filename, overwrite)
124
+ _validate_candidate(strategy, params, scanner, position_limits, provenance)
125
+ position_limits = _reconcile_book(position_limits, provenance)
126
+ capital = _reconcile_capital(capital, provenance)
127
+ cost = _recorded_cost(provenance)
128
+ prov = config_store.Provenance(**provenance) if isinstance(provenance, dict) else provenance
129
+ path = config_store.save_config(
130
+ filename,
131
+ strategy=strategy,
132
+ params=params,
133
+ scanner=scanner,
134
+ symbols=symbols,
135
+ capital=capital,
136
+ position_limits=position_limits,
137
+ cost=cost,
138
+ provenance=prov,
139
+ )
140
+ return {"path": str(path), "name": name}
141
+
142
+
143
+ def _refuse_replacing(filename: str, overwrite: bool) -> None:
144
+ """Refuse to replace a saved config unless asked to.
145
+
146
+ A config is what a person promotes, and an agent proposing a new one under a name
147
+ already in use silently replaced it. Compared without case, because a
148
+ case-insensitive filesystem (the macOS default) maps ``Momentum.json`` onto
149
+ ``momentum.json``.
150
+ """
151
+ root = config_store.DEFAULT_CONFIG_DIR
152
+ if overwrite or not root.exists():
153
+ return
154
+ taken = {path.name.lower(): path.name for path in root.glob("*.json")}
155
+ if filename.lower() in taken:
156
+ raise ValueError(
157
+ f"A config named {taken[filename.lower()]!r} already exists. Choose another name, or "
158
+ "pass overwrite=true to replace it."
159
+ )
160
+
161
+
162
+ def _reconcile_capital(capital: Optional[float], provenance: Optional[Any]) -> Optional[float]:
163
+ """The capital to write, checked against the one the provenance recorded.
164
+
165
+ The same rule the book gets, for the same reason: guarding one runnable field and
166
+ not the next leaves a config that can still argue with itself, one column over. An
167
+ omitted capital is taken from the campaign material that recorded it; a disagreeing
168
+ one is refused rather than resolved.
169
+
170
+ A campaign whose capital was never recorded changes nothing — absent stays absent,
171
+ and the caller's value (including none) stands.
172
+ """
173
+ recorded = _campaign_fact(provenance, "capital")
174
+ if recorded is None:
175
+ return capital
176
+ if capital is None:
177
+ return recorded
178
+ if float(capital) != float(recorded):
179
+ raise ValueError(
180
+ f"capital {capital} disagrees with the capital this config's own provenance "
181
+ f"records as validated ({recorded}). One of them is wrong and this cannot "
182
+ "tell which; pass the validated capital, or omit it and it will be used."
183
+ )
184
+ return capital
185
+
186
+
187
+ def _campaign_fact(provenance: Optional[Any], fact: str) -> Optional[Any]:
188
+ """One recorded run-context fact out of campaign material, or ``None``.
189
+
190
+ ``None`` covers both "no campaign material" and "the material recorded no such
191
+ fact", which are the same thing to a caller deciding whether there is anything to
192
+ reconcile against.
193
+ """
194
+ campaign = (provenance or {}).get("campaign") if isinstance(provenance, dict) else None
195
+ entry = ((campaign or {}).get("recipe") or {}).get("context", {}).get(fact) or {}
196
+ return entry.get("value") if entry.get("recorded") else None
197
+
198
+
199
+ def _recorded_cost(provenance: Optional[Any]) -> Optional[Dict[str, Any]]:
200
+ """The cost model the provenance's campaign material says was validated, or ``None``.
201
+
202
+ Taken rather than reconciled: this surface has no cost parameter to disagree with,
203
+ and a config that records an evidence block but no cost trades under the defaults
204
+ - the same file-argues-with-itself defect the book and capital guard against, and
205
+ the one ``trials promote`` shared. Absent stays absent.
206
+ """
207
+ from quantred.services.analysis import recorded_cost
208
+
209
+ campaign = (provenance or {}).get("campaign") if isinstance(provenance, dict) else None
210
+ return recorded_cost(((campaign or {}).get("recipe") or {}).get("folded_into_identity"))
211
+
212
+
213
+ def _reconcile_book(
214
+ position_limits: Optional[Dict[str, Any]], provenance: Optional[Any]
215
+ ) -> Optional[Dict[str, Any]]:
216
+ """The book to write, checked against the one the provenance says was validated.
217
+
218
+ A parameter is not a guarantee. Campaign material carries the validated book in its
219
+ recipe, and a caller could still pass ``None`` beside it - writing a config that
220
+ records an eight-position validation and runs at the class default of one, which is
221
+ exactly the contradiction this parameter was added to end. So when the provenance
222
+ knows the book:
223
+
224
+ * omitted means *take it from the provenance*, because the caller supplied the
225
+ evidence and leaving the runnable half blank is never what they meant;
226
+ * disagreeing is refused outright, because one of the two numbers is wrong and
227
+ nothing here can tell which - and a file that argues with itself is worse than
228
+ no file.
229
+
230
+ Provenance that carries no campaign material, or campaign material with no recorded
231
+ book, changes nothing: absent stays absent rather than being filled from a default
232
+ nobody validated.
233
+ """
234
+ from quantred.services.analysis import recorded_book
235
+
236
+ campaign = (provenance or {}).get("campaign") if isinstance(provenance, dict) else None
237
+ validated = recorded_book(((campaign or {}).get("recipe") or {}).get("folded_into_identity"))
238
+ if not validated:
239
+ return position_limits
240
+ if position_limits is None:
241
+ return validated
242
+ if dict(position_limits) != dict(validated):
243
+ raise ValueError(
244
+ f"position_limits {position_limits} disagrees with the book this config's own "
245
+ f"provenance records as validated ({validated}). One of them is wrong and this "
246
+ "cannot tell which; pass the validated book, or omit it and it will be used."
247
+ )
248
+ return position_limits
249
+
250
+
251
+ def load_config(name: str) -> Dict[str, Any]:
252
+ """Load a previously saved config by name (with or without ``.json``)."""
253
+ filename = config_filename(name)
254
+ path = config_store.DEFAULT_CONFIG_DIR / filename
255
+ if not path.is_file():
256
+ raise FileNotFoundError(f"No saved config named {name!r}.")
257
+ return config_store.load_config(path)
258
+
259
+
260
+ def list_configs(directory: Optional[str] = None) -> List[Dict[str, Any]]:
261
+ """List saved candidate configs with a compact summary of each."""
262
+ root = Path(directory) if directory else config_store.DEFAULT_CONFIG_DIR
263
+ if not root.exists():
264
+ return []
265
+ out: List[Dict[str, Any]] = []
266
+ for path in sorted(root.glob("*.json")):
267
+ try:
268
+ doc = config_store.load_config(path)
269
+ except (ValueError, OSError):
270
+ continue
271
+ prov = doc.get("provenance", {})
272
+ out.append(
273
+ {
274
+ "name": path.stem,
275
+ "strategy": doc.get("strategy"),
276
+ "scanner": doc.get("scanner"),
277
+ "objective": prov.get("objective"),
278
+ "oos_metrics": prov.get("oos_metrics", {}),
279
+ "timestamp": prov.get("timestamp"),
280
+ }
281
+ )
282
+ return out