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,359 @@
|
|
|
1
|
+
"""The Gate: deterministic checks that every candidate passes before it is evaluated.
|
|
2
|
+
|
|
3
|
+
``Gate.check`` never reads data; what candidates may write with is its
|
|
4
|
+
``Vocabulary``, from the campaign's field and operator files. A bad candidate comes back ``Rejected``
|
|
5
|
+
with readable reasons; only an infrastructure failure raises. The checks
|
|
6
|
+
run in this order:
|
|
7
|
+
|
|
8
|
+
1. static checks on the code (syntax allow-list, names in the vocabulary, ``alpha`` assigned);
|
|
9
|
+
2. the graph build in a sandboxed subprocess (timeout from when the sandbox is
|
|
10
|
+
ready, memory limit, bubblewrap filesystem isolation where it is installed);
|
|
11
|
+
3. the stage rules of an intraday family's candidate (``stages.split``):
|
|
12
|
+
an intraday field reaches the daily stage only through a session
|
|
13
|
+
aggregation, the intraday stage reads no daily field, no aggregation of
|
|
14
|
+
a daily value or of an aggregation, each operator is used only in its
|
|
15
|
+
stages, each window and lag is one of its stage's set; each refusal
|
|
16
|
+
says how to write it instead (``Rejected.hints``);
|
|
17
|
+
4. the graph's size, parameter values, dimensions and complexity, all reported together;
|
|
18
|
+
5. originality against the zoo: not a rewrite of a zoo factor, not one with
|
|
19
|
+
other windows or parameters only, and no common subtree with one larger
|
|
20
|
+
than the limit. The reasons name the zoo factor and quote the subtree;
|
|
21
|
+
6. not a duplicate of a candidate this gate passed before: the same canonical
|
|
22
|
+
graph up to what keeps or reverses its ranking (``Graph.ranking``): a
|
|
23
|
+
factor and its negation predict alike, in opposite directions.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import ctypes
|
|
27
|
+
import ctypes.util
|
|
28
|
+
import importlib
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
import select
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import tempfile
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from functools import cache
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import TYPE_CHECKING
|
|
42
|
+
|
|
43
|
+
import psutil
|
|
44
|
+
|
|
45
|
+
from factor_agent.candidate import Candidate
|
|
46
|
+
from factor_agent.gate import _constraints, _originality, stages
|
|
47
|
+
from factor_agent.gate._checks import static_reasons
|
|
48
|
+
from factor_agent.gate.graph import Graph
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING: # the sandbox imports this package; it must not load pandas through the config
|
|
51
|
+
from factor_agent.config import GateConfig
|
|
52
|
+
from factor_agent.gate.vocabulary import Vocabulary
|
|
53
|
+
from factor_agent.zoo import Zoo
|
|
54
|
+
|
|
55
|
+
#: Characters of the subprocess's standard error quoted when it crashes.
|
|
56
|
+
_STDERR_TAIL = 2000
|
|
57
|
+
|
|
58
|
+
#: Seconds between two looks at the sandbox's memory.
|
|
59
|
+
_POLL_SECONDS = 0.02
|
|
60
|
+
|
|
61
|
+
#: The interpreter arguments that run the sandbox subprocess.
|
|
62
|
+
SANDBOX_ARGS = ("-m", "factor_agent.gate._sandbox")
|
|
63
|
+
|
|
64
|
+
#: The line the sandbox writes once it has started, before it runs the candidate's code.
|
|
65
|
+
READY = b"ready\n"
|
|
66
|
+
|
|
67
|
+
#: Seconds the sandbox may take to be ready (its imports and the vocabulary's rebuild); a graph build's time counts from then.
|
|
68
|
+
STARTUP_SECONDS = 60.0
|
|
69
|
+
|
|
70
|
+
#: The reason given when a graph build runs out of memory.
|
|
71
|
+
MEMORY_REASON = "graph build used more than the {memory_mb} MB memory limit"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Passed:
|
|
76
|
+
"""A candidate whose graph was built: the graph, its fingerprint and its statistics."""
|
|
77
|
+
|
|
78
|
+
graph: Graph
|
|
79
|
+
fingerprint: str
|
|
80
|
+
stats: dict = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Rejected:
|
|
85
|
+
"""A candidate refused by the gate, with the reasons sent back to the proposer.
|
|
86
|
+
|
|
87
|
+
``hints`` say how to write it instead: the vocabulary's fixes for a
|
|
88
|
+
writing error (a static check or the graph build), or those of the
|
|
89
|
+
stage rules it breaks; they go with the reasons into the rewrite
|
|
90
|
+
request only.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
reasons: tuple[str, ...]
|
|
94
|
+
hints: tuple[str, ...] = ()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Gate:
|
|
98
|
+
"""Check candidates; see the module docstring for the order of the checks.
|
|
99
|
+
|
|
100
|
+
A gate remembers the ranking of every candidate it passed and rejects
|
|
101
|
+
a later candidate that only keeps or reverses one.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
config : GateConfig
|
|
106
|
+
The sandbox limits and the graph constraints.
|
|
107
|
+
vocabulary : Vocabulary
|
|
108
|
+
The fields, operators and parameter values candidates may use;
|
|
109
|
+
the sandbox rebuilds the same one from its sources.
|
|
110
|
+
seen : iterable of Graph, optional
|
|
111
|
+
The graphs of candidates passed before, such as those of a campaign
|
|
112
|
+
that is resumed; a candidate that keeps or reverses one's ranking
|
|
113
|
+
(``Graph.ranking``) is a duplicate.
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, config: "GateConfig", vocabulary: "Vocabulary", seen=()):
|
|
117
|
+
self.config = config
|
|
118
|
+
self.vocabulary = vocabulary
|
|
119
|
+
self._seen: set[str] = {graph.ranking() for graph in seen}
|
|
120
|
+
|
|
121
|
+
def check(self, candidate: Candidate, zoo: "Zoo") -> Passed | Rejected:
|
|
122
|
+
"""Return ``Passed`` with the candidate's graph, or ``Rejected`` with why not.
|
|
123
|
+
|
|
124
|
+
``zoo`` holds the factors the candidate must be original against.
|
|
125
|
+
A writing error carries the hints of the vocabulary's fixes it
|
|
126
|
+
matches, a broken stage rule its own (``Rejected.hints``).
|
|
127
|
+
|
|
128
|
+
Raises
|
|
129
|
+
------
|
|
130
|
+
RuntimeError
|
|
131
|
+
If the sandbox cannot be started or replies with something
|
|
132
|
+
that is not its protocol.
|
|
133
|
+
"""
|
|
134
|
+
reasons = static_reasons(candidate.code, self.vocabulary.names)
|
|
135
|
+
if reasons:
|
|
136
|
+
return self._written_badly(reasons)
|
|
137
|
+
reply = self._build(candidate.code)
|
|
138
|
+
if isinstance(reply, Rejected):
|
|
139
|
+
return self._written_badly(reply.reasons)
|
|
140
|
+
graph = Graph.from_json(reply, self.vocabulary.classes)
|
|
141
|
+
staged = stages.split(graph, self.vocabulary)
|
|
142
|
+
if staged.reasons:
|
|
143
|
+
return Rejected(staged.reasons, staged.hints)
|
|
144
|
+
reasons, stats = _constraints.check(graph, self.config, self.vocabulary)
|
|
145
|
+
if reasons:
|
|
146
|
+
return Rejected(tuple(reasons))
|
|
147
|
+
reasons = _originality.reasons(graph, zoo, self.config.max_common_subtree_nodes, self.vocabulary)
|
|
148
|
+
if reasons:
|
|
149
|
+
return Rejected(tuple(reasons))
|
|
150
|
+
ranking = graph.ranking()
|
|
151
|
+
if ranking in self._seen:
|
|
152
|
+
return Rejected((
|
|
153
|
+
"the graph is a duplicate: it keeps or reverses the ranking of an earlier candidate (the same "
|
|
154
|
+
"canonical graph up to a rank, a constant offset or a scale, a sign flip included); "
|
|
155
|
+
"propose a different mechanism",
|
|
156
|
+
))
|
|
157
|
+
self._seen.add(ranking)
|
|
158
|
+
return Passed(graph, graph.fingerprint(), stats)
|
|
159
|
+
|
|
160
|
+
def withdraw(self, graph: Graph) -> None:
|
|
161
|
+
"""Forget that ``graph`` passed, so a rewrite of its candidate is not refused as its duplicate.
|
|
162
|
+
|
|
163
|
+
A candidate the reviewer sends back is rewritten, and its rewrite
|
|
164
|
+
often keeps the graph: the rewrite continues the same candidate, so
|
|
165
|
+
the version it replaces gives up its ranking. A candidate the
|
|
166
|
+
reviewer rejects for good keeps it, as a resume restores it.
|
|
167
|
+
"""
|
|
168
|
+
self._seen.discard(graph.ranking())
|
|
169
|
+
|
|
170
|
+
def _written_badly(self, reasons) -> Rejected:
|
|
171
|
+
"""A writing error's refusal, with the hints of the fixes its reasons match."""
|
|
172
|
+
return Rejected(tuple(reasons), self.vocabulary.fix_hints(reasons))
|
|
173
|
+
|
|
174
|
+
def _build(self, code: str) -> list | Rejected:
|
|
175
|
+
"""Build the graph in the sandbox subprocess; return its nodes or the rejection."""
|
|
176
|
+
request = json.dumps({"code": code, "memory_mb": self.config.memory_mb, "vocabulary": self.vocabulary.sources})
|
|
177
|
+
# The request (the vocabulary's files included) is larger than a pipe holds, so it is read from a file.
|
|
178
|
+
with tempfile.TemporaryDirectory(prefix="factor-agent-gate-") as scratch, tempfile.TemporaryFile("w+") as stdin:
|
|
179
|
+
stdin.write(request)
|
|
180
|
+
stdin.flush()
|
|
181
|
+
stdin.seek(0)
|
|
182
|
+
command = [sys.executable, "-I", *SANDBOX_ARGS]
|
|
183
|
+
if shutil.which("bwrap") and _bubblewrap_works():
|
|
184
|
+
command = _bubblewrap(command, self.vocabulary.sources["operator_modules"])
|
|
185
|
+
try:
|
|
186
|
+
process = subprocess.Popen(
|
|
187
|
+
command, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
188
|
+
cwd=scratch, env={"PATH": os.defpath},
|
|
189
|
+
)
|
|
190
|
+
except OSError as exc:
|
|
191
|
+
raise RuntimeError(f"Gate: cannot start the sandbox: {exc}") from exc
|
|
192
|
+
outcome = self._watch(process)
|
|
193
|
+
if isinstance(outcome, Rejected):
|
|
194
|
+
return outcome
|
|
195
|
+
stdout, stderr = outcome
|
|
196
|
+
if process.returncode != 0:
|
|
197
|
+
tail = stderr[-_STDERR_TAIL:].strip()
|
|
198
|
+
if "ModuleNotFoundError" in tail and "factor_agent" in tail:
|
|
199
|
+
raise RuntimeError(f"Gate: the sandbox cannot import factor_agent:\n{tail}")
|
|
200
|
+
if "MemoryError" in tail:
|
|
201
|
+
return Rejected((MEMORY_REASON.format(memory_mb=self.config.memory_mb),))
|
|
202
|
+
return Rejected((f"graph build crashed (exit {process.returncode}): {_last_line(tail)}",))
|
|
203
|
+
try:
|
|
204
|
+
message = json.loads(stdout)
|
|
205
|
+
except json.JSONDecodeError as exc:
|
|
206
|
+
raise RuntimeError(f"Gate: the sandbox replied {stdout[:200]!r}") from exc
|
|
207
|
+
if "reasons" in message:
|
|
208
|
+
return Rejected(tuple(message["reasons"]))
|
|
209
|
+
return message["graph"]
|
|
210
|
+
|
|
211
|
+
def _watch(self, process: subprocess.Popen) -> tuple[str, str] | Rejected:
|
|
212
|
+
"""Wait for the reply, killing the sandbox past the time or memory limit.
|
|
213
|
+
|
|
214
|
+
The time limit counts from the sandbox's ``READY``: its start, the
|
|
215
|
+
imports and the vocabulary's rebuild, is not the candidate's.
|
|
216
|
+
|
|
217
|
+
Raises
|
|
218
|
+
------
|
|
219
|
+
RuntimeError
|
|
220
|
+
If the sandbox is not ready within ``STARTUP_SECONDS``: a broken
|
|
221
|
+
environment, not a bad candidate.
|
|
222
|
+
"""
|
|
223
|
+
limit = self.config.memory_mb * 2**20
|
|
224
|
+
watched = psutil.Process(process.pid)
|
|
225
|
+
output = _Output(process)
|
|
226
|
+
deadline, ready = time.monotonic() + STARTUP_SECONDS, False
|
|
227
|
+
while True:
|
|
228
|
+
output.read(_POLL_SECONDS)
|
|
229
|
+
if output.closed:
|
|
230
|
+
process.wait()
|
|
231
|
+
return output.stdout, output.stderr
|
|
232
|
+
if output.ready and not ready:
|
|
233
|
+
deadline, ready = time.monotonic() + self.config.timeout_seconds, True
|
|
234
|
+
try:
|
|
235
|
+
used = sum(_memory(p) for p in [watched, *watched.children(recursive=True)])
|
|
236
|
+
except psutil.Error:
|
|
237
|
+
continue
|
|
238
|
+
if used > limit:
|
|
239
|
+
outcome = Rejected((MEMORY_REASON.format(memory_mb=self.config.memory_mb),))
|
|
240
|
+
elif time.monotonic() > deadline and ready:
|
|
241
|
+
outcome = Rejected((f"graph build timed out after {self.config.timeout_seconds:g} s",))
|
|
242
|
+
elif time.monotonic() > deadline:
|
|
243
|
+
outcome = RuntimeError(f"Gate: the sandbox was not ready within {STARTUP_SECONDS:g} s")
|
|
244
|
+
else:
|
|
245
|
+
continue
|
|
246
|
+
process.kill()
|
|
247
|
+
process.wait()
|
|
248
|
+
if isinstance(outcome, RuntimeError):
|
|
249
|
+
raise outcome
|
|
250
|
+
return outcome
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _Output:
|
|
254
|
+
"""The sandbox's standard output and error, read to their end as they come, so neither pipe fills."""
|
|
255
|
+
|
|
256
|
+
def __init__(self, process: subprocess.Popen):
|
|
257
|
+
self._stdout, self._stderr = process.stdout.fileno(), process.stderr.fileno()
|
|
258
|
+
self._open = [self._stdout, self._stderr]
|
|
259
|
+
self._read = {fd: b"" for fd in self._open}
|
|
260
|
+
|
|
261
|
+
def read(self, timeout: float) -> None:
|
|
262
|
+
"""Read what the sandbox writes within ``timeout`` seconds."""
|
|
263
|
+
for fd in select.select(self._open, [], [], timeout)[0]:
|
|
264
|
+
chunk = os.read(fd, 65536)
|
|
265
|
+
self._read[fd] += chunk
|
|
266
|
+
if not chunk:
|
|
267
|
+
self._open.remove(fd)
|
|
268
|
+
|
|
269
|
+
@property
|
|
270
|
+
def closed(self) -> bool:
|
|
271
|
+
"""Whether the sandbox has closed both pipes: it has exited."""
|
|
272
|
+
return not self._open
|
|
273
|
+
|
|
274
|
+
@property
|
|
275
|
+
def ready(self) -> bool:
|
|
276
|
+
"""Whether the sandbox has written ``READY``: it has started."""
|
|
277
|
+
return READY in self._read[self._stdout]
|
|
278
|
+
|
|
279
|
+
@property
|
|
280
|
+
def stdout(self) -> str:
|
|
281
|
+
"""The standard output after ``READY``, all of it if the sandbox exited before."""
|
|
282
|
+
before, ready, after = self._read[self._stdout].partition(READY)
|
|
283
|
+
return (after if ready else before).decode()
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def stderr(self) -> str:
|
|
287
|
+
"""The standard error."""
|
|
288
|
+
return self._read[self._stderr].decode(errors="replace")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _memory(process: psutil.Process) -> int:
|
|
292
|
+
"""Return the bytes ``process`` uses.
|
|
293
|
+
|
|
294
|
+
On macOS this is its physical footprint, which counts the pages the
|
|
295
|
+
memory compressor holds; the resident set alone falls as the compressor works.
|
|
296
|
+
"""
|
|
297
|
+
if sys.platform != "darwin":
|
|
298
|
+
return process.memory_info().rss
|
|
299
|
+
usage = (ctypes.c_uint64 * 40)() # struct rusage_info_v2
|
|
300
|
+
if _libproc().proc_pid_rusage(process.pid, 2, ctypes.byref(usage)) != 0:
|
|
301
|
+
raise psutil.NoSuchProcess(process.pid)
|
|
302
|
+
return usage[9] # ri_phys_footprint, after the 16-byte uuid and seven counters
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@cache
|
|
306
|
+
def _libproc() -> ctypes.CDLL:
|
|
307
|
+
"""macOS's libproc, loaded once."""
|
|
308
|
+
return ctypes.CDLL(ctypes.util.find_library("proc"))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _last_line(stderr: str) -> str:
|
|
312
|
+
"""The last line of a crash's stderr, its exception, with no file path, so a replay elsewhere reads the same."""
|
|
313
|
+
lines = [line for line in stderr.splitlines() if line.strip()]
|
|
314
|
+
return re.sub(r"(?<![\w.])(?:/[\w.-]+){2,}/", "", lines[-1].strip()) if lines else "no output"
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@cache
|
|
318
|
+
def _bubblewrap_works() -> bool:
|
|
319
|
+
"""Check once that the installed bubblewrap can run the sandbox's imports.
|
|
320
|
+
|
|
321
|
+
Raises
|
|
322
|
+
------
|
|
323
|
+
RuntimeError
|
|
324
|
+
If it cannot (for example where unprivileged user namespaces are
|
|
325
|
+
disabled): every build would crash, which is a broken sandbox, not
|
|
326
|
+
a bad candidate.
|
|
327
|
+
"""
|
|
328
|
+
probe = _bubblewrap([sys.executable, "-I", "-c", "import factor_agent.gate.graph"])
|
|
329
|
+
done = subprocess.run(probe, capture_output=True, text=True, timeout=60, env={"PATH": os.defpath})
|
|
330
|
+
if done.returncode != 0:
|
|
331
|
+
raise RuntimeError(f"Gate: bubblewrap is installed but cannot run the sandbox:\n{done.stderr[-_STDERR_TAIL:]}")
|
|
332
|
+
return True
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _bubblewrap(command: list[str], modules=()) -> list[str]:
|
|
336
|
+
"""Wrap ``command`` in bubblewrap.
|
|
337
|
+
|
|
338
|
+
The sandbox gets no network, a private /tmp, and read-only access to the
|
|
339
|
+
system directories, the Python installation and the packages it imports
|
|
340
|
+
(the vocabulary's ``modules`` among them), nothing else. A package
|
|
341
|
+
imported from outside the Python installation (an editable checkout) is
|
|
342
|
+
bound alone, so the rest of its checkout, credentials included, stays
|
|
343
|
+
out of view.
|
|
344
|
+
"""
|
|
345
|
+
prefixes = {Path(p).resolve() for p in (sys.prefix, sys.base_prefix, sys.exec_prefix)}
|
|
346
|
+
visible = set(prefixes) | {Path(p) for p in ("/usr", "/lib", "/lib64", "/bin", "/etc/ld.so.cache")}
|
|
347
|
+
for name in dict.fromkeys(["factor_agent", "quantlab", "KunQuant", *(m.split(".")[0] for m in modules)]):
|
|
348
|
+
source = Path(importlib.import_module(name).__file__).resolve()
|
|
349
|
+
package = source.parent if source.name == "__init__.py" else source
|
|
350
|
+
if not any(package.is_relative_to(prefix) for prefix in prefixes):
|
|
351
|
+
visible.add(package)
|
|
352
|
+
binds = []
|
|
353
|
+
for path in sorted(visible):
|
|
354
|
+
binds += ["--ro-bind-try", str(path), str(path)]
|
|
355
|
+
return [
|
|
356
|
+
"bwrap", "--unshare-all", "--die-with-parent", "--new-session",
|
|
357
|
+
*binds, "--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--chdir", "/tmp",
|
|
358
|
+
*command,
|
|
359
|
+
]
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""Canonical hashes of a graph's nodes: what the fingerprint and duplicate checks compare.
|
|
2
|
+
|
|
3
|
+
Each node's hash is a Merkle hash of its operator, attributes, inputs and
|
|
4
|
+
enclosing loop, computed after three rewrites:
|
|
5
|
+
|
|
6
|
+
- constant folding: an elementwise operator whose inputs are all constants
|
|
7
|
+
becomes the constant it computes;
|
|
8
|
+
- constant arithmetic in one form: ``x + c``, ``Add(x, ConstantOp(c))`` and
|
|
9
|
+
``x - (-c)`` are all ``AddConst(x, c)``, ``x / c`` is ``MulConst(x, 1/c)``,
|
|
10
|
+
nested constant offsets and scales merge, and ``x + 0`` and ``x * 1`` are ``x``;
|
|
11
|
+
- commutative ordering: the inputs of a commutative operator are sorted;
|
|
12
|
+
- division guards: ``Select(d > eps, n / d, nan)``, also on ``Abs(d)``, with
|
|
13
|
+
``d <= eps`` and the NaN first, and with the denominator itself guarded
|
|
14
|
+
(``n / Select(d > eps, d, 1)``, as quantlab's ``FundamentalAlpha`` writes
|
|
15
|
+
its ratios), is ``n / d`` for any positive ``eps`` up to ``GUARD_EPSILON``.
|
|
16
|
+
|
|
17
|
+
A repeated subexpression hashes the same wherever it appears, so the
|
|
18
|
+
fingerprint does not depend on how the code shared intermediate values.
|
|
19
|
+
Multiplying by zero is never folded: ``0 * x`` is NaN where ``x`` is, which
|
|
20
|
+
the NaN-preserving stand-ins rely on.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import json
|
|
25
|
+
import math
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from factor_agent.gate.graph import Graph
|
|
30
|
+
|
|
31
|
+
#: Operators whose inputs may be reordered.
|
|
32
|
+
COMMUTATIVE = frozenset({
|
|
33
|
+
"Add", "Mul", "Max", "Min", "Equals", "And", "Or", "WindowedCorrelation", "WindowedCovariance", "EWCov",
|
|
34
|
+
"SessionCorr",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
#: Elementwise operators folded when every input is a constant.
|
|
38
|
+
_FOLD = {
|
|
39
|
+
"Add": lambda a, b: a + b,
|
|
40
|
+
"Sub": lambda a, b: a - b,
|
|
41
|
+
"Mul": lambda a, b: a * b,
|
|
42
|
+
"Div": lambda a, b: a / b,
|
|
43
|
+
"Max": max,
|
|
44
|
+
"Min": min,
|
|
45
|
+
"Pow": lambda a, b: a ** b,
|
|
46
|
+
"Abs": abs,
|
|
47
|
+
"Sign": lambda a: float((a > 0) - (a < 0)),
|
|
48
|
+
"Exp": math.exp,
|
|
49
|
+
"Log": math.log,
|
|
50
|
+
"Sqrt": math.sqrt,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#: Significant digits a constant keeps, so 1/3 * 3 and 1 hash alike.
|
|
54
|
+
_DIGITS = 12
|
|
55
|
+
|
|
56
|
+
#: The largest threshold a division guard may test the denominator against.
|
|
57
|
+
GUARD_EPSILON = 1e-6
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Const:
|
|
61
|
+
"""A node that folded to a constant."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, value: float):
|
|
64
|
+
self.value = value
|
|
65
|
+
|
|
66
|
+
def key(self):
|
|
67
|
+
return ["const", "nan" if math.isnan(self.value) else _rounded(self.value)]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _Node:
|
|
71
|
+
"""A node's canonical form: operator, attributes, input terms, loop term."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, op: str, attrs: dict, inputs: list, parent):
|
|
74
|
+
self.op = op
|
|
75
|
+
self.attrs = attrs
|
|
76
|
+
self.inputs = inputs
|
|
77
|
+
self.parent = parent
|
|
78
|
+
self.hash = _sha([op, sorted(attrs.items()), [_key(t) for t in inputs], None if parent is None else _key(parent)])
|
|
79
|
+
|
|
80
|
+
def key(self):
|
|
81
|
+
return self.hash
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _rounded(value: float) -> float:
|
|
85
|
+
return float(f"{value:.{_DIGITS}g}")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sha(value) -> str:
|
|
89
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _key(term):
|
|
93
|
+
return term.key()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _same(one, other) -> bool:
|
|
97
|
+
return (one is None and other is None) or (one is not None and other is not None and _key(one) == _key(other))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _number(value):
|
|
101
|
+
"""Attribute values as JSON numbers; 5 and 5.0 are the same window."""
|
|
102
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
103
|
+
return value
|
|
104
|
+
return float(value)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def canonical_hashes(graph: "Graph") -> list[str]:
|
|
108
|
+
"""Return the canonical hash of every node of ``graph``, in node order; the last is the factor's."""
|
|
109
|
+
return [_sha(["term", _key(t)]) for t in canonical_terms(graph)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
#: Operators whose result does not depend on the order of their inputs, to the last bit, NaN included.
|
|
113
|
+
#: ``Max`` and ``Min`` are not among them: a SIMD maximum returns its second input when one is NaN.
|
|
114
|
+
_EXACTLY_COMMUTATIVE = frozenset({"Add", "Mul", "Equals", "And", "Or", "SessionCorr"})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def computation_hashes(graph: "Graph") -> list[str]:
|
|
118
|
+
"""Return a hash of what every node of ``graph`` computes, in node order; the last is the factor's.
|
|
119
|
+
|
|
120
|
+
Two nodes hash alike only when they compute the same values to the last
|
|
121
|
+
bit: the hash is of the operator, attributes, inputs and loop as
|
|
122
|
+
written, without the canonical hash's rewrites (``x + 1e8 - 1e8`` is not
|
|
123
|
+
``x`` in float32, nor ``x / 3`` ``x * (1/3)``), the inputs of an
|
|
124
|
+
operator whose result does not depend on their order sorted. A repeated
|
|
125
|
+
subexpression hashes the same wherever it appears.
|
|
126
|
+
"""
|
|
127
|
+
hashes: list[str] = []
|
|
128
|
+
for node in graph.nodes:
|
|
129
|
+
inputs = [hashes[i] for i in node.inputs]
|
|
130
|
+
if node.op in _EXACTLY_COMMUTATIVE:
|
|
131
|
+
inputs.sort()
|
|
132
|
+
parent = None if node.parent is None else hashes[node.parent]
|
|
133
|
+
hashes.append(_sha(["computation", node.op, sorted(node.attrs), inputs, parent]))
|
|
134
|
+
return hashes
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def effective_parameters(graph: "Graph", lags_below: frozenset[str] = frozenset()) -> list[tuple[str, str, float]]:
|
|
138
|
+
"""Return ``(operator, kind, value)`` for every value the factor uses once constants are merged.
|
|
139
|
+
|
|
140
|
+
Kinds are ``"constant"`` (a merged offset or a folded constant),
|
|
141
|
+
``"scale"`` (a merged multiplier; dividing by ``c`` is scaling by ``1/c``)
|
|
142
|
+
and ``"lag"`` (a merged chain of ``BackRef``). Chaining allowed values
|
|
143
|
+
(``x * 0.25 * 1.5``, ``BackRef(BackRef(x, 5), 10)``) shows up here as the
|
|
144
|
+
value it really is. The lags below an operator of ``lags_below`` (a
|
|
145
|
+
session aggregation, whose inputs count bars, not days) are left out.
|
|
146
|
+
"""
|
|
147
|
+
found, seen, stack = [], set(), [(canonical_terms(graph)[-1], True)]
|
|
148
|
+
while stack:
|
|
149
|
+
term, lags = stack.pop()
|
|
150
|
+
if isinstance(term, _Const):
|
|
151
|
+
found.append(("ConstantOp", "constant", term.value))
|
|
152
|
+
continue
|
|
153
|
+
if (term.hash, lags) in seen:
|
|
154
|
+
continue
|
|
155
|
+
seen.add((term.hash, lags))
|
|
156
|
+
if term.op in ("AddConst", "SubConstSwap", "DivConstSwap"):
|
|
157
|
+
found.append((term.op, "constant", term.attrs["value"]))
|
|
158
|
+
elif term.op == "MulConst":
|
|
159
|
+
found.append((term.op, "scale", term.attrs["value"]))
|
|
160
|
+
elif term.op == "BackRef" and lags:
|
|
161
|
+
found.append((term.op, "lag", term.attrs["window"]))
|
|
162
|
+
below = lags and term.op not in lags_below
|
|
163
|
+
stack += [(t, below) for t in term.inputs + ([] if term.parent is None else [term.parent])]
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def canonical_terms(graph: "Graph") -> list:
|
|
168
|
+
"""Return every node's canonical term, in node order; the last is the factor's."""
|
|
169
|
+
terms = []
|
|
170
|
+
for node in graph.nodes:
|
|
171
|
+
attrs = dict(node.attrs)
|
|
172
|
+
inputs = [terms[i] for i in node.inputs]
|
|
173
|
+
parent = None if node.parent is None else terms[node.parent]
|
|
174
|
+
terms.append(_canonical(node.op, attrs, inputs, parent))
|
|
175
|
+
return terms
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _canonical(op: str, attrs: dict, inputs: list, parent):
|
|
179
|
+
"""Return one node's canonical term, given its inputs' and loop's terms."""
|
|
180
|
+
if op == "ConstantOp":
|
|
181
|
+
value = attrs["value"]
|
|
182
|
+
return _Const(float("nan") if value == "nan" else float(value))
|
|
183
|
+
swap = bool(attrs.pop("swap", False))
|
|
184
|
+
attrs = {k: _number(v) for k, v in attrs.items()}
|
|
185
|
+
|
|
186
|
+
if op in ("AddConst", "SubConst", "MulConst", "DivConst"):
|
|
187
|
+
x, c = inputs[0], attrs["value"]
|
|
188
|
+
return _with_constant(op, x, c, swap, parent)
|
|
189
|
+
if op in _FOLD and inputs and all(isinstance(t, _Const) for t in inputs):
|
|
190
|
+
try:
|
|
191
|
+
return _Const(float(_FOLD[op](*(t.value for t in inputs))))
|
|
192
|
+
except (ArithmeticError, ValueError):
|
|
193
|
+
pass
|
|
194
|
+
if op in ("Add", "Sub", "Mul", "Div") and len(inputs) == 2:
|
|
195
|
+
left, right = inputs
|
|
196
|
+
if isinstance(right, _Const) and not isinstance(left, _Const):
|
|
197
|
+
return _with_constant(op + "Const", left, right.value, False, parent)
|
|
198
|
+
if isinstance(left, _Const) and not isinstance(right, _Const):
|
|
199
|
+
swapped = op in ("Sub", "Div")
|
|
200
|
+
return _with_constant(op + "Const", right, left.value, swapped, parent)
|
|
201
|
+
if op == "BackRef" and isinstance(inputs[0], _Node) and inputs[0].op == "BackRef" and _same(inputs[0].parent, parent):
|
|
202
|
+
return _Node(op, {"window": inputs[0].attrs["window"] + attrs["window"]}, inputs[0].inputs, parent)
|
|
203
|
+
if op == "Select":
|
|
204
|
+
ratio = _unguarded(inputs, parent)
|
|
205
|
+
if ratio is not None:
|
|
206
|
+
return ratio
|
|
207
|
+
if op in COMMUTATIVE:
|
|
208
|
+
inputs = sorted(inputs, key=lambda t: json.dumps(_key(t)))
|
|
209
|
+
return _Node(op, attrs, inputs, parent)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _guard(condition) -> tuple[object, bool] | None:
|
|
213
|
+
"""``(d, holds)`` when ``condition`` tests ``d`` (or ``Abs(d)``) against a small positive threshold.
|
|
214
|
+
|
|
215
|
+
``holds`` is True for ``d > eps`` (the ratio is valid where it holds),
|
|
216
|
+
False for ``d <= eps``; None for any other condition.
|
|
217
|
+
"""
|
|
218
|
+
if not isinstance(condition, _Node):
|
|
219
|
+
return None
|
|
220
|
+
tested, threshold, holds = None, None, None
|
|
221
|
+
if condition.op in ("GreaterThan", "LessEqual") and isinstance(condition.inputs[1], _Const):
|
|
222
|
+
tested, threshold, holds = condition.inputs[0], condition.inputs[1].value, condition.op == "GreaterThan"
|
|
223
|
+
elif condition.op in ("LessThan", "GreaterEqual") and isinstance(condition.inputs[0], _Const):
|
|
224
|
+
tested, threshold, holds = condition.inputs[1], condition.inputs[0].value, condition.op == "LessThan"
|
|
225
|
+
elif condition.op in ("GreaterThanConst", "LessEqualConst") and not condition.attrs.get("swap"):
|
|
226
|
+
tested, threshold, holds = condition.inputs[0], condition.attrs["value"], condition.op == "GreaterThanConst"
|
|
227
|
+
if tested is None or not 0 < threshold <= GUARD_EPSILON:
|
|
228
|
+
return None
|
|
229
|
+
if isinstance(tested, _Node) and tested.op == "Abs":
|
|
230
|
+
tested = tested.inputs[0]
|
|
231
|
+
return tested, holds
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _unguarded(inputs: list, parent):
|
|
235
|
+
"""``n / d`` for a guarded ratio ``Select(guard on d, n / d, nan)`` (either order); None for another Select."""
|
|
236
|
+
if len(inputs) != 3:
|
|
237
|
+
return None
|
|
238
|
+
guard = _guard(inputs[0])
|
|
239
|
+
if guard is None:
|
|
240
|
+
return None
|
|
241
|
+
d, holds = guard
|
|
242
|
+
ratio, fill = (inputs[1], inputs[2]) if holds else (inputs[2], inputs[1])
|
|
243
|
+
if not (isinstance(fill, _Const) and math.isnan(fill.value)) or not isinstance(ratio, _Node):
|
|
244
|
+
return None
|
|
245
|
+
if ratio.op == "Div":
|
|
246
|
+
numerator, denominator = ratio.inputs
|
|
247
|
+
elif ratio.op == "DivConstSwap":
|
|
248
|
+
numerator, [denominator] = None, ratio.inputs
|
|
249
|
+
else:
|
|
250
|
+
return None
|
|
251
|
+
inner = _guard(denominator.inputs[0]) if isinstance(denominator, _Node) and denominator.op == "Select" else None
|
|
252
|
+
if inner is not None and inner[1] and _same(inner[0], d) and _same(denominator.inputs[1], d):
|
|
253
|
+
denominator = d
|
|
254
|
+
if not _same(denominator, d) or not _same(ratio.parent, parent):
|
|
255
|
+
return None
|
|
256
|
+
if numerator is None:
|
|
257
|
+
return _Node("DivConstSwap", ratio.attrs, [d], parent)
|
|
258
|
+
return _canonical("Div", {}, [numerator, d], parent)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _with_constant(op: str, x, c: float, swap: bool, parent):
|
|
262
|
+
"""``x op c`` (``c op x`` when ``swap``) as ``AddConst``/``MulConst``, or a swapped form."""
|
|
263
|
+
if isinstance(x, _Const):
|
|
264
|
+
a, b = (c, x.value) if swap else (x.value, c)
|
|
265
|
+
try:
|
|
266
|
+
return _Const(float(_FOLD[op.removesuffix("Const")](a, b)))
|
|
267
|
+
except (ArithmeticError, ValueError):
|
|
268
|
+
pass
|
|
269
|
+
if op == "SubConst" and not swap:
|
|
270
|
+
op, c = "AddConst", -c
|
|
271
|
+
elif op == "DivConst" and not swap and c != 0:
|
|
272
|
+
op, c = "MulConst", 1.0 / c
|
|
273
|
+
if op in ("SubConst", "DivConst"): # c - x, c / x
|
|
274
|
+
return _Node(op + "Swap", {"value": _number(c)}, [x], parent)
|
|
275
|
+
if isinstance(x, _Node) and x.op == op and _same(x.parent, parent):
|
|
276
|
+
inner = x.attrs["value"]
|
|
277
|
+
c = inner + c if op == "AddConst" else inner * c
|
|
278
|
+
x = x.inputs[0]
|
|
279
|
+
if (op == "AddConst" and c == 0) or (op == "MulConst" and c == 1):
|
|
280
|
+
return x
|
|
281
|
+
return _Node(op, {"value": _rounded(c)}, [x], parent)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
#: Root operators a cross-sectional rank IC cannot tell from their input.
|
|
285
|
+
_RANK_PRESERVING = frozenset({"Rank", "Scale", "CrossSectionalZScore"})
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def ranked_root(term):
|
|
289
|
+
"""Return ``term`` without the rank-preserving operators at its root.
|
|
290
|
+
|
|
291
|
+
A cross-sectional rank, scale or z-score, a constant offset and a
|
|
292
|
+
nonzero scale keep or reverse the factor's cross-sectional order, so
|
|
293
|
+
``Rank(x) * 2 - 1`` and ``1 - x`` are ``x`` to the originality check
|
|
294
|
+
(the sign is the factor's direction, read from its training IC, not a new factor).
|
|
295
|
+
"""
|
|
296
|
+
while isinstance(term, _Node) and (
|
|
297
|
+
term.op in _RANK_PRESERVING
|
|
298
|
+
or term.op in ("AddConst", "SubConstSwap")
|
|
299
|
+
or (term.op == "MulConst" and term.attrs["value"] != 0)
|
|
300
|
+
):
|
|
301
|
+
term = term.inputs[0]
|
|
302
|
+
return term
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def term_key(term) -> str:
|
|
306
|
+
"""Return a canonical term's identity as a string."""
|
|
307
|
+
return json.dumps(_key(term))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def shape_key(term) -> str:
|
|
311
|
+
"""Return the hash of a canonical term with every numeric attribute erased.
|
|
312
|
+
|
|
313
|
+
Two terms with the same shape differ at most in their windows, lags and
|
|
314
|
+
constants.
|
|
315
|
+
"""
|
|
316
|
+
memo: dict[int, str] = {}
|
|
317
|
+
|
|
318
|
+
def shape(t) -> str:
|
|
319
|
+
if isinstance(t, _Const):
|
|
320
|
+
return "const"
|
|
321
|
+
if id(t) not in memo:
|
|
322
|
+
attrs = sorted(
|
|
323
|
+
(k, "#" if isinstance(v, (int, float)) and not isinstance(v, bool) else v) for k, v in t.attrs.items()
|
|
324
|
+
)
|
|
325
|
+
inputs = [shape(i) for i in t.inputs]
|
|
326
|
+
if t.op in COMMUTATIVE:
|
|
327
|
+
inputs.sort()
|
|
328
|
+
memo[id(t)] = _sha([t.op, attrs, inputs, None if t.parent is None else shape(t.parent)])
|
|
329
|
+
return memo[id(t)]
|
|
330
|
+
|
|
331
|
+
return shape(term)
|