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,399 @@
|
|
|
1
|
+
"""The context the planner and the proposer read: what is known, assembled from the ledger.
|
|
2
|
+
|
|
3
|
+
It shows only training-segment statistics: each candidate's IC and its
|
|
4
|
+
Newey-West t, its residual (partial) IC, what the pool explains of it and the
|
|
5
|
+
pool factors most correlated with it, the order of its deciles' returns (as
|
|
6
|
+
ranks), the nearest existing factor and the correlation with it, the nearest
|
|
7
|
+
known factor and its sampled correlation, the rejection reasons, and each
|
|
8
|
+
accepted factor's marginal weight and residual IC in the accepted pool. It
|
|
9
|
+
never shows a return, a date, or advice to change windows or parameters. The first round
|
|
10
|
+
has no history; it sees the data, the seed directions and the existing
|
|
11
|
+
factor families (the reference set's, then the known factors', marked as
|
|
12
|
+
known) by name and description, without any statistic.
|
|
13
|
+
|
|
14
|
+
Every trial is named by its id, ``T<round>-<slot>`` (``named``), by which a
|
|
15
|
+
research direction names the trial it builds on (``parent``).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
|
|
21
|
+
from factor_agent._admission import direction, reason_kind
|
|
22
|
+
from factor_agent.evaluator import Contribution
|
|
23
|
+
from factor_agent.ledger import TrialRecord
|
|
24
|
+
from factor_agent.zoo import owner
|
|
25
|
+
|
|
26
|
+
_DECIMAL = re.compile(r"[-+]?\d+\.\d+")
|
|
27
|
+
_TRIAL_ID = re.compile(r"T(\d+)-(\d+)")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def first_recent_round(rounds: int, recent_rounds: int) -> int:
|
|
31
|
+
"""The first round of the recent history after ``rounds`` rounds; the rounds before it are the earlier summary's."""
|
|
32
|
+
return rounds - recent_rounds + 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def distill_due(rounds: int, memory_round: int, memory_every: int) -> bool:
|
|
36
|
+
"""Whether the distiller is asked after ``rounds`` rounds, when it was last asked after ``memory_round``."""
|
|
37
|
+
return rounds - memory_round >= memory_every
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def accepted_source(number: int) -> str:
|
|
41
|
+
"""The zoo source of a factor accepted in round ``number``."""
|
|
42
|
+
return f"accepted in round {number}"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def accepted_label(record: TrialRecord) -> str:
|
|
46
|
+
"""The name an accepted factor has in the zoo, the pool and the prompts (``ZooEntry.label``)."""
|
|
47
|
+
return f"{record.candidate['description']} ({accepted_source(record.round)})"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
#: How the families section marks a known factor's class.
|
|
51
|
+
KNOWN_MARK = "known, not in the reference set, refused when rewritten"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def families(reference, known=()) -> str:
|
|
55
|
+
"""One line per factor of the reference set, then per known factor: its owner's class, doc line and outputs.
|
|
56
|
+
|
|
57
|
+
The owner (``zoo.owner``) is the factor a wrapper such as
|
|
58
|
+
``NeutralizedFactor`` shows; the doc line is the first line of its
|
|
59
|
+
class docstring. A known factor's class is marked with ``KNOWN_MARK``.
|
|
60
|
+
"""
|
|
61
|
+
lines = []
|
|
62
|
+
for factor, mark in [(f, "") for f in reference] + [(f, f" ({KNOWN_MARK})") for f in known]:
|
|
63
|
+
names = factor.get_factor_names()
|
|
64
|
+
kind = type(owner(factor))
|
|
65
|
+
doc = (kind.__doc__ or "").strip().splitlines()
|
|
66
|
+
shown = ", ".join(names[:12]) + (", ..." if len(names) > 12 else "")
|
|
67
|
+
lines.append(f"- {kind.__name__}{mark}: {doc[0] if doc else 'no description'} ({len(names)} outputs: {shown})")
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def trial_id(number: int, slot: int) -> str:
|
|
72
|
+
"""The id of the trial of round ``number`` in ``slot`` (from 0): ``T<round>-<slot from 1, two digits>``."""
|
|
73
|
+
return f"T{number}-{slot + 1:02d}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def named(records: list[TrialRecord]) -> list[tuple[str, TrialRecord]]:
|
|
77
|
+
"""``records`` with their ids; a round's records are in slot order, as the ledger holds them."""
|
|
78
|
+
slots: dict[int, int] = {}
|
|
79
|
+
pairs = []
|
|
80
|
+
for r in records:
|
|
81
|
+
slots[r.round] = slots.get(r.round, -1) + 1
|
|
82
|
+
pairs.append((trial_id(r.round, slots[r.round]), r))
|
|
83
|
+
return pairs
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def history(
|
|
87
|
+
trials: list[tuple[str, TrialRecord]], statistics: bool = True, earlier: list[tuple[str, TrialRecord]] = (),
|
|
88
|
+
) -> str:
|
|
89
|
+
"""One line per trial (``named``). Without ``statistics`` no metric is shown and the reasons' decimals are masked.
|
|
90
|
+
|
|
91
|
+
``earlier`` holds the run's trials (``named``), by which a trial's parent
|
|
92
|
+
is shown with its residual t, the measure its child is compared on.
|
|
93
|
+
"""
|
|
94
|
+
by_id = dict(earlier)
|
|
95
|
+
lines = []
|
|
96
|
+
for trial, r in trials:
|
|
97
|
+
line = (
|
|
98
|
+
f"- {trial}, round {r.round}, {_direction(r, by_id if statistics else {})}: "
|
|
99
|
+
f"{r.candidate.get('description', '(unparsed)')} -> {outcome(r)}"
|
|
100
|
+
)
|
|
101
|
+
if r.evaluated:
|
|
102
|
+
line += f"; {direction_text(r.metrics['ic_mean'])}"
|
|
103
|
+
if statistics and r.evaluated:
|
|
104
|
+
m = r.metrics
|
|
105
|
+
line += (
|
|
106
|
+
f"; training IC {_num(m['ic_mean'], 4)} (t {_num(m['ic_t'], 2)}), "
|
|
107
|
+
f"residual IC {_num(m['partial_ic_mean'], 4)} (t {_num(m['partial_ic_t'], 2)})"
|
|
108
|
+
)
|
|
109
|
+
share = m.get("pool_share", float("nan"))
|
|
110
|
+
if share is not None and math.isfinite(share) and share > 0:
|
|
111
|
+
line += f", {share:.0%} explained by the pool"
|
|
112
|
+
if not statistics and r.reasons:
|
|
113
|
+
line += "; " + _DECIMAL.sub("#", " ".join(r.reasons))
|
|
114
|
+
lines.append(line)
|
|
115
|
+
return "\n".join(lines)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def earlier_summary(records: list[TrialRecord], mechanism_types: tuple[str, ...] = ()) -> str:
|
|
119
|
+
"""The trials of ``records`` counted by mechanism type: trials, evaluated, accepted and each kind of refusal.
|
|
120
|
+
|
|
121
|
+
The types come in the order of ``mechanism_types``, then any other in
|
|
122
|
+
name order; a type without trials is left out. A refusal is counted by
|
|
123
|
+
the stage that refused it (parse, gate, review inconsistent, compile) and,
|
|
124
|
+
at admission, by the kind of its reason (``_admission.reason_kind``).
|
|
125
|
+
"""
|
|
126
|
+
if not records:
|
|
127
|
+
return ""
|
|
128
|
+
by_type: dict[str, list[TrialRecord]] = {}
|
|
129
|
+
for r in records:
|
|
130
|
+
by_type.setdefault((r.direction or {}).get("mechanism_type", "?"), []).append(r)
|
|
131
|
+
order = [t for t in mechanism_types if t in by_type] + sorted(set(by_type) - set(mechanism_types))
|
|
132
|
+
rounds = sorted({r.round for r in records})
|
|
133
|
+
lines = [f"The trials of rounds {rounds[0]} to {rounds[-1]}, by mechanism type:"]
|
|
134
|
+
for kind in order:
|
|
135
|
+
trials = by_type[kind]
|
|
136
|
+
refused: dict[str, int] = {}
|
|
137
|
+
for r in trials:
|
|
138
|
+
if r.status != "accepted":
|
|
139
|
+
label = _refusal(r)
|
|
140
|
+
refused[label] = refused.get(label, 0) + 1
|
|
141
|
+
line = (
|
|
142
|
+
f"- {kind}: {len(trials)} trials, {sum(r.evaluated for r in trials)} evaluated, "
|
|
143
|
+
f"{sum(r.status == 'accepted' for r in trials)} accepted"
|
|
144
|
+
)
|
|
145
|
+
if refused:
|
|
146
|
+
line += "; refused: " + ", ".join(f"{label} {count}" for label, count in refused.items())
|
|
147
|
+
lines.append(line)
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
#: Groups ``themes`` lists, the largest first; the rest are counted.
|
|
152
|
+
THEMES_LISTED = 30
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def theme_groups(names: list[str], matrix, threshold: float) -> list[list[str]]:
|
|
156
|
+
"""The tried trials ``names`` in groups, by the matrix of their correlations (rows and columns in ``names``' order).
|
|
157
|
+
|
|
158
|
+
The trials are taken in the order they were tried, by their ids
|
|
159
|
+
(``trial_id``: by round, then by slot), whatever the order of ``names``.
|
|
160
|
+
Each joins the group of the earlier trial it is most correlated with,
|
|
161
|
+
when that correlation is at least ``threshold`` in absolute value, and
|
|
162
|
+
opens a group of its own otherwise. The groups are in the order they
|
|
163
|
+
were opened, each in the order its trials were tried.
|
|
164
|
+
"""
|
|
165
|
+
order = sorted(range(len(names)), key=lambda k: _trial_order(names[k]))
|
|
166
|
+
group_of: dict[int, int] = {}
|
|
167
|
+
groups: list[list[str]] = []
|
|
168
|
+
for position, j in enumerate(order):
|
|
169
|
+
earlier = [(abs(float(matrix[i][j])), i) for i in order[:position]]
|
|
170
|
+
best = max(((c, i) for c, i in earlier if math.isfinite(c)), default=(0.0, -1))
|
|
171
|
+
if best[0] >= threshold:
|
|
172
|
+
group_of[j] = group_of[best[1]]
|
|
173
|
+
groups[group_of[j]].append(names[j])
|
|
174
|
+
else:
|
|
175
|
+
group_of[j] = len(groups)
|
|
176
|
+
groups.append([names[j]])
|
|
177
|
+
return groups
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _trial_order(trial: str) -> tuple[int, int]:
|
|
181
|
+
"""The round and the slot of a trial id (``trial_id``)."""
|
|
182
|
+
number, slot = _TRIAL_ID.fullmatch(trial).groups()
|
|
183
|
+
return int(number), int(slot)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def themes(
|
|
188
|
+
trials: list[tuple[str, TrialRecord]], names: list[str], matrix, threshold: float, sent_back: bool = False,
|
|
189
|
+
) -> str:
|
|
190
|
+
"""The evaluated trials (of ``named`` ``trials``) grouped by what their factors measure (``theme_groups``).
|
|
191
|
+
|
|
192
|
+
``names`` and ``matrix`` are the tried trials and their correlations
|
|
193
|
+
(``Evaluator.tried``). Every group of two trials or more and every group
|
|
194
|
+
holding an accepted factor is listed, the largest first and at most
|
|
195
|
+
``THEMES_LISTED``: the trial that opened it, its trials, how many were
|
|
196
|
+
accepted, its largest residual t in its trial's direction, the last
|
|
197
|
+
round it was tried in and its refusals by kind; the other groups are
|
|
198
|
+
counted. With ``sent_back`` the planner is told that a new direction's
|
|
199
|
+
candidate falling in a group is sent back to be rewritten.
|
|
200
|
+
"""
|
|
201
|
+
if not names:
|
|
202
|
+
return ""
|
|
203
|
+
by_id = dict(trials)
|
|
204
|
+
groups = [[t for t in group if t in by_id] for group in theme_groups(names, matrix, threshold)]
|
|
205
|
+
groups = [g for g in groups if g]
|
|
206
|
+
shown = [g for g in groups if len(g) > 1 or any(by_id[t].status == "accepted" for t in g)]
|
|
207
|
+
shown.sort(key=lambda g: -len(g))
|
|
208
|
+
lines = [
|
|
209
|
+
f"Each evaluated trial is grouped with the earlier trial its factor is most correlated with (the mean "
|
|
210
|
+
f"daily correlation on a sample of the training dates), when that correlation is at least {threshold:g} "
|
|
211
|
+
f"in absolute value; a trial without one opens a group. A trial of a group measures what the trial that "
|
|
212
|
+
f"opened it measures, whatever its hypothesis says. A new research direction is a mechanism no group "
|
|
213
|
+
f"measures" + (": a new direction's candidate that falls in a group is sent back to be rewritten." if sent_back else ".")
|
|
214
|
+
]
|
|
215
|
+
for group in shown[:THEMES_LISTED]:
|
|
216
|
+
records = [by_id[t] for t in group]
|
|
217
|
+
first = records[0]
|
|
218
|
+
accepted = [t for t in group if by_id[t].status == "accepted"]
|
|
219
|
+
best_t, best_id = max(
|
|
220
|
+
((direction(r.metrics["ic_mean"]) * r.metrics["partial_ic_t"], t) for t, r in zip(group, records)
|
|
221
|
+
if math.isfinite(r.metrics["partial_ic_t"])),
|
|
222
|
+
default=(float("nan"), ""),
|
|
223
|
+
)
|
|
224
|
+
refused: dict[str, int] = {}
|
|
225
|
+
for r in records:
|
|
226
|
+
if r.status != "accepted":
|
|
227
|
+
refused[_refusal(r)] = refused.get(_refusal(r), 0) + 1
|
|
228
|
+
line = (
|
|
229
|
+
f"- opened by {group[0]} ({first.candidate.get('description', '')}): {len(group)} trial(s) "
|
|
230
|
+
f"({', '.join(group)}), {len(accepted)} accepted{' (' + ', '.join(accepted) + ')' if accepted else ''}"
|
|
231
|
+
)
|
|
232
|
+
if best_id:
|
|
233
|
+
line += f"; largest residual t in its direction {_num(best_t, 2)} ({best_id})"
|
|
234
|
+
line += f"; last tried in round {max(r.round for r in records)}"
|
|
235
|
+
if refused:
|
|
236
|
+
line += "; refused: " + ", ".join(f"{label} {count}" for label, count in refused.items())
|
|
237
|
+
lines.append(line)
|
|
238
|
+
if len(shown) > THEMES_LISTED:
|
|
239
|
+
lines.append(f"- {len(shown) - THEMES_LISTED} smaller group(s) of several trials, not listed")
|
|
240
|
+
if len(groups) > len(shown):
|
|
241
|
+
lines.append(f"- {len(groups) - len(shown)} group(s) of a single refused trial, not listed")
|
|
242
|
+
return "\n".join(lines)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _refusal(r: TrialRecord) -> str:
|
|
246
|
+
"""The kind of refusal of a trial that was not accepted."""
|
|
247
|
+
if r.stage == "admission":
|
|
248
|
+
return f"admission ({reason_kind(r.reasons[0]) if r.reasons else 'other'})"
|
|
249
|
+
return {"review": "review inconsistent", "evaluate": "compile"}.get(r.stage, r.stage)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def last_round(trials: list[tuple[str, TrialRecord]], earlier: list[tuple[str, TrialRecord]] = ()) -> str:
|
|
253
|
+
"""Every candidate of a round (``named``) with its outcome, its statistics and why it was refused.
|
|
254
|
+
|
|
255
|
+
``earlier`` holds the run's trials (``named``), by which a parent is
|
|
256
|
+
shown with its residual t and an accepted factor is named by its id.
|
|
257
|
+
"""
|
|
258
|
+
by_id = dict(earlier)
|
|
259
|
+
blocks = []
|
|
260
|
+
for i, (trial, r) in enumerate(trials, 1):
|
|
261
|
+
lines = [
|
|
262
|
+
f"{i}. {trial} [{_direction(r, by_id)}] {r.candidate.get('description', '(unparsed)')}",
|
|
263
|
+
f" hypothesis: {r.candidate.get('hypothesis', '')}",
|
|
264
|
+
f" outcome: {outcome(r)}",
|
|
265
|
+
]
|
|
266
|
+
blocks.append("\n".join(lines + _statistics(r, earlier)))
|
|
267
|
+
return "\n".join(blocks)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parent(trial: str, r: TrialRecord, earlier: list[tuple[str, TrialRecord]] = ()) -> str:
|
|
271
|
+
"""The trial a research direction builds on, as its proposer reads it: what it computed, its code, statistics and reasons."""
|
|
272
|
+
code = "\n".join(" " + line for line in r.candidate.get("code", "").splitlines())
|
|
273
|
+
lines = [
|
|
274
|
+
f"{trial}, round {r.round}, {r.candidate.get('mechanism_type', '?')}: {r.candidate.get('description', '')}",
|
|
275
|
+
f" hypothesis: {r.candidate.get('hypothesis', '')}",
|
|
276
|
+
f" outcome: {outcome(r)}",
|
|
277
|
+
" code:", code,
|
|
278
|
+
]
|
|
279
|
+
return "\n".join(lines + _statistics(r, earlier))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _statistics(r: TrialRecord, earlier: list[tuple[str, TrialRecord]] = ()) -> list[str]:
|
|
283
|
+
"""A trial's training statistics, what the pool explains of it, its factor direction and why it was refused, as lines.
|
|
284
|
+
|
|
285
|
+
An accepted factor of ``earlier`` (``named``) is named by its id.
|
|
286
|
+
"""
|
|
287
|
+
lines = []
|
|
288
|
+
if r.evaluated:
|
|
289
|
+
m = r.metrics
|
|
290
|
+
nearest = (
|
|
291
|
+
f"nearest existing factor {m['nearest']}, correlation {_num(m['nearest_correlation'], 2)}"
|
|
292
|
+
if m["nearest"] else "no existing factor to compare with"
|
|
293
|
+
)
|
|
294
|
+
if m["nearest_known"]:
|
|
295
|
+
nearest += (
|
|
296
|
+
f"; nearest known factor {m['nearest_known']}, "
|
|
297
|
+
f"sampled correlation {_num(m['nearest_known_correlation'], 2)}"
|
|
298
|
+
)
|
|
299
|
+
lines.append(
|
|
300
|
+
f" training IC {_num(m['ic_mean'], 4)} (Newey-West t {_num(m['ic_t'], 2)}); "
|
|
301
|
+
f"residual IC {_num(m['partial_ic_mean'], 4)} (Newey-West t {_num(m['partial_ic_t'], 2)}); {nearest}"
|
|
302
|
+
)
|
|
303
|
+
overlap = _overlap(m, {accepted_label(e): t for t, e in earlier if e.status == "accepted"})
|
|
304
|
+
if overlap:
|
|
305
|
+
lines.append(overlap)
|
|
306
|
+
order = _decile_order(m.get("decile_returns") or [])
|
|
307
|
+
if order:
|
|
308
|
+
lines.append(
|
|
309
|
+
f" the deciles' returns ranked from 1 (lowest) to 10, lowest factor values first: {order} "
|
|
310
|
+
f"(rising in the factor's direction is 1 to 10, or 10 to 1 for direction -1)"
|
|
311
|
+
)
|
|
312
|
+
lines.append(f" {direction_text(m['ic_mean'])}")
|
|
313
|
+
if r.reasons:
|
|
314
|
+
lines.append(" reasons: " + " | ".join(r.reasons))
|
|
315
|
+
return lines
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def accepted(records: list[TrialRecord]) -> str:
|
|
319
|
+
"""The accepted factors as the distiller reads them: name, mechanism type, hypothesis and direction, no statistic."""
|
|
320
|
+
return "\n".join(
|
|
321
|
+
f"- {accepted_label(r)} [{r.candidate['mechanism_type']}]: {r.candidate['hypothesis']}; "
|
|
322
|
+
f"{direction_text(r.metrics['ic_mean'])}"
|
|
323
|
+
for r in records if r.status == "accepted"
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def best(trials: list[tuple[str, TrialRecord]], contributions: dict[str, Contribution]) -> str:
|
|
328
|
+
"""The accepted factors (of ``named`` trials): id, name, mechanism, code, and marginal weight and residual IC in the accepted pool."""
|
|
329
|
+
blocks = []
|
|
330
|
+
for trial, r in trials:
|
|
331
|
+
if r.status != "accepted":
|
|
332
|
+
continue
|
|
333
|
+
label = accepted_label(r)
|
|
334
|
+
part = contributions.get(label)
|
|
335
|
+
code = "\n".join(" " + line for line in r.candidate["code"].splitlines())
|
|
336
|
+
lines = [
|
|
337
|
+
f"- {trial}: {label} [{r.candidate['mechanism_type']}]: {r.candidate['hypothesis']}",
|
|
338
|
+
f" {direction_text(r.metrics['ic_mean'])}", " code:", code,
|
|
339
|
+
]
|
|
340
|
+
if part is not None:
|
|
341
|
+
lines.append(
|
|
342
|
+
f" in the accepted pool: marginal weight {_num(part.weight, 3)}, residual IC {_num(part.residual_ic, 4)}"
|
|
343
|
+
)
|
|
344
|
+
blocks.append("\n".join(lines))
|
|
345
|
+
return "\n".join(blocks)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def direction_text(ic_mean: float) -> str:
|
|
349
|
+
"""The factor direction the training IC shows, in words."""
|
|
350
|
+
sign = direction(ic_mean)
|
|
351
|
+
if sign == 0:
|
|
352
|
+
return "no factor direction (the training IC is zero or not a number)"
|
|
353
|
+
return f"factor direction {sign:+d} (a higher value went with a {'higher' if sign > 0 else 'lower'} return)"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _decile_order(returns: list[float]) -> str:
|
|
357
|
+
"""The rank of each decile's mean return among the deciles, lowest factor values first, or "" when one is missing.
|
|
358
|
+
|
|
359
|
+
The ranks show the shape (rising, U-shaped, broken at one end) that the
|
|
360
|
+
monotonicity check reads, and no return.
|
|
361
|
+
"""
|
|
362
|
+
if len(returns) != 10 or not all(isinstance(x, (int, float)) and math.isfinite(x) for x in returns):
|
|
363
|
+
return ""
|
|
364
|
+
order = sorted(range(10), key=lambda i: returns[i])
|
|
365
|
+
return " ".join(str(order.index(i) + 1) for i in range(10))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _overlap(metrics: dict, ids: dict[str, str]) -> str:
|
|
369
|
+
"""What the pool explains of a candidate and the pool factors most correlated with it (accepted ones by ``ids``), or ""."""
|
|
370
|
+
share, overlaps = metrics.get("pool_share"), metrics.get("overlaps") or []
|
|
371
|
+
if share is None or not math.isfinite(share) or not overlaps:
|
|
372
|
+
return ""
|
|
373
|
+
named = ", ".join(f"{ids.get(name, name)} {_num(correlation, 2)}" for name, correlation in overlaps)
|
|
374
|
+
return (
|
|
375
|
+
f" {max(share, 0):.0%} of its daily cross-section is explained by the reference set and the accepted "
|
|
376
|
+
f"factors (the part the residual IC leaves out); most correlated: {named}"
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _direction(r: TrialRecord, by_id: dict[str, TrialRecord] | None = None) -> str:
|
|
381
|
+
"""The trial's mechanism type and action, with its parent and, from ``by_id``, the parent's residual t."""
|
|
382
|
+
d = r.direction or {}
|
|
383
|
+
text = f"{d.get('mechanism_type', '?')}, {d.get('action', '?')}"
|
|
384
|
+
if not d.get("parent"):
|
|
385
|
+
return text
|
|
386
|
+
earlier = (by_id or {}).get(d["parent"])
|
|
387
|
+
if earlier is None or not earlier.evaluated:
|
|
388
|
+
return f"{text} of {d['parent']}"
|
|
389
|
+
return f"{text} of {d['parent']} (whose residual t was {_num(earlier.metrics['partial_ic_t'], 2)})"
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def outcome(r: TrialRecord) -> str:
|
|
393
|
+
"""A trial's outcome in words: accepted, or its status at the stage that decided it."""
|
|
394
|
+
return "accepted" if r.status == "accepted" else f"{r.status} at {r.stage}"
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _num(value: float, digits: int) -> str:
|
|
399
|
+
return f"{value:+.{digits}f}" if math.isfinite(value) else "n/a"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""KunQuant's max drawdown, over a value computed from the fields.
|
|
2
|
+
|
|
3
|
+
KunQuant's ``WindowedMaxDrawdown`` loops over the values of its input without
|
|
4
|
+
putting them in a windowed buffer first: the ``decompose`` pass buffers the
|
|
5
|
+
loop's input, but the loop's ``IterValue`` still reads the input, and
|
|
6
|
+
compiling fails with "Bad buffer" on any input that is not a data column. The
|
|
7
|
+
price-volume fields vwap and amount are computed, so a candidate of
|
|
8
|
+
``WindowedMaxDrawdown(amount, ...)`` would fail to evaluate. ``patch`` makes the operator loop over a buffer of its
|
|
9
|
+
input, as KunQuant's other windowed operators do; it is the fix proposed to
|
|
10
|
+
KunQuant (https://github.com/Menooker/KunQuant/pull/85), and a KunQuant that
|
|
11
|
+
has it is left as it is.
|
|
12
|
+
|
|
13
|
+
``_decompose`` is adapted from ``WindowedMaxDrawdown.decompose`` in
|
|
14
|
+
``KunQuant/ops/CompOp.py`` of KunQuant 0.1.12 (https://github.com/Menooker/KunQuant),
|
|
15
|
+
licensed under the Apache License 2.0; see ``KUNQUANT_LICENSE`` beside this file. The
|
|
16
|
+
one change is that the loop runs over ``WindowedTempOutput(v, window)`` instead of ``v``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from KunQuant.Op import Builder, ConstantOp, ForeachBackWindow, Input, IterValue, WindowedDataSourceOp, WindowedTempOutput, WindowLoopIndex
|
|
20
|
+
from KunQuant.ops import Mul, ReduceMin, Select, TsArgMax, WindowedMax, WindowedMaxDrawdown
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _decompose(self, options: dict) -> list:
|
|
24
|
+
builder = Builder(self.get_parent())
|
|
25
|
+
window = self.attrs["window"]
|
|
26
|
+
v = self.inputs[0]
|
|
27
|
+
with builder:
|
|
28
|
+
peak = WindowedMax(v, window)
|
|
29
|
+
max_bar_index = TsArgMax(v, window) - 1 # TsArgMax starts from 1
|
|
30
|
+
inf = ConstantOp(float("inf"))
|
|
31
|
+
buffered = WindowedTempOutput(v, window)
|
|
32
|
+
with ForeachBackWindow(buffered, window) as each:
|
|
33
|
+
index = WindowLoopIndex(each)
|
|
34
|
+
filtered = Select(index >= max_bar_index, IterValue(each, buffered), inf)
|
|
35
|
+
trough = ReduceMin(filtered)
|
|
36
|
+
(peak - trough) / peak
|
|
37
|
+
return builder.ops
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _loops_over_a_buffer() -> bool:
|
|
41
|
+
"""Whether KunQuant's max drawdown of a computed value reads the loop's values from a windowed buffer."""
|
|
42
|
+
with Builder():
|
|
43
|
+
x = Input("x")
|
|
44
|
+
drawdown = WindowedMaxDrawdown(Mul(x, x), 2)
|
|
45
|
+
return all(isinstance(op.inputs[1], WindowedDataSourceOp) for op in drawdown.decompose({}) if isinstance(op, IterValue))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def patch() -> None:
|
|
49
|
+
"""Make KunQuant's max drawdown loop over a buffer of its input, unless it already does; calling it again does nothing."""
|
|
50
|
+
if not _loops_over_a_buffer():
|
|
51
|
+
WindowedMaxDrawdown.decompose = _decompose
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The trained models' results of one acceptance segment, cached so a re-run trains only what is missing.
|
|
2
|
+
|
|
3
|
+
A model's key is the sha256 of its filled-in component config (the template,
|
|
4
|
+
its features' component configs, the label, the windows and the seed), less
|
|
5
|
+
its save directory and with every path under the run directory relative to
|
|
6
|
+
it (``portable``), so a moved run directory keeps its cache, and of whatever
|
|
7
|
+
else its result depends on (the code version, the TopK size, the scoring).
|
|
8
|
+
The digest is of plain JSON: a value JSON cannot hold raises rather than
|
|
9
|
+
hashing its ``repr``. ``ModelCache`` keeps one JSON file per key under the
|
|
10
|
+
segment's acceptance directory: the result as a plain dict, the seconds its
|
|
11
|
+
training and scoring took and the digest of its template, the record the
|
|
12
|
+
duration estimate reads.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def key(config: dict, root: Path, **extra) -> str:
|
|
21
|
+
"""The cache key of a model of component ``config`` in the run directory ``root``, whose result also depends on ``extra``.
|
|
22
|
+
|
|
23
|
+
Raises
|
|
24
|
+
------
|
|
25
|
+
TypeError
|
|
26
|
+
If ``config`` or ``extra`` holds a value JSON cannot hold.
|
|
27
|
+
"""
|
|
28
|
+
return digest({"model": portable(_without_save_dir(config), root), **extra})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def digest(value) -> str:
|
|
32
|
+
"""The sha256 of ``value`` as sorted JSON; a value JSON cannot hold raises ``TypeError``."""
|
|
33
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def portable(value, root: Path):
|
|
37
|
+
"""``value`` with every string path under ``root`` written relative to it, as ``<run_dir>/...``."""
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
return {k: portable(v, root) for k, v in value.items()}
|
|
40
|
+
if isinstance(value, (list, tuple)):
|
|
41
|
+
return [portable(v, root) for v in value]
|
|
42
|
+
if isinstance(value, str):
|
|
43
|
+
for prefix in {str(Path(root)), str(Path(root).resolve())}:
|
|
44
|
+
if value == prefix or value.startswith(prefix + "/"):
|
|
45
|
+
return "<run_dir>" + value[len(prefix):]
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ModelCache:
|
|
50
|
+
"""Cached model results in ``directory``, by ``key``."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, directory: Path):
|
|
53
|
+
self.directory = Path(directory)
|
|
54
|
+
|
|
55
|
+
def get(self, key: str) -> dict | None:
|
|
56
|
+
"""The result cached for ``key``, or None."""
|
|
57
|
+
path = self.directory / f"{key}.json"
|
|
58
|
+
return json.loads(path.read_text())["result"] if path.exists() else None
|
|
59
|
+
|
|
60
|
+
def put(self, key: str, result: dict, elapsed: float, template: str) -> None:
|
|
61
|
+
"""Cache ``result`` of a model of the template digested ``template``, trained and scored in ``elapsed`` seconds.
|
|
62
|
+
|
|
63
|
+
Written whole or not at all.
|
|
64
|
+
"""
|
|
65
|
+
self.directory.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = self.directory / f"{key}.json"
|
|
67
|
+
partial = path.with_suffix(".partial")
|
|
68
|
+
partial.write_text(json.dumps({"result": result, "elapsed": elapsed, "template": template}) + "\n")
|
|
69
|
+
partial.replace(path)
|
|
70
|
+
|
|
71
|
+
def summary(self, keys: list[str], template: str) -> str:
|
|
72
|
+
"""How many of ``keys`` are to train and cached, and the time to train them.
|
|
73
|
+
|
|
74
|
+
The estimate averages the elapsed times of the finished models of the template digested ``template``.
|
|
75
|
+
"""
|
|
76
|
+
missing = sum(not (self.directory / f"{k}.json").exists() for k in keys)
|
|
77
|
+
line = f"{missing} models to train, {len(keys) - missing} cached"
|
|
78
|
+
records = [json.loads(p.read_text()) for p in sorted(self.directory.glob("*.json"))]
|
|
79
|
+
elapsed = [r["elapsed"] for r in records if r["template"] == template]
|
|
80
|
+
if missing and elapsed:
|
|
81
|
+
mean = sum(elapsed) / len(elapsed)
|
|
82
|
+
line += f", about {_duration(missing * mean)} ({_duration(mean)} a model over {len(elapsed)} finished)"
|
|
83
|
+
return line
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _without_save_dir(config):
|
|
87
|
+
"""``config`` without any ``model_save_dir``, an ensemble's members' included."""
|
|
88
|
+
if isinstance(config, dict):
|
|
89
|
+
return {k: _without_save_dir(v) for k, v in config.items() if k != "model_save_dir"}
|
|
90
|
+
if isinstance(config, (list, tuple)):
|
|
91
|
+
return [_without_save_dir(v) for v in config]
|
|
92
|
+
return config
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _duration(seconds: float) -> str:
|
|
96
|
+
if seconds < 90:
|
|
97
|
+
return f"{seconds:.0f} s"
|
|
98
|
+
if seconds < 90 * 60:
|
|
99
|
+
return f"{seconds / 60:.0f} min"
|
|
100
|
+
return f"{seconds / 3600:.1f} h"
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The round's quota of candidates per mechanism type (after RD-Agent(Q)'s bandit).
|
|
2
|
+
|
|
3
|
+
Every type gets ``min_per_type`` slots. Each of the other slots goes to the
|
|
4
|
+
type with the largest draw from the Beta posterior of its admission rate:
|
|
5
|
+
``Beta(1 + accepted, 1 + trials - accepted)`` over every earlier trial
|
|
6
|
+
written for a direction of that type, failed ones included, so a type whose
|
|
7
|
+
candidates keep being accepted draws the extra slots. The types are the
|
|
8
|
+
config's, in its order; a record of another type counts toward none. Round n draws from
|
|
9
|
+
the generator seeded ``(seed, n)``: a resumed campaign plans the round as
|
|
10
|
+
the uninterrupted one would.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from factor_agent.config import RoundConfig
|
|
16
|
+
from factor_agent.ledger import TrialRecord
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def quota(number: int, records: list[TrialRecord], config: RoundConfig, types: tuple[str, ...]) -> dict[str, int]:
|
|
20
|
+
"""The slots of each of ``types`` in round ``number``, given the records of the earlier rounds."""
|
|
21
|
+
trials = dict.fromkeys(types, 0)
|
|
22
|
+
accepted = dict.fromkeys(types, 0)
|
|
23
|
+
for record in records:
|
|
24
|
+
kind = record.direction["mechanism_type"]
|
|
25
|
+
if kind not in trials:
|
|
26
|
+
continue
|
|
27
|
+
trials[kind] += 1
|
|
28
|
+
accepted[kind] += record.status == "accepted"
|
|
29
|
+
slots = dict.fromkeys(types, config.min_per_type)
|
|
30
|
+
rng = np.random.default_rng([config.seed, number])
|
|
31
|
+
alpha = np.array([1 + accepted[kind] for kind in types])
|
|
32
|
+
beta = np.array([1 + trials[kind] - accepted[kind] for kind in types])
|
|
33
|
+
for _ in range(config.candidates - config.min_per_type * len(types)):
|
|
34
|
+
slots[types[int(np.argmax(rng.beta(alpha, beta)))]] += 1
|
|
35
|
+
return slots
|