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,574 @@
|
|
|
1
|
+
"""Append-only audit logging.
|
|
2
|
+
|
|
3
|
+
Every tool/service call can be logged with its inputs, the exact resolved config,
|
|
4
|
+
a ``run_id``, the git SHA, and a server-side timestamp - so any decision an agent
|
|
5
|
+
makes is replayable by a human later. Timestamps come from here, never the agent.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Optional
|
|
17
|
+
|
|
18
|
+
from quantred.engine.backtest import ACCOUNTING_VERSION
|
|
19
|
+
from quantred.optimization.config_store import current_git_sha
|
|
20
|
+
from quantred.settings import state_root, trial_journal_path
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
#: Default audit log location (append-only JSONL).
|
|
25
|
+
DEFAULT_AUDIT_PATH = state_root() / "logs" / "mcp_audit.jsonl"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
#: The shared research/trial journal: the append-only source of truth a trial store
|
|
29
|
+
#: indexes so multiple-testing counts can span a campaign, not one run.
|
|
30
|
+
#: The research agent and CLI ``backtest``/``optimize`` all append here, so they must
|
|
31
|
+
#: name the *same* file.
|
|
32
|
+
#:
|
|
33
|
+
#: Resolved on first *access*, not at import — see ``store.trials`` for why: reaching
|
|
34
|
+
#: the path creates the directory, and a constant that does so at import time turns a
|
|
35
|
+
#: read-only state root into a failure every command inherits before it runs.
|
|
36
|
+
def default_trial_journal() -> Path:
|
|
37
|
+
"""The journal every writer here appends to, honouring an override of the constant.
|
|
38
|
+
|
|
39
|
+
See ``store.trials.default_journal_path`` for why this is a function: a module
|
|
40
|
+
``__getattr__`` fires only for attribute access on the module, and an override
|
|
41
|
+
binds a name into the globals, which shadows it. Both paths have to keep working -
|
|
42
|
+
the constant is lazy *and* still redirectable.
|
|
43
|
+
"""
|
|
44
|
+
override = globals().get("DEFAULT_TRIAL_JOURNAL")
|
|
45
|
+
return Path(override) if override is not None else trial_journal_path()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def __getattr__(name: str) -> Any:
|
|
49
|
+
if name == "DEFAULT_TRIAL_JOURNAL":
|
|
50
|
+
return trial_journal_path()
|
|
51
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@contextlib.contextmanager
|
|
55
|
+
def open_trial_store(journal_path: Optional[Any] = None):
|
|
56
|
+
"""A trial store against ``journal_path`` (default: this module's journal), or
|
|
57
|
+
``None`` on any failure to open one.
|
|
58
|
+
|
|
59
|
+
One definition, because there were three: the CLI's, the analysis service's, and
|
|
60
|
+
the MCP server's, each opening the same file the same way and each free to drift
|
|
61
|
+
on the thing that matters — *which* journal. The default has to be
|
|
62
|
+
:func:`default_trial_journal`, not ``store.trials.default_journal_path``: those
|
|
63
|
+
two resolve alike in production and differ under a redirected journal, which is
|
|
64
|
+
every test in the suite and any run passing an explicit path.
|
|
65
|
+
|
|
66
|
+
The store is derived and passive, so a failure to open one is never the caller's
|
|
67
|
+
problem: every caller treats ``None`` as "skip the store, run normally", never as
|
|
68
|
+
an error to propagate. A broken index must not brick the command attached to it.
|
|
69
|
+
"""
|
|
70
|
+
from quantred.store.trials import TrialStore, db_path_for_journal
|
|
71
|
+
|
|
72
|
+
path = journal_path or default_trial_journal()
|
|
73
|
+
try:
|
|
74
|
+
store = TrialStore(db_path_for_journal(path), journal_path=path)
|
|
75
|
+
except Exception: # noqa: BLE001 - a passive store never breaks its caller
|
|
76
|
+
logger.warning("Trial store unavailable; continuing without it", exc_info=True)
|
|
77
|
+
yield None
|
|
78
|
+
return
|
|
79
|
+
try:
|
|
80
|
+
yield store
|
|
81
|
+
finally:
|
|
82
|
+
store.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
#: Metrics denormalized onto a trial record — enough for the gates and the Deflated
|
|
86
|
+
#: Sharpe without dumping the full metric block per config.
|
|
87
|
+
_TRIAL_METRICS = (
|
|
88
|
+
"sharpe_ratio",
|
|
89
|
+
"total_return",
|
|
90
|
+
"max_drawdown",
|
|
91
|
+
"profit_factor",
|
|
92
|
+
"total_trades",
|
|
93
|
+
"deflated_sharpe_ratio",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
_LOCK = threading.Lock()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def new_run_id() -> str:
|
|
100
|
+
"""A short, unique id for one tool invocation (for cross-referencing artifacts)."""
|
|
101
|
+
return uuid.uuid4().hex[:12]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def audit_log(
|
|
105
|
+
tool: str,
|
|
106
|
+
inputs: Dict[str, Any],
|
|
107
|
+
*,
|
|
108
|
+
run_id: Optional[str] = None,
|
|
109
|
+
resolved_config: Optional[Dict[str, Any]] = None,
|
|
110
|
+
result_summary: Optional[Dict[str, Any]] = None,
|
|
111
|
+
path: Optional[Path] = None,
|
|
112
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
113
|
+
) -> str:
|
|
114
|
+
"""Append one well-formed JSONL record describing a call; return its ``run_id``."""
|
|
115
|
+
run_id = run_id or new_run_id()
|
|
116
|
+
record = {
|
|
117
|
+
"run_id": run_id,
|
|
118
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
119
|
+
"git_sha": current_git_sha(),
|
|
120
|
+
# Which capital-accounting model produced any metrics in this record, so a
|
|
121
|
+
# journal spanning an engine change stays interpretable on replay.
|
|
122
|
+
"accounting": ACCOUNTING_VERSION,
|
|
123
|
+
"pid": os.getpid(),
|
|
124
|
+
"tool": tool,
|
|
125
|
+
"inputs": _safe(inputs),
|
|
126
|
+
"resolved_config": _safe(resolved_config) if resolved_config is not None else None,
|
|
127
|
+
"result_summary": _safe(result_summary) if result_summary is not None else None,
|
|
128
|
+
}
|
|
129
|
+
if extra:
|
|
130
|
+
record.update(_safe(extra))
|
|
131
|
+
|
|
132
|
+
target = Path(path) if path else DEFAULT_AUDIT_PATH
|
|
133
|
+
with _LOCK:
|
|
134
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
with target.open("a") as fh:
|
|
136
|
+
fh.write(json.dumps(record, default=str) + "\n")
|
|
137
|
+
return run_id
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
#: Keys of the run-context block, in the order a reader wants them. Declared so the
|
|
141
|
+
#: writer and every reader agree on the vocabulary rather than each inventing one.
|
|
142
|
+
RUN_CONTEXT_KEYS = (
|
|
143
|
+
"capital",
|
|
144
|
+
"scanner",
|
|
145
|
+
"scan_as_of",
|
|
146
|
+
"scan_as_of_explicit",
|
|
147
|
+
"cache",
|
|
148
|
+
"probes",
|
|
149
|
+
"notes",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_context(
|
|
154
|
+
*,
|
|
155
|
+
capital: Optional[float] = None,
|
|
156
|
+
scanner: Optional[str] = None,
|
|
157
|
+
scan_as_of: Optional[Any] = None,
|
|
158
|
+
scan_as_of_explicit: Optional[bool] = None,
|
|
159
|
+
cache: Optional[Dict[str, Any]] = None,
|
|
160
|
+
probes: Optional[Dict[str, Any]] = None,
|
|
161
|
+
notes: Optional[str] = None,
|
|
162
|
+
) -> Dict[str, Any]:
|
|
163
|
+
"""How a run was set up, for the parts that are not its identity.
|
|
164
|
+
|
|
165
|
+
The capital it ran at, the scanner and as-of clock its universe was resolved
|
|
166
|
+
through, the cache policy its bars came under, the probes that were run and what
|
|
167
|
+
they said, and whatever note a human attached. Nothing here is hashed, so adding a
|
|
168
|
+
field changes no trial's dedup key and invalidates no memo. Capital is the one fact
|
|
169
|
+
that also changes the result (whole-share rounding scales with it), so the backtest
|
|
170
|
+
and walk-forward keys fold it in separately - ``backtest_run_key`` and
|
|
171
|
+
``walk_forward_recipe`` - rather than through this record.
|
|
172
|
+
|
|
173
|
+
**Only what was actually known is written.** A key absent from the returned dict
|
|
174
|
+
means the run did not record that fact, which every reader must render as *not
|
|
175
|
+
recorded* rather than filling in a default. Capital in particular: a config with no
|
|
176
|
+
recorded capital is not a config that ran at zero, and the day one defaults it to
|
|
177
|
+
100,000 is the day a promoted config quietly claims a capital nobody chose.
|
|
178
|
+
|
|
179
|
+
One builder, because the vocabulary has to be identical across surfaces: a CLI trial
|
|
180
|
+
saying ``scan_as_of`` and an MCP one saying ``as_of`` would be two schemas for one
|
|
181
|
+
fact, and the campaign material reading them would have to know both.
|
|
182
|
+
"""
|
|
183
|
+
context: Dict[str, Any] = {
|
|
184
|
+
"capital": None if capital is None else float(capital),
|
|
185
|
+
"scanner": scanner or None,
|
|
186
|
+
"scan_as_of": _iso(scan_as_of),
|
|
187
|
+
"scan_as_of_explicit": scan_as_of_explicit,
|
|
188
|
+
"cache": dict(cache) if cache else None,
|
|
189
|
+
"probes": dict(probes) if probes else None,
|
|
190
|
+
"notes": notes or None,
|
|
191
|
+
}
|
|
192
|
+
return {key: value for key, value in context.items() if value is not None}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def cache_policy(
|
|
196
|
+
*,
|
|
197
|
+
cache: Optional[bool] = None,
|
|
198
|
+
offline: Optional[bool] = None,
|
|
199
|
+
cache_dir: Optional[Any] = None,
|
|
200
|
+
workers: Optional[int] = None,
|
|
201
|
+
) -> Dict[str, Any]:
|
|
202
|
+
"""The bar-cache policy a run actually read under, as a recorded fact.
|
|
203
|
+
|
|
204
|
+
Whether a result came from a live fetch or a cache, and whether the run was allowed
|
|
205
|
+
to reach the network at all, is part of what produced it - an offline replay against
|
|
206
|
+
a stale cache and a fresh fetch are different runs with the same parameters.
|
|
207
|
+
|
|
208
|
+
**The effective policy, not the flag.** Parallel execution is cache-backed by
|
|
209
|
+
construction: a live client cannot cross a process boundary, so asking for workers
|
|
210
|
+
implies the cache whether or not ``--cache`` was passed (see
|
|
211
|
+
``services.analysis._worker_data_spec``). Recording the flag alone had a
|
|
212
|
+
``--workers 4`` run write ``cache: false`` while every bar it read came through the
|
|
213
|
+
cache - a record less true than the run it describes. ``cache_implied_by`` names
|
|
214
|
+
what turned it on when the caller did not.
|
|
215
|
+
"""
|
|
216
|
+
from quantred.optimization.parallel import resolve_workers
|
|
217
|
+
|
|
218
|
+
parallel = workers is not None and resolve_workers(workers) > 1
|
|
219
|
+
effective = True if parallel else (None if cache is None else bool(cache))
|
|
220
|
+
policy: Dict[str, Any] = {
|
|
221
|
+
"cache": effective,
|
|
222
|
+
"cache_implied_by": "workers" if parallel and not cache else None,
|
|
223
|
+
"offline": None if offline is None else bool(offline),
|
|
224
|
+
"cache_dir": str(cache_dir) if cache_dir else None,
|
|
225
|
+
"workers": int(workers) if workers else None,
|
|
226
|
+
}
|
|
227
|
+
return {key: value for key, value in policy.items() if value is not None}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def probe_verdicts(report: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
231
|
+
"""Which probes a gate report ran, and what each said.
|
|
232
|
+
|
|
233
|
+
Three-valued on purpose. A probe that did not run has no entry at all, which is not
|
|
234
|
+
the same as one that ran and passed - a gate report that never exercised the leakage
|
|
235
|
+
probe and one that exercised it and cleared look identical everywhere else, and only
|
|
236
|
+
this keeps "not exercised" from reading as a pass.
|
|
237
|
+
|
|
238
|
+
Lives here rather than in the CLI because the service journals the same fact, and a
|
|
239
|
+
second extractor is a second answer to "did the probe run".
|
|
240
|
+
"""
|
|
241
|
+
checks = (report or {}).get("checks") or {}
|
|
242
|
+
verdicts: Dict[str, Any] = {}
|
|
243
|
+
for name, check in checks.items():
|
|
244
|
+
if not str(name).endswith("_probe"):
|
|
245
|
+
continue
|
|
246
|
+
verdicts[str(name)] = {
|
|
247
|
+
"value": check.get("value"),
|
|
248
|
+
"passed": check.get("passed"),
|
|
249
|
+
"ran": check.get("value") is not None,
|
|
250
|
+
}
|
|
251
|
+
return verdicts
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def journal_trial(
|
|
255
|
+
kind: str,
|
|
256
|
+
*,
|
|
257
|
+
strategy: str,
|
|
258
|
+
symbols: Any,
|
|
259
|
+
candidate_symbols: Optional[Any] = None,
|
|
260
|
+
start: Any,
|
|
261
|
+
end: Any,
|
|
262
|
+
params: Dict[str, Any],
|
|
263
|
+
metrics: Dict[str, Any],
|
|
264
|
+
objective: Optional[str] = None,
|
|
265
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
266
|
+
returns: Optional[Any] = None,
|
|
267
|
+
weights: Optional[Dict[str, Any]] = None,
|
|
268
|
+
trades: Optional[Dict[str, Any]] = None,
|
|
269
|
+
path: Optional[Path] = None,
|
|
270
|
+
dedup_params: Optional[Dict[str, Any]] = None,
|
|
271
|
+
context: Optional[Dict[str, Any]] = None,
|
|
272
|
+
) -> str:
|
|
273
|
+
"""Record one *evaluated configuration* as a trial in the research journal.
|
|
274
|
+
|
|
275
|
+
A trial is one config scored on one ``(universe, window)`` — the unit the
|
|
276
|
+
Deflated Sharpe counts. A grid search of 50 configs is 50 trials, so callers
|
|
277
|
+
over a search loop invoke this once per config, not once per run.
|
|
278
|
+
|
|
279
|
+
``kind`` groups records so a query can include or exclude a class of trial —
|
|
280
|
+
e.g. ``alpha`` runs are read-only forecasts with no Sharpe, so a multiple-testing
|
|
281
|
+
count should skip them while a dedup or IC query would not.
|
|
282
|
+
|
|
283
|
+
``extra`` carries record-level fields the flat ``(params, metrics)`` shape does
|
|
284
|
+
not, such as a walk-forward's internal ``n_trials`` or a promotion verdict.
|
|
285
|
+
|
|
286
|
+
``returns`` (optional) is this trial's own dated per-period OOS
|
|
287
|
+
return series (a ``pandas.Series`` with a ``DatetimeIndex``) — when given, it
|
|
288
|
+
is journaled alongside the summary metrics (so ``rebuild()`` can restore it
|
|
289
|
+
from the journal, the sole source of truth) and dual-written into the trial
|
|
290
|
+
store's ``trial_returns`` table, which is what lets a later Reality Check
|
|
291
|
+
resample this trial jointly with every other trial in its family. Omit it
|
|
292
|
+
(the default) for trial kinds with no genuine OOS series — e.g. ``optimize``
|
|
293
|
+
rows are in-sample search configs, not OOS track records, so they are never
|
|
294
|
+
passed one.
|
|
295
|
+
|
|
296
|
+
The universe is normalized (upper-cased, de-duplicated, sorted) so the same set
|
|
297
|
+
of symbols keys identically regardless of how it was typed — a trial store's
|
|
298
|
+
dedup and campaign counts depend on that.
|
|
299
|
+
|
|
300
|
+
``weights`` (optional) is the book this trial proposed - a
|
|
301
|
+
``{as_of, weights, active_weights, exposures}`` payload from a trial kind that
|
|
302
|
+
constructs a portfolio. Journaled on the record and mirrored into the trial
|
|
303
|
+
store's ``trial_weights`` table on the same journal-first terms as ``returns``,
|
|
304
|
+
so a result's holdings and factor exposures survive without re-running the
|
|
305
|
+
optimizer. Trial kinds that propose no portfolio omit it.
|
|
306
|
+
|
|
307
|
+
``trades`` (optional) is this run's trade table as
|
|
308
|
+
``{columns: [...], rows: [[...], ...]}``, journaled and mirrored into the trial
|
|
309
|
+
store's ``trial_trades`` table on the same journal-first terms. Opt-in at the
|
|
310
|
+
caller: a search evaluating thousands of candidates, each with hundreds of
|
|
311
|
+
trades, is storage nobody asked for, so only runs meant to be inspected pass it.
|
|
312
|
+
|
|
313
|
+
``dedup_params`` (optional) overrides what the trial store's dedup hash is
|
|
314
|
+
computed from, when a kind's *identity* (what makes a repeat a repeat) isn't
|
|
315
|
+
the same thing as ``params`` (what's useful to display). Persisted alongside
|
|
316
|
+
the record so a later ``trials rebuild`` reconstructs the identical hash from
|
|
317
|
+
the journal alone. Most kinds omit it — ``params`` already serves both roles.
|
|
318
|
+
"""
|
|
319
|
+
inputs: Dict[str, Any] = {
|
|
320
|
+
"strategy": strategy,
|
|
321
|
+
"symbols": sorted({str(s).upper() for s in symbols}),
|
|
322
|
+
"window": {"start": _iso(start), "end": _iso(end)},
|
|
323
|
+
}
|
|
324
|
+
if candidate_symbols:
|
|
325
|
+
# The list the universe was resolved *from*, so a config promoted out of this
|
|
326
|
+
# trial can offer a genuine re-resolution rather than a second filter over an
|
|
327
|
+
# already-filtered book. Omitted when absent: a trial that never recorded its
|
|
328
|
+
# candidates should read as incomplete, not as one whose universe was its own
|
|
329
|
+
# candidate list.
|
|
330
|
+
inputs["candidate_symbols"] = sorted({str(s).upper() for s in candidate_symbols})
|
|
331
|
+
if objective:
|
|
332
|
+
inputs["objective"] = objective
|
|
333
|
+
if context:
|
|
334
|
+
# Under `inputs`, and deliberately not under `params`: nothing here is hashed,
|
|
335
|
+
# so a trial recorded before these fields existed keeps its identity and a run
|
|
336
|
+
# that records them keys exactly as it would have without them.
|
|
337
|
+
inputs["context"] = {k: v for k, v in context.items() if v is not None}
|
|
338
|
+
result_summary = {k: metrics[k] for k in _TRIAL_METRICS if k in metrics}
|
|
339
|
+
journal_path = path or default_trial_journal()
|
|
340
|
+
extra_dict = dict(extra or {})
|
|
341
|
+
if dedup_params is not None:
|
|
342
|
+
extra_dict["dedup_params"] = dedup_params
|
|
343
|
+
returns_payload = _serialize_returns(returns)
|
|
344
|
+
if returns_payload is not None:
|
|
345
|
+
extra_dict["returns"] = returns_payload
|
|
346
|
+
if weights:
|
|
347
|
+
extra_dict["weights"] = _safe(weights)
|
|
348
|
+
if trades:
|
|
349
|
+
extra_dict["trades"] = _safe(trades)
|
|
350
|
+
run_id = audit_log(
|
|
351
|
+
f"trial:{kind}",
|
|
352
|
+
inputs,
|
|
353
|
+
resolved_config=dict(params),
|
|
354
|
+
result_summary=result_summary,
|
|
355
|
+
path=journal_path,
|
|
356
|
+
extra={"kind": kind, **extra_dict},
|
|
357
|
+
)
|
|
358
|
+
_index_trial(run_id, kind, inputs, params, result_summary, extra_dict, journal_path)
|
|
359
|
+
return run_id
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _serialize_returns(returns: Optional[Any]) -> Optional[Dict[str, Any]]:
|
|
363
|
+
"""A dated return series -> a JSON-safe ``{dates, values}`` payload, or
|
|
364
|
+
``None`` when there's nothing to persist (never a zero-length record)."""
|
|
365
|
+
if returns is None or len(returns) == 0:
|
|
366
|
+
return None
|
|
367
|
+
return {
|
|
368
|
+
"dates": [_iso(d) for d in returns.index],
|
|
369
|
+
"values": [float(v) for v in returns.to_numpy()],
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _index_trial(
|
|
374
|
+
run_id: str,
|
|
375
|
+
kind: str,
|
|
376
|
+
inputs: Dict[str, Any],
|
|
377
|
+
params: Dict[str, Any],
|
|
378
|
+
metrics: Dict[str, Any],
|
|
379
|
+
extra: Dict[str, Any],
|
|
380
|
+
journal_path: Path,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Dual-write this trial into the trial store, alongside the
|
|
383
|
+
journal append. Best-effort: the store is derived, never authoritative, so a
|
|
384
|
+
write failure here must never break the caller - ``trials rebuild`` resyncs
|
|
385
|
+
from the journal, which just succeeded.
|
|
386
|
+
"""
|
|
387
|
+
try:
|
|
388
|
+
from quantred.store.trials import TrialStore, db_path_for_journal
|
|
389
|
+
|
|
390
|
+
window = inputs.get("window") or {}
|
|
391
|
+
with TrialStore(db_path_for_journal(journal_path), journal_path=journal_path) as store:
|
|
392
|
+
store.record(
|
|
393
|
+
id=run_id,
|
|
394
|
+
kind=kind,
|
|
395
|
+
strategy=inputs.get("strategy"),
|
|
396
|
+
symbols=inputs.get("symbols") or [],
|
|
397
|
+
params=params,
|
|
398
|
+
accounting=ACCOUNTING_VERSION,
|
|
399
|
+
ts=datetime.now(timezone.utc).isoformat(),
|
|
400
|
+
window_start=window.get("start"),
|
|
401
|
+
window_end=window.get("end"),
|
|
402
|
+
oos_sharpe=metrics.get("sharpe_ratio"),
|
|
403
|
+
oos_profit_factor=metrics.get("profit_factor"),
|
|
404
|
+
oos_max_dd=metrics.get("max_drawdown"),
|
|
405
|
+
deflated_sharpe=metrics.get("deflated_sharpe_ratio"),
|
|
406
|
+
oos_trades=metrics.get("total_trades"),
|
|
407
|
+
efficiency=extra.get("efficiency"),
|
|
408
|
+
promotable=extra.get("promotable"),
|
|
409
|
+
n_trials_in_session=extra.get("n_trials"),
|
|
410
|
+
git_sha=current_git_sha(),
|
|
411
|
+
metrics_full=metrics,
|
|
412
|
+
hash_params=extra.get("dedup_params"),
|
|
413
|
+
)
|
|
414
|
+
returns_payload = extra.get("returns")
|
|
415
|
+
if returns_payload:
|
|
416
|
+
store.record_returns(
|
|
417
|
+
run_id, returns_payload.get("dates") or [], returns_payload.get("values") or []
|
|
418
|
+
)
|
|
419
|
+
store.record_weights(run_id, extra.get("weights"))
|
|
420
|
+
store.record_trades(run_id, extra.get("trades"))
|
|
421
|
+
except Exception: # noqa: BLE001 - the journal append above already succeeded
|
|
422
|
+
logger.warning("Trial store dual-write failed; `trials rebuild` will resync", exc_info=True)
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _iso(when: Any) -> Optional[str]:
|
|
426
|
+
"""ISO-format a datetime; pass through strings; ``None`` stays ``None``."""
|
|
427
|
+
if when is None or isinstance(when, str):
|
|
428
|
+
return when
|
|
429
|
+
iso = getattr(when, "isoformat", None)
|
|
430
|
+
return iso() if callable(iso) else str(when)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _safe(value: Any) -> Any:
|
|
434
|
+
"""Best-effort JSON-able coercion (datetimes, numpy scalars, nested)."""
|
|
435
|
+
if isinstance(value, dict):
|
|
436
|
+
return {str(k): _safe(v) for k, v in value.items()}
|
|
437
|
+
if isinstance(value, (list, tuple)):
|
|
438
|
+
return [_safe(v) for v in value]
|
|
439
|
+
if isinstance(value, datetime):
|
|
440
|
+
return value.isoformat()
|
|
441
|
+
item = getattr(value, "item", None)
|
|
442
|
+
if callable(item):
|
|
443
|
+
try:
|
|
444
|
+
return item()
|
|
445
|
+
except (ValueError, TypeError):
|
|
446
|
+
return str(value)
|
|
447
|
+
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
448
|
+
return value
|
|
449
|
+
return str(value)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def journal_record_for_trial(trial_id: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
|
|
453
|
+
"""The whole journal line a trial was written as, or ``None`` if there is none.
|
|
454
|
+
|
|
455
|
+
The store is derived and deliberately keeps only what its queries need — a
|
|
456
|
+
universe *hash* rather than symbols, a params *hash* rather than the recipe that
|
|
457
|
+
was hashed. Everything else a trial recorded is still in the journal, which is the
|
|
458
|
+
source of truth, so reading through to it beats adding columns: a trial written
|
|
459
|
+
before some field existed then reads as *less complete* rather than being
|
|
460
|
+
backfilled into looking more authoritative than it is.
|
|
461
|
+
|
|
462
|
+
One scanner, because "which line is this trial's" is one question. A second copy
|
|
463
|
+
of this loop would be free to disagree about what counts as a trial record.
|
|
464
|
+
"""
|
|
465
|
+
journal = Path(path or default_trial_journal())
|
|
466
|
+
if not journal.exists():
|
|
467
|
+
return None
|
|
468
|
+
for line in journal.read_text().splitlines():
|
|
469
|
+
try:
|
|
470
|
+
record = json.loads(line)
|
|
471
|
+
except ValueError: # a torn final line loses one record, never the file
|
|
472
|
+
continue
|
|
473
|
+
if record.get("run_id") == trial_id and str(record.get("tool", "")).startswith("trial:"):
|
|
474
|
+
return record
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def holdout_extra(result: Any, holdout_days: int) -> Dict[str, Any]:
|
|
479
|
+
"""The ``holdout`` field a walk-forward's journal line carries, or ``{}``.
|
|
480
|
+
|
|
481
|
+
A holdout is scored once and printed once, and the journal kept nothing of it: the
|
|
482
|
+
store, ``show`` and ``campaign`` could not say a holdout had ever been scored, and a
|
|
483
|
+
reused result dropped it without a word. The field is new and **absent on every line
|
|
484
|
+
written before it**, which a reader must keep distinct from a run that asked for no
|
|
485
|
+
holdout (``holdout_days`` in the recipe is 0) - see :func:`holdout_from_record`.
|
|
486
|
+
|
|
487
|
+
Written whenever a holdout was requested, even if it produced nothing, so "asked and
|
|
488
|
+
got no result" is also distinguishable from "asked and the result was not kept".
|
|
489
|
+
"""
|
|
490
|
+
if not holdout_days:
|
|
491
|
+
return {}
|
|
492
|
+
metrics = getattr(result, "holdout", None)
|
|
493
|
+
params = getattr(result, "holdout_params", None)
|
|
494
|
+
return {
|
|
495
|
+
"holdout": {
|
|
496
|
+
"days": int(holdout_days),
|
|
497
|
+
"metrics": _safe(metrics) if metrics else None,
|
|
498
|
+
"params": _safe(params) if params else None,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def holdout_from_record(record: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
504
|
+
"""What a walk-forward's journal line says about its holdout.
|
|
505
|
+
|
|
506
|
+
``state`` is one of ``recorded`` (the metrics are here, or ``metrics`` is None when a
|
|
507
|
+
holdout was requested and produced nothing), ``not_retained`` (a holdout was
|
|
508
|
+
requested but the line predates the field, so the result is gone), ``none_requested``
|
|
509
|
+
(the recipe asked for none) and ``unknown`` (no readable line, or a recipe that does
|
|
510
|
+
not say). Never a guess: an absent field is reported as absent.
|
|
511
|
+
"""
|
|
512
|
+
if record is None:
|
|
513
|
+
return {"state": "unknown", "days": None, "metrics": None}
|
|
514
|
+
saved = record.get("holdout")
|
|
515
|
+
if isinstance(saved, dict):
|
|
516
|
+
return {
|
|
517
|
+
"state": "recorded",
|
|
518
|
+
"days": saved.get("days"),
|
|
519
|
+
"metrics": saved.get("metrics"),
|
|
520
|
+
"params": saved.get("params"),
|
|
521
|
+
}
|
|
522
|
+
days = (record.get("dedup_params") or {}).get("holdout_days")
|
|
523
|
+
if days is None:
|
|
524
|
+
return {"state": "unknown", "days": None, "metrics": None}
|
|
525
|
+
if not days:
|
|
526
|
+
return {"state": "none_requested", "days": 0, "metrics": None}
|
|
527
|
+
return {"state": "not_retained", "days": int(days), "metrics": None}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def holdout_for_trial(trial_id: str, path: Optional[Path] = None) -> Dict[str, Any]:
|
|
531
|
+
""":func:`holdout_from_record` for the journal line of one trial."""
|
|
532
|
+
return holdout_from_record(journal_record_for_trial(trial_id, path))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def research_record_for_trial(trial_id: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
|
|
536
|
+
"""The journal line a research-agent round was written as, or ``None``.
|
|
537
|
+
|
|
538
|
+
A round is journaled as ``research:trial``, not ``trial:<kind>``, so
|
|
539
|
+
:func:`journal_record_for_trial` never finds it - and the campaign then reported
|
|
540
|
+
"the journal line could not be read" about a line that was sitting in the file under
|
|
541
|
+
a different tool name. A separate function because the two shapes differ (this one
|
|
542
|
+
keeps its figures under ``inputs`` and has no dedup recipe), and one that accepted
|
|
543
|
+
both would have to guess which it was holding.
|
|
544
|
+
"""
|
|
545
|
+
journal = Path(path or default_trial_journal())
|
|
546
|
+
if not journal.exists():
|
|
547
|
+
return None
|
|
548
|
+
for line in journal.read_text().splitlines():
|
|
549
|
+
try:
|
|
550
|
+
record = json.loads(line)
|
|
551
|
+
except ValueError:
|
|
552
|
+
continue
|
|
553
|
+
if record.get("run_id") == trial_id and record.get("tool") == "research:trial":
|
|
554
|
+
return record
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def universe_for_trial(trial_id: str, path: Optional[Path] = None) -> Optional[Dict[str, Any]]:
|
|
559
|
+
"""The universe a journaled trial ran on, read from the journal itself.
|
|
560
|
+
|
|
561
|
+
Returns ``None`` when no journaled trial carries this id - which is different from
|
|
562
|
+
a trial whose journal line has no ``candidate_symbols``, and callers must keep the
|
|
563
|
+
two apart.
|
|
564
|
+
"""
|
|
565
|
+
record = journal_record_for_trial(trial_id, path)
|
|
566
|
+
if record is None:
|
|
567
|
+
return None
|
|
568
|
+
inputs = record.get("inputs") or {}
|
|
569
|
+
return {
|
|
570
|
+
"symbols": list(inputs.get("symbols") or []),
|
|
571
|
+
"candidate_symbols": list(inputs.get("candidate_symbols") or []) or None,
|
|
572
|
+
"strategy": inputs.get("strategy"),
|
|
573
|
+
"window": inputs.get("window") or {},
|
|
574
|
+
}
|