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/events.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""The events file: what a campaign is doing, appended as it happens, for anyone watching the run.
|
|
2
|
+
|
|
3
|
+
A campaign appends one event per line to ``events.jsonl`` in its run
|
|
4
|
+
directory: a JSON object with the file's schema version (``"schema"``), the
|
|
5
|
+
event's type (``"type"``), the time it happened (``"time"``, UTC, ISO 8601)
|
|
6
|
+
and the event's fields. The file is append-only, and the campaign never
|
|
7
|
+
reads it: it takes no part in a resume, a replay or the run's snapshot, so
|
|
8
|
+
deleting it loses the record of what happened but changes no result. A
|
|
9
|
+
resume that drops a round a crash left half written says so in its
|
|
10
|
+
``resume`` event instead of removing that round's events.
|
|
11
|
+
|
|
12
|
+
The event types, defined here once and read back by ``read``:
|
|
13
|
+
|
|
14
|
+
- ``start`` and ``resume`` (``Start``, ``Resume``): the campaign's process
|
|
15
|
+
id and host, the code versions, the rounds already written, the stop
|
|
16
|
+
conditions (round and trial limits, patience) and the admission limits;
|
|
17
|
+
a resume also names the round it dropped;
|
|
18
|
+
- ``plan`` (``Plan``): a round's research directions in slot order, its
|
|
19
|
+
quota and the plan's issues;
|
|
20
|
+
- ``call_start`` and ``call_end`` (``CallStart``, ``CallEnd``): every LLM
|
|
21
|
+
call, with its role, the proposal group and the slots it concerns; its
|
|
22
|
+
end names the call file it wrote, how long it took, its tokens as far
|
|
23
|
+
as the provider's reply reports them, and whether the reply was cut off
|
|
24
|
+
at the output limit or empty;
|
|
25
|
+
- ``gate`` (``GateOutcome``): every version of a slot's candidate the
|
|
26
|
+
parser and the Gate checked, passed or refused with the reasons and hints;
|
|
27
|
+
- ``review`` (``ReviewVerdict``): the reviewer's verdict on a slot, with its reason;
|
|
28
|
+
- ``evaluation`` (``EvaluationStart``): the Evaluator starts on the round's
|
|
29
|
+
candidates the reviewer found consistent;
|
|
30
|
+
- ``admission`` (``AdmissionOutcome``): a slot's outcome after evaluation,
|
|
31
|
+
with the t threshold in force and the metrics;
|
|
32
|
+
- ``round_end`` (``RoundEnd``): a round's trials are written;
|
|
33
|
+
- ``stop`` (``Stopped``): the run stopped, as the state file records it.
|
|
34
|
+
|
|
35
|
+
A slot is a candidate's place in its round, from 0, in the order of the
|
|
36
|
+
round's research directions (and of the round's lines in the ledger). A
|
|
37
|
+
value the campaign holds as NaN or infinite is written as ``null``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
import itertools
|
|
41
|
+
import json
|
|
42
|
+
import math
|
|
43
|
+
import threading
|
|
44
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
45
|
+
from datetime import datetime, timezone
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Literal
|
|
48
|
+
|
|
49
|
+
from factor_agent.llm._raw import raw_facts
|
|
50
|
+
|
|
51
|
+
#: The events file's name in a run directory.
|
|
52
|
+
FILE = "events.jsonl"
|
|
53
|
+
|
|
54
|
+
#: The version of the events file's format: it changes when an event loses or changes a field.
|
|
55
|
+
SCHEMA = 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now() -> datetime:
|
|
59
|
+
return datetime.now(timezone.utc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, kw_only=True)
|
|
63
|
+
class Event:
|
|
64
|
+
"""What every event has: the time it happened (UTC)."""
|
|
65
|
+
|
|
66
|
+
#: The event's type in the file; each event class sets its own.
|
|
67
|
+
type = ""
|
|
68
|
+
time: datetime = field(default_factory=_now)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, kw_only=True)
|
|
72
|
+
class Start(Event):
|
|
73
|
+
"""A campaign opened its run directory, with what it runs on and when it stops.
|
|
74
|
+
|
|
75
|
+
``pid`` and ``host`` are the campaign's process id and host name;
|
|
76
|
+
``versions`` the code versions the run's snapshot records for this
|
|
77
|
+
opening; ``rounds`` the rounds already written. ``max_rounds``,
|
|
78
|
+
``max_trials`` and ``patience`` are the stop conditions, ``candidates``
|
|
79
|
+
the slots of a round; ``t_threshold``, ``trial_exponent``,
|
|
80
|
+
``max_correlation``, ``min_coverage`` and ``min_monotonicity`` the
|
|
81
|
+
admission settings (``trial_exponent`` is 1 in a record written before it
|
|
82
|
+
was).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
type = "start"
|
|
86
|
+
pid: int
|
|
87
|
+
host: str
|
|
88
|
+
versions: dict
|
|
89
|
+
rounds: int
|
|
90
|
+
candidates: int
|
|
91
|
+
max_rounds: int
|
|
92
|
+
max_trials: int
|
|
93
|
+
patience: int
|
|
94
|
+
t_threshold: float
|
|
95
|
+
max_correlation: float
|
|
96
|
+
min_coverage: float
|
|
97
|
+
min_monotonicity: float
|
|
98
|
+
trial_exponent: float = 1.0
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, kw_only=True)
|
|
102
|
+
class Resume(Start):
|
|
103
|
+
"""A campaign opened a run directory it had opened before; ``dropped`` is the round it drops, if any.
|
|
104
|
+
|
|
105
|
+
The events of the dropped round stay in the file before this one; the
|
|
106
|
+
round runs again under the same number after it.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
type = "resume"
|
|
110
|
+
dropped: int | None = None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, kw_only=True)
|
|
114
|
+
class Plan(Event):
|
|
115
|
+
"""Round ``round``'s research directions, in slot order, the quota by mechanism type and the plan's issues."""
|
|
116
|
+
|
|
117
|
+
type = "plan"
|
|
118
|
+
round: int
|
|
119
|
+
directions: list[dict]
|
|
120
|
+
quota: dict[str, int]
|
|
121
|
+
issues: list[str]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, kw_only=True)
|
|
125
|
+
class CallStart(Event):
|
|
126
|
+
"""An LLM call was sent.
|
|
127
|
+
|
|
128
|
+
``call`` tells the calls of one opening of the run apart (``CallEnd``
|
|
129
|
+
names the same). ``group`` is the proposal group and ``slots`` the
|
|
130
|
+
slots the call concerns; both are empty for the planner and the
|
|
131
|
+
distiller, whose calls concern the whole round.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
type = "call_start"
|
|
135
|
+
call: int
|
|
136
|
+
round: int
|
|
137
|
+
role: str
|
|
138
|
+
model: str
|
|
139
|
+
group: int | None = None
|
|
140
|
+
slots: list[int] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, kw_only=True)
|
|
144
|
+
class CallEnd(CallStart):
|
|
145
|
+
"""An LLM call came back, or failed.
|
|
146
|
+
|
|
147
|
+
``file`` is the call file's name in ``llm_log/``, ``None`` when the call
|
|
148
|
+
failed (``error`` says how) and wrote none. ``seconds`` is how long it
|
|
149
|
+
took. The token counts are the provider's, ``None`` where its reply
|
|
150
|
+
does not report them: ``cache_hits`` are input tokens read from the
|
|
151
|
+
provider's cache, ``reasoning`` output tokens spent reasoning.
|
|
152
|
+
``truncated`` is true when the reply was cut off at the output limit,
|
|
153
|
+
``None`` when the provider's reply does not say; ``empty`` when the
|
|
154
|
+
reply holds no text.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
type = "call_end"
|
|
158
|
+
file: str | None = None
|
|
159
|
+
seconds: float = 0.0
|
|
160
|
+
input_tokens: int | None = None
|
|
161
|
+
output_tokens: int | None = None
|
|
162
|
+
cache_hits: int | None = None
|
|
163
|
+
reasoning: int | None = None
|
|
164
|
+
truncated: bool | None = None
|
|
165
|
+
empty: bool = False
|
|
166
|
+
error: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
#: Which version of a slot's candidate the Gate checks: the first, one fixed after a refusal, or one rewritten.
|
|
170
|
+
GateStep = Literal["proposed", "fixed", "rewritten"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass(frozen=True, kw_only=True)
|
|
174
|
+
class GateOutcome(Event):
|
|
175
|
+
"""One version of a slot's candidate checked by the parser and the Gate.
|
|
176
|
+
|
|
177
|
+
``step`` is ``"proposed"`` (the first version), ``"fixed"`` (a version
|
|
178
|
+
sent back by the parser or the Gate) or ``"rewritten"`` (a version the
|
|
179
|
+
reviewer sent back). ``item`` is the version as the proposer wrote it.
|
|
180
|
+
``stage`` is ``"parse"`` or ``"gate"`` for a refusal, with its
|
|
181
|
+
``reasons`` and ``hints``, and ``None`` when it ``passed``.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
type = "gate"
|
|
185
|
+
round: int
|
|
186
|
+
slot: int
|
|
187
|
+
step: GateStep
|
|
188
|
+
item: object
|
|
189
|
+
passed: bool
|
|
190
|
+
stage: str | None = None
|
|
191
|
+
reasons: list[str] = field(default_factory=list)
|
|
192
|
+
hints: list[str] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True, kw_only=True)
|
|
196
|
+
class ReviewVerdict(Event):
|
|
197
|
+
"""The reviewer's verdict on a slot's candidate, with its reason; ``rewrite`` when it judged a rewrite."""
|
|
198
|
+
|
|
199
|
+
type = "review"
|
|
200
|
+
round: int
|
|
201
|
+
slot: int
|
|
202
|
+
consistent: bool
|
|
203
|
+
reason: str
|
|
204
|
+
rewrite: bool = False
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, kw_only=True)
|
|
208
|
+
class EvaluationStart(Event):
|
|
209
|
+
"""The Evaluator starts on round ``round``'s ``slots``: the candidates the reviewer found consistent."""
|
|
210
|
+
|
|
211
|
+
type = "evaluation"
|
|
212
|
+
round: int
|
|
213
|
+
slots: list[int]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True, kw_only=True)
|
|
217
|
+
class AdmissionOutcome(Event):
|
|
218
|
+
"""A slot's outcome after evaluation.
|
|
219
|
+
|
|
220
|
+
``status`` is ``"accepted"`` or ``"rejected"`` (``stage`` ``"admission"``),
|
|
221
|
+
or ``"failed"`` (``stage`` ``"evaluate"``) when its graph did not
|
|
222
|
+
compile. ``threshold`` is the partial-IC t it had to reach; ``metrics``
|
|
223
|
+
are the Evaluator's (``None`` for a failure).
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
type = "admission"
|
|
227
|
+
round: int
|
|
228
|
+
slot: int
|
|
229
|
+
status: str
|
|
230
|
+
stage: str
|
|
231
|
+
reasons: list[str]
|
|
232
|
+
threshold: float | None
|
|
233
|
+
metrics: dict | None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@dataclass(frozen=True, kw_only=True)
|
|
237
|
+
class RoundEnd(Event):
|
|
238
|
+
"""Round ``round``'s ``trials`` are written, ``accepted`` of them accepted."""
|
|
239
|
+
|
|
240
|
+
type = "round_end"
|
|
241
|
+
round: int
|
|
242
|
+
trials: int
|
|
243
|
+
accepted: int
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, kw_only=True)
|
|
247
|
+
class Stopped(Event):
|
|
248
|
+
"""The run stopped: ``by`` the stop condition, ``reason`` its sentence, as the state file records them."""
|
|
249
|
+
|
|
250
|
+
type = "stop"
|
|
251
|
+
by: str
|
|
252
|
+
reason: str
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
#: Every event class by its type.
|
|
256
|
+
TYPES: dict[str, type[Event]] = {
|
|
257
|
+
cls.type: cls
|
|
258
|
+
for cls in (Start, Resume, Plan, CallStart, CallEnd, GateOutcome, ReviewVerdict, EvaluationStart,
|
|
259
|
+
AdmissionOutcome, RoundEnd, Stopped)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Events:
|
|
264
|
+
"""The events file of one run directory, appended to by a campaign; safe to use from several threads."""
|
|
265
|
+
|
|
266
|
+
def __init__(self, path: str | Path):
|
|
267
|
+
self.path = Path(path)
|
|
268
|
+
self._writing = threading.Lock()
|
|
269
|
+
self._calls = itertools.count(1)
|
|
270
|
+
|
|
271
|
+
def write(self, event: Event) -> None:
|
|
272
|
+
"""Append ``event`` as one line."""
|
|
273
|
+
line = {"schema": SCHEMA, "type": event.type, **_plain(asdict(event))}
|
|
274
|
+
text = json.dumps(line, ensure_ascii=False, allow_nan=False) + "\n"
|
|
275
|
+
with self._writing:
|
|
276
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
277
|
+
with self.path.open("a") as out:
|
|
278
|
+
out.write(text)
|
|
279
|
+
|
|
280
|
+
def next_call(self) -> int:
|
|
281
|
+
"""A number for the next call, unique in this opening of the run."""
|
|
282
|
+
with self._writing:
|
|
283
|
+
return next(self._calls)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def read(path: str | Path) -> list[Event]:
|
|
287
|
+
"""The events in the file at ``path``, in order; an empty list when there is none.
|
|
288
|
+
|
|
289
|
+
A line a crash cut short, a line of a later schema and an event of an
|
|
290
|
+
unknown type are skipped; a field this version does not know is ignored.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
lines = Path(path).read_text().splitlines()
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
return []
|
|
296
|
+
events = []
|
|
297
|
+
for line in lines:
|
|
298
|
+
try:
|
|
299
|
+
value = json.loads(line)
|
|
300
|
+
except json.JSONDecodeError:
|
|
301
|
+
continue
|
|
302
|
+
cls = TYPES.get(value.get("type")) if isinstance(value, dict) else None
|
|
303
|
+
if cls is None or value.get("schema") != SCHEMA:
|
|
304
|
+
continue
|
|
305
|
+
known = {f.name for f in fields(cls)}
|
|
306
|
+
try:
|
|
307
|
+
events.append(cls(**{k: v for k, v in value.items() if k in known and k != "time"},
|
|
308
|
+
time=datetime.fromisoformat(value["time"])))
|
|
309
|
+
except (TypeError, KeyError, ValueError):
|
|
310
|
+
continue
|
|
311
|
+
return events
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def reply_facts(reply) -> dict:
|
|
315
|
+
"""What a call's ``CallEnd`` says of its reply: tokens, truncation and emptiness, as far as the reply reports them.
|
|
316
|
+
|
|
317
|
+
The input and output tokens are the reply's ``usage``; the cache hits,
|
|
318
|
+
the reasoning tokens and the stop reason come from the provider's raw
|
|
319
|
+
reply: an Anthropic message, an OpenAI-compatible chat completion or
|
|
320
|
+
the Claude Code CLI's result.
|
|
321
|
+
"""
|
|
322
|
+
usage, facts = reply.usage or {}, raw_facts(reply.raw)
|
|
323
|
+
return {
|
|
324
|
+
"input_tokens": usage.get("input_tokens"),
|
|
325
|
+
"output_tokens": usage.get("output_tokens"),
|
|
326
|
+
"cache_hits": facts.cache_hits,
|
|
327
|
+
"reasoning": facts.reasoning_tokens,
|
|
328
|
+
"truncated": facts.truncated,
|
|
329
|
+
"empty": not reply.text.strip(),
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _plain(value):
|
|
334
|
+
"""``value`` as strict JSON holds it: NaN and infinities as ``None``, times as ISO 8601 text."""
|
|
335
|
+
if isinstance(value, float):
|
|
336
|
+
return value if math.isfinite(value) else None
|
|
337
|
+
if isinstance(value, datetime):
|
|
338
|
+
return value.isoformat()
|
|
339
|
+
if isinstance(value, dict):
|
|
340
|
+
return {str(k): _plain(v) for k, v in value.items()}
|
|
341
|
+
if isinstance(value, (list, tuple)):
|
|
342
|
+
return [_plain(v) for v in value]
|
|
343
|
+
return value
|
factor_agent/exported.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Accepted factors as quantlab factors: a ``FactorKunQuant`` whose outputs are recorded graphs.
|
|
2
|
+
|
|
3
|
+
Each output is a candidate's graph ranked across the whole market (KunQuant
|
|
4
|
+
``Rank``), the values the Evaluator scores (``factor_agent.scoring``), built by the
|
|
5
|
+
same ``ranked_function``. ``Acceptance.export`` writes one module per
|
|
6
|
+
factor, a subclass of ``GraphFactor`` that fixes ``GRAPHS`` and the
|
|
7
|
+
``CLASSES`` the campaign's vocabulary built them from;
|
|
8
|
+
``Acceptance.validate`` uses ``GraphFactor`` itself, with both in
|
|
9
|
+
``config.kwargs``.
|
|
10
|
+
|
|
11
|
+
A factor of an intraday family is held in its stages
|
|
12
|
+
(``factor_agent.gate.stages``, ``staged``): its graph is the daily stage,
|
|
13
|
+
reading each session column as an input, and ``SESSIONS`` maps each session
|
|
14
|
+
column to its intraday graph, computed from the intraday dataset of
|
|
15
|
+
``INTRADAY`` by ``factor_agent.session.SessionColumns``, as the Evaluator
|
|
16
|
+
computes it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
|
|
21
|
+
from KunQuant.Op import OpBase, Output, Rank
|
|
22
|
+
from KunQuant.ops import AddConst, MulConst
|
|
23
|
+
from KunQuant.Stage import Function
|
|
24
|
+
from quantlab.core.component import rebuild
|
|
25
|
+
|
|
26
|
+
from factor_agent import derived, period_lag
|
|
27
|
+
from factor_agent.gate import stages
|
|
28
|
+
from factor_agent.gate.graph import Graph
|
|
29
|
+
from factor_agent.gate.vocabulary import Vocabulary, resolve_classes
|
|
30
|
+
from factor_agent.session import SessionColumns
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def ranked_function(
|
|
34
|
+
graphs: Mapping[str, Graph], presence: Mapping[str, str] | None = None, directions: Mapping[str, int] | None = None,
|
|
35
|
+
) -> Function:
|
|
36
|
+
"""One KunQuant function with an output per graph, named after it, ranked across the market.
|
|
37
|
+
|
|
38
|
+
A graph whose ``directions`` entry is -1 (a higher value went with a lower
|
|
39
|
+
return) is output as one minus its rank, so every output reads the same
|
|
40
|
+
way: a higher value, a higher return.
|
|
41
|
+
|
|
42
|
+
KunQuant's ``Rank`` leaves a missing value missing and ranks the finite
|
|
43
|
+
values among themselves. ``presence`` maps a graph's name to the name of
|
|
44
|
+
a second output, 0 where the graph itself has a value and NaN where it
|
|
45
|
+
has none, read raw (never neutralised, so a symbol without exposures
|
|
46
|
+
still counts) and before any fill: it is computed from
|
|
47
|
+
``Graph.without_fills``, so a missing value a ``SetInfOrNanToValue``
|
|
48
|
+
fills stays missing in it.
|
|
49
|
+
"""
|
|
50
|
+
inputs: dict[str, OpBase] = {}
|
|
51
|
+
ops: list[OpBase] = []
|
|
52
|
+
for name, graph in graphs.items():
|
|
53
|
+
output, new = graph.build(inputs)
|
|
54
|
+
ranked = Rank(output)
|
|
55
|
+
ops += [*new, ranked]
|
|
56
|
+
if directions and directions.get(name) == -1:
|
|
57
|
+
flipped = MulConst(ranked, -1.0)
|
|
58
|
+
ranked = AddConst(flipped, 1.0)
|
|
59
|
+
ops += [flipped, ranked]
|
|
60
|
+
ops.append(Output(ranked, name))
|
|
61
|
+
if presence and name in presence:
|
|
62
|
+
unfilled = graph.without_fills()
|
|
63
|
+
if unfilled is not graph:
|
|
64
|
+
output, new = unfilled.build(inputs)
|
|
65
|
+
ops += new
|
|
66
|
+
zero = MulConst(output, 0.0)
|
|
67
|
+
ops += [zero, Output(zero, presence[name])]
|
|
68
|
+
return Function(ops)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GraphFactor(derived.DerivedColumnsFactor):
|
|
72
|
+
"""Recorded graphs, each ranked across the market, as one factor.
|
|
73
|
+
|
|
74
|
+
The graphs, name to ``Graph.to_json()``, are the class's ``GRAPHS`` (an
|
|
75
|
+
exported factor) or, when it has none, ``config.kwargs["graphs"]``; the
|
|
76
|
+
class path of each operator they record, ``CLASSES`` or
|
|
77
|
+
``config.kwargs["classes"]`` (``Vocabulary.class_paths``); how each lag
|
|
78
|
+
column they read is computed, ``PERIOD_LAGS`` or
|
|
79
|
+
``config.kwargs["period_lags"]`` (``period_lags``), none when neither is
|
|
80
|
+
set; each output's direction, ``DIRECTIONS`` or
|
|
81
|
+
``config.kwargs["directions"]`` (+1 when unset), turns it so a higher
|
|
82
|
+
value means a higher return. A graph of an intraday family is its daily
|
|
83
|
+
stage (``staged``): the intraday graph of each session column it reads
|
|
84
|
+
is in ``SESSIONS`` or ``config.kwargs["sessions"]``, and the intraday
|
|
85
|
+
dataset they run on, as a ``get_config()`` dict, in ``INTRADAY`` or
|
|
86
|
+
``config.kwargs["intraday"]``. ``config.data_columns`` must hold every
|
|
87
|
+
dataset column the graphs and their lags read (``data_columns``).
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
#: Output name -> ``Graph.to_json()``; set by an exported subclass.
|
|
91
|
+
GRAPHS: dict = {}
|
|
92
|
+
#: Operator name -> class path, of every operator ``GRAPHS`` records; set by an exported subclass.
|
|
93
|
+
CLASSES: dict = {}
|
|
94
|
+
#: Lag column -> how it is computed, of every lag column ``GRAPHS`` reads; set by an exported subclass.
|
|
95
|
+
PERIOD_LAGS: dict = {}
|
|
96
|
+
#: Output name -> its direction, +1 or -1; set by an exported subclass.
|
|
97
|
+
DIRECTIONS: dict = {}
|
|
98
|
+
#: Session column -> its intraday graph, of every session column ``GRAPHS`` reads; set by an exported subclass.
|
|
99
|
+
SESSIONS: dict = {}
|
|
100
|
+
#: The intraday dataset ``SESSIONS`` runs on, as a ``get_config()`` dict; set by an exported subclass.
|
|
101
|
+
INTRADAY: dict = {}
|
|
102
|
+
|
|
103
|
+
def _recorded(self) -> dict:
|
|
104
|
+
return type(self).GRAPHS or dict((self.config.kwargs or {})["graphs"])
|
|
105
|
+
|
|
106
|
+
def _class_paths(self) -> dict:
|
|
107
|
+
return type(self).CLASSES or dict((self.config.kwargs or {})["classes"])
|
|
108
|
+
|
|
109
|
+
def _classes(self) -> dict:
|
|
110
|
+
return resolve_classes(self._class_paths())
|
|
111
|
+
|
|
112
|
+
def _period_lags(self) -> dict:
|
|
113
|
+
return type(self).PERIOD_LAGS or dict((self.config.kwargs or {}).get("period_lags") or {})
|
|
114
|
+
|
|
115
|
+
def _sessions(self) -> dict:
|
|
116
|
+
return type(self).SESSIONS or dict((self.config.kwargs or {}).get("sessions") or {})
|
|
117
|
+
|
|
118
|
+
def _providers(self) -> list[derived.Provider]:
|
|
119
|
+
providers = period_lag.providers(self._period_lags())
|
|
120
|
+
sessions = self._sessions()
|
|
121
|
+
if sessions:
|
|
122
|
+
intraday = type(self).INTRADAY or (self.config.kwargs or {})["intraday"]
|
|
123
|
+
providers.append(SessionColumns(rebuild(intraday), sessions, self._class_paths(), self.config.njobs))
|
|
124
|
+
return providers
|
|
125
|
+
|
|
126
|
+
def _directions(self) -> dict:
|
|
127
|
+
return type(self).DIRECTIONS or dict((self.config.kwargs or {}).get("directions") or {})
|
|
128
|
+
|
|
129
|
+
def _get_factor_names(self) -> tuple[str, ...]:
|
|
130
|
+
return tuple(self._recorded())
|
|
131
|
+
|
|
132
|
+
def _get_factor_func(self) -> Function:
|
|
133
|
+
classes = self._classes()
|
|
134
|
+
graphs = {name: Graph.from_json(graph, classes) for name, graph in self._recorded().items()}
|
|
135
|
+
return ranked_function(graphs, directions=self._directions())
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def data_columns(
|
|
139
|
+
graphs: Mapping[str, list], period_lags: Mapping[str, Mapping] | None = None, sessions: Mapping[str, list] | None = None,
|
|
140
|
+
) -> tuple[str, ...]:
|
|
141
|
+
"""The daily dataset columns the recorded graphs read, with the columns and keys of their ``period_lags``, sorted.
|
|
142
|
+
|
|
143
|
+
The session columns of ``sessions`` are filled from the intraday dataset, not read.
|
|
144
|
+
"""
|
|
145
|
+
return tuple(sorted(set(derived.data_columns(_inputs(graphs), period_lag.providers(period_lags or {})))
|
|
146
|
+
- set(sessions or {})))
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def staged(graphs: Mapping[str, list], vocabulary: Vocabulary) -> tuple[dict, dict]:
|
|
150
|
+
"""Recorded graphs in their stages: each one's daily graph, by name, and every session column's intraday graph.
|
|
151
|
+
|
|
152
|
+
A graph of no intraday family is its own daily graph and reads no
|
|
153
|
+
session column. Both are ``Graph.to_json()``.
|
|
154
|
+
|
|
155
|
+
Raises
|
|
156
|
+
------
|
|
157
|
+
ValueError
|
|
158
|
+
If a graph breaks a stage rule (``stages.split``).
|
|
159
|
+
"""
|
|
160
|
+
daily, sessions = {}, {}
|
|
161
|
+
for name, recorded in graphs.items():
|
|
162
|
+
split = stages.split(Graph.from_json(recorded, vocabulary.classes), vocabulary)
|
|
163
|
+
if split.reasons:
|
|
164
|
+
raise ValueError(f"{name}: the graph breaks the stage rules: {'; '.join(split.reasons)}")
|
|
165
|
+
daily[name] = split.daily.to_json()
|
|
166
|
+
sessions |= {column: graph.to_json() for column, graph in split.intraday.items()}
|
|
167
|
+
return daily, sessions
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def period_lags(graphs: Mapping[str, list], lag_columns: Mapping[str, Mapping]) -> dict:
|
|
171
|
+
"""The entries of ``lag_columns`` (``Vocabulary.lag_columns``) the recorded graphs read."""
|
|
172
|
+
return period_lag.read(_inputs(graphs), lag_columns)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _inputs(graphs: Mapping[str, list]) -> set[str]:
|
|
176
|
+
"""The names of the ``Input`` nodes of recorded graphs."""
|
|
177
|
+
return {dict(node[1])["name"] for graph in graphs.values() for node in graph if node[0] == "Input"}
|