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,868 @@
1
+ """The autonomous research loop and its non-negotiable guardrails - an AI that
2
+ hunts for edge, wrapped in enough skepticism to stop it from gleefully reporting
3
+ the 200 noise configs it "discovered."
4
+
5
+ ```
6
+ goal -> propose (Proposer) -> hygiene gate -> validate OUT-OF-SAMPLE (walk-forward)
7
+ -> keep iff "promotable" AND beats incumbent OOS (drawdown-guarded)
8
+ -> loop until trial budget / token budget / K dry rounds
9
+ -> score the shortlist ONCE on the sacred holdout -> save configs for a human
10
+ ```
11
+
12
+ Guardrails enforced here in code (not by prompt):
13
+ 1. **OOS-only fitness** - selection uses the walk-forward OOS aggregate; in-sample
14
+ metrics are recorded but never the criterion.
15
+ 2. **Multiple-testing correction** - ``n_trials`` accumulates across the whole
16
+ session and feeds the Deflated Sharpe (more attempts => higher bar).
17
+ 3. **Sacred holdout** - reserved up front, never passed to any search, scored
18
+ once at the end on the final shortlist.
19
+ 4. **Budgets + dryness stop** - hard caps on trials and tokens, and a
20
+ "K rounds with no OOS improvement" stop, so the loop can't chase noise forever.
21
+ 5. **Human-in-the-loop** - output is config files + a journal; nothing live, no
22
+ ``PAPER_TRADE`` toggle, no order capability reachable.
23
+ 6. **Full audit** - every proposal, trial, and decision is journaled and replayable.
24
+ 7. **Sandbox + hygiene** - generated code and configs pass :mod:`quantred.research.sandbox`
25
+ (<=5 params, rationale required, contract-valid) before evaluation.
26
+ """
27
+
28
+ import json
29
+ import logging
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime
32
+ from statistics import median
33
+ from typing import Any, Callable, Dict, List, Optional, Type
34
+
35
+ from quantred.costs.base import CostModel
36
+ from quantred.engine.backtest import ACCOUNTING_VERSION, BacktestError
37
+ from quantred.marketdata.client import MarketDataClient
38
+ from quantred.optimization import config_store
39
+ from quantred.optimization.walk_forward import WalkForwardValidator
40
+ from quantred.research.proposer import Proposal, ProposalContext, Proposer
41
+ from quantred.research.sandbox import load_strategy_from_code, validate_hygiene
42
+ from quantred.services import audit
43
+ from quantred.services.audit import audit_log, new_run_id
44
+ from quantred.services.registry import resolve_strategy_class
45
+ from quantred.store.trials import TrialStore, db_path_for_journal
46
+ from quantred.strategies.base import Strategy
47
+ from quantred.utils.errors import RunRefused
48
+ from quantred.utils.timeutils import shift_days
49
+
50
+ logger = logging.getLogger(__name__)
51
+
52
+
53
+ def draft_code_hash(code: str) -> str:
54
+ """The digest a generated strategy is identified by (one definition, in the service)."""
55
+ from quantred.services.analysis import draft_code_hash as _hash
56
+
57
+ return _hash(code)
58
+
59
+
60
+ def prior_holdout_looks(journal_path: str, holdout_window: Dict[str, str], symbols: List[str]) -> int:
61
+ """How many earlier sessions in this journal scored this same holdout over this universe.
62
+
63
+ Each look at a holdout is information the next search can overfit to, so a repeat is
64
+ worth saying out loud. Counted from the journal (the only record that carries both
65
+ the window and the scoring), and best-effort: an unreadable journal is zero looks
66
+ found, never a reason to stop a session.
67
+ """
68
+ wanted = sorted(s.upper() for s in symbols)
69
+ windows: Dict[str, bool] = {}
70
+ scored: set = set()
71
+ try:
72
+ with open(journal_path, encoding="utf-8") as handle:
73
+ for line in handle:
74
+ try:
75
+ record = json.loads(line)
76
+ except ValueError:
77
+ continue
78
+ inputs = record.get("inputs") or {}
79
+ sid = inputs.get("session_id")
80
+ if record.get("tool") == "research:session_start":
81
+ same = (
82
+ inputs.get("holdout_window") == holdout_window
83
+ and sorted(str(s).upper() for s in inputs.get("symbols") or []) == wanted
84
+ )
85
+ windows[sid] = same
86
+ elif record.get("tool") == "research:holdout_score":
87
+ scored.add(sid)
88
+ except OSError:
89
+ return 0
90
+ return sum(1 for sid in scored if windows.get(sid))
91
+
92
+
93
+ #: Default research-journal location (append-only JSONL). The same file CLI
94
+ #: ``backtest``/``optimize`` trials append to, so a trial store indexes one journal.
95
+ #: Lazy for the same reason the constant it names is: resolving the path creates the
96
+ #: directory, and doing that at import time makes a read-only state root fail before
97
+ #: any command has run.
98
+ def __getattr__(name: str) -> Any:
99
+ if name == "DEFAULT_JOURNAL":
100
+ return str(audit.default_trial_journal())
101
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
102
+
103
+
104
+ @dataclass
105
+ class ResearchConfig:
106
+ """Budgets and validation settings for one research session."""
107
+
108
+ goal: str = ""
109
+ # Walk-forward / validation settings (reused per trial).
110
+ mode: str = "anchored"
111
+ n_folds: int = 4
112
+ embargo_days: Optional[int] = None
113
+ holdout_days: int = 60
114
+ method: str = "grid"
115
+ objective: str = "sharpe_ratio"
116
+ max_evals: int = 25
117
+ capital: float = 100_000.0
118
+ # Loop budgets / stopping (guardrail).
119
+ max_trials: int = 10
120
+ max_dry_rounds: int = 3
121
+ max_tokens: Optional[int] = None
122
+ shortlist_size: int = 3
123
+ # Decision rule.
124
+ drawdown_guard_tolerance: float = 0.25 # OOS max_dd may worsen at most this fraction
125
+ allow_code_gen: bool = False
126
+ gates: Optional[Dict[str, float]] = None
127
+ #: Advisory only: annotate each shortlisted candidate with a
128
+ #: bootstrap-skill report (own + family p) after holdout scoring. Off by
129
+ #: default - it never influences keep/discard (guardrail 1 stays untouched).
130
+ bootstrap_skill: bool = False
131
+ #: Charged on every simulated fill. Left ``None`` the loop selects on gross
132
+ #: returns, which favors turnover the strategy could not afford live.
133
+ cost_model: Optional[CostModel] = None
134
+ #: How that model is spelled in a trial's identity (``{"gross": True}`` or the three
135
+ #: cost parameters) - the same shape every other command folds into its dedup key.
136
+ #: Left ``None`` it is derived from ``cost_model``, so a caller that builds the model
137
+ #: from flags passes the key it built the model from rather than have it re-derived.
138
+ cost_key: Optional[Dict[str, Any]] = None
139
+
140
+ def cost_identity(self) -> Dict[str, Any]:
141
+ """The cost assumptions as they enter a round's recorded identity."""
142
+ if self.cost_key is not None:
143
+ return dict(self.cost_key)
144
+ model = self.cost_model
145
+ if model is None:
146
+ return {"gross": True}
147
+ if hasattr(model, "commission_rate"):
148
+ return {
149
+ "gross": False,
150
+ "commission_bps": round(model.commission_rate * 1e4, 9),
151
+ "impact_eta": model.impact_eta,
152
+ "borrow_bps": round(model.annual_borrow_rate * 1e4, 9),
153
+ }
154
+ return {"gross": False, "model": type(model).__name__}
155
+
156
+
157
+ @dataclass
158
+ class Candidate:
159
+ """A config that cleared the gates and entered the shortlist."""
160
+
161
+ id: str
162
+ lineage: str
163
+ hypothesis: str
164
+ kind: str
165
+ strategy: str
166
+ params: Dict[str, Any]
167
+ oos_metrics: Dict[str, float]
168
+ gate_report: Dict[str, Any]
169
+ n_trials_at_selection: int
170
+ #: Median-across-folds OOS Sharpe - the measure the promotion gate applies, and so the
171
+ #: one candidates are compared and ranked on. ``oos_metrics`` is the stitched
172
+ #: aggregate, a different number; mixing the two compared a median with an aggregate.
173
+ oos_sharpe: Optional[float] = None
174
+ #: Source of the generated strategy, for a ``kind == "code"`` candidate. The config
175
+ #: saved from it names a ``draft:`` identity and cannot be loaded as a strategy; this
176
+ #: is what a human reviews before adding it to a strategy pack.
177
+ code: Optional[str] = None
178
+ holdout_metrics: Optional[Dict[str, float]] = None
179
+ saved_path: Optional[str] = None
180
+ strategy_cls: Optional[Type[Strategy]] = field(default=None, repr=False)
181
+ #: This candidate's dated OOS return series - kept so
182
+ #: ``bootstrap_skill`` can be computed once the holdout score is in, without
183
+ #: re-running the walk-forward.
184
+ oos_returns: Optional[Any] = field(default=None, repr=False)
185
+ #: The bootstrap-skill report, when ``ResearchConfig.bootstrap_skill``
186
+ #: is on - advisory only, computed after holdout scoring; never influences
187
+ #: ``_beats_incumbent`` or the keep/discard decision (guardrail 1: OOS-only,
188
+ #: gate-based fitness stays exactly as specced).
189
+ bootstrap_skill: Optional[Dict[str, Any]] = None
190
+
191
+
192
+ @dataclass
193
+ class ResearchResult:
194
+ shortlist: List[Candidate]
195
+ n_trials_total: int
196
+ rounds: int
197
+ stopped_reason: str
198
+ journal_path: str
199
+ holdout_window: Optional[Dict[str, str]] = None
200
+ saved_configs: List[str] = field(default_factory=list)
201
+ #: Rounds that reached a walk-forward (each is one journaled trial) and rounds
202
+ #: rejected before one ran. ``rounds`` is both: the number of proposals made.
203
+ n_evaluated: int = 0
204
+ n_rejected: int = 0
205
+ #: Whether the holdout was scored at all - it is only scored for a shortlist.
206
+ holdout_scored: bool = False
207
+ #: Earlier sessions in this journal that scored this same holdout window over this
208
+ #: same universe; each look at a holdout erodes it.
209
+ prior_holdout_looks: int = 0
210
+
211
+
212
+ class ResearchAgent:
213
+ """Drives the bounded research loop over the shared service core."""
214
+
215
+ def __init__(
216
+ self,
217
+ strategy_name: str,
218
+ data_client: MarketDataClient,
219
+ proposer: Proposer,
220
+ config: ResearchConfig,
221
+ *,
222
+ seed: int = 42,
223
+ # ``None``, not the constant: a default argument is evaluated when the class
224
+ # body runs, which is import time, and resolving this path creates a directory.
225
+ journal_path: Optional[str] = None,
226
+ observer: Optional[Callable[[str, Dict[str, Any]], None]] = None,
227
+ trial_store: Optional[TrialStore] = None,
228
+ ):
229
+ self.strategy_name = strategy_name
230
+ self.data_client = data_client
231
+ self.proposer = proposer
232
+ self.config = config
233
+ self.seed = seed
234
+ journal_path = journal_path or str(audit.default_trial_journal())
235
+ self.journal_path = journal_path
236
+ #: Optional live callback, invoked with every journaled ``(event, payload)``.
237
+ #: Purely observational - it cannot influence the loop or its decisions.
238
+ self.observer = observer
239
+ self.session_id = new_run_id()
240
+ #: The round being decided and the reason the last rejection gave; the loop reads
241
+ #: the second to tell the proposer why its last idea was refused.
242
+ self._round = 0
243
+ self._last_reject: Optional[str] = None
244
+ #: Counts as of the last failure, for a caller that has only the exception.
245
+ self.progress: Dict[str, int] = {}
246
+ #: The trial store this session dual-writes into and dedups
247
+ #: against - defaults to the store alongside this session's journal, so
248
+ #: redirecting ``journal_path`` (as tests do) isolates the store too.
249
+ #: Best-effort like every other trial-store touchpoint: the store is
250
+ #: derived, never authoritative, so a store-open failure (read-only
251
+ #: filesystem, disk full, sandboxed CI) degrades to no dedup/recording
252
+ #: rather than aborting the whole research session before it starts.
253
+ if trial_store is not None:
254
+ self.trial_store: Optional[TrialStore] = trial_store
255
+ else:
256
+ try:
257
+ self.trial_store = TrialStore(db_path_for_journal(journal_path), journal_path=journal_path)
258
+ except Exception: # noqa: BLE001 - never let store setup block research
259
+ logger.warning("Trial store unavailable; continuing without dedup/recording", exc_info=True)
260
+ self.trial_store = None
261
+
262
+ def run(self, symbols: List[str], start: datetime, end: datetime) -> ResearchResult:
263
+ cfg = self.config
264
+ # Refused before anything is journaled: these are inputs that cannot describe a
265
+ # session at all, and a session_start for one would be a claim nobody measured.
266
+ if cfg.holdout_days < 1:
267
+ raise RunRefused(
268
+ "holdout_days must be at least 1: the holdout is what the shortlist is scored on "
269
+ "once the search is over, and an empty one scores nothing."
270
+ )
271
+ if cfg.max_trials < 1 or cfg.max_dry_rounds < 1:
272
+ raise RunRefused("max_trials and max_dry_rounds must each be at least 1.")
273
+ # Reserve the sacred holdout up front; the search never sees it (guardrail).
274
+ research_end = shift_days(end, -cfg.holdout_days)
275
+ holdout_window = {"start": research_end.isoformat(), "end": end.isoformat()}
276
+ if research_end <= start:
277
+ raise RunRefused(
278
+ f"A {cfg.holdout_days}-day holdout leaves no research window between "
279
+ f"{start:%Y-%m-%d} and {end:%Y-%m-%d} (holdout_days leaves no research window); "
280
+ "widen the date range or shorten the holdout."
281
+ )
282
+
283
+ prior_looks = prior_holdout_looks(self.journal_path, holdout_window, symbols)
284
+ cost_identity = cfg.cost_identity()
285
+ self._journal(
286
+ "session_start",
287
+ {
288
+ "session_id": self.session_id,
289
+ "goal": cfg.goal,
290
+ "strategy": self.strategy_name,
291
+ "symbols": symbols,
292
+ "research_window": {"start": start.isoformat(), "end": research_end.isoformat()},
293
+ "holdout_window": holdout_window,
294
+ "budgets": {
295
+ "max_trials": cfg.max_trials,
296
+ "max_dry_rounds": cfg.max_dry_rounds,
297
+ "max_tokens": cfg.max_tokens,
298
+ },
299
+ "seed": self.seed,
300
+ "cost": cost_identity,
301
+ "prior_holdout_looks": prior_looks,
302
+ },
303
+ )
304
+
305
+ shortlist: List[Candidate] = []
306
+ saved: List[str] = []
307
+ state = {"rounds": 0, "evaluated": 0, "rejected": 0, "trials": 0}
308
+ stopped = "max_trials"
309
+ try:
310
+ stopped = self._loop(symbols, start, research_end, shortlist, state)
311
+ # Final exam: score the shortlist ONCE on the sacred holdout (guardrail).
312
+ saved = self._finalize(shortlist, symbols, research_end, end, state["trials"])
313
+ except Exception as exc:
314
+ # A session that was started is a session that ends: without this line the
315
+ # journal shows rounds that count toward the campaign and no sign of why
316
+ # they stopped.
317
+ category = getattr(exc, "category", None)
318
+ reason = f"provider_error:{category}" if category else f"error:{type(exc).__name__}"
319
+ self._journal(
320
+ "session_end",
321
+ {
322
+ "session_id": self.session_id,
323
+ "stopped_reason": reason,
324
+ "rounds": state["rounds"],
325
+ "n_evaluated": state["evaluated"],
326
+ "n_rejected": state["rejected"],
327
+ "n_trials_total": state["trials"],
328
+ "shortlist": [],
329
+ "saved_configs": [],
330
+ "holdout_scored": False,
331
+ },
332
+ )
333
+ self.progress = dict(state)
334
+ raise
335
+
336
+ self._journal(
337
+ "session_end",
338
+ {
339
+ "session_id": self.session_id,
340
+ "stopped_reason": stopped,
341
+ "rounds": state["rounds"],
342
+ "n_evaluated": state["evaluated"],
343
+ "n_rejected": state["rejected"],
344
+ "n_trials_total": state["trials"],
345
+ "shortlist": [c.id for c in shortlist],
346
+ "saved_configs": saved,
347
+ "holdout_scored": bool(shortlist),
348
+ },
349
+ )
350
+ return ResearchResult(
351
+ shortlist=shortlist,
352
+ n_trials_total=state["trials"],
353
+ rounds=state["rounds"],
354
+ stopped_reason=stopped,
355
+ journal_path=self.journal_path,
356
+ holdout_window=holdout_window,
357
+ saved_configs=saved,
358
+ n_evaluated=state["evaluated"],
359
+ n_rejected=state["rejected"],
360
+ holdout_scored=bool(shortlist),
361
+ prior_holdout_looks=prior_looks,
362
+ )
363
+
364
+ def _loop(self, symbols, start, research_end, shortlist: List[Candidate], state: Dict[str, int]) -> str:
365
+ """The propose -> evaluate -> keep loop; returns why it stopped."""
366
+ cfg = self.config
367
+ param_ranges = resolve_strategy_class(self.strategy_name).PARAM_RANGES
368
+ incumbent: Optional[Candidate] = None
369
+ history: List[Dict[str, Any]] = []
370
+ tokens_used = 0
371
+ dry = 0
372
+ stopped = "max_trials"
373
+
374
+ # The budget counts evaluated trials - rounds that reached a walk-forward and were
375
+ # journaled as one - not proposals. A rejected proposal costs no walk-forward and
376
+ # adds nothing to the multiple-testing count, so it does not spend a trial; the
377
+ # dry-round stop is what bounds a proposer that keeps being rejected.
378
+ while state["evaluated"] < cfg.max_trials:
379
+ if dry >= cfg.max_dry_rounds:
380
+ stopped = "dry_rounds"
381
+ break
382
+ if cfg.max_tokens is not None and tokens_used >= cfg.max_tokens:
383
+ stopped = "token_budget"
384
+ break
385
+
386
+ context = ProposalContext(
387
+ goal=cfg.goal,
388
+ strategy=self.strategy_name,
389
+ param_ranges=param_ranges,
390
+ history=history[-10:],
391
+ incumbent=self._incumbent_summary(incumbent),
392
+ round_index=state["rounds"],
393
+ )
394
+ proposal = self.proposer.propose(context)
395
+ if proposal is None:
396
+ stopped = "proposer_exhausted"
397
+ break
398
+ state["rounds"] += 1
399
+ rounds = state["rounds"]
400
+ self._round = rounds
401
+ tokens_used += proposal.tokens_used
402
+
403
+ n_trials_offset = state["trials"]
404
+ verdict = self._evaluate(proposal, symbols, start, research_end, n_trials_offset)
405
+ if verdict is None:
406
+ dry += 1
407
+ state["rejected"] += 1
408
+ history.append({"round": rounds, "rejected": True, "reason": self._last_reject})
409
+ continue
410
+
411
+ state["evaluated"] += 1
412
+ result, cls, full_params = verdict
413
+ state["trials"] = result.n_trials_total
414
+ n_trials_cumulative = state["trials"]
415
+ # In-sample median is recorded for contrast only - selection is OOS-only
416
+ # (guardrail 1). It is what makes the IS -> OOS collapse legible.
417
+ is_sharpe = float(
418
+ median([fr.is_metrics.get("sharpe_ratio", 0.0) for fr in result.folds] or [0.0])
419
+ )
420
+ # Median across folds: what the promotion gate applies, so what a round is
421
+ # compared and ranked on. The stitched aggregate stays in `oos_aggregate`.
422
+ oos_sharpe = result.median_oos("sharpe_ratio")
423
+ oos_dd = result.oos_aggregate.get("max_drawdown", 0.0)
424
+ gate_report = result.gate_report(cfg.gates)
425
+ promotable = gate_report["promotable"]
426
+ advanced = promotable and self._beats_incumbent(oos_sharpe, oos_dd, incumbent)
427
+
428
+ # Generated code is a different mechanism from the base strategy it was
429
+ # proposed beside, and recording it under the base's name made
430
+ # `--strategy <base>` mix the two (and fold their trials into one family).
431
+ # The draft walk-forward already records `draft:<Class>:<hash>`; this matches it.
432
+ row_strategy = self._row_strategy(proposal, cls)
433
+ # The cost assumptions are part of what was measured, so part of the identity
434
+ # a repeat is looked up under - a gross round must not answer a net request.
435
+ identity_params = {**full_params, "_cost": cfg.cost_identity()}
436
+ trial_payload = {
437
+ "session_id": self.session_id,
438
+ # Written into the journal line, which the store's rebuild reads: without
439
+ # it the rebuilt row falls back to the session's strategy and the store
440
+ # and its journal disagree about which strategy a round ran.
441
+ "strategy": row_strategy,
442
+ "round": rounds,
443
+ "kind": proposal.kind,
444
+ "hypothesis": proposal.hypothesis,
445
+ "params": identity_params,
446
+ "is_sharpe": is_sharpe,
447
+ "oos_sharpe": oos_sharpe,
448
+ "efficiency": result.median_efficiency(),
449
+ "oos_max_drawdown": oos_dd,
450
+ "oos_aggregate": result.oos_aggregate,
451
+ "gate_report": gate_report,
452
+ "promotable": promotable,
453
+ "advanced": advanced,
454
+ "n_trials_cumulative": n_trials_cumulative,
455
+ "tokens_used": tokens_used,
456
+ }
457
+ if proposal.kind == "code":
458
+ # The only durable copy of the mechanism: the strategy identity above is
459
+ # a hash, and a hash cannot be reviewed or added to a strategy pack.
460
+ trial_payload["code"] = proposal.code
461
+ trial_payload["code_hash"] = draft_code_hash(proposal.code)
462
+ # Persist this round's own dated OOS return series in the
463
+ # journal too (not just the trial store) - rebuild() must be able to
464
+ # restore it from the journal alone, the sole source of truth.
465
+ if result.oos_returns is not None and len(result.oos_returns):
466
+ trial_payload["oos_returns"] = {
467
+ "dates": [d.isoformat() for d in result.oos_returns.index],
468
+ "values": [float(v) for v in result.oos_returns.to_numpy()],
469
+ }
470
+ trial_run_id = self._journal("trial", trial_payload)
471
+ self._record_trial_row(
472
+ trial_run_id,
473
+ symbols,
474
+ start,
475
+ research_end,
476
+ identity_params,
477
+ result,
478
+ # Same resolution _evaluate's dedup check used - a proposal naming
479
+ # a different registered strategy must be recorded (and later
480
+ # deduped) under that strategy, not the session's default.
481
+ strategy=row_strategy,
482
+ is_sharpe=is_sharpe,
483
+ oos_sharpe=oos_sharpe,
484
+ oos_dd=oos_dd,
485
+ promotable=promotable,
486
+ n_trials_offset=n_trials_offset,
487
+ )
488
+ history.append(
489
+ {
490
+ "round": rounds,
491
+ "params": full_params,
492
+ "oos_sharpe": oos_sharpe,
493
+ "promotable": promotable,
494
+ "advanced": advanced,
495
+ }
496
+ )
497
+
498
+ if advanced:
499
+ candidate = Candidate(
500
+ id=new_run_id(),
501
+ lineage=self._lineage(proposal, incumbent),
502
+ hypothesis=proposal.hypothesis,
503
+ kind=proposal.kind,
504
+ strategy=row_strategy,
505
+ params=full_params,
506
+ oos_metrics=result.oos_aggregate,
507
+ gate_report=gate_report,
508
+ n_trials_at_selection=n_trials_cumulative,
509
+ oos_sharpe=oos_sharpe,
510
+ code=proposal.code if proposal.kind == "code" else None,
511
+ strategy_cls=cls,
512
+ oos_returns=result.oos_returns,
513
+ )
514
+ shortlist.append(candidate)
515
+ shortlist.sort(key=lambda c: c.oos_sharpe, reverse=True)
516
+ del shortlist[cfg.shortlist_size :]
517
+ incumbent = shortlist[0]
518
+ dry = 0
519
+ else:
520
+ dry += 1
521
+ return stopped
522
+
523
+ # ------------------------------------------------------------------ #
524
+ # Evaluation
525
+ # ------------------------------------------------------------------ #
526
+ def _reject(self, reason: str, proposal: Proposal, **extra: Any) -> None:
527
+ """Journal a rejected round, with the round it was and the reason it was refused."""
528
+ self._last_reject = reason
529
+ payload: Dict[str, Any] = {
530
+ "session_id": self.session_id,
531
+ "round": self._round,
532
+ "reason": reason,
533
+ "hypothesis": proposal.hypothesis,
534
+ "kind": proposal.kind,
535
+ }
536
+ if proposal.kind == "code" and isinstance(proposal.code, str) and proposal.code:
537
+ payload["code_hash"] = draft_code_hash(proposal.code)
538
+ payload.update(extra)
539
+ self._journal("reject", payload)
540
+
541
+ def _evaluate(self, proposal: Proposal, symbols, start, research_end, n_trials_offset):
542
+ """Hygiene-gate and validate one proposal OOS; return (result, cls, params) or None."""
543
+ cfg = self.config
544
+ if proposal.malformed:
545
+ self._reject(proposal.malformed, proposal)
546
+ return None
547
+ if proposal.kind == "code":
548
+ if not cfg.allow_code_gen:
549
+ self._reject("code-gen disabled", proposal)
550
+ return None
551
+ ok, reason = validate_hygiene(proposal)
552
+ if not ok:
553
+ self._reject(reason, proposal)
554
+ return None
555
+ try:
556
+ cls: Type[Strategy] = load_strategy_from_code(proposal.code)
557
+ except Exception as exc: # noqa: BLE001 - rejection, not crash
558
+ self._reject(f"sandbox: {exc}", proposal)
559
+ return None
560
+ ok, reason = validate_hygiene(proposal, cls)
561
+ else:
562
+ try:
563
+ cls = resolve_strategy_class(proposal.strategy or self.strategy_name)
564
+ except ValueError as exc:
565
+ self._reject(str(exc), proposal)
566
+ return None
567
+ ok, reason = validate_hygiene(proposal, cls)
568
+
569
+ if not ok:
570
+ self._reject(reason, proposal, params=(proposal.params))
571
+ return None
572
+
573
+ # Dedup: a repeat of an exact prior config for this strategy/universe/window/cost
574
+ # is the same lottery ticket checked twice - reject it before it burns a
575
+ # walk-forward. A generated strategy is identified by its code hash, so a repeat
576
+ # is the same source over the same window (its params are the *result* of a
577
+ # search and cannot be compared before it runs).
578
+ if self.trial_store is not None:
579
+ cost = {"_cost": cfg.cost_identity()}
580
+ if proposal.kind == "code":
581
+ repeat = self.trial_store.seen_strategy(
582
+ strategy=self._row_strategy(proposal, cls),
583
+ symbols=symbols,
584
+ window_start=start,
585
+ window_end=research_end,
586
+ accounting=ACCOUNTING_VERSION,
587
+ params_include=cost,
588
+ )
589
+ full_params: Dict[str, Any] = {}
590
+ else:
591
+ full_params = self._full_params(cls, proposal.params)
592
+ repeat = self.trial_store.seen(
593
+ strategy=proposal.strategy or self.strategy_name,
594
+ params={**full_params, **cost},
595
+ symbols=symbols,
596
+ window_start=start,
597
+ window_end=research_end,
598
+ accounting=ACCOUNTING_VERSION,
599
+ )
600
+ if repeat:
601
+ self._reject(
602
+ "duplicate: identical config already tried this campaign",
603
+ proposal,
604
+ params=(full_params) if full_params else None,
605
+ )
606
+ return None
607
+
608
+ try:
609
+ return self._validate(proposal, cls, symbols, start, research_end, n_trials_offset)
610
+ except BacktestError as exc:
611
+ # Unrunnable is not the same as unprofitable. Journal it as its own
612
+ # rejection so a broken proposal can never be scored as "no edge".
613
+ self._reject(f"unrunnable: {exc}", proposal)
614
+ return None
615
+
616
+ def _validate(self, proposal: Proposal, cls, symbols, start, research_end, n_trials_offset):
617
+ """Run the walk-forward validation for an already hygiene-cleared proposal."""
618
+ cfg = self.config
619
+ validator = WalkForwardValidator(
620
+ cls, self.data_client, cfg.capital, self.seed, cfg.gates, cfg.cost_model
621
+ )
622
+ if proposal.kind == "code":
623
+ # A new mechanism: let the optimizer search its PARAM_RANGES OOS.
624
+ result = validator.run(
625
+ symbols,
626
+ start,
627
+ research_end,
628
+ mode=cfg.mode,
629
+ n_folds=cfg.n_folds,
630
+ embargo_days=cfg.embargo_days,
631
+ holdout_days=0,
632
+ method=cfg.method,
633
+ objective=cfg.objective,
634
+ max_evals=cfg.max_evals,
635
+ n_trials_offset=n_trials_offset,
636
+ )
637
+ full_params = result.folds[-1].is_best_params if result.folds else {}
638
+ else:
639
+ full_params = self._full_params(cls, proposal.params)
640
+ result = validator.evaluate_config(
641
+ symbols,
642
+ start,
643
+ research_end,
644
+ full_params,
645
+ mode=cfg.mode,
646
+ n_folds=cfg.n_folds,
647
+ embargo_days=cfg.embargo_days,
648
+ objective=cfg.objective,
649
+ n_trials_offset=n_trials_offset,
650
+ )
651
+ if not result.folds:
652
+ self._reject(
653
+ "no folds evaluable (no fold produced a valid in-sample config or out-of-sample result)",
654
+ proposal,
655
+ params=(full_params),
656
+ )
657
+ return None
658
+ return result, cls, full_params
659
+
660
+ def _finalize(self, shortlist, symbols, holdout_start, holdout_end, n_trials_total) -> List[str]:
661
+ """Score each shortlisted config once on the holdout and persist it."""
662
+ saved: List[str] = []
663
+ for candidate in shortlist:
664
+ cls = candidate.strategy_cls or resolve_strategy_class(candidate.strategy)
665
+ validator = WalkForwardValidator(
666
+ cls,
667
+ self.data_client,
668
+ self.config.capital,
669
+ self.seed,
670
+ self.config.gates,
671
+ self.config.cost_model,
672
+ )
673
+ candidate.holdout_metrics = validator.score_window(
674
+ symbols,
675
+ holdout_start,
676
+ holdout_end,
677
+ candidate.params,
678
+ embargo_days=self.config.embargo_days,
679
+ n_trials=n_trials_total,
680
+ )
681
+ cost_identity = self.config.cost_identity()
682
+ provenance = config_store.build_provenance(
683
+ method=self.config.method,
684
+ objective=self.config.objective,
685
+ windows={
686
+ "holdout_start": holdout_start.isoformat(),
687
+ "holdout_end": holdout_end.isoformat(),
688
+ "mode": self.config.mode,
689
+ "n_folds": self.config.n_folds,
690
+ },
691
+ oos_metrics=candidate.oos_metrics,
692
+ n_trials=n_trials_total,
693
+ seed=self.seed,
694
+ notes=f"Research session {self.session_id}. Hypothesis: {candidate.hypothesis}",
695
+ )
696
+ # Generated source never executes when a config is loaded. The config names a
697
+ # draft identity no registry resolves; this is what a human reviews before
698
+ # adding the class to a strategy pack.
699
+ generated = (
700
+ {
701
+ "code": candidate.code,
702
+ "code_hash": draft_code_hash(candidate.code),
703
+ "class_name": cls.__name__,
704
+ }
705
+ if candidate.code is not None
706
+ else None
707
+ )
708
+ path = config_store.save_config(
709
+ f"research_{self.session_id}_{candidate.id}.json",
710
+ strategy=candidate.strategy,
711
+ params=candidate.params,
712
+ symbols=symbols,
713
+ # The agent searches parameters and never the book, so what it validated
714
+ # is the class's declared limits - stated rather than inherited, because
715
+ # a config that omits them is not unspecified, it silently resolves to
716
+ # the same defaults at load and nothing records that anyone chose them.
717
+ position_limits=(candidate.strategy_cls or resolve_strategy_class(candidate.strategy))(
718
+ dict(candidate.params)
719
+ ).position_limits(),
720
+ cost=cost_identity,
721
+ provenance=provenance,
722
+ generated_code=generated,
723
+ )
724
+ candidate.saved_path = str(path)
725
+ saved.append(str(path))
726
+ if self.config.bootstrap_skill:
727
+ candidate.bootstrap_skill = self._bootstrap_skill_for(candidate, symbols, n_trials_total)
728
+ self._journal(
729
+ "holdout_score",
730
+ {
731
+ "session_id": self.session_id,
732
+ "candidate": candidate.id,
733
+ "holdout_metrics": candidate.holdout_metrics,
734
+ "saved": str(path),
735
+ "bootstrap_skill": candidate.bootstrap_skill,
736
+ },
737
+ )
738
+ return saved
739
+
740
+ def _bootstrap_skill_for(
741
+ self, candidate: Candidate, symbols, n_trials_total: int
742
+ ) -> Optional[Dict[str, Any]]:
743
+ """Advisory-only: own p from this candidate's OOS return
744
+ series, next to the family p from every trial this session (and prior
745
+ sessions) has recorded for its strategy/universe. Never influences
746
+ keep/discard - a best-effort annotation on the final shortlist only."""
747
+ try:
748
+ from quantred.services.analysis import compute_bootstrap_skill
749
+
750
+ return compute_bootstrap_skill(
751
+ candidate.oos_returns,
752
+ candidate.strategy,
753
+ symbols,
754
+ n_trials_total,
755
+ candidate.oos_metrics,
756
+ journal_path=self.journal_path,
757
+ )
758
+ except Exception: # noqa: BLE001 - advisory only, must never break finalize
759
+ logger.warning("Bootstrap-skill annotation failed for a shortlisted candidate", exc_info=True)
760
+ return None
761
+
762
+ # ------------------------------------------------------------------ #
763
+ # Helpers
764
+ # ------------------------------------------------------------------ #
765
+ def _full_params(self, cls: Type[Strategy], overrides: Dict[str, Any]) -> Dict[str, Any]:
766
+ params = {name: spec["default"] for name, spec in cls.PARAM_RANGES.items() if "default" in spec}
767
+ params.update(overrides)
768
+ for name, value in params.items():
769
+ # An integral float for an int parameter (10.0) is the same number; keep the
770
+ # declared type so it is recorded, deduped and saved as 10, not 10.0.
771
+ spec = cls.PARAM_RANGES.get(name) or {}
772
+ if spec.get("type") == "int" and isinstance(value, float) and value.is_integer():
773
+ params[name] = int(value)
774
+ return params
775
+
776
+ def _beats_incumbent(self, oos_sharpe: float, oos_dd: float, incumbent: Optional[Candidate]) -> bool:
777
+ if incumbent is None:
778
+ return True
779
+ # Both sides are the median across folds - the gate's own measure. The incumbent's
780
+ # `oos_metrics` is the stitched aggregate, which is a different number.
781
+ incumbent_sharpe = incumbent.oos_sharpe if incumbent.oos_sharpe is not None else float("-inf")
782
+ better_sharpe = oos_sharpe > incumbent_sharpe
783
+ incumbent_dd = incumbent.oos_metrics.get("max_drawdown", float("inf"))
784
+ dd_ok = incumbent_dd == 0.0 or oos_dd <= incumbent_dd * (1 + self.config.drawdown_guard_tolerance)
785
+ return better_sharpe and dd_ok
786
+
787
+ @staticmethod
788
+ def _incumbent_summary(incumbent: Optional[Candidate]) -> Optional[Dict[str, Any]]:
789
+ if incumbent is None:
790
+ return None
791
+ return {"params": incumbent.params, "oos_sharpe": incumbent.oos_sharpe}
792
+
793
+ @staticmethod
794
+ def _lineage(proposal: Proposal, incumbent: Optional[Candidate]) -> str:
795
+ if proposal.parent_id:
796
+ return f"{proposal.parent_id}->v"
797
+ return incumbent.lineage + ".m" if incumbent else "root"
798
+
799
+ def _journal(self, event: str, payload: Dict[str, Any]) -> str:
800
+ run_id = audit_log(f"research:{event}", payload, path=self.journal_path)
801
+ if self.observer is not None:
802
+ # An observer must never be able to break the research loop.
803
+ try:
804
+ self.observer(event, payload)
805
+ except Exception: # noqa: BLE001 - narration is not load-bearing
806
+ logger.debug("research observer raised; continuing", exc_info=True)
807
+ return run_id
808
+
809
+ def _row_strategy(self, proposal: Proposal, cls) -> str:
810
+ """The strategy identity a round is recorded under."""
811
+ if proposal.kind == "code":
812
+ return f"draft:{cls.__name__}:{draft_code_hash(proposal.code)}"
813
+ return proposal.strategy or self.strategy_name
814
+
815
+ def _record_trial_row(
816
+ self,
817
+ run_id: str,
818
+ symbols,
819
+ start,
820
+ research_end,
821
+ full_params: Dict[str, Any],
822
+ result,
823
+ *,
824
+ strategy: str,
825
+ is_sharpe: float,
826
+ oos_sharpe: float,
827
+ oos_dd: float,
828
+ promotable: bool,
829
+ n_trials_offset: int,
830
+ ) -> None:
831
+ """Dual-write this round's trial into the trial store, keyed
832
+ on the same ``run_id`` the journal line just got - so a later rebuild
833
+ replaying that line is a no-op, not a duplicate.
834
+
835
+ Best-effort: the store is derived, never authoritative, so a write
836
+ failure here must never break the research loop.
837
+ """
838
+ try:
839
+ self.trial_store.record(
840
+ id=run_id,
841
+ kind="research",
842
+ strategy=strategy,
843
+ symbols=symbols,
844
+ params=full_params,
845
+ accounting=ACCOUNTING_VERSION,
846
+ session_id=self.session_id,
847
+ window_start=start,
848
+ window_end=research_end,
849
+ is_sharpe=is_sharpe,
850
+ oos_sharpe=oos_sharpe,
851
+ oos_profit_factor=result.oos_aggregate.get("profit_factor"),
852
+ oos_max_dd=oos_dd,
853
+ deflated_sharpe=result.oos_aggregate.get("deflated_sharpe_ratio"),
854
+ efficiency=result.median_efficiency(),
855
+ oos_trades=result.oos_aggregate.get("total_trades"),
856
+ promotable=promotable,
857
+ n_trials_in_session=result.n_trials_total - n_trials_offset,
858
+ git_sha=config_store.current_git_sha(),
859
+ metrics_full=result.oos_aggregate,
860
+ )
861
+ if result.oos_returns is not None and len(result.oos_returns):
862
+ self.trial_store.record_returns(
863
+ run_id,
864
+ [d.isoformat() for d in result.oos_returns.index],
865
+ [float(v) for v in result.oos_returns.to_numpy()],
866
+ )
867
+ except Exception: # noqa: BLE001 - the journal append above already succeeded
868
+ logger.warning("Trial store dual-write failed for a research trial", exc_info=True)