quantlab-factor-agent 0.1.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.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
"""Acceptance: the accepted factors' incremental test in the acceptance model, the one-time hold-out, and their export.
|
|
2
|
+
|
|
3
|
+
``validate`` trains the acceptance model (``AcceptanceConfig.model``, a
|
|
4
|
+
template that ``factor_agent._acceptance_model`` fills in) twice per seed: on
|
|
5
|
+
its baseline (its own factors, or the reference set) and on that plus a
|
|
6
|
+
selection of the factors the campaign accepted (every one by default, else
|
|
7
|
+
named as ``export`` names them), all read through the campaign's
|
|
8
|
+
``Scoring`` (on the members of day t, or on every symbol without a
|
|
9
|
+
membership). It trains on the training segment and predicts the validation
|
|
10
|
+
segment, both purged by ``Segments``. Each accepted factor enters the model
|
|
11
|
+
as the Evaluator scored it: its graph ranked across the whole market and
|
|
12
|
+
neutralised when the scoring neutralises (a store written under the run
|
|
13
|
+
directory), read as the scoring reads. A factor of an intraday family is
|
|
14
|
+
computed in its stages, its session columns from the intraday dataset
|
|
15
|
+
(``GraphFactor.staged``), as the Evaluator computes it.
|
|
16
|
+
|
|
17
|
+
With ``acceptance.contribution``, ``validate`` also measures each factor of
|
|
18
|
+
the selection's contribution, with the same seeds: drop-one trains the model
|
|
19
|
+
on the baseline plus every factor of the selection but it (the whole
|
|
20
|
+
selection against that), add-one on the baseline plus only it (against the
|
|
21
|
+
baseline). A feature set equal to another (a one-factor selection's drop-one
|
|
22
|
+
is the baseline) is trained once.
|
|
23
|
+
|
|
24
|
+
``open_holdout`` makes the same comparison on the hold-out for a selection
|
|
25
|
+
that ``validate`` reported on, trained on the training and validation
|
|
26
|
+
segments. It first records the open in the lock
|
|
27
|
+
(``Segments.open_holdout``): a second open is refused unless forced, and a
|
|
28
|
+
forced one is stated in its report, and it never measures contribution. Reports of every accepted factor are
|
|
29
|
+
``acceptance/<segment>.json|md``, of a selection of fewer
|
|
30
|
+
``acceptance/<segment>_<digest>.json|md``.
|
|
31
|
+
|
|
32
|
+
Every trained model's result is cached (``factor_agent._model_cache``), so a
|
|
33
|
+
re-run trains only the models not finished; each run first prints how many
|
|
34
|
+
it will train and how many are cached.
|
|
35
|
+
|
|
36
|
+
The comparison, on the members of each day of the predicted segment (every
|
|
37
|
+
symbol without a membership) and
|
|
38
|
+
against the label the model trains on, per model and averaged over seeds:
|
|
39
|
+
|
|
40
|
+
- ``rank_ic``, ``ic``: the mean daily rank and Pearson IC of the prediction;
|
|
41
|
+
- ``decile_returns``, ``monotonicity``, ``long_short``: the mean label of
|
|
42
|
+
each tenth of the daily cross section (lowest predictions first), their
|
|
43
|
+
Spearman correlation with the decile number and the top decile's minus
|
|
44
|
+
the bottom one's;
|
|
45
|
+
- ``top_k_return``, ``top_k_excess``: the mean label of the plain TopK book
|
|
46
|
+
(the ``top_k`` members with the highest prediction, equal weights, no
|
|
47
|
+
smoothing) and its excess over all members, fee-free, over the label's
|
|
48
|
+
horizon; ``top_k_annualised`` compounds ``top_k_return`` to a year of 252
|
|
49
|
+
bars. These read the label as a raw forward return.
|
|
50
|
+
|
|
51
|
+
``export`` writes each named accepted factor as a module of
|
|
52
|
+
``AcceptanceConfig.package`` holding a ``GraphFactor`` subclass, and its
|
|
53
|
+
whole-market store, the ranked graph scored as above (neutralised or not), under
|
|
54
|
+
``factor_root/<name>/`` with ``README.md`` and ``component.json``. An
|
|
55
|
+
intraday factor's class holds its daily graph, the intraday graph of each
|
|
56
|
+
session column it reads and the intraday dataset's config, so it recomputes
|
|
57
|
+
from the intraday bars wherever the module is imported.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
import dataclasses
|
|
61
|
+
import hashlib
|
|
62
|
+
import importlib
|
|
63
|
+
import json
|
|
64
|
+
import keyword
|
|
65
|
+
import pprint
|
|
66
|
+
import shutil
|
|
67
|
+
import time
|
|
68
|
+
import warnings
|
|
69
|
+
from collections.abc import Callable
|
|
70
|
+
from dataclasses import dataclass, field
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
import numpy as np
|
|
74
|
+
import xarray as xr
|
|
75
|
+
from quantlab.core.store_folder import save_component
|
|
76
|
+
from quantlab.factor.config import FactorConfig
|
|
77
|
+
from quantlab.model.base import BaseModel
|
|
78
|
+
from quantlab.model.evaluation import cross_sectional_ic_series, cross_sectional_rank_ic_series
|
|
79
|
+
from quantlab.runs.trained_run import TrainedRun
|
|
80
|
+
|
|
81
|
+
from factor_agent import _acceptance_model, _admission, _feedback, _model_cache
|
|
82
|
+
from factor_agent.config import CampaignConfig, load
|
|
83
|
+
from factor_agent.evaluator import decile_returns
|
|
84
|
+
from factor_agent.exported import GraphFactor
|
|
85
|
+
from factor_agent.ledger import Ledger, TrialRecord
|
|
86
|
+
from factor_agent.scoring import Scoring
|
|
87
|
+
from factor_agent.segments import HoldoutLock, Segment, code_version
|
|
88
|
+
from factor_agent.snapshot import RunSnapshot
|
|
89
|
+
|
|
90
|
+
#: Bars a year, for ``top_k_annualised``.
|
|
91
|
+
BARS_PER_YEAR = 252
|
|
92
|
+
#: The statistics averaged over seeds and compared.
|
|
93
|
+
COMPARED = ("rank_ic", "ic", "monotonicity", "long_short", "top_k_return", "top_k_excess", "top_k_annualised")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class ModelResult:
|
|
98
|
+
"""One model's statistics on the predicted segment; see the module docstring."""
|
|
99
|
+
|
|
100
|
+
seed: int
|
|
101
|
+
rank_ic: float
|
|
102
|
+
ic: float
|
|
103
|
+
monotonicity: float
|
|
104
|
+
long_short: float
|
|
105
|
+
top_k_return: float
|
|
106
|
+
top_k_excess: float
|
|
107
|
+
top_k_annualised: float
|
|
108
|
+
decile_returns: tuple[float, ...]
|
|
109
|
+
n_days: int
|
|
110
|
+
run: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _mean(results: tuple[ModelResult, ...]) -> dict[str, float]:
|
|
114
|
+
"""Each compared statistic averaged over the seeds."""
|
|
115
|
+
return {name: float(np.mean([getattr(r, name) for r in results])) for name in COMPARED}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _difference(without: tuple[ModelResult, ...], with_: tuple[ModelResult, ...]) -> dict[str, float]:
|
|
119
|
+
"""``with_`` minus ``without``, of each seed-averaged statistic."""
|
|
120
|
+
base, new = _mean(without), _mean(with_)
|
|
121
|
+
return {name: new[name] - base[name] for name in COMPARED}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class Contribution:
|
|
126
|
+
"""One accepted factor's contribution in one mode: the models with it against the same models without it.
|
|
127
|
+
|
|
128
|
+
``mode`` is ``drop_one`` (``without``: the selection without the factor,
|
|
129
|
+
``with_factor``: the whole selection) or ``add_one`` (``without``: the
|
|
130
|
+
baseline, ``with_factor``: the baseline with only the factor); both
|
|
131
|
+
sides hold the same seeds in the same order.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
factor: str
|
|
135
|
+
mode: str
|
|
136
|
+
without: tuple[ModelResult, ...]
|
|
137
|
+
with_factor: tuple[ModelResult, ...]
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def difference(self) -> dict[str, float]:
|
|
141
|
+
"""With the factor minus without it, of each seed-averaged statistic."""
|
|
142
|
+
return _difference(self.without, self.with_factor)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def agreeing(self) -> dict[str, int]:
|
|
146
|
+
"""Per statistic, the seeds whose difference has the sign of the seed-averaged difference."""
|
|
147
|
+
mean = self.difference
|
|
148
|
+
return {
|
|
149
|
+
name: sum(
|
|
150
|
+
int(np.sign(getattr(w, name) - getattr(o, name)) == np.sign(mean[name]))
|
|
151
|
+
for o, w in zip(self.without, self.with_factor)
|
|
152
|
+
)
|
|
153
|
+
for name in COMPARED
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class AcceptanceReport:
|
|
159
|
+
"""The baseline against the baseline plus the accepted factors, seed by seed and on average.
|
|
160
|
+
|
|
161
|
+
``accepted`` maps each factor added to the baseline, by label, to its
|
|
162
|
+
output name in the model: every accepted factor, or the members of a
|
|
163
|
+
selection when ``selection`` is true. ``forced`` is true when the hold-out was opened past its lock;
|
|
164
|
+
``holdout_opens`` lists every open of the hold-out so far (empty for a
|
|
165
|
+
validation report). ``contributions`` holds each accepted factor's
|
|
166
|
+
contribution per mode (``acceptance.contribution``), empty when none was
|
|
167
|
+
asked for; there is no threshold.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
segment: str
|
|
171
|
+
train_window: tuple[str, str]
|
|
172
|
+
test_window: tuple[str, str]
|
|
173
|
+
seeds: tuple[int, ...]
|
|
174
|
+
top_k: int
|
|
175
|
+
accepted: dict
|
|
176
|
+
baseline: tuple[ModelResult, ...]
|
|
177
|
+
with_accepted: tuple[ModelResult, ...]
|
|
178
|
+
selection: bool = False
|
|
179
|
+
forced: bool = False
|
|
180
|
+
holdout_opens: tuple[dict, ...] = ()
|
|
181
|
+
code_version: dict = field(default_factory=dict)
|
|
182
|
+
contributions: tuple[Contribution, ...] = ()
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def difference(self) -> dict[str, float]:
|
|
186
|
+
"""With the accepted factors minus the baseline, of each seed-averaged statistic."""
|
|
187
|
+
return _difference(self.baseline, self.with_accepted)
|
|
188
|
+
|
|
189
|
+
def contribution_table(self) -> list[dict]:
|
|
190
|
+
"""One row per factor and mode: ``factor``, ``mode``, ``difference`` and ``agreeing`` of every statistic.
|
|
191
|
+
|
|
192
|
+
Sorted by the rank IC difference, the largest first; a NaN difference sorts last.
|
|
193
|
+
"""
|
|
194
|
+
rows = [
|
|
195
|
+
{"factor": c.factor, "mode": c.mode, "difference": c.difference, "agreeing": c.agreeing}
|
|
196
|
+
for c in self.contributions
|
|
197
|
+
]
|
|
198
|
+
return sorted(rows, key=lambda row: -row["difference"]["rank_ic"] if np.isfinite(row["difference"]["rank_ic"]) else np.inf)
|
|
199
|
+
|
|
200
|
+
def to_dict(self) -> dict:
|
|
201
|
+
"""The report as ``<segment>.json`` holds it, with the seed means, the difference and the contribution table."""
|
|
202
|
+
out = dataclasses.asdict(self)
|
|
203
|
+
out["mean"] = {"baseline": _mean(self.baseline), "with_accepted": _mean(self.with_accepted)}
|
|
204
|
+
out["difference"] = self.difference
|
|
205
|
+
out["contribution"] = self.contribution_table()
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
def markdown(self) -> str:
|
|
209
|
+
"""The report as a short Markdown page."""
|
|
210
|
+
base, new, diff = _mean(self.baseline), _mean(self.with_accepted), self.difference
|
|
211
|
+
lines = [f"# Acceptance: {self.segment}", ""]
|
|
212
|
+
if self.forced:
|
|
213
|
+
lines += [
|
|
214
|
+
f"**The hold-out was opened again with force: this is open {len(self.holdout_opens)}, the first "
|
|
215
|
+
f"was on {self.holdout_opens[0]['opened_at']}. It is no longer an untouched hold-out.**", "",
|
|
216
|
+
]
|
|
217
|
+
lines += [
|
|
218
|
+
f"- Trained {self.train_window[0]} to {self.train_window[1]}, predicted "
|
|
219
|
+
f"{self.test_window[0]} to {self.test_window[1]} (both purged); seeds {list(self.seeds)}.",
|
|
220
|
+
f"- {'Selection of accepted factors' if self.selection else 'Accepted factors'} ({len(self.accepted)}): "
|
|
221
|
+
+ ", ".join(self.accepted) + ".",
|
|
222
|
+
f"- Plain TopK of {self.top_k}, equal weights; returns are fee-free, over the label's horizon.", "",
|
|
223
|
+
"| statistic | baseline | with accepted | difference |", "|---|---|---|---|",
|
|
224
|
+
*(f"| {name} | {base[name]:.4f} | {new[name]:.4f} | {diff[name]:+.4f} |" for name in COMPARED), "",
|
|
225
|
+
"| seed | baseline rank IC | with accepted rank IC | baseline TopK excess | with accepted TopK excess |",
|
|
226
|
+
"|---|---|---|---|---|",
|
|
227
|
+
*(
|
|
228
|
+
f"| {b.seed} | {b.rank_ic:.4f} | {a.rank_ic:.4f} | {b.top_k_excess:.4f} | {a.top_k_excess:.4f} |"
|
|
229
|
+
for b, a in zip(self.baseline, self.with_accepted)
|
|
230
|
+
),
|
|
231
|
+
]
|
|
232
|
+
if self.contributions:
|
|
233
|
+
lines += [
|
|
234
|
+
"", "## Contribution", "",
|
|
235
|
+
"Per accepted factor: drop_one is the whole selection against the selection without it, add_one the "
|
|
236
|
+
"baseline with only it against the baseline; seed-averaged differences, and the seeds whose rank IC "
|
|
237
|
+
"difference has the mean's sign. No threshold.", "",
|
|
238
|
+
"| factor | mode | rank IC difference | seeds agreeing | TopK excess difference |", "|---|---|---|---|---|",
|
|
239
|
+
*(
|
|
240
|
+
f"| {row['factor']} | {row['mode']} | {row['difference']['rank_ic']:+.4f} | "
|
|
241
|
+
f"{row['agreeing']['rank_ic']}/{len(self.seeds)} | {row['difference']['top_k_excess']:+.4f} |"
|
|
242
|
+
for row in self.contribution_table()
|
|
243
|
+
),
|
|
244
|
+
]
|
|
245
|
+
return "\n".join(lines) + "\n"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class Acceptance:
|
|
249
|
+
"""Validate, open the hold-out of, and export a campaign's accepted factors; see the module docstring.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
config : CampaignConfig
|
|
254
|
+
The campaign's config; its ``segments`` need ``validation`` (and
|
|
255
|
+
``holdout`` for ``open_holdout``).
|
|
256
|
+
run_dir : str or Path, optional
|
|
257
|
+
The campaign's run directory; ``config.run_dir`` by default.
|
|
258
|
+
|
|
259
|
+
The accepted graphs are read with the vocabulary of the run's snapshot
|
|
260
|
+
(``RunSnapshot``), not of the config's field and operator files.
|
|
261
|
+
|
|
262
|
+
Raises
|
|
263
|
+
------
|
|
264
|
+
FileNotFoundError
|
|
265
|
+
If the run directory has no snapshot.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, config: CampaignConfig, run_dir: str | Path | None = None):
|
|
269
|
+
self.config = config
|
|
270
|
+
self.run_dir = Path(run_dir if run_dir is not None else config.run_dir)
|
|
271
|
+
#: The campaign's vocabulary, from its run's snapshot: the operator classes the accepted graphs are built from.
|
|
272
|
+
self.vocabulary = config.vocabulary(files=RunSnapshot.read(self.run_dir).search_files)
|
|
273
|
+
self._loaded: dict = {}
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def lock_path(self) -> Path:
|
|
277
|
+
"""The hold-out lock."""
|
|
278
|
+
return self.run_dir / "holdout.lock"
|
|
279
|
+
|
|
280
|
+
def accepted(self) -> list[TrialRecord]:
|
|
281
|
+
"""The ledger's accepted factors, in the order they were accepted."""
|
|
282
|
+
return [r for r in Ledger(self.run_dir / "ledger.jsonl").records() if r.status == "accepted"]
|
|
283
|
+
|
|
284
|
+
def validate(self, selection: list[str] | None = None) -> AcceptanceReport:
|
|
285
|
+
"""Compare the baseline with the baseline plus a selection of the accepted factors on the validation segment.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
selection : list of str, optional
|
|
290
|
+
The accepted factors to add, named as ``export`` names them (by
|
|
291
|
+
description or label); every accepted factor by default.
|
|
292
|
+
|
|
293
|
+
Writes ``acceptance/validation.json`` and ``acceptance/validation.md``
|
|
294
|
+
under the run directory for every accepted factor, and
|
|
295
|
+
``acceptance/validation_<digest>.json|md`` for a selection of fewer,
|
|
296
|
+
the digest over its members' labels.
|
|
297
|
+
|
|
298
|
+
Raises
|
|
299
|
+
------
|
|
300
|
+
KeyError
|
|
301
|
+
If a name names no accepted factor, or a description several.
|
|
302
|
+
ValueError
|
|
303
|
+
If there is no validation segment or no accepted factor, the
|
|
304
|
+
selection is empty or names a factor twice.
|
|
305
|
+
"""
|
|
306
|
+
segments = self.config.segments
|
|
307
|
+
if segments.validation is None:
|
|
308
|
+
raise ValueError("acceptance: the config has no validation segment")
|
|
309
|
+
members, suffix = self._selected(selection)
|
|
310
|
+
return self._compare(
|
|
311
|
+
segments.validation, segments.confirmation or segments.training, members, suffix,
|
|
312
|
+
contribution=self.config.acceptance.contribution,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def open_holdout(self, selection: list[str], force: bool = False) -> AcceptanceReport:
|
|
316
|
+
"""Open the hold-out, once, and compare on it with a selection, trained on the training and validation segments.
|
|
317
|
+
|
|
318
|
+
Parameters
|
|
319
|
+
----------
|
|
320
|
+
selection : list of str
|
|
321
|
+
The accepted factors to add, named as in ``validate``; ``validate``
|
|
322
|
+
must have written the report of exactly this selection first.
|
|
323
|
+
force : bool, default False
|
|
324
|
+
Open a hold-out that was opened before.
|
|
325
|
+
|
|
326
|
+
The open is recorded in ``holdout.lock`` before any hold-out data is
|
|
327
|
+
read, so an open that fails midway still counts (the hold-out may
|
|
328
|
+
have been seen) and running it again needs ``force``; the lock counts
|
|
329
|
+
opens of any selection. Writes ``acceptance/holdout.json|md``, or
|
|
330
|
+
``acceptance/holdout_<digest>.json|md`` for a selection of fewer than
|
|
331
|
+
every accepted factor.
|
|
332
|
+
|
|
333
|
+
Raises
|
|
334
|
+
------
|
|
335
|
+
HoldoutLocked
|
|
336
|
+
If the hold-out was opened before and ``force`` is false.
|
|
337
|
+
KeyError
|
|
338
|
+
If a name names no accepted factor, or a description several.
|
|
339
|
+
ValueError
|
|
340
|
+
If there is no validation or hold-out segment, no accepted factor,
|
|
341
|
+
the selection is empty or names a factor twice, or there is no
|
|
342
|
+
validation report of the selection.
|
|
343
|
+
"""
|
|
344
|
+
segments = self.config.segments
|
|
345
|
+
if segments.validation is None or segments.holdout is None:
|
|
346
|
+
raise ValueError("acceptance: the config needs validation and hold-out segments to open the hold-out")
|
|
347
|
+
members, suffix = self._selected(selection)
|
|
348
|
+
validation = self.run_dir / "acceptance" / f"{segments.validation.name}{suffix}.json"
|
|
349
|
+
if not validation.exists() or set(json.loads(validation.read_text())["accepted"]) != set(members):
|
|
350
|
+
raise ValueError(f"acceptance: run validate({sorted(members)}) before opening the hold-out on that selection")
|
|
351
|
+
lock = segments.open_holdout(self.lock_path, force=force)
|
|
352
|
+
return self._compare(segments.holdout, segments.validation, members, suffix, contribution="none", lock=lock)
|
|
353
|
+
|
|
354
|
+
def export(self, names: dict[str, str]) -> list[Path]:
|
|
355
|
+
"""Export accepted factors as ``GraphFactor`` subclasses and whole-market stores.
|
|
356
|
+
|
|
357
|
+
Parameters
|
|
358
|
+
----------
|
|
359
|
+
names : dict
|
|
360
|
+
An accepted factor's description (as the ledger holds it) or
|
|
361
|
+
label (``description (accepted in round n)``) to the name of its
|
|
362
|
+
module, output and store: a lowercase identifier.
|
|
363
|
+
|
|
364
|
+
Returns
|
|
365
|
+
-------
|
|
366
|
+
list of Path
|
|
367
|
+
The store folders written, in the order of ``names``.
|
|
368
|
+
|
|
369
|
+
Raises
|
|
370
|
+
------
|
|
371
|
+
KeyError
|
|
372
|
+
If a key names no accepted factor, or a description several.
|
|
373
|
+
ValueError
|
|
374
|
+
If the config sets no ``acceptance.factor_root``, a name is not a lowercase
|
|
375
|
+
identifier, or its module or store already exists.
|
|
376
|
+
"""
|
|
377
|
+
if self.config.acceptance.factor_root is None:
|
|
378
|
+
raise ValueError("export: the config sets no acceptance.factor_root, the folder the stores are written into")
|
|
379
|
+
records = self._named(names)
|
|
380
|
+
package = importlib.import_module(self.config.acceptance.package)
|
|
381
|
+
package_dir = Path(package.__path__[0])
|
|
382
|
+
root = Path(self.config.acceptance.factor_root)
|
|
383
|
+
if len(set(names.values())) != len(names):
|
|
384
|
+
raise ValueError(f"export: the names {list(names.values())} repeat")
|
|
385
|
+
for name in names.values():
|
|
386
|
+
if not (name.isidentifier() and name == name.lower() and not keyword.iskeyword(name)):
|
|
387
|
+
raise ValueError(f"export: {name!r} is not a lowercase identifier")
|
|
388
|
+
for path in (package_dir / f"{name}.py", root / name):
|
|
389
|
+
if path.exists():
|
|
390
|
+
raise ValueError(f"export: {path} already exists")
|
|
391
|
+
start, end = self._export_window()
|
|
392
|
+
validation = self._validation_report(list(names))
|
|
393
|
+
folders = []
|
|
394
|
+
for (key, name), record in zip(names.items(), records):
|
|
395
|
+
module, folder = package_dir / f"{name}.py", root / name
|
|
396
|
+
staged = self._staged([record], [name])
|
|
397
|
+
module.write_text(_module_source(
|
|
398
|
+
name, record, self.run_dir, _admission.direction(record.metrics["ic_mean"]), staged,
|
|
399
|
+
))
|
|
400
|
+
try:
|
|
401
|
+
importlib.invalidate_caches()
|
|
402
|
+
factor_class = getattr(importlib.import_module(f"{package.__name__}.{name}"), _class_name(name))
|
|
403
|
+
factor = factor_class(self._factor_config(staged, kwargs={
|
|
404
|
+
"directions": dict(factor_class.DIRECTIONS), "training_ic": {name: record.metrics["ic_mean"]},
|
|
405
|
+
}))
|
|
406
|
+
scored = self._scoring().apply(factor, folder / f"{name}.zarr")
|
|
407
|
+
scored.build(start, end)
|
|
408
|
+
save_component(scored, folder, readme=self._readme(name, record, factor_class, start, end, validation))
|
|
409
|
+
except BaseException: # leave nothing half written, so the export can be run again
|
|
410
|
+
module.unlink(missing_ok=True)
|
|
411
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
412
|
+
raise
|
|
413
|
+
folders.append(folder)
|
|
414
|
+
return folders
|
|
415
|
+
|
|
416
|
+
# The comparison.
|
|
417
|
+
|
|
418
|
+
def _compare(
|
|
419
|
+
self, segment: Segment, trained_to: Segment, members: list[str], suffix: str, contribution: str,
|
|
420
|
+
lock: HoldoutLock | None = None,
|
|
421
|
+
) -> AcceptanceReport:
|
|
422
|
+
"""Compare on ``segment`` the baseline with it plus the accepted factors labelled ``members``.
|
|
423
|
+
|
|
424
|
+
With ``contribution`` (``AcceptanceConfig.contribution``), also each member's contribution.
|
|
425
|
+
Reports and models go under names ending in ``suffix``.
|
|
426
|
+
"""
|
|
427
|
+
settings, segments = self.config.acceptance, self.config.segments
|
|
428
|
+
label = self._data("label")
|
|
429
|
+
calendar = self._data("prices").calendar(segments.training.start, segment.end)
|
|
430
|
+
lookahead = label.lookahead_bars()
|
|
431
|
+
train_window = (segments.training.start, segments.scored(trained_to, calendar, lookahead)[1])
|
|
432
|
+
test_window = segments.scored(segment, calendar, lookahead)
|
|
433
|
+
# An accepted factor keeps its output name, its place among all accepted, in every selection; every
|
|
434
|
+
# selection reads its outputs from the one store of all accepted, so equal feature sets share their models.
|
|
435
|
+
records = self.accepted()
|
|
436
|
+
everything = {_feedback.accepted_label(r): f"accepted_{k}" for k, r in enumerate(records)}
|
|
437
|
+
outputs = {label: output for label, output in everything.items() if label in members}
|
|
438
|
+
accepted, build = self._accepted_feature(records, list(everything.values()), test_window[1])
|
|
439
|
+
labels = list(outputs)
|
|
440
|
+
planned, contributions = _plan(list(outputs.values()), contribution)
|
|
441
|
+
scoring = self._scoring()
|
|
442
|
+
feature_sets = {
|
|
443
|
+
kind: [scoring.on_members(type(accepted)(dataclasses.replace(accepted.config, factor_names=names)))] if names else []
|
|
444
|
+
for kind, names in planned.items()
|
|
445
|
+
}
|
|
446
|
+
results = self._train(segment, suffix, feature_sets, build, {
|
|
447
|
+
"labels": [label], "factor_data_strategy": "read", "label_data_strategy": "read",
|
|
448
|
+
"start_date": train_window[0], "end_date": test_window[1],
|
|
449
|
+
"train_start": train_window[0], "train_end": train_window[1],
|
|
450
|
+
"test_start": test_window[0], "test_end": test_window[1],
|
|
451
|
+
"val_size": settings.val_size, "metric_splits": ("test",),
|
|
452
|
+
})
|
|
453
|
+
report = AcceptanceReport(
|
|
454
|
+
segment=segment.name, train_window=train_window, test_window=test_window, seeds=settings.seeds,
|
|
455
|
+
top_k=settings.top_k, accepted=outputs, selection=bool(suffix),
|
|
456
|
+
baseline=tuple(results["baseline"]), with_accepted=tuple(results["with_accepted"]),
|
|
457
|
+
contributions=tuple(
|
|
458
|
+
Contribution(labels[k], mode, tuple(results[without]), tuple(results[with_factor]))
|
|
459
|
+
for k, mode, without, with_factor in contributions
|
|
460
|
+
),
|
|
461
|
+
forced=lock.last.forced if lock else False,
|
|
462
|
+
holdout_opens=tuple(dataclasses.asdict(o) for o in lock.opens) if lock else (),
|
|
463
|
+
code_version=code_version(),
|
|
464
|
+
)
|
|
465
|
+
out = self.run_dir / "acceptance"
|
|
466
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
467
|
+
(out / f"{segment.name}{suffix}.json").write_text(json.dumps(report.to_dict(), indent=2) + "\n")
|
|
468
|
+
(out / f"{segment.name}{suffix}.md").write_text(report.markdown())
|
|
469
|
+
return report
|
|
470
|
+
|
|
471
|
+
def _train(
|
|
472
|
+
self, segment: Segment, suffix: str, feature_sets: dict[str, list], build: Callable[[], None], filled: dict,
|
|
473
|
+
) -> dict[str, list[ModelResult]]:
|
|
474
|
+
"""Each feature set's results on ``segment``, one per seed, read from the cache or trained and cached.
|
|
475
|
+
|
|
476
|
+
``feature_sets`` maps a name (its models are saved under
|
|
477
|
+
``acceptance/<segment><suffix>/<name>_s<seed>``) to the features added to the
|
|
478
|
+
baseline; the cache, ``acceptance/<segment>/cache``, is shared by every selection; ``build`` writes the
|
|
479
|
+
accepted factors' store, called before the first model is trained; ``filled`` is every
|
|
480
|
+
``_acceptance_model.FILLED`` value but the seed and the save directory. Before any training it prints how many
|
|
481
|
+
models it will train, how many are cached and, from the elapsed times
|
|
482
|
+
cached with the template's finished models, an estimate of the time it will take.
|
|
483
|
+
"""
|
|
484
|
+
settings, scoring, label = self.config.acceptance, self._scoring(), filled["labels"][0]
|
|
485
|
+
directory = self.run_dir / "acceptance" / f"{segment.name}{suffix}"
|
|
486
|
+
cache = _model_cache.ModelCache(self.run_dir / "acceptance" / segment.name / "cache")
|
|
487
|
+
models, code = {}, code_version()
|
|
488
|
+
for kind, added in feature_sets.items():
|
|
489
|
+
for seed in settings.seeds:
|
|
490
|
+
model = _acceptance_model.build(settings.model, self._baseline, added, {
|
|
491
|
+
**filled, "random_seed": seed, "model_save_dir": str(directory / f"{kind}_s{seed}"),
|
|
492
|
+
})
|
|
493
|
+
models[kind, seed] = model, _model_cache.key(
|
|
494
|
+
model.get_config(), self.run_dir, code=code, top_k=settings.top_k,
|
|
495
|
+
scoring=dataclasses.asdict(self.config.scoring),
|
|
496
|
+
)
|
|
497
|
+
template = _model_cache.digest(settings.model)
|
|
498
|
+
print(f"acceptance ({segment.name}): " + cache.summary([k for _, k in models.values()], template), flush=True)
|
|
499
|
+
name = label.get_factor_names()[0]
|
|
500
|
+
truth = None
|
|
501
|
+
results = {kind: [] for kind in feature_sets}
|
|
502
|
+
for kind in feature_sets:
|
|
503
|
+
panel = None
|
|
504
|
+
for seed in settings.seeds:
|
|
505
|
+
model, key = models[kind, seed]
|
|
506
|
+
cached = cache.get(key)
|
|
507
|
+
if cached is None:
|
|
508
|
+
started = time.monotonic()
|
|
509
|
+
if truth is None:
|
|
510
|
+
build()
|
|
511
|
+
truth = label.read(filled["test_start"], filled["test_end"])[name]
|
|
512
|
+
panel = _collect(model, panel)
|
|
513
|
+
run = TrainedRun.open(model.train())
|
|
514
|
+
predictions = scoring.mask(xr.open_zarr(run.test_predictions)[[name]].load())[name]
|
|
515
|
+
result = self._result(seed, predictions, truth, label.span_bars(), str(run.path))
|
|
516
|
+
cache.put(key, dataclasses.asdict(result), time.monotonic() - started, template)
|
|
517
|
+
else:
|
|
518
|
+
result = ModelResult(**{**cached, "decile_returns": tuple(cached["decile_returns"])})
|
|
519
|
+
results[kind].append(result)
|
|
520
|
+
return results
|
|
521
|
+
|
|
522
|
+
def _baseline(self, entries: tuple) -> list:
|
|
523
|
+
"""A model's baseline: its factor entries, or the reference set, read as the scoring reads; built once."""
|
|
524
|
+
key = ("baseline", json.dumps(entries, sort_keys=True))
|
|
525
|
+
if key not in self._loaded:
|
|
526
|
+
features = [self._scoring().on_members(load(c)) for c in (entries or self.config.reference)]
|
|
527
|
+
if not features:
|
|
528
|
+
raise ValueError("acceptance: a baseline has no feature (the model names none and there is no reference)")
|
|
529
|
+
self._loaded[key] = features
|
|
530
|
+
return self._loaded[key]
|
|
531
|
+
|
|
532
|
+
def _result(self, seed: int, predictions: xr.DataArray, truth: xr.DataArray, horizon: int, run: str) -> ModelResult:
|
|
533
|
+
"""One model's statistics on the dates and members both the prediction and the label have."""
|
|
534
|
+
truth = truth.reindex_like(predictions)
|
|
535
|
+
pred, real = predictions.values.astype(np.float64), truth.values.astype(np.float64)
|
|
536
|
+
top_k = self.config.acceptance.top_k
|
|
537
|
+
book, members = np.full(len(pred), np.nan), np.full(len(pred), np.nan)
|
|
538
|
+
for d in range(len(pred)):
|
|
539
|
+
ok = np.isfinite(pred[d]) & np.isfinite(real[d])
|
|
540
|
+
if ok.sum() < top_k:
|
|
541
|
+
continue
|
|
542
|
+
ranked = real[d, ok][np.argsort(-pred[d, ok], kind="stable")]
|
|
543
|
+
book[d], members[d] = ranked[:top_k].mean(), ranked.mean()
|
|
544
|
+
deciles = decile_returns(pred, real)
|
|
545
|
+
with warnings.catch_warnings():
|
|
546
|
+
warnings.simplefilter("ignore", RuntimeWarning) # a segment with no usable date stays NaN
|
|
547
|
+
top_return = float(np.nanmean(book))
|
|
548
|
+
return ModelResult(
|
|
549
|
+
seed=seed,
|
|
550
|
+
rank_ic=float(np.nanmean(cross_sectional_rank_ic_series(pred, real))),
|
|
551
|
+
ic=float(np.nanmean(cross_sectional_ic_series(pred, real))),
|
|
552
|
+
monotonicity=_admission.monotonicity(deciles, 1),
|
|
553
|
+
long_short=deciles[-1] - deciles[0],
|
|
554
|
+
top_k_return=top_return,
|
|
555
|
+
top_k_excess=float(np.nanmean(book - members)),
|
|
556
|
+
top_k_annualised=float((1 + top_return) ** (BARS_PER_YEAR / horizon) - 1),
|
|
557
|
+
decile_returns=deciles, n_days=int(np.isfinite(book).sum()), run=run,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _accepted_feature(self, records: list[TrialRecord], outputs: list[str], end: str):
|
|
561
|
+
"""Every accepted factor ``records``, scored, its outputs named ``outputs``, and the function writing its store.
|
|
562
|
+
|
|
563
|
+
The store, under ``acceptance/accepted_<end>``, runs from the training segment's start to ``end``, so a
|
|
564
|
+
validation never computes a hold-out date; it is written again only when its build record differs.
|
|
565
|
+
"""
|
|
566
|
+
staged = self._staged(records, outputs)
|
|
567
|
+
directions = {name: _admission.direction(r.metrics["ic_mean"]) for name, r in zip(outputs, records)}
|
|
568
|
+
factor = GraphFactor(self._factor_config(staged, kwargs={**staged.kwargs(), "directions": directions}))
|
|
569
|
+
folder = self.run_dir / "acceptance" / f"accepted_{end}"
|
|
570
|
+
scored = self._scoring().apply(factor, folder / "accepted.zarr")
|
|
571
|
+
start = self.config.segments.training.start
|
|
572
|
+
|
|
573
|
+
def build() -> None:
|
|
574
|
+
built = {"start": start, "end": end, "config": _model_cache.portable(scored.get_config(), self.run_dir)}
|
|
575
|
+
record = folder / "accepted.json"
|
|
576
|
+
if not record.exists() or json.loads(record.read_text()) != json.loads(json.dumps(built)):
|
|
577
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
578
|
+
scored.build(start, end)
|
|
579
|
+
record.write_text(json.dumps(built) + "\n")
|
|
580
|
+
|
|
581
|
+
return scored, build
|
|
582
|
+
|
|
583
|
+
# Shared.
|
|
584
|
+
|
|
585
|
+
def _staged(self, records: list[TrialRecord], outputs: list[str]) -> "_Staged":
|
|
586
|
+
"""The graphs of ``records`` in their stages, as the outputs ``outputs`` of one ``GraphFactor``.
|
|
587
|
+
|
|
588
|
+
A graph of no intraday family is its own daily graph (``GraphFactor.staged``).
|
|
589
|
+
"""
|
|
590
|
+
daily, sessions = GraphFactor.staged({o: r.graph for o, r in zip(outputs, records)}, self.vocabulary)
|
|
591
|
+
return _Staged(
|
|
592
|
+
daily, sessions, self.vocabulary.class_paths([*daily.values(), *sessions.values()]),
|
|
593
|
+
GraphFactor.period_lags(daily, self.vocabulary.lag_columns),
|
|
594
|
+
self._data("intraday").get_config() if sessions else None,
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
def _factor_config(self, staged: "_Staged", kwargs: dict | None = None) -> FactorConfig:
|
|
598
|
+
"""The config of a factor of the ``staged`` graphs."""
|
|
599
|
+
return FactorConfig(
|
|
600
|
+
warmup_bars=self.config.evaluator.warmup_bars, dataset=self._data("prices"), mode="batch",
|
|
601
|
+
data_columns=GraphFactor.data_columns(staged.graphs, staged.period_lags, staged.sessions),
|
|
602
|
+
njobs=self.config.evaluator.njobs, kwargs=kwargs,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
def _scoring(self) -> Scoring:
|
|
606
|
+
"""The campaign's scoring, the one its Evaluator admitted with, built once."""
|
|
607
|
+
if "scoring" not in self._loaded:
|
|
608
|
+
self._loaded["scoring"] = Scoring.from_config(
|
|
609
|
+
self.config.scoring, self.config.data, njobs=self.config.evaluator.njobs,
|
|
610
|
+
)
|
|
611
|
+
return self._loaded["scoring"]
|
|
612
|
+
|
|
613
|
+
def _data(self, name: str):
|
|
614
|
+
"""A data component of the config, rebuilt once."""
|
|
615
|
+
if name not in self._loaded:
|
|
616
|
+
self._loaded[name] = self.config.data.load(name)
|
|
617
|
+
return self._loaded[name]
|
|
618
|
+
|
|
619
|
+
def _export_window(self) -> tuple[str, str]:
|
|
620
|
+
settings, segments = self.config.acceptance, self.config.segments
|
|
621
|
+
last = segments.holdout or segments.validation or segments.training
|
|
622
|
+
return settings.export_start or segments.training.start, settings.export_end or last.end
|
|
623
|
+
|
|
624
|
+
def _selected(self, selection: list[str] | None) -> tuple[list[str], str]:
|
|
625
|
+
"""The labels of the accepted factors ``selection`` names (every one for None) and the suffix of their reports.
|
|
626
|
+
|
|
627
|
+
The suffix is empty for every accepted factor, else ``_`` and a digest of the sorted labels.
|
|
628
|
+
"""
|
|
629
|
+
accepted = [_feedback.accepted_label(r) for r in self.accepted()]
|
|
630
|
+
if not accepted:
|
|
631
|
+
raise ValueError(f"acceptance: {self.run_dir} has no accepted factor")
|
|
632
|
+
if len(set(accepted)) != len(accepted):
|
|
633
|
+
raise ValueError("acceptance: two accepted factors share a label (description and round)")
|
|
634
|
+
if selection is None:
|
|
635
|
+
return accepted, ""
|
|
636
|
+
if not selection:
|
|
637
|
+
raise ValueError("acceptance: the selection is empty")
|
|
638
|
+
members = [_feedback.accepted_label(r) for r in self._named(selection, "acceptance")]
|
|
639
|
+
if len(set(members)) == len(accepted):
|
|
640
|
+
return accepted, ""
|
|
641
|
+
return members, "_" + hashlib.sha256(json.dumps(sorted(members)).encode()).hexdigest()[:12]
|
|
642
|
+
|
|
643
|
+
def _named(self, names, caller: str = "export") -> list[TrialRecord]:
|
|
644
|
+
"""The accepted record each of ``names`` (or each key) names, by label or description.
|
|
645
|
+
|
|
646
|
+
Raises ``KeyError`` for a name of no accepted factor or a description of several, and
|
|
647
|
+
``ValueError`` for two names of one factor.
|
|
648
|
+
"""
|
|
649
|
+
accepted = self.accepted()
|
|
650
|
+
out = []
|
|
651
|
+
for key in names:
|
|
652
|
+
matches = [r for r in accepted if _feedback.accepted_label(r) == key] or [
|
|
653
|
+
r for r in accepted if r.candidate["description"] == key
|
|
654
|
+
]
|
|
655
|
+
if len(matches) != 1:
|
|
656
|
+
raise KeyError(
|
|
657
|
+
f"{caller}: {key!r} names {len(matches)} accepted factors; "
|
|
658
|
+
f"accepted: {[_feedback.accepted_label(r) for r in accepted]}"
|
|
659
|
+
)
|
|
660
|
+
if any(r is matches[0] for r in out):
|
|
661
|
+
raise ValueError(f"{caller}: {_feedback.accepted_label(matches[0])!r} is named twice")
|
|
662
|
+
out.append(matches[0])
|
|
663
|
+
return out
|
|
664
|
+
|
|
665
|
+
def _validation_report(self, selection: list[str]) -> dict | None:
|
|
666
|
+
"""``validate``'s report of the accepted factors ``selection`` names, or None when there is none."""
|
|
667
|
+
if self.config.segments.validation is None:
|
|
668
|
+
return None
|
|
669
|
+
_, suffix = self._selected(selection)
|
|
670
|
+
path = self.run_dir / "acceptance" / f"{self.config.segments.validation.name}{suffix}.json"
|
|
671
|
+
return json.loads(path.read_text()) if path.exists() else None
|
|
672
|
+
|
|
673
|
+
def _readme(
|
|
674
|
+
self, name: str, record: TrialRecord, factor_class: type, start: str, end: str, validation: dict | None,
|
|
675
|
+
) -> str:
|
|
676
|
+
"""The exported store's README; ``validation`` is the report of the exported selection, if validated."""
|
|
677
|
+
candidate, ic = record.candidate, record.metrics["ic_mean"]
|
|
678
|
+
lines = [
|
|
679
|
+
f"# {name}", "",
|
|
680
|
+
f"{candidate['description']}", "",
|
|
681
|
+
"## What it is", "",
|
|
682
|
+
f"- Hypothesis: {candidate['hypothesis']}",
|
|
683
|
+
f"- Mechanism type: {candidate['mechanism_type']}; {_feedback.direction_text(ic)}"
|
|
684
|
+
+ ("; the factor is turned to it (one minus its rank) before scoring, so a higher value means a higher return."
|
|
685
|
+
if _admission.direction(ic) < 0 else "; a higher value means a higher return.")
|
|
686
|
+
+ f" Training IC {ic:+.4f}.",
|
|
687
|
+
f"- Horizon: {candidate['horizon_rationale']}",
|
|
688
|
+
f"- Output `{name}`: the graph's whole-market values as the campaign scored them "
|
|
689
|
+
f"({self._scoring().describe()}); the store is not masked to members.", "",
|
|
690
|
+
"```python", candidate["code"], "```", "",
|
|
691
|
+
"## How it was made", "",
|
|
692
|
+
f"- Proposed and accepted in round {record.round} of the factor-agent campaign `{self.run_dir}` "
|
|
693
|
+
"(quantlab-factor-agent), on the training segment "
|
|
694
|
+
f"{self.config.segments.training.start} to {self.config.segments.training.end}.",
|
|
695
|
+
f"- Computed by `{factor_class.__module__}.{factor_class.__name__}` (a `FactorKunQuant`), "
|
|
696
|
+
f"code {code_version()}.",
|
|
697
|
+
]
|
|
698
|
+
if factor_class.SESSIONS:
|
|
699
|
+
lines.append(
|
|
700
|
+
f"- An intraday factor: its {len(factor_class.SESSIONS)} session column(s) are computed from the "
|
|
701
|
+
"bars of the intraday dataset (`INTRADAY`), each session reduced to one value per stock and day.",
|
|
702
|
+
)
|
|
703
|
+
if validation is not None:
|
|
704
|
+
lines.append(
|
|
705
|
+
f"- Validation of the exported selection ({', '.join(validation['accepted'])}) added to the baseline, "
|
|
706
|
+
"against the baseline, seed mean: "
|
|
707
|
+
+ ", ".join(f"{k} {v:+.4f}" for k, v in validation["difference"].items()) + "."
|
|
708
|
+
)
|
|
709
|
+
lines += [
|
|
710
|
+
"", "## Coverage", "",
|
|
711
|
+
f"- {start} to {end}, every symbol of the price dataset (the whole market).", "",
|
|
712
|
+
"## Who uses it", "",
|
|
713
|
+
"- Read it on a universe with `MembershipMaskedFactor(RosterFactor(load_component(<this folder>)))`.",
|
|
714
|
+
]
|
|
715
|
+
return "\n".join(lines) + "\n"
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _plan(outputs: list[str], contribution: str) -> tuple[dict[str, tuple[str, ...]], list[tuple]]:
|
|
719
|
+
"""The feature sets to train and the contributions they make; the comparison plan.
|
|
720
|
+
|
|
721
|
+
The feature sets map a name to the accepted ``outputs`` added to the
|
|
722
|
+
baseline: ``baseline`` (none), ``with_accepted`` (all), and per output
|
|
723
|
+
``drop_<output>`` (all but it) for drop-one and ``add_<output>`` (only
|
|
724
|
+
it) for add-one; a set equal to an earlier one is that one, so a
|
|
725
|
+
one-factor selection trains nothing more. The contributions are
|
|
726
|
+
``(index of the output, mode, set without it, set with it)``.
|
|
727
|
+
"""
|
|
728
|
+
named = {(): "baseline", tuple(outputs): "with_accepted"}
|
|
729
|
+
contributions = []
|
|
730
|
+
for k, output in enumerate(outputs):
|
|
731
|
+
if contribution in ("drop_one", "both"):
|
|
732
|
+
without = named.setdefault(tuple(o for o in outputs if o != output), f"drop_{output}")
|
|
733
|
+
contributions.append((k, "drop_one", without, "with_accepted"))
|
|
734
|
+
if contribution in ("add_one", "both"):
|
|
735
|
+
contributions.append((k, "add_one", "baseline", named.setdefault((output,), f"add_{output}")))
|
|
736
|
+
return {name: members for members, name in named.items()}, contributions
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _collect(model, panel):
|
|
740
|
+
"""Collect ``model``'s data, or hand it ``panel``, collected by a model of the same features; the panel to share.
|
|
741
|
+
|
|
742
|
+
A single model shares a panel across seeds; an ensemble's members collect their own each time (None).
|
|
743
|
+
"""
|
|
744
|
+
if not isinstance(model, BaseModel):
|
|
745
|
+
model.collect()
|
|
746
|
+
return None
|
|
747
|
+
if panel is None:
|
|
748
|
+
model.collect()
|
|
749
|
+
else:
|
|
750
|
+
model.use_panel(panel)
|
|
751
|
+
return model.collected_panel
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
@dataclass(frozen=True)
|
|
755
|
+
class _Staged:
|
|
756
|
+
"""Accepted factors' graphs as a ``GraphFactor`` holds them (``GraphFactor.staged``).
|
|
757
|
+
|
|
758
|
+
``graphs`` maps each output to its daily graph, ``sessions`` each session column they read to its intraday
|
|
759
|
+
graph, ``classes`` gives the class paths of their operators and ``period_lags`` how their lag columns are
|
|
760
|
+
computed, both from the campaign's vocabulary, and ``intraday`` is the intraday dataset's config, None
|
|
761
|
+
without a session column.
|
|
762
|
+
"""
|
|
763
|
+
|
|
764
|
+
graphs: dict
|
|
765
|
+
sessions: dict
|
|
766
|
+
classes: dict
|
|
767
|
+
period_lags: dict
|
|
768
|
+
intraday: dict | None
|
|
769
|
+
|
|
770
|
+
def kwargs(self) -> dict:
|
|
771
|
+
"""The ``config.kwargs`` of a ``GraphFactor`` of the graphs, their directions left out."""
|
|
772
|
+
return {
|
|
773
|
+
"graphs": self.graphs, "classes": self.classes,
|
|
774
|
+
**({"period_lags": self.period_lags} if self.period_lags else {}),
|
|
775
|
+
**({"sessions": self.sessions, "intraday": self.intraday} if self.sessions else {}),
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _class_name(name: str) -> str:
|
|
780
|
+
return "".join(part.capitalize() for part in name.split("_") if part) or "Factor"
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _module_source(name: str, record: TrialRecord, run_dir: Path, direction: int, staged: _Staged) -> str:
|
|
784
|
+
"""The module of an exported factor: the candidate as data and a ``GraphFactor`` subclass fixing its graph.
|
|
785
|
+
|
|
786
|
+
``staged`` holds the record's graph in its stages, as the output ``name``; ``direction`` is the sign of its
|
|
787
|
+
training IC.
|
|
788
|
+
"""
|
|
789
|
+
[graph] = staged.graphs.values()
|
|
790
|
+
code = "\n".join(f"# {line}" for line in record.candidate["code"].splitlines())
|
|
791
|
+
return (
|
|
792
|
+
f'"""{name}: a factor accepted by quantlab-factor-agent, exported by ``Acceptance.export``.\n\n'
|
|
793
|
+
f"Accepted in round {record.round} of the campaign run directory {str(run_dir)!r}. The\n"
|
|
794
|
+
"graph is the one the Evaluator scored; ``CANDIDATE`` is the proposal it came from.\n"
|
|
795
|
+
'"""\n\n'
|
|
796
|
+
"# The candidate's code:\n#\n"
|
|
797
|
+
f"{code}\n\n"
|
|
798
|
+
"from factor_agent.exported import GraphFactor\n\n"
|
|
799
|
+
f"CANDIDATE = {pprint.pformat(record.candidate, width=100, sort_dicts=False)}\n\n\n"
|
|
800
|
+
f"class {_class_name(name)}(GraphFactor):\n"
|
|
801
|
+
f' """The accepted factor ``{name}``, ranked across the market; see ``CANDIDATE``."""\n\n'
|
|
802
|
+
f" GRAPHS = {{{name!r}: {pprint.pformat(graph, width=100)}}}\n"
|
|
803
|
+
f" CLASSES = {pprint.pformat(staged.classes, width=100)}\n"
|
|
804
|
+
+ (f" PERIOD_LAGS = {pprint.pformat(staged.period_lags, width=100)}\n" if staged.period_lags else "")
|
|
805
|
+
+ f" DIRECTIONS = {{{name!r}: {direction}}}\n"
|
|
806
|
+
+ (f" SESSIONS = {pprint.pformat(staged.sessions, width=100)}\n" if staged.sessions else "")
|
|
807
|
+
+ (f" INTRADAY = {pprint.pformat(staged.intraday, width=100)}\n" if staged.sessions else "")
|
|
808
|
+
)
|