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
factor_agent/scoring.py
ADDED
|
@@ -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}."
|
factor_agent/segments.py
ADDED
|
@@ -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 "")
|