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.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. 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)