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.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. 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
+ )