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