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