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,143 @@
1
+ """Scoring: how a factor's raw values become the values that are judged.
2
+
3
+ One setting, shared by the Evaluator and Acceptance, so what is admitted is
4
+ what is validated and exported. A factor is
5
+
6
+ 1. ranked across the whole market each day: the graph factors rank their own
7
+ outputs (``exported.ranked_function``), so the rank is in the compiled
8
+ graph and an exported factor computes it;
9
+ 2. neutralised, when the config asks for it, against the industry code and
10
+ the logarithm of the market cap of the configured exposures and z-scored
11
+ (quantlab's ``NeutralizedFactor``), or left as ranked;
12
+ 3. kept on the members of each day t (``IndexConstituentDataset.mask``), or
13
+ on every symbol when there is no membership.
14
+
15
+ Steps 1 and 2 give the whole-market values a store holds (``apply``);
16
+ step 3 is how they, and any stored reference or baseline factor, are read
17
+ (``on_members``, ``read``, ``mask``). ``read`` takes a reference or known
18
+ factor given as a component through steps 2 and 3, and one given as a
19
+ store through step 3 alone. A stored factor is read from its store's first
20
+ bar on: it has no value, so no score, before.
21
+ """
22
+
23
+ from collections.abc import Iterator
24
+ from dataclasses import dataclass, replace
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ import xarray as xr
29
+ from quantlab.factor.config import (
30
+ MembershipMaskedFactorConfig, NeutralizedConfig, RosterConfig, StartClippedConfig,
31
+ )
32
+ from quantlab.factor.predefined.membership_mask import MembershipMaskedFactor
33
+ from quantlab.factor.predefined.neutralized import NeutralizedFactor
34
+ from quantlab.factor.predefined.roster import RosterFactor
35
+ from quantlab.factor.predefined.start_clipped import StartClippedFactor
36
+
37
+ from factor_agent.config import DataConfig, ScoringConfig, load_each
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Neutralization:
42
+ """What a ranked factor is neutralised against.
43
+
44
+ ``exposures`` is the dataset, or list of datasets, holding
45
+ ``size_column`` (the market cap, whose logarithm is regressed on) and
46
+ ``industry_column`` (the integer industry code): ``NeutralizedConfig``'s
47
+ ``dataset``, ``size_column`` and ``industry_column``.
48
+ """
49
+
50
+ exposures: Any
51
+ size_column: str
52
+ industry_column: str
53
+
54
+
55
+ class Scoring:
56
+ """Score factors one way; see the module docstring.
57
+
58
+ Parameters
59
+ ----------
60
+ membership : quantlab IndexConstituentDataset or None
61
+ The members of each day; None scores every symbol.
62
+ neutralization : Neutralization or None
63
+ What ranked factors are neutralised against; None leaves them ranked.
64
+ njobs : int
65
+ KunQuant executor threads of the neutralisation.
66
+ """
67
+
68
+ def __init__(self, membership=None, neutralization: Neutralization | None = None, njobs: int = 1):
69
+ self.membership = membership
70
+ self.neutralization = neutralization
71
+ self._njobs = njobs
72
+
73
+ @classmethod
74
+ def from_config(cls, scoring: ScoringConfig, data: DataConfig, njobs: int) -> "Scoring":
75
+ """The scoring of a campaign config: its ``scoring`` section and ``data.membership``."""
76
+ membership = data.load("membership") if data.membership is not None else None
77
+ neutralization = None
78
+ if scoring.neutralize is not None:
79
+ n = scoring.neutralize
80
+ neutralization = Neutralization(load_each(n.exposures), n.size_column, n.industry_column)
81
+ return cls(membership, neutralization, njobs)
82
+
83
+ def apply(self, factor, file_path: str | Path | None = None, names: tuple[str, ...] | None = None):
84
+ """``factor``, whose outputs are ranked across the market, as scored on the whole market.
85
+
86
+ The neutralised factor, or ``factor`` itself without neutralisation;
87
+ with ``file_path``, the returned factor's store is there (``build``
88
+ writes it). ``names`` are the outputs scored, all by default; a
89
+ neutralised factor holds only them, an unneutralised one keeps the
90
+ rest unchanged.
91
+ """
92
+ path = None if file_path is None else str(file_path)
93
+ if self.neutralization is None:
94
+ return factor if path is None else type(factor)(replace(factor.config, file_path=path))
95
+ n = self.neutralization
96
+ pinned = {} if names is None else {"factor_names": tuple(names)}
97
+ return NeutralizedFactor(NeutralizedConfig(
98
+ factor=factor, dataset=n.exposures, size_column=n.size_column, industry_column=n.industry_column,
99
+ file_path=path, njobs=self._njobs, **pinned,
100
+ ))
101
+
102
+ def on_members(self, factor):
103
+ """``factor`` read from its store's first bar on, on the membership's roster, masked to the members of day t.
104
+
105
+ Without membership, ``factor`` read from its store's first bar on, on every symbol.
106
+ """
107
+ clipped = StartClippedFactor(StartClippedConfig(factor=factor))
108
+ if self.membership is None:
109
+ return clipped
110
+ viewed = RosterFactor(RosterConfig(factor=clipped, roster=self.membership))
111
+ return MembershipMaskedFactor(MembershipMaskedFactorConfig(factor=viewed, membership=self.membership))
112
+
113
+ def read(self, factor, start, end) -> Iterator[tuple[str, xr.DataArray]]:
114
+ """Yield ``(name, values)`` of every output of ``factor`` from ``start`` to ``end``, masked to day-t members.
115
+
116
+ A factor with a store (an entry given as a store) is read as stored,
117
+ from its first bar on; any other (an entry given as a component) is
118
+ scored by ``apply``, as a candidate is, and computed. The outputs are
119
+ masked one at a time, so only one masked copy is in memory.
120
+ """
121
+ if factor.store_range() is not None:
122
+ panel = StartClippedFactor(StartClippedConfig(factor=factor)).read(start, end)
123
+ else:
124
+ panel = self.apply(factor).compute(start, end)
125
+ for name in factor.get_factor_names():
126
+ yield name, self.mask(panel[[name]])[name]
127
+
128
+ def mask(self, panel: xr.Dataset) -> xr.Dataset:
129
+ """``panel`` NaN where the symbol is not a member on the bar's date; unchanged without membership."""
130
+ return panel if self.membership is None else self.membership.mask(panel)
131
+
132
+ def describe(self) -> str:
133
+ """One sentence for the prompts: what is done to a factor's values before they are judged."""
134
+ if self.neutralization is None:
135
+ neutral = "not neutralised"
136
+ else:
137
+ n = self.neutralization
138
+ neutral = (
139
+ f"neutralised against the industry ({n.industry_column}) and the logarithm of the market cap "
140
+ f"({n.size_column}) and z-scored"
141
+ )
142
+ members = "on the members of the research universe that day" if self.membership is not None else "on every symbol"
143
+ return f"A factor is ranked across the whole market each day, {neutral}, and scored {members}."
@@ -0,0 +1,184 @@
1
+ """Evaluation segments: the date ranges the campaign and the acceptance read, the purge and the hold-out lock.
2
+
3
+ The training segment is the only one the campaign evaluates; the validation
4
+ segment is read by ``Acceptance.validate`` and the hold-out by
5
+ ``Acceptance.open_holdout``, once.
6
+
7
+ The purge: a forward label at t reads prices up to ``lookahead`` bars past t,
8
+ so wherever labels are read the last bars of a segment are dropped and no
9
+ label reads a bar of the next segment. At least ``purge_bars`` (five)
10
+ trading days are dropped, more when the label reads further (``ret_5``,
11
+ whose label at t reads the open of t + 6, drops six).
12
+
13
+ The hold-out lock: ``Segments.open_holdout`` writes a lock file recording
14
+ when and with which code version the hold-out was opened. A second open is
15
+ refused unless forced; every open, forced or not, is appended to the lock.
16
+ """
17
+
18
+ import datetime
19
+ import json
20
+ import subprocess
21
+ from dataclasses import asdict, dataclass
22
+ from pathlib import Path
23
+
24
+ import pandas as pd
25
+
26
+ #: Trading days dropped at the end of a segment at least.
27
+ PURGE_BARS = 5
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Segment:
32
+ """A named, inclusive date range."""
33
+
34
+ name: str
35
+ start: str
36
+ end: str
37
+
38
+ def __post_init__(self):
39
+ if pd.Timestamp(self.start) > pd.Timestamp(self.end):
40
+ raise ValueError(f"segment {self.name}: start {self.start} is after end {self.end}")
41
+
42
+
43
+ def purge(lookahead: int, purge_bars: int = PURGE_BARS) -> int:
44
+ """The bars dropped at the end of a segment for a label that reads ``lookahead`` bars past t."""
45
+ return max(purge_bars, lookahead)
46
+
47
+
48
+ class HoldoutLocked(RuntimeError):
49
+ """The hold-out was opened before and the open was not forced."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class HoldoutOpen:
54
+ """One open of the hold-out: when (UTC), with which code, and whether it was forced past the lock."""
55
+
56
+ opened_at: str
57
+ code_version: dict
58
+ forced: bool
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class HoldoutLock:
63
+ """The lock after an open: ``opens`` holds every open so far, this one last."""
64
+
65
+ path: Path
66
+ opens: tuple[HoldoutOpen, ...]
67
+
68
+ @property
69
+ def last(self) -> HoldoutOpen:
70
+ """This open."""
71
+ return self.opens[-1]
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Segments:
76
+ """The training, confirmation, validation and hold-out segments, in that order, and the purge between them.
77
+
78
+ Parameters
79
+ ----------
80
+ training : Segment
81
+ The segment the campaign evaluates.
82
+ confirmation : Segment, optional
83
+ The later part of the training data: a candidate is evaluated there
84
+ too and must hold there to be accepted, and no prompt shows a
85
+ statistic of it (``AdmissionConfig.confirm_t``).
86
+ validation, holdout : Segment, optional
87
+ The segments ``Acceptance`` reads; each starts after the one before ends.
88
+ purge_bars : int
89
+ The fewest trading days dropped at the end of a segment.
90
+ """
91
+
92
+ training: Segment
93
+ confirmation: Segment | None = None
94
+ validation: Segment | None = None
95
+ holdout: Segment | None = None
96
+ purge_bars: int = PURGE_BARS
97
+
98
+ def __post_init__(self):
99
+ order = [s for s in (self.training, self.confirmation, self.validation, self.holdout) if s is not None]
100
+ for before, after in zip(order, order[1:]):
101
+ if pd.Timestamp(after.start) <= pd.Timestamp(before.end):
102
+ raise ValueError(f"segment {after.name} starts on {after.start}, before {before.name} ends ({before.end})")
103
+ if self.purge_bars < 0:
104
+ raise ValueError(f"segments: purge_bars must be non-negative, got {self.purge_bars}")
105
+
106
+ def scored(self, segment: Segment, calendar: pd.DatetimeIndex, lookahead: int) -> tuple[str, str]:
107
+ """The first and last dates of ``segment`` whose label lies inside it.
108
+
109
+ Parameters
110
+ ----------
111
+ segment : Segment
112
+ The segment.
113
+ calendar : pd.DatetimeIndex
114
+ The trading days, covering the segment.
115
+ lookahead : int
116
+ Bars past t the label at t reads (``lookahead_bars()``).
117
+
118
+ Raises
119
+ ------
120
+ ValueError
121
+ If the purge leaves the segment no date.
122
+ """
123
+ bars = calendar[(calendar >= pd.Timestamp(segment.start)) & (calendar <= pd.Timestamp(segment.end))]
124
+ dropped = purge(lookahead, self.purge_bars)
125
+ kept = bars[: max(len(bars) - dropped, 0)]
126
+ if not len(kept):
127
+ raise ValueError(f"segment {segment.name}: the purge of {dropped} bars leaves no date")
128
+ return kept[0].strftime("%Y-%m-%d"), kept[-1].strftime("%Y-%m-%d")
129
+
130
+ def open_holdout(self, lock_path: str | Path, force: bool = False) -> HoldoutLock:
131
+ """Record an open of the hold-out in the lock at ``lock_path`` and return the lock.
132
+
133
+ Raises
134
+ ------
135
+ ValueError
136
+ If there is no hold-out segment.
137
+ HoldoutLocked
138
+ If the hold-out was opened before and ``force`` is false; the lock is unchanged.
139
+ """
140
+ if self.holdout is None:
141
+ raise ValueError("segments: there is no hold-out segment to open")
142
+ path = Path(lock_path)
143
+ opens = [HoldoutOpen(**o) for o in json.loads(path.read_text())["opens"]] if path.exists() else []
144
+ if opens and not force:
145
+ first = opens[0]
146
+ raise HoldoutLocked(
147
+ f"the hold-out was opened on {first.opened_at} (code {first.code_version.get('factor_agent')}), "
148
+ f"{len(opens)} open(s) so far; pass force=True to open it again, which the report states"
149
+ )
150
+ opens.append(HoldoutOpen(
151
+ opened_at=datetime.datetime.now(datetime.UTC).isoformat(timespec="seconds"),
152
+ code_version=code_version(), forced=bool(opens),
153
+ ))
154
+ path.parent.mkdir(parents=True, exist_ok=True)
155
+ tmp = path.with_suffix(".tmp")
156
+ tmp.write_text(json.dumps({"segment": asdict(self.holdout), "opens": [asdict(o) for o in opens]}, indent=2) + "\n")
157
+ tmp.replace(path)
158
+ return HoldoutLock(path, tuple(opens))
159
+
160
+
161
+ def code_version() -> dict:
162
+ """The commit (``+dirty`` with uncommitted changes) of this package and of quantlab, and quantlab's version."""
163
+ import quantlab
164
+
165
+ import factor_agent
166
+
167
+ return {
168
+ "factor_agent": _commit(Path(factor_agent.__file__).parent),
169
+ "quantlab": _commit(Path(quantlab.__file__).parent),
170
+ "quantlab_version": quantlab.__version__,
171
+ }
172
+
173
+
174
+ def _commit(directory: Path) -> str:
175
+ """The git commit of the checkout holding ``directory``, or ``"unknown"`` outside one."""
176
+ def git(*args: str) -> str:
177
+ return subprocess.run(["git", "-C", str(directory), *args], capture_output=True, text=True, check=True).stdout
178
+
179
+ try:
180
+ head = git("rev-parse", "HEAD").strip()
181
+ dirty = bool(git("status", "--porcelain", "--untracked-files=no").strip())
182
+ except (OSError, subprocess.CalledProcessError):
183
+ return "unknown"
184
+ return head + ("+dirty" if dirty else "")