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,1097 @@
|
|
|
1
|
+
"""The Vocabulary: the fields, operators and parameter values a campaign's candidates may write with.
|
|
2
|
+
|
|
3
|
+
It is built from files only (``Vocabulary.load``): one field file per input
|
|
4
|
+
family and one operator file, named by the campaign config. The framework
|
|
5
|
+
holds no field, column or operator of its own; it keeps the rules a file
|
|
6
|
+
picks from (the dimension rules of ``_dimension``, the input kinds, the
|
|
7
|
+
syntax of ``_checks``), the ``Input`` a graph reads a column with, and the
|
|
8
|
+
``Builder`` of window loops.
|
|
9
|
+
|
|
10
|
+
A field file lists its family's fields. A field reads a column of the price
|
|
11
|
+
dataset, in a unit that is a product of powers of dollars and shares, or is
|
|
12
|
+
derived: a formula over the family's earlier fields, checked under the
|
|
13
|
+
syntax rules of candidate code and built with the operators. A family with ``bar: true``
|
|
14
|
+
makes a missing value in any of its columns a missing bar: candidate code
|
|
15
|
+
runs inside quantlab's ``missing_bars_only`` over those columns, so the
|
|
16
|
+
NaN-preserving stand-ins of ``quantlab.factor.kunquant_nan`` keep such a
|
|
17
|
+
bar NaN.
|
|
18
|
+
|
|
19
|
+
A field file marked ``intraday`` is the campaign's intraday family: its
|
|
20
|
+
fields read columns of the intraday dataset (the bars of each session, the
|
|
21
|
+
session inputs ``factor_agent.session`` adds among them), not of the price
|
|
22
|
+
dataset, and it gives the window lengths and the lags, in bars, its stage
|
|
23
|
+
may use (``intraday: {windows: [...], lags: [...]}``; without ``lags``, the
|
|
24
|
+
operator file's lag set). A campaign opens one at most. Candidate
|
|
25
|
+
code reaches the daily stage from its fields only through a session
|
|
26
|
+
aggregation (``factor_agent.gate.stages``).
|
|
27
|
+
|
|
28
|
+
A family may declare a period lag (``period_lag``): the column pattern of
|
|
29
|
+
its lags (``{column}`` and ``{k}`` fill it, ``"{column}__q{k}"`` as
|
|
30
|
+
quantlab's ``FundamentalAlpha`` names its report lags), the allowed ``k``
|
|
31
|
+
and what a period is: the dataset column holding the period end of the
|
|
32
|
+
report a bar shows (``key``), the spacing of consecutive periods in days and
|
|
33
|
+
the tolerance on it, and the most bars a period spans. Candidate code then
|
|
34
|
+
calls ``PeriodLag(x, k)`` on a field of the family that reads a column; it
|
|
35
|
+
reads the lag column of the field, which ``factor_agent.period_lag``
|
|
36
|
+
computes from the field's column and the key when the factor is computed.
|
|
37
|
+
The lags of a field count as the field itself, toward the field limit and
|
|
38
|
+
in dimensions.
|
|
39
|
+
|
|
40
|
+
The operator file lists each operator's class (a KunQuant ``OpBase``
|
|
41
|
+
subclass, what a graph records), the callable candidate code calls under
|
|
42
|
+
its name (the class, or a stand-in such as ``kunquant_nan.Max``), its
|
|
43
|
+
signature, argument and parameter kinds, dimension rule and prompt
|
|
44
|
+
description, plus the parameter value sets, the fixes (each a
|
|
45
|
+
regular expression an operator library's refusal matches, with how to
|
|
46
|
+
write it instead) and, in the repository's example file, every other
|
|
47
|
+
operator class with why it is excluded. A class or a callable must come from KunQuant,
|
|
48
|
+
``quantlab.factor`` or a module the config lists in ``operator_modules``:
|
|
49
|
+
the path is checked before it is imported, no attribute hop may go through
|
|
50
|
+
another module, and the object must be defined under one of them.
|
|
51
|
+
Each operator names the stages it may be used in (``stages``): a list of
|
|
52
|
+
``intraday`` and ``daily``, ``daily`` alone when it names none; a session
|
|
53
|
+
aggregation (``factor_agent.aggregations``) is its own kind,
|
|
54
|
+
``[aggregation]``. An operator of unbounded memory (a KunQuant state
|
|
55
|
+
without a window, as ``ExpMovingAvg``'s) may not name ``intraday``.
|
|
56
|
+
|
|
57
|
+
``load`` reports every problem of the files at once (``VocabularyError``).
|
|
58
|
+
The sandbox rebuilds the same vocabulary from ``sources``, the files' text.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
import ast
|
|
62
|
+
import builtins
|
|
63
|
+
import contextlib
|
|
64
|
+
import importlib
|
|
65
|
+
import keyword
|
|
66
|
+
import math
|
|
67
|
+
import re
|
|
68
|
+
import string
|
|
69
|
+
import types
|
|
70
|
+
from collections.abc import Iterable, Mapping
|
|
71
|
+
from dataclasses import dataclass
|
|
72
|
+
from fractions import Fraction
|
|
73
|
+
from functools import cached_property
|
|
74
|
+
from pathlib import Path
|
|
75
|
+
|
|
76
|
+
import yaml
|
|
77
|
+
from KunQuant.Op import Builder, GloablStatefulOpTrait, Input, OpBase, WindowedTrait
|
|
78
|
+
from quantlab.factor import kunquant_nan
|
|
79
|
+
|
|
80
|
+
from factor_agent.aggregations import SessionAggregation, WithinSession
|
|
81
|
+
from factor_agent.gate import _checks, _dimension, stages
|
|
82
|
+
from factor_agent.gate._canonical import canonical_hashes, effective_parameters
|
|
83
|
+
from factor_agent.gate.graph import Graph
|
|
84
|
+
|
|
85
|
+
#: Modules a class or a callable may always come from, factor-agent's session aggregations among them; the
|
|
86
|
+
#: config's ``operator_modules`` add to them.
|
|
87
|
+
FRAMEWORK_MODULES = ("KunQuant", "quantlab.factor", "factor_agent.aggregations")
|
|
88
|
+
|
|
89
|
+
#: The builtins candidate code may call.
|
|
90
|
+
BUILTINS = {
|
|
91
|
+
name: getattr(builtins, name)
|
|
92
|
+
for name in ("abs", "bool", "dict", "enumerate", "float", "int", "len", "list", "range",
|
|
93
|
+
"round", "tuple", "zip")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
#: Kinds of a series argument: a number series, a comparison, or a ``ForeachBackWindow`` loop.
|
|
97
|
+
INPUT_KINDS = ("series", "condition", "loop")
|
|
98
|
+
|
|
99
|
+
#: The stages an operator may be used in: on each session's intraday bars, or on one value per day.
|
|
100
|
+
STAGES = ("intraday", "daily")
|
|
101
|
+
#: The stages of a session aggregation, which leads from one stage to the other.
|
|
102
|
+
AGGREGATION = "aggregation"
|
|
103
|
+
#: The stages of an operator whose entry names none.
|
|
104
|
+
DAILY_ONLY = ("daily",)
|
|
105
|
+
#: With an intraday family, the prompt's groups of operators: each operator's stages and the group's heading.
|
|
106
|
+
_STAGE_HEADINGS = (
|
|
107
|
+
(STAGES, "In both stages:"),
|
|
108
|
+
(("intraday",), "In the intraday stage only:"),
|
|
109
|
+
(DAILY_ONLY, "In the daily stage only:"),
|
|
110
|
+
((AGGREGATION,), "Session aggregations, each from the intraday stage to the daily stage:"),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
#: The parameter set of an attribute that is bookkeeping, never checked.
|
|
114
|
+
FLAG = "flag"
|
|
115
|
+
|
|
116
|
+
#: Names the framework binds in candidate code itself.
|
|
117
|
+
_RESERVED = frozenset({"Builder", "Input", "PeriodLag", "alpha"})
|
|
118
|
+
|
|
119
|
+
#: The bases of a unit, in the order of a dimension's exponents.
|
|
120
|
+
_UNIT_BASES = ("dollars", "shares")
|
|
121
|
+
_UNIT_FACTOR = re.compile(r"(dollars|shares|1)(?:\^(-?\d+))?")
|
|
122
|
+
|
|
123
|
+
_FIELD_FILE_KEYS = {"family", "description", "bar", "fields", "period_lag", "intraday"}
|
|
124
|
+
_INTRADAY_KEYS = {"windows", "lags"}
|
|
125
|
+
_PERIOD_LAG_KEYS = {"column", "k", "period"}
|
|
126
|
+
_PERIOD_KEYS = {"description", "key", "days", "tolerance_days", "bars"}
|
|
127
|
+
_FIELD_KEYS = {"name", "column", "formula", "unit", "description"}
|
|
128
|
+
_OPERATOR_FILE_KEYS = {"parameters", "operators", "excluded", "fixes"}
|
|
129
|
+
_FIX_KEYS = {"match", "hint"}
|
|
130
|
+
#: The names ``_ArithmeticCalls`` writes ``-x`` and ``a ** b`` as; candidate code may not use such names.
|
|
131
|
+
_NEGATE, _POWER = "__negate__", "__power__"
|
|
132
|
+
_OPERATOR_KEYS = {"name", "class", "call", "signature", "inputs", "params", "dimension", "description", "stages"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class PeriodLagError(ValueError):
|
|
136
|
+
"""Candidate code called ``PeriodLag`` on what has no such lag; the message says why."""
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class VocabularyError(ValueError):
|
|
140
|
+
"""The field or operator files are wrong; the message lists every problem."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, problems: list[str]):
|
|
143
|
+
self.problems = tuple(problems)
|
|
144
|
+
super().__init__("the vocabulary files have problems:\n" + "\n".join(f"- {p}" for p in problems))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Field:
|
|
149
|
+
"""One field: its name, family, the column it reads or the formula it is derived by, and its description.
|
|
150
|
+
|
|
151
|
+
``dimension`` is the unit's ``(dollars, shares)`` exponents; None for a
|
|
152
|
+
derived field, whose dimension follows from its formula.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
family: str
|
|
157
|
+
column: str | None
|
|
158
|
+
formula: str | None
|
|
159
|
+
dimension: tuple | None
|
|
160
|
+
description: str
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass(frozen=True)
|
|
164
|
+
class OpSpec:
|
|
165
|
+
"""One registered operator; see the module docstring.
|
|
166
|
+
|
|
167
|
+
``path`` is the class's dotted path; ``params`` maps each attribute to
|
|
168
|
+
its parameter set, or ``FLAG``; ``stages`` are the stages it may be used
|
|
169
|
+
in (of ``STAGES``), or ``(AGGREGATION,)``.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
name: str
|
|
173
|
+
cls: type
|
|
174
|
+
path: str
|
|
175
|
+
call: object
|
|
176
|
+
signature: str
|
|
177
|
+
inputs: tuple[str, ...]
|
|
178
|
+
params: dict
|
|
179
|
+
dimension: str
|
|
180
|
+
description: str
|
|
181
|
+
stages: tuple[str, ...] = DAILY_ONLY
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def is_aggregation(self) -> bool:
|
|
185
|
+
"""Whether the operator is a session aggregation."""
|
|
186
|
+
return self.stages == (AGGREGATION,)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@dataclass(frozen=True)
|
|
190
|
+
class PeriodLag:
|
|
191
|
+
"""A family's declared period lag; see the module docstring."""
|
|
192
|
+
|
|
193
|
+
family: str
|
|
194
|
+
column: str
|
|
195
|
+
k: tuple[int, ...]
|
|
196
|
+
description: str
|
|
197
|
+
key: str
|
|
198
|
+
days: float
|
|
199
|
+
tolerance_days: float
|
|
200
|
+
bars: int
|
|
201
|
+
|
|
202
|
+
def lag_column(self, column: str, k: int) -> str:
|
|
203
|
+
"""The column a lag of ``k`` periods of ``column`` is read under."""
|
|
204
|
+
return self.column.format(column=column, k=k)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
#: ``Input``, which every graph records and no candidate calls.
|
|
208
|
+
INPUT = OpSpec("Input", Input, "KunQuant.Op.Input", None, "", (), {"name": FLAG}, "input", "", STAGES)
|
|
209
|
+
#: ``WithinSession``, which the stage split wraps the intraday stage's windows in and no candidate calls.
|
|
210
|
+
WITHIN_SESSION = OpSpec(
|
|
211
|
+
"WithinSession", WithinSession, "factor_agent.aggregations.WithinSession", None, "",
|
|
212
|
+
("series", "series"), {"bars": FLAG}, "first", "", ("intraday",),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class Vocabulary:
|
|
217
|
+
"""What candidates may write with; build it with ``load``, or ``from_sources`` in the sandbox.
|
|
218
|
+
|
|
219
|
+
Raises
|
|
220
|
+
------
|
|
221
|
+
VocabularyError
|
|
222
|
+
If the files have any problem; every problem is listed.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(
|
|
226
|
+
self, sources: dict, columns: Iterable[str] | None = None, intraday_columns: Iterable[str] | None = None,
|
|
227
|
+
):
|
|
228
|
+
#: The files' paths and text and the operator modules: what the sandbox rebuilds from.
|
|
229
|
+
self.sources = sources
|
|
230
|
+
#: The parameter sets of the operator file, by kind.
|
|
231
|
+
self.parameters: dict[str, tuple] = {}
|
|
232
|
+
#: The fields of every family, in file order.
|
|
233
|
+
self.fields: dict[str, Field] = {}
|
|
234
|
+
#: The description of every family, in file order.
|
|
235
|
+
self.families: dict[str, str] = {}
|
|
236
|
+
#: The operator file's excluded classes, with their reasons.
|
|
237
|
+
self.excluded: dict[type, str] = {}
|
|
238
|
+
self._problems: list[str] = []
|
|
239
|
+
# The operator file's fixes, in file order: what a refusal matches, and its hint.
|
|
240
|
+
self._fixes: list[tuple[re.Pattern, str]] = []
|
|
241
|
+
self._modules = FRAMEWORK_MODULES + tuple(sources.get("operator_modules") or ())
|
|
242
|
+
self._operators: dict[str, OpSpec] = {}
|
|
243
|
+
self._bar_columns: list[str] = []
|
|
244
|
+
#: The declared period lags, by family.
|
|
245
|
+
self.period_lags: dict[str, PeriodLag] = {}
|
|
246
|
+
#: The intraday family, None when no field file is marked intraday.
|
|
247
|
+
self.intraday_family: str | None = None
|
|
248
|
+
#: The window lengths, in bars, the intraday stage may use.
|
|
249
|
+
self.intraday_windows: tuple[int, ...] = ()
|
|
250
|
+
# The lags, in bars, the intraday stage may use; None for the operator file's lag set (``intraday_lags``).
|
|
251
|
+
self._intraday_lags: tuple[int, ...] | None = None
|
|
252
|
+
operators = sources["operators"]
|
|
253
|
+
self._read_operators(operators["path"], self._yaml(operators))
|
|
254
|
+
for document in sources["fields"]:
|
|
255
|
+
self._read_fields(document["path"], self._yaml(document))
|
|
256
|
+
if not self._problems:
|
|
257
|
+
self._build_derived()
|
|
258
|
+
self._check_lag_columns()
|
|
259
|
+
if columns is not None:
|
|
260
|
+
available = set(columns)
|
|
261
|
+
self._problems += [
|
|
262
|
+
f"field {f.name} ({f.family}): column {f.column!r} is not in the price dataset"
|
|
263
|
+
for f in self.fields.values()
|
|
264
|
+
if f.column is not None and not self._is_intraday(f) and f.column not in available
|
|
265
|
+
]
|
|
266
|
+
self._problems += [
|
|
267
|
+
f"period lag of {lag.family}: key column {lag.key!r} is not in the price dataset"
|
|
268
|
+
for lag in self.period_lags.values() if lag.key not in available
|
|
269
|
+
]
|
|
270
|
+
if intraday_columns is not None:
|
|
271
|
+
available = set(intraday_columns)
|
|
272
|
+
self._problems += [
|
|
273
|
+
f"field {f.name} ({f.family}): column {f.column!r} is not in the intraday dataset"
|
|
274
|
+
for f in self.fields.values()
|
|
275
|
+
if f.column is not None and self._is_intraday(f) and f.column not in available
|
|
276
|
+
]
|
|
277
|
+
if self._problems:
|
|
278
|
+
raise VocabularyError(self._problems)
|
|
279
|
+
|
|
280
|
+
@classmethod
|
|
281
|
+
def load(
|
|
282
|
+
cls, fields: Iterable[str | Path], operators: str | Path, operator_modules: Iterable[str] = (),
|
|
283
|
+
columns: Iterable[str] | None = None, intraday_columns: Iterable[str] | None = None,
|
|
284
|
+
) -> "Vocabulary":
|
|
285
|
+
"""Read the field files ``fields`` and the operator file ``operators``.
|
|
286
|
+
|
|
287
|
+
``operator_modules`` are the modules besides KunQuant and
|
|
288
|
+
``quantlab.factor`` a class or a callable may come from; ``columns``,
|
|
289
|
+
when given, are the price dataset's columns, among which every daily
|
|
290
|
+
field's column must be, and ``intraday_columns`` the intraday
|
|
291
|
+
dataset's (the session inputs included), among which every intraday
|
|
292
|
+
field's must be.
|
|
293
|
+
|
|
294
|
+
Raises
|
|
295
|
+
------
|
|
296
|
+
VocabularyError
|
|
297
|
+
With every problem of the files.
|
|
298
|
+
"""
|
|
299
|
+
def document(path) -> dict:
|
|
300
|
+
return {"path": str(path), "text": Path(path).read_text()}
|
|
301
|
+
|
|
302
|
+
sources = {
|
|
303
|
+
"fields": [document(path) for path in fields], "operators": document(operators),
|
|
304
|
+
"operator_modules": list(operator_modules),
|
|
305
|
+
}
|
|
306
|
+
return cls(sources, columns, intraday_columns)
|
|
307
|
+
|
|
308
|
+
@classmethod
|
|
309
|
+
def from_sources(cls, sources: dict) -> "Vocabulary":
|
|
310
|
+
"""Rebuild a vocabulary from another's ``sources``."""
|
|
311
|
+
return cls(sources)
|
|
312
|
+
|
|
313
|
+
# What candidate code may use.
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def names(self) -> frozenset[str]:
|
|
317
|
+
"""Every name candidate code may read without binding it first."""
|
|
318
|
+
lag = {"PeriodLag"} if self.period_lags else set()
|
|
319
|
+
return frozenset(self.fields) | frozenset(self._usable) | frozenset(BUILTINS) | {"Builder"} | lag
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def classes(self) -> dict[str, type]:
|
|
323
|
+
"""Every class a graph may hold, by the name it is recorded under (``Input`` included)."""
|
|
324
|
+
return {"Input": Input, **{name: spec.cls for name, spec in self._operators.items()}}
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def operator_classes(self) -> dict[type, str]:
|
|
328
|
+
"""The registered classes, each with the name a graph records it under."""
|
|
329
|
+
return {spec.cls: name for name, spec in self._operators.items()}
|
|
330
|
+
|
|
331
|
+
def spec(self, name: str) -> OpSpec:
|
|
332
|
+
"""The operator a graph records as ``name`` (or the stage split writes, ``WithinSession``)."""
|
|
333
|
+
builtin = {"Input": INPUT, "WithinSession": WITHIN_SESSION}
|
|
334
|
+
return builtin[name] if name in builtin else self._operators[name]
|
|
335
|
+
|
|
336
|
+
def class_paths(self, graphs: Iterable[list]) -> dict[str, str]:
|
|
337
|
+
"""The class path of every operator recorded graphs (``Graph.to_json()``) hold, by name."""
|
|
338
|
+
used = {node[0] for graph in graphs for node in graph}
|
|
339
|
+
return {name: self.spec(name).path for name in sorted(used)}
|
|
340
|
+
|
|
341
|
+
def execute(self, code: str) -> dict:
|
|
342
|
+
"""Run candidate code, which passed ``_checks.static_reasons``, and return the names it bound.
|
|
343
|
+
|
|
344
|
+
The fields are new operators every time, so two executions share no
|
|
345
|
+
node; the NaN-preserving stand-ins add the bar mask. ``-x`` of an
|
|
346
|
+
operator is ``MulConst(x, -1)`` and ``a ** b`` is ``Pow(a, b)`` (a
|
|
347
|
+
number ``b`` as a ``ConstantOp``), when the vocabulary has them.
|
|
348
|
+
|
|
349
|
+
Raises
|
|
350
|
+
------
|
|
351
|
+
Exception
|
|
352
|
+
Whatever the code raises, for the Gate to report back to it.
|
|
353
|
+
"""
|
|
354
|
+
fields = self._inputs()
|
|
355
|
+
lag = {"PeriodLag": self._period_lag_call()} if self.period_lags else {}
|
|
356
|
+
scope = {"__builtins__": dict(BUILTINS), **self._calls(), "Builder": Builder, **lag, **fields, **self._arithmetic()}
|
|
357
|
+
with self._bar_mask(fields):
|
|
358
|
+
exec(compile(_arithmetic_calls(ast.parse(code, mode="exec")), "<candidate>", "exec"), scope)
|
|
359
|
+
return scope
|
|
360
|
+
|
|
361
|
+
def fix_hints(self, reasons: Iterable[str]) -> tuple[str, ...]:
|
|
362
|
+
"""The hint of every fix one of ``reasons`` matches, each once, in the operator file's order."""
|
|
363
|
+
reasons = list(reasons)
|
|
364
|
+
return tuple(hint for pattern, hint in self._fixes if any(pattern.search(r) for r in reasons))
|
|
365
|
+
|
|
366
|
+
def recorded_name(self, op_class: type) -> str | None:
|
|
367
|
+
"""Return the name a graph records ``op_class`` under, or None when it is not registered.
|
|
368
|
+
|
|
369
|
+
A class made at run time (``CrossSectionalWinsorize_0p05_0p95``,
|
|
370
|
+
which quantlab's public class makes per bound pair) is recorded
|
|
371
|
+
under the registered class it derives from; a declared class that is
|
|
372
|
+
not registered is not.
|
|
373
|
+
"""
|
|
374
|
+
registered = self.operator_classes
|
|
375
|
+
for klass in op_class.__mro__:
|
|
376
|
+
if klass is Input:
|
|
377
|
+
return "Input"
|
|
378
|
+
if klass in registered:
|
|
379
|
+
return registered[klass]
|
|
380
|
+
if klass.__name__.startswith("_") or _declared(klass):
|
|
381
|
+
return None
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
# What the Gate checks.
|
|
385
|
+
|
|
386
|
+
def check(self, graph: Graph) -> tuple[list[str], str]:
|
|
387
|
+
"""Return why ``graph``'s parameter values or dimensions are refused (empty if none) and its dimension."""
|
|
388
|
+
reasons = self._parameter_reasons(graph) or self._effective_reasons(graph)
|
|
389
|
+
dimension, dimension_reasons = _dimension.infer(graph, self)
|
|
390
|
+
return reasons + dimension_reasons, _dimension.name(dimension)
|
|
391
|
+
|
|
392
|
+
@cached_property
|
|
393
|
+
def constant_set(self) -> str | None:
|
|
394
|
+
"""The parameter set of constants: the one ``ConstantOp``'s value is in (else ``AddConst``'s or ``MulConst``'s).
|
|
395
|
+
|
|
396
|
+
The operator file names its sets as it likes; the set an operator's
|
|
397
|
+
attribute is in says what kind of value it holds.
|
|
398
|
+
"""
|
|
399
|
+
return self._set_of(("ConstantOp", "value"), ("AddConst", "value"), ("MulConst", "value"))
|
|
400
|
+
|
|
401
|
+
@cached_property
|
|
402
|
+
def lag_set(self) -> str | None:
|
|
403
|
+
"""The parameter set of lags: the one ``BackRef``'s window is in."""
|
|
404
|
+
return self._set_of(("BackRef", "window"))
|
|
405
|
+
|
|
406
|
+
def _set_of(self, *attributes: tuple[str, str]) -> str | None:
|
|
407
|
+
for name, attribute in attributes:
|
|
408
|
+
kind = self._operators[name].params.get(attribute) if name in self._operators else None
|
|
409
|
+
if kind is not None and kind != FLAG:
|
|
410
|
+
return kind
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
def allowed(self, kind: str, value) -> bool:
|
|
414
|
+
"""Whether ``value`` is in the parameter set ``kind``; a constant may also be negative or ``"nan"``."""
|
|
415
|
+
constant = kind == self.constant_set
|
|
416
|
+
if constant and value == "nan":
|
|
417
|
+
return True
|
|
418
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
419
|
+
return False
|
|
420
|
+
magnitude = abs(value) if constant else value
|
|
421
|
+
return any(
|
|
422
|
+
math.isclose(magnitude, v, rel_tol=1e-9, abs_tol=0.0) or magnitude == v
|
|
423
|
+
for v in self.parameters.get(kind, ())
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def intraday_lags(self) -> tuple[int, ...]:
|
|
428
|
+
"""The lags, in bars, the intraday stage may use: the intraday family's, else the operator file's lag set."""
|
|
429
|
+
if self._intraday_lags is not None:
|
|
430
|
+
return self._intraday_lags
|
|
431
|
+
return tuple(int(v) for v in self.parameters.get(self.lag_set, ())) if self.lag_set else ()
|
|
432
|
+
|
|
433
|
+
@property
|
|
434
|
+
def intraday_columns(self) -> frozenset[str]:
|
|
435
|
+
"""The columns the intraday family's fields read; empty without an intraday family."""
|
|
436
|
+
return frozenset(
|
|
437
|
+
f.column for f in self.fields.values() if self._is_intraday(f) and f.column is not None
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def field_name(self, column: str) -> str:
|
|
441
|
+
"""The field reading ``column`` or a period lag of it, or the column itself when no field reads it."""
|
|
442
|
+
if column in self._lag_columns:
|
|
443
|
+
return self._lag_columns[column][0]
|
|
444
|
+
return self._column_fields.get(column, column)
|
|
445
|
+
|
|
446
|
+
def written(self, column: str) -> str:
|
|
447
|
+
"""``column`` as candidate code reads it: a field's name, or ``PeriodLag(field, k)`` for a lag column."""
|
|
448
|
+
if column in self._lag_columns:
|
|
449
|
+
name, k = self._lag_columns[column]
|
|
450
|
+
return f"PeriodLag({name}, {k})"
|
|
451
|
+
return self.field_name(column)
|
|
452
|
+
|
|
453
|
+
def column_dimension(self, column: str) -> tuple | None:
|
|
454
|
+
"""The ``(dollars, shares)`` exponents of the field reading ``column`` or a period lag of it, or None."""
|
|
455
|
+
name = self.field_name(column)
|
|
456
|
+
return self.fields[name].dimension if name in self.fields else None
|
|
457
|
+
|
|
458
|
+
@property
|
|
459
|
+
def lag_columns(self) -> dict[str, dict]:
|
|
460
|
+
"""How each lag column candidate code may read is computed, by its name (``factor_agent.period_lag``)."""
|
|
461
|
+
found = {}
|
|
462
|
+
for lag_column, (name, k) in self._lag_columns.items():
|
|
463
|
+
field = self.fields[name]
|
|
464
|
+
lag = self.period_lags[field.family]
|
|
465
|
+
found[lag_column] = {
|
|
466
|
+
"column": field.column, "k": k, "key": lag.key, "days": lag.days,
|
|
467
|
+
"tolerance_days": lag.tolerance_days, "bars": lag.bars,
|
|
468
|
+
}
|
|
469
|
+
return found
|
|
470
|
+
|
|
471
|
+
@cached_property
|
|
472
|
+
def reference_hashes(self) -> tuple[dict[str, str], frozenset[str]]:
|
|
473
|
+
"""The canonical hashes of the derived fields (hash -> name) and of every node of the bar mask.
|
|
474
|
+
|
|
475
|
+
The mask is a sum over the bar columns; its partial sums are part of it too, so a graph that
|
|
476
|
+
shares only them with a reference factor shares nothing the candidate wrote.
|
|
477
|
+
"""
|
|
478
|
+
fields = self._inputs()
|
|
479
|
+
derived = {
|
|
480
|
+
canonical_hashes(Graph.record(fields[f.name], self, strict=False))[-1]: f.name
|
|
481
|
+
for f in self.fields.values() if f.formula is not None
|
|
482
|
+
}
|
|
483
|
+
if not self._bar_columns:
|
|
484
|
+
return derived, frozenset()
|
|
485
|
+
with self._bar_mask(fields):
|
|
486
|
+
probe = kunquant_nan.SetInfOrNanToValue(fields[self._column_fields[self._bar_columns[0]]])
|
|
487
|
+
mask = Graph.record(probe.inputs[1], self, strict=False)
|
|
488
|
+
return derived, frozenset(h for h, n in zip(canonical_hashes(mask), mask.nodes) if n.op != "Input")
|
|
489
|
+
|
|
490
|
+
# What the prompts show.
|
|
491
|
+
|
|
492
|
+
def prompt(self) -> dict[str, str]:
|
|
493
|
+
"""The prompt's description of the vocabulary: inputs, operators, parameter values, builtins.
|
|
494
|
+
|
|
495
|
+
With an intraday family the operators are listed by the stages they may be used in, the
|
|
496
|
+
session aggregations last, and the parameter values end with the intraday window and lag sets.
|
|
497
|
+
"""
|
|
498
|
+
def operator(spec: OpSpec) -> str:
|
|
499
|
+
return f"- {spec.signature}: {spec.description}" if spec.description else f"- {spec.signature}"
|
|
500
|
+
|
|
501
|
+
if self.intraday_family is None:
|
|
502
|
+
lines = [operator(spec) for spec in self._usable.values()]
|
|
503
|
+
lines += self._period_lag_lines()
|
|
504
|
+
else:
|
|
505
|
+
lines = []
|
|
506
|
+
for stages, heading in _STAGE_HEADINGS:
|
|
507
|
+
listed = [operator(spec) for spec in self._usable.values() if spec.stages == stages]
|
|
508
|
+
if stages == DAILY_ONLY:
|
|
509
|
+
listed += self._period_lag_lines()
|
|
510
|
+
if listed:
|
|
511
|
+
lines += [heading, *listed]
|
|
512
|
+
parameters = [
|
|
513
|
+
f"- {kind}: {', '.join(f'{v:g}' for v in values)}" for kind, values in self.parameters.items()
|
|
514
|
+
]
|
|
515
|
+
if self.intraday_family is not None:
|
|
516
|
+
parameters += [
|
|
517
|
+
f"- intraday window (in bars, every window of the intraday stage): "
|
|
518
|
+
f"{', '.join(map(str, self.intraday_windows))}",
|
|
519
|
+
f"- intraday lag (in bars, every lag of the intraday stage): {', '.join(map(str, self.intraday_lags))}",
|
|
520
|
+
]
|
|
521
|
+
daily = [name for name, f in self.fields.items() if not self._is_intraday(f)]
|
|
522
|
+
intraday = [name for name, f in self.fields.items() if self._is_intraday(f)]
|
|
523
|
+
return {
|
|
524
|
+
"inputs": ", ".join(self.fields) if not intraday else (
|
|
525
|
+
f"daily {', '.join(daily)}; intraday {', '.join(intraday)}"
|
|
526
|
+
),
|
|
527
|
+
"operators": "\n".join(lines),
|
|
528
|
+
"parameters": "\n".join(parameters),
|
|
529
|
+
"builtins": ", ".join(sorted(BUILTINS)),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
def _period_lag_lines(self) -> list[str]:
|
|
533
|
+
"""The prompt's lines on ``PeriodLag``: none without a family declaring a period lag."""
|
|
534
|
+
lines = []
|
|
535
|
+
if self.period_lags:
|
|
536
|
+
lines.append(
|
|
537
|
+
"- PeriodLag(x, k): field x as of the k-th earlier period, counted in periods, not in bars; "
|
|
538
|
+
"missing when that period is missing or is not k periods before the current one. "
|
|
539
|
+
"x is a field of a family below, k one of its values:"
|
|
540
|
+
)
|
|
541
|
+
for lag in self.period_lags.values():
|
|
542
|
+
names = [f.name for f in self.fields.values() if f.family == lag.family and f.column is not None]
|
|
543
|
+
lines.append(
|
|
544
|
+
f" - {', '.join(names)} ({lag.family}): k one of {', '.join(map(str, lag.k))}; "
|
|
545
|
+
f"a period is {lag.description}"
|
|
546
|
+
)
|
|
547
|
+
return lines
|
|
548
|
+
|
|
549
|
+
# Internals.
|
|
550
|
+
|
|
551
|
+
def _is_intraday(self, field: Field) -> bool:
|
|
552
|
+
"""Whether ``field`` is of the intraday family."""
|
|
553
|
+
return field.family == self.intraday_family
|
|
554
|
+
|
|
555
|
+
@property
|
|
556
|
+
def _usable(self) -> dict[str, OpSpec]:
|
|
557
|
+
"""The operators candidate code may call: the session aggregations only with an intraday family."""
|
|
558
|
+
if self.intraday_family is not None:
|
|
559
|
+
return self._operators
|
|
560
|
+
return {name: spec for name, spec in self._operators.items() if not spec.is_aggregation}
|
|
561
|
+
|
|
562
|
+
@cached_property
|
|
563
|
+
def _column_fields(self) -> dict[str, str]:
|
|
564
|
+
return {f.column: f.name for f in self.fields.values() if f.column is not None}
|
|
565
|
+
|
|
566
|
+
@cached_property
|
|
567
|
+
def _lag_columns(self) -> dict[str, tuple[str, int]]:
|
|
568
|
+
"""``(field, k)`` of every lag column, by its name."""
|
|
569
|
+
return {
|
|
570
|
+
lag.lag_column(f.column, k): (f.name, k)
|
|
571
|
+
for f in self.fields.values() if f.column is not None and f.family in self.period_lags
|
|
572
|
+
for lag in [self.period_lags[f.family]] for k in lag.k
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
def _period_lag_call(self):
|
|
576
|
+
"""``PeriodLag`` of one execution of candidate code: one ``Input`` per lag column."""
|
|
577
|
+
made: dict[str, OpBase] = {}
|
|
578
|
+
families = ", ".join(self.period_lags)
|
|
579
|
+
|
|
580
|
+
def period_lag(x, k):
|
|
581
|
+
name = self._column_fields.get(x.attrs.get("name")) if isinstance(x, Input) else None
|
|
582
|
+
if name is None:
|
|
583
|
+
raise PeriodLagError(
|
|
584
|
+
"PeriodLag takes a field read from a column, not an expression or a derived field"
|
|
585
|
+
)
|
|
586
|
+
field = self.fields[name]
|
|
587
|
+
lag = self.period_lags.get(field.family)
|
|
588
|
+
if lag is None:
|
|
589
|
+
raise PeriodLagError(
|
|
590
|
+
f"PeriodLag({name}, ...): {field.family} declares no period lag; "
|
|
591
|
+
f"only fields of {families} have one"
|
|
592
|
+
)
|
|
593
|
+
if isinstance(k, bool) or not isinstance(k, int) or k not in lag.k:
|
|
594
|
+
raise PeriodLagError(
|
|
595
|
+
f"PeriodLag({name}, {k!r}): k must be one of {', '.join(map(str, lag.k))} for {field.family}"
|
|
596
|
+
)
|
|
597
|
+
column = lag.lag_column(field.column, k)
|
|
598
|
+
return made.setdefault(column, Input(column))
|
|
599
|
+
|
|
600
|
+
return period_lag
|
|
601
|
+
|
|
602
|
+
def _inputs(self) -> dict[str, OpBase]:
|
|
603
|
+
"""Fresh input operators by field name: an ``Input`` per column, each derived field built by its formula."""
|
|
604
|
+
fields: dict[str, OpBase] = {}
|
|
605
|
+
for f in self.fields.values():
|
|
606
|
+
fields[f.name] = Input(f.column) if f.column is not None else self._derive(f, fields)
|
|
607
|
+
return fields
|
|
608
|
+
|
|
609
|
+
def _derive(self, f: Field, fields: dict[str, OpBase]) -> OpBase:
|
|
610
|
+
"""Build a derived field over the ``fields`` before it; its formula passed ``_checks.expression_reasons``."""
|
|
611
|
+
tree = _arithmetic_calls(ast.parse(f.formula, mode="eval"))
|
|
612
|
+
return eval(compile(tree, f"<field {f.name}>", "eval"), {"__builtins__": {}, **self._calls(), **fields, **self._arithmetic()})
|
|
613
|
+
|
|
614
|
+
def _calls(self) -> dict:
|
|
615
|
+
"""What candidate code calls under each operator's name."""
|
|
616
|
+
return {name: spec.call for name, spec in self._operators.items()}
|
|
617
|
+
|
|
618
|
+
def _arithmetic(self) -> dict:
|
|
619
|
+
"""The functions ``_ArithmeticCalls`` writes ``-x`` and ``a ** b`` as: an operator's, or Python's."""
|
|
620
|
+
calls = self._calls()
|
|
621
|
+
|
|
622
|
+
def negate(x):
|
|
623
|
+
return calls["MulConst"](x, -1) if isinstance(x, OpBase) and "MulConst" in calls else -x
|
|
624
|
+
|
|
625
|
+
def power(a, b):
|
|
626
|
+
if isinstance(a, OpBase) and not isinstance(b, OpBase) and "Pow" in calls and "ConstantOp" in calls:
|
|
627
|
+
return calls["Pow"](a, calls["ConstantOp"](b))
|
|
628
|
+
return a ** b
|
|
629
|
+
|
|
630
|
+
return {_NEGATE: negate, _POWER: power}
|
|
631
|
+
|
|
632
|
+
def _bar_mask(self, fields: dict):
|
|
633
|
+
if not self._bar_columns:
|
|
634
|
+
return contextlib.nullcontext()
|
|
635
|
+
return kunquant_nan.missing_bars_only([fields[self._column_fields[c]] for c in self._bar_columns])
|
|
636
|
+
|
|
637
|
+
def _parameter_reasons(self, graph: Graph) -> list[str]:
|
|
638
|
+
"""Why ``graph``'s attribute values are refused; a staged graph's windows and lags are the stage rules'."""
|
|
639
|
+
staged = stages.is_staged(graph, self)
|
|
640
|
+
reasons = []
|
|
641
|
+
for node in graph.nodes:
|
|
642
|
+
params = self.spec(node.op).params
|
|
643
|
+
for key, value in node.attrs:
|
|
644
|
+
kind = params.get(key)
|
|
645
|
+
if staged and stages.window_kind(self, node.op, key) is not None:
|
|
646
|
+
continue
|
|
647
|
+
if kind is None:
|
|
648
|
+
reasons.append(f"{node.op} has an attribute {key!r} the gate does not know")
|
|
649
|
+
elif kind != FLAG and not self.allowed(kind, value):
|
|
650
|
+
allowed = ", ".join(f"{v:g}" for v in sorted(set(self.parameters[kind])))
|
|
651
|
+
reasons.append(f"{node.op}: {kind} {value!r} is not one of the allowed values ({allowed})")
|
|
652
|
+
return reasons
|
|
653
|
+
|
|
654
|
+
def _effective_reasons(self, graph: Graph) -> list[str]:
|
|
655
|
+
"""Refuse values reached by chaining allowed ones, such as ``x * 0.25 * 1.5`` or a lag of 5 plus 10.
|
|
656
|
+
|
|
657
|
+
A merged lag is checked against the lag set and a merged offset or
|
|
658
|
+
scale against the constant set (``lag_set``, ``constant_set``), when
|
|
659
|
+
the operator file has them. The lags of a staged graph's intraday
|
|
660
|
+
stage are the stage rules' (``stages``).
|
|
661
|
+
"""
|
|
662
|
+
reasons = []
|
|
663
|
+
aggregations = frozenset(name for name, spec in self._operators.items() if spec.is_aggregation)
|
|
664
|
+
for op, kind, value in effective_parameters(graph, lags_below=aggregations):
|
|
665
|
+
checked = self.lag_set if kind == "lag" else self.constant_set
|
|
666
|
+
if checked is None:
|
|
667
|
+
continue
|
|
668
|
+
if kind == "scale":
|
|
669
|
+
ok = value == 0 or self.allowed(checked, value) or self.allowed(checked, 1 / value)
|
|
670
|
+
else:
|
|
671
|
+
ok = self.allowed(checked, value) or (kind == "constant" and math.isnan(value))
|
|
672
|
+
if not ok:
|
|
673
|
+
reasons.append(
|
|
674
|
+
f"the constants combine to {op} {kind} {value:g}, which is not one of the allowed values; "
|
|
675
|
+
"use one allowed value instead of chaining several"
|
|
676
|
+
)
|
|
677
|
+
return list(dict.fromkeys(reasons))
|
|
678
|
+
|
|
679
|
+
# Reading the files.
|
|
680
|
+
|
|
681
|
+
def _problem(self, where: str, text: str) -> None:
|
|
682
|
+
self._problems.append(f"{where}: {text}")
|
|
683
|
+
|
|
684
|
+
def _yaml(self, document: dict) -> dict | None:
|
|
685
|
+
try:
|
|
686
|
+
value = yaml.safe_load(document["text"])
|
|
687
|
+
except yaml.YAMLError as exc:
|
|
688
|
+
self._problem(document["path"], f"not YAML: {exc}")
|
|
689
|
+
return None
|
|
690
|
+
if not isinstance(value, dict):
|
|
691
|
+
self._problem(document["path"], "must be a mapping")
|
|
692
|
+
return None
|
|
693
|
+
return value
|
|
694
|
+
|
|
695
|
+
def _unknown_keys(self, where: str, entry: dict, known: set) -> None:
|
|
696
|
+
unknown = set(entry) - known
|
|
697
|
+
if unknown:
|
|
698
|
+
self._problem(where, f"unknown key(s) {sorted(unknown)}")
|
|
699
|
+
|
|
700
|
+
def _resolve(self, where: str, path, what: str) -> object | None:
|
|
701
|
+
"""Import ``path`` from an allowed module; None, with the problem recorded, if it cannot be."""
|
|
702
|
+
if not isinstance(path, str) or not path:
|
|
703
|
+
self._problem(where, f"{what} must be a dotted path, got {path!r}")
|
|
704
|
+
return None
|
|
705
|
+
allowed = "KunQuant, quantlab.factor or a module listed in operator_modules"
|
|
706
|
+
if not _under(path, self._modules):
|
|
707
|
+
self._problem(where, f"{what} {path} is not from {allowed}")
|
|
708
|
+
return None
|
|
709
|
+
try:
|
|
710
|
+
return resolve(path, self._modules)
|
|
711
|
+
except _OutsideModules as exc:
|
|
712
|
+
self._problem(where, f"{what} {path} {exc}, which is not {allowed}")
|
|
713
|
+
return None
|
|
714
|
+
except (ImportError, AttributeError) as exc:
|
|
715
|
+
self._problem(where, f"{what} {path} cannot be imported: {exc}")
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
def _name_ok(self, where: str, name) -> bool:
|
|
719
|
+
if not isinstance(name, str) or not name.isidentifier() or keyword.iskeyword(name):
|
|
720
|
+
self._problem(where, f"name {name!r} is not a Python identifier")
|
|
721
|
+
return False
|
|
722
|
+
if name in BUILTINS or name in _RESERVED:
|
|
723
|
+
self._problem(where, f"the name {name} is a builtin of candidate code")
|
|
724
|
+
return False
|
|
725
|
+
return True
|
|
726
|
+
|
|
727
|
+
def _read_operators(self, path: str, document: dict | None) -> None:
|
|
728
|
+
if document is None:
|
|
729
|
+
return
|
|
730
|
+
self._unknown_keys(path, document, _OPERATOR_FILE_KEYS)
|
|
731
|
+
for kind, values in (document.get("parameters") or {}).items():
|
|
732
|
+
if not isinstance(values, list) or not all(_number(v) for v in values):
|
|
733
|
+
self._problem(path, f"parameter set {kind!r} must be a list of numbers")
|
|
734
|
+
else:
|
|
735
|
+
self.parameters[str(kind)] = tuple(values)
|
|
736
|
+
for i, entry in enumerate(document.get("fixes") or [], 1):
|
|
737
|
+
self._read_fix(f"{path}: fix {i}", entry)
|
|
738
|
+
for i, entry in enumerate(document.get("operators") or []):
|
|
739
|
+
self._read_operator(f"{path}: operator {i + 1}", entry)
|
|
740
|
+
for class_path, reason in (document.get("excluded") or {}).items():
|
|
741
|
+
where = f"{path}: excluded {class_path}"
|
|
742
|
+
cls = self._resolve(where, class_path, "class")
|
|
743
|
+
if cls is None:
|
|
744
|
+
continue
|
|
745
|
+
if not _is_operator(cls):
|
|
746
|
+
self._problem(where, "is not a KunQuant operator class (an OpBase subclass)")
|
|
747
|
+
elif cls in self.operator_classes:
|
|
748
|
+
self._problem(where, f"is also registered as {self.operator_classes[cls]}")
|
|
749
|
+
else:
|
|
750
|
+
self.excluded[cls] = str(reason)
|
|
751
|
+
|
|
752
|
+
def _read_fix(self, where: str, entry) -> None:
|
|
753
|
+
if not isinstance(entry, dict):
|
|
754
|
+
self._problem(where, "must be a mapping with match and hint")
|
|
755
|
+
return
|
|
756
|
+
problems = len(self._problems)
|
|
757
|
+
self._unknown_keys(where, entry, _FIX_KEYS)
|
|
758
|
+
if not isinstance(entry.get("hint"), str) or not entry["hint"]:
|
|
759
|
+
self._problem(where, "needs a hint, the text added to the refusal")
|
|
760
|
+
pattern = None
|
|
761
|
+
if not isinstance(entry.get("match"), str) or not entry["match"]:
|
|
762
|
+
self._problem(where, "needs a match, the regular expression a refusal matches")
|
|
763
|
+
else:
|
|
764
|
+
try:
|
|
765
|
+
pattern = re.compile(entry["match"])
|
|
766
|
+
except re.error as exc:
|
|
767
|
+
self._problem(where, f"match is not a regular expression: {exc}")
|
|
768
|
+
if pattern is not None and len(self._problems) == problems:
|
|
769
|
+
self._fixes.append((pattern, entry["hint"]))
|
|
770
|
+
|
|
771
|
+
def _read_operator(self, where: str, entry) -> None:
|
|
772
|
+
if not isinstance(entry, dict):
|
|
773
|
+
self._problem(where, "must be a mapping")
|
|
774
|
+
return
|
|
775
|
+
name = entry.get("name")
|
|
776
|
+
where = f"{where} ({name})"
|
|
777
|
+
self._unknown_keys(where, entry, _OPERATOR_KEYS)
|
|
778
|
+
ok = self._name_ok(where, name)
|
|
779
|
+
if ok and name in self._operators:
|
|
780
|
+
self._problem(where, f"the name {name} is registered twice")
|
|
781
|
+
ok = False
|
|
782
|
+
cls = self._resolve(where, entry.get("class"), "class")
|
|
783
|
+
if cls is not None and not _is_operator(cls):
|
|
784
|
+
self._problem(where, f"class {entry['class']} is not a KunQuant operator class (an OpBase subclass)")
|
|
785
|
+
cls = None
|
|
786
|
+
call = cls
|
|
787
|
+
if "call" in entry:
|
|
788
|
+
call = self._resolve(where, entry["call"], "callable")
|
|
789
|
+
if call is not None and not callable(call):
|
|
790
|
+
self._problem(where, f"callable {entry['call']} cannot be called")
|
|
791
|
+
call = None
|
|
792
|
+
inputs = tuple(entry.get("inputs", ["series"]))
|
|
793
|
+
for kind in inputs:
|
|
794
|
+
if kind not in INPUT_KINDS:
|
|
795
|
+
self._problem(where, f"input kind {kind!r} is not one of {list(INPUT_KINDS)}")
|
|
796
|
+
params = dict(entry.get("params") or {})
|
|
797
|
+
for attribute, kind in params.items():
|
|
798
|
+
if kind != FLAG and kind not in self.parameters:
|
|
799
|
+
self._problem(where, f"parameter {attribute!r} names the set {kind!r}, which the file does not define")
|
|
800
|
+
dimension = entry.get("dimension")
|
|
801
|
+
if dimension not in _dimension.OPERATOR_RULES:
|
|
802
|
+
self._problem(where, f"dimension rule {dimension!r} is not one of {list(_dimension.OPERATOR_RULES)}")
|
|
803
|
+
stages = self._read_stages(where, name, cls, entry.get("stages"))
|
|
804
|
+
if ok and cls is not None and call is not None and stages is not None:
|
|
805
|
+
self._operators[name] = OpSpec(
|
|
806
|
+
name, cls, entry["class"], call, str(entry.get("signature", name)), inputs, params, dimension,
|
|
807
|
+
str(entry.get("description", "")), stages,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
def _read_stages(self, where: str, name, cls: type | None, stages) -> tuple[str, ...] | None:
|
|
811
|
+
"""The stages an operator entry names, or its default; None, with the problem recorded, if they are wrong.
|
|
812
|
+
|
|
813
|
+
An operator of unbounded memory (a KunQuant state carried from bar to
|
|
814
|
+
bar without a window, as ``ExpMovingAvg``'s) is daily only.
|
|
815
|
+
"""
|
|
816
|
+
if isinstance(cls, type) and issubclass(cls, SessionAggregation):
|
|
817
|
+
if stages is not None and stages != [AGGREGATION]:
|
|
818
|
+
self._problem(where, f"a session aggregation's stages are [{AGGREGATION}], not {stages!r}")
|
|
819
|
+
return None
|
|
820
|
+
return (AGGREGATION,)
|
|
821
|
+
if stages is None:
|
|
822
|
+
return DAILY_ONLY
|
|
823
|
+
if not isinstance(stages, list) or not stages or not set(stages) <= set(STAGES) or len(set(stages)) < len(stages):
|
|
824
|
+
self._problem(where, f"stages {stages!r} must be a non-empty list of {' and '.join(STAGES)}")
|
|
825
|
+
return None
|
|
826
|
+
unbounded = isinstance(cls, type) and issubclass(cls, GloablStatefulOpTrait) and not issubclass(cls, WindowedTrait)
|
|
827
|
+
if unbounded and "intraday" in stages:
|
|
828
|
+
self._problem(
|
|
829
|
+
where, f"{name} has unbounded memory, which would carry one session into the next: its stages are [daily]",
|
|
830
|
+
)
|
|
831
|
+
return None
|
|
832
|
+
return tuple(stage for stage in STAGES if stage in stages)
|
|
833
|
+
|
|
834
|
+
def _read_fields(self, path: str, document: dict | None) -> None:
|
|
835
|
+
if document is None:
|
|
836
|
+
return
|
|
837
|
+
self._unknown_keys(path, document, _FIELD_FILE_KEYS)
|
|
838
|
+
family = str(document.get("family", Path(path).stem))
|
|
839
|
+
self.families[family] = str(document.get("description") or "")
|
|
840
|
+
mine: list[str] = []
|
|
841
|
+
declared: list[str] = [] # every name the file gives, so a formula over a refused field adds no problem
|
|
842
|
+
for i, entry in enumerate(document.get("fields") or []):
|
|
843
|
+
name = self._read_field(f"{path}: field {i + 1}", family, entry, declared)
|
|
844
|
+
if name is not None:
|
|
845
|
+
mine.append(name)
|
|
846
|
+
if isinstance(entry, dict) and isinstance(entry.get("name"), str):
|
|
847
|
+
declared.append(entry["name"])
|
|
848
|
+
if document.get("intraday") is not None:
|
|
849
|
+
self._read_intraday(f"{path}: intraday", family, document)
|
|
850
|
+
elif document.get("bar"):
|
|
851
|
+
self._bar_columns += [self.fields[n].column for n in mine if self.fields[n].column is not None]
|
|
852
|
+
if document.get("period_lag") is not None:
|
|
853
|
+
self._read_period_lag(f"{path}: period_lag", family, document["period_lag"])
|
|
854
|
+
|
|
855
|
+
def _read_field(self, where: str, family: str, entry, earlier: list[str]) -> str | None:
|
|
856
|
+
if not isinstance(entry, dict):
|
|
857
|
+
self._problem(where, "must be a mapping")
|
|
858
|
+
return None
|
|
859
|
+
name = entry.get("name")
|
|
860
|
+
where = f"{where} ({name})"
|
|
861
|
+
self._unknown_keys(where, entry, _FIELD_KEYS)
|
|
862
|
+
ok = self._name_ok(where, name)
|
|
863
|
+
if ok and name in self._operators:
|
|
864
|
+
self._problem(where, f"the name {name} is also an operator")
|
|
865
|
+
ok = False
|
|
866
|
+
if ok and name in self.fields:
|
|
867
|
+
self._problem(where, f"the name {name} is also a field of {self.fields[name].family}")
|
|
868
|
+
ok = False
|
|
869
|
+
column, formula, dimension = entry.get("column"), entry.get("formula"), None
|
|
870
|
+
if (column is None) == (formula is None):
|
|
871
|
+
self._problem(where, "needs exactly one of column and formula")
|
|
872
|
+
ok = False
|
|
873
|
+
elif column is not None:
|
|
874
|
+
reader = next((f for f in self.fields.values() if f.column == column), None)
|
|
875
|
+
if reader is not None:
|
|
876
|
+
self._problem(where, f"column {column!r} is also read by field {reader.name}")
|
|
877
|
+
ok = False
|
|
878
|
+
dimension = _unit(entry.get("unit"))
|
|
879
|
+
if dimension is None: # still a field, so formulas over it report nothing more
|
|
880
|
+
self._problem(where, f"unit {entry.get('unit')!r} is not a product of powers of dollars and shares")
|
|
881
|
+
else:
|
|
882
|
+
if "unit" in entry:
|
|
883
|
+
self._problem(where, "a derived field's unit follows from its formula; give none")
|
|
884
|
+
for reason in _checks.expression_reasons(str(formula), frozenset(earlier) | frozenset(self._operators)):
|
|
885
|
+
self._problem(where, f"formula {formula!r}: {reason}")
|
|
886
|
+
ok = False
|
|
887
|
+
if not ok:
|
|
888
|
+
return None
|
|
889
|
+
self.fields[name] = Field(name, family, column, formula, dimension, str(entry.get("description", "")))
|
|
890
|
+
return name
|
|
891
|
+
|
|
892
|
+
def _read_intraday(self, where: str, family: str, document: dict) -> None:
|
|
893
|
+
"""Make ``family`` the intraday family, with its window set."""
|
|
894
|
+
if self.intraday_family is not None:
|
|
895
|
+
self._problem(where, f"{family} is a second intraday family; a campaign opens one, {self.intraday_family}")
|
|
896
|
+
return
|
|
897
|
+
self.intraday_family = family
|
|
898
|
+
if document.get("bar"):
|
|
899
|
+
self._problem(where, "an intraday family cannot be bar: true; a bar without a trade has no price but is a bar")
|
|
900
|
+
entry = document["intraday"]
|
|
901
|
+
if not isinstance(entry, dict):
|
|
902
|
+
self._problem(where, "must be a mapping holding windows, the window lengths in bars")
|
|
903
|
+
return
|
|
904
|
+
self._unknown_keys(where, entry, _INTRADAY_KEYS)
|
|
905
|
+
windows = entry.get("windows")
|
|
906
|
+
if not isinstance(windows, list) or not windows or not all(_number(v) and v == int(v) and v > 0 for v in windows):
|
|
907
|
+
self._problem(where, f"intraday windows must be a list of positive whole numbers of bars, got {windows!r}")
|
|
908
|
+
return
|
|
909
|
+
self.intraday_windows = tuple(int(v) for v in windows)
|
|
910
|
+
lags = entry.get("lags")
|
|
911
|
+
if lags is not None:
|
|
912
|
+
if not isinstance(lags, list) or not lags or not all(_number(v) and v == int(v) and v > 0 for v in lags):
|
|
913
|
+
self._problem(where, f"intraday lags must be a list of positive whole numbers of bars, got {lags!r}")
|
|
914
|
+
return
|
|
915
|
+
self._intraday_lags = tuple(int(v) for v in lags)
|
|
916
|
+
|
|
917
|
+
def _read_period_lag(self, where: str, family: str, entry) -> None:
|
|
918
|
+
if not isinstance(entry, dict):
|
|
919
|
+
self._problem(where, "must be a mapping of column, k and period")
|
|
920
|
+
return
|
|
921
|
+
self._unknown_keys(where, entry, _PERIOD_LAG_KEYS)
|
|
922
|
+
ok = True
|
|
923
|
+
column = entry.get("column")
|
|
924
|
+
placeholders = set()
|
|
925
|
+
if isinstance(column, str):
|
|
926
|
+
with contextlib.suppress(ValueError):
|
|
927
|
+
placeholders = {f for _, f, _, _ in string.Formatter().parse(column) if f is not None}
|
|
928
|
+
if placeholders != {"column", "k"}:
|
|
929
|
+
self._problem(where, f"column {column!r} must be a pattern with exactly {{column}} and {{k}}")
|
|
930
|
+
ok = False
|
|
931
|
+
k = entry.get("k")
|
|
932
|
+
if not isinstance(k, list) or not k or not all(_number(v) and v == int(v) and v > 0 for v in k):
|
|
933
|
+
self._problem(where, f"k {k!r} must be a list of positive whole numbers")
|
|
934
|
+
ok = False
|
|
935
|
+
period = entry.get("period")
|
|
936
|
+
if not isinstance(period, dict):
|
|
937
|
+
self._problem(where, "period must be a mapping of description, key, days, tolerance_days and bars")
|
|
938
|
+
return
|
|
939
|
+
self._unknown_keys(f"{where}: period", period, _PERIOD_KEYS)
|
|
940
|
+
for name in ("description", "key"):
|
|
941
|
+
if not isinstance(period.get(name), str) or not period[name].strip():
|
|
942
|
+
self._problem(where, f"period {name} must be a non-empty text, got {period.get(name)!r}")
|
|
943
|
+
ok = False
|
|
944
|
+
for name in ("days", "tolerance_days", "bars"):
|
|
945
|
+
value = period.get(name)
|
|
946
|
+
if not _number(value) or value <= 0 or (name == "bars" and value != int(value)):
|
|
947
|
+
self._problem(where, f"period {name} must be a positive number, got {value!r}")
|
|
948
|
+
ok = False
|
|
949
|
+
if ok:
|
|
950
|
+
self.period_lags[family] = PeriodLag(
|
|
951
|
+
family, column, tuple(int(v) for v in k), period["description"], period["key"],
|
|
952
|
+
float(period["days"]), float(period["tolerance_days"]), int(period["bars"]),
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
def _check_lag_columns(self) -> None:
|
|
956
|
+
"""A lag column may not be a field's column or a declared key."""
|
|
957
|
+
keys = {lag.key for lag in self.period_lags.values()}
|
|
958
|
+
for column, (name, k) in self._lag_columns.items():
|
|
959
|
+
if column in self._column_fields or column in keys:
|
|
960
|
+
self._problem(
|
|
961
|
+
f"period lag of {self.fields[name].family}",
|
|
962
|
+
f"PeriodLag({name}, {k}) reads {column!r}, which is also a column of the price dataset",
|
|
963
|
+
)
|
|
964
|
+
|
|
965
|
+
def _build_derived(self) -> None:
|
|
966
|
+
"""Build every derived field once, so a formula that cannot be built is a start-up error."""
|
|
967
|
+
fields: dict[str, OpBase] = {}
|
|
968
|
+
for f in self.fields.values():
|
|
969
|
+
if f.column is not None:
|
|
970
|
+
fields[f.name] = Input(f.column)
|
|
971
|
+
continue
|
|
972
|
+
where = f"field {f.name} ({f.family})"
|
|
973
|
+
try:
|
|
974
|
+
fields[f.name] = self._derive(f, fields)
|
|
975
|
+
except Exception as exc: # the formula's own error, reported with its field
|
|
976
|
+
self._problem(where, f"formula {f.formula!r} cannot be built: {type(exc).__name__}: {exc}")
|
|
977
|
+
return
|
|
978
|
+
if not isinstance(fields[f.name], OpBase):
|
|
979
|
+
self._problem(where, f"formula {f.formula!r} is not an operator")
|
|
980
|
+
return
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
class _OutsideModules(ImportError):
|
|
984
|
+
"""A path left the allowed modules: an attribute hop through another module, or an object defined in one."""
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def resolve(path: str, roots: Iterable[str] | None = None) -> object:
|
|
988
|
+
"""Import the object at ``path``: the longest importable module prefix, then attributes.
|
|
989
|
+
|
|
990
|
+
With ``roots``, the path may not leave the modules under them: no
|
|
991
|
+
attribute hop may go through a module outside them (``KunQuant.jit.cfake
|
|
992
|
+
.subprocess.run`` is refused, though its prefix is KunQuant's), and the
|
|
993
|
+
object must be defined in one of them (its ``__module__``), so a name an
|
|
994
|
+
allowed module imported from elsewhere is refused too.
|
|
995
|
+
|
|
996
|
+
Raises
|
|
997
|
+
------
|
|
998
|
+
ImportError, AttributeError
|
|
999
|
+
If no prefix imports or an attribute is missing; the ``ImportError``
|
|
1000
|
+
subclass ``_OutsideModules`` if the path leaves ``roots``.
|
|
1001
|
+
"""
|
|
1002
|
+
roots = None if roots is None else tuple(roots)
|
|
1003
|
+
parts = path.split(".")
|
|
1004
|
+
for cut in range(len(parts) - 1, 0, -1):
|
|
1005
|
+
try:
|
|
1006
|
+
value = importlib.import_module(".".join(parts[:cut]))
|
|
1007
|
+
except ModuleNotFoundError:
|
|
1008
|
+
continue
|
|
1009
|
+
for attribute in parts[cut:]:
|
|
1010
|
+
value = getattr(value, attribute)
|
|
1011
|
+
if roots is not None and isinstance(value, types.ModuleType) and not _under(value.__name__, roots):
|
|
1012
|
+
raise _OutsideModules(f"goes through module {value.__name__}")
|
|
1013
|
+
if roots is not None:
|
|
1014
|
+
module = getattr(value, "__module__", None)
|
|
1015
|
+
if not isinstance(module, str) or not _under(module, roots):
|
|
1016
|
+
raise _OutsideModules(f"is defined in {module}")
|
|
1017
|
+
return value
|
|
1018
|
+
raise ImportError(f"no module of {path} can be imported")
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def _under(name: str, roots: Iterable[str]) -> bool:
|
|
1022
|
+
"""Whether the dotted ``name`` is one of ``roots`` or inside one."""
|
|
1023
|
+
return any(name == root or name.startswith(root + ".") for root in roots)
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def resolve_classes(paths: Mapping[str, str]) -> dict[str, type]:
|
|
1027
|
+
"""The operator class at each path, by name, as ``Vocabulary.class_paths`` gave them.
|
|
1028
|
+
|
|
1029
|
+
Raises
|
|
1030
|
+
------
|
|
1031
|
+
TypeError
|
|
1032
|
+
If a path is not a KunQuant operator class.
|
|
1033
|
+
"""
|
|
1034
|
+
classes = {name: resolve(path) for name, path in paths.items()}
|
|
1035
|
+
wrong = [paths[name] for name, cls in classes.items() if not _is_operator(cls)]
|
|
1036
|
+
if wrong:
|
|
1037
|
+
raise TypeError(f"not KunQuant operator classes: {wrong}")
|
|
1038
|
+
return classes
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _unit(text) -> tuple | None:
|
|
1042
|
+
"""The ``(dollars, shares)`` exponents of a unit such as ``dollars/shares`` or ``1``; None if malformed."""
|
|
1043
|
+
if text == 1:
|
|
1044
|
+
text = "1"
|
|
1045
|
+
if not isinstance(text, str):
|
|
1046
|
+
return None
|
|
1047
|
+
exponents = [Fraction(0), Fraction(0)]
|
|
1048
|
+
sign = 1
|
|
1049
|
+
for i, token in enumerate(re.split(r"\s*([*/])\s*", text.strip())):
|
|
1050
|
+
if i % 2:
|
|
1051
|
+
sign = -1 if token == "/" else 1
|
|
1052
|
+
continue
|
|
1053
|
+
match = _UNIT_FACTOR.fullmatch(token)
|
|
1054
|
+
if match is None:
|
|
1055
|
+
return None
|
|
1056
|
+
if match.group(1) != "1":
|
|
1057
|
+
exponents[_UNIT_BASES.index(match.group(1))] += sign * int(match.group(2) or 1)
|
|
1058
|
+
return tuple(exponents)
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
def _is_operator(cls) -> bool:
|
|
1062
|
+
return isinstance(cls, type) and issubclass(cls, OpBase)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _declared(klass: type) -> bool:
|
|
1066
|
+
"""Whether ``klass`` is its module's attribute of its name: declared, not made at run time."""
|
|
1067
|
+
try:
|
|
1068
|
+
module = importlib.import_module(klass.__module__)
|
|
1069
|
+
except ImportError:
|
|
1070
|
+
return False
|
|
1071
|
+
return getattr(module, klass.__name__, None) is klass
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _number(value) -> bool:
|
|
1075
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _arithmetic_calls(tree: ast.AST) -> ast.AST:
|
|
1080
|
+
"""``tree`` with ``-x`` and ``a ** b`` written as calls (``_ArithmeticCalls``), ready to compile."""
|
|
1081
|
+
return ast.fix_missing_locations(_ArithmeticCalls().visit(tree))
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
class _ArithmeticCalls(ast.NodeTransformer):
|
|
1085
|
+
"""Write ``-x`` and ``a ** b`` as calls, which KunQuant's operators do not support as operators."""
|
|
1086
|
+
|
|
1087
|
+
def visit_UnaryOp(self, node: ast.UnaryOp) -> ast.AST:
|
|
1088
|
+
self.generic_visit(node)
|
|
1089
|
+
if not isinstance(node.op, ast.USub):
|
|
1090
|
+
return node
|
|
1091
|
+
return ast.copy_location(ast.Call(ast.Name(_NEGATE, ast.Load()), [node.operand], []), node)
|
|
1092
|
+
|
|
1093
|
+
def visit_BinOp(self, node: ast.BinOp) -> ast.AST:
|
|
1094
|
+
self.generic_visit(node)
|
|
1095
|
+
if not isinstance(node.op, ast.Pow):
|
|
1096
|
+
return node
|
|
1097
|
+
return ast.copy_location(ast.Call(ast.Name(_POWER, ast.Load()), [node.left, node.right], []), node)
|