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