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,223 @@
1
+ """A candidate's KunQuant graph as plain data: what the Gate records and the Evaluator compiles.
2
+
3
+ The sandbox subprocess builds the graph and sends it back as a node list, so
4
+ the parent process never runs candidate code or unpickles anything; it
5
+ rebuilds the operators from registered classes only. A node records its
6
+ operator under the vocabulary's name for it; a graph keeps the classes of
7
+ the vocabulary it was read or recorded with (``classes``, not part of its
8
+ identity), which ``build`` makes the operators from.
9
+ """
10
+
11
+ import hashlib
12
+ from collections import OrderedDict
13
+ from collections.abc import Mapping
14
+ from dataclasses import dataclass, field
15
+ from typing import TYPE_CHECKING
16
+
17
+ from KunQuant.Op import Input, OpBase
18
+ from KunQuant.ops import SetInfOrNanToValue
19
+
20
+ from factor_agent.gate._canonical import canonical_hashes, canonical_terms, ranked_root, term_key
21
+
22
+ if TYPE_CHECKING:
23
+ from factor_agent.gate.vocabulary import Vocabulary
24
+
25
+ #: Instance attributes an operator may carry; anything else cannot be recorded.
26
+ _OP_STATE = frozenset({"inputs", "attrs", "_parent_loop"})
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Node:
31
+ """One operator: its class name, attributes, input nodes and enclosing loop node."""
32
+
33
+ op: str
34
+ attrs: tuple[tuple[str, object], ...]
35
+ inputs: tuple[int, ...]
36
+ parent: int | None
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class Graph:
41
+ """A factor graph in topological order; the last node is the factor."""
42
+
43
+ nodes: tuple[Node, ...]
44
+ #: The class of every operator name, from the vocabulary; None for a graph that is never built.
45
+ classes: Mapping[str, type] | None = field(default=None, compare=False, repr=False)
46
+
47
+ @classmethod
48
+ def record(cls, output: OpBase, vocabulary: "Vocabulary", strict: bool = True) -> "Graph":
49
+ """Record every operator ``output`` depends on, its inputs and loops first, under ``vocabulary``'s names.
50
+
51
+ With ``strict=False`` (a reference factor's graph, compared but never
52
+ rebuilt) an operator outside the vocabulary is recorded under its class
53
+ name, other state is ignored and an attribute that is not a number,
54
+ string or bool is recorded as its ``repr``.
55
+
56
+ Raises
57
+ ------
58
+ ValueError
59
+ If ``strict`` and an operator is not registered or carries
60
+ state other than its inputs, attributes and loop.
61
+ """
62
+ index: dict[int, int] = {}
63
+ nodes: list[Node] = []
64
+
65
+ def visit(op: OpBase) -> int:
66
+ if id(op) in index:
67
+ return index[id(op)]
68
+ name = vocabulary.recorded_name(type(op))
69
+ attrs = tuple(op.attrs.items())
70
+ if not strict:
71
+ name = name or type(op).__name__
72
+ attrs = tuple((k, v if isinstance(v, (bool, int, float, str)) else repr(v)) for k, v in attrs)
73
+ if name is None:
74
+ raise ValueError(f"operator {type(op).__name__} is not an operator of the vocabulary")
75
+ for key, value in attrs:
76
+ if strict and isinstance(value, OpBase):
77
+ raise ValueError(_operator_in_a_number_slot(name, key))
78
+ extra = set(vars(op)) - _OP_STATE
79
+ if extra and strict:
80
+ raise ValueError(f"operator {name} carries state the gate cannot record: {sorted(extra)}")
81
+ loop = op.get_parent()
82
+ parent = visit(loop) if loop is not None else None
83
+ inputs = tuple(visit(inp) for inp in op.inputs)
84
+ index[id(op)] = len(nodes)
85
+ nodes.append(Node(name, attrs, inputs, parent))
86
+ return index[id(op)]
87
+
88
+ visit(output)
89
+ return cls(tuple(nodes), vocabulary.classes)
90
+
91
+ @property
92
+ def input_names(self) -> tuple[str, ...]:
93
+ """The dataset columns the graph reads, sorted."""
94
+ return tuple(sorted(dict(node.attrs)["name"] for node in self.nodes if node.op == "Input"))
95
+
96
+ def build(self, inputs: dict[str, OpBase]) -> tuple[OpBase, list[OpBase]]:
97
+ """Rebuild the operators and return ``(factor, every new operator)``.
98
+
99
+ ``inputs`` maps a column name to its ``Input`` operator, so several
100
+ graphs rebuilt into one function share their inputs; a missing name
101
+ is added to it.
102
+
103
+ Raises
104
+ ------
105
+ ValueError
106
+ If the graph has no classes to build from.
107
+ """
108
+ made, new = self.build_nodes(inputs)
109
+ return made[-1], new
110
+
111
+ def build_nodes(self, inputs: dict[str, OpBase]) -> tuple[list[OpBase], list[OpBase]]:
112
+ """Rebuild the operators as ``build`` does and return ``(the operator of every node, in order, every new operator)``.
113
+
114
+ Raises
115
+ ------
116
+ ValueError
117
+ If the graph has no classes to build from.
118
+ """
119
+ if self.classes is None:
120
+ raise ValueError("Graph.build: the graph was made without the classes of a vocabulary")
121
+ made: list[OpBase] = []
122
+ new: list[OpBase] = []
123
+ for node in self.nodes:
124
+ if node.op == "Input":
125
+ name = dict(node.attrs)["name"]
126
+ if name not in inputs:
127
+ inputs[name] = Input(name)
128
+ new.append(inputs[name])
129
+ made.append(inputs[name])
130
+ continue
131
+ cls = self.classes[node.op]
132
+ # quantlab's quantile-bound operators pick their per-bounds subclass in __new__.
133
+ op = cls.__new__(cls) if cls.__new__ is object.__new__ else cls.__new__(cls, None, **dict(node.attrs))
134
+ op.inputs = [made[i] for i in node.inputs]
135
+ op.attrs = OrderedDict(node.attrs)
136
+ op._parent_loop = made[node.parent] if node.parent is not None else None
137
+ made.append(op)
138
+ new.append(op)
139
+ return made, new
140
+
141
+ def without_fills(self) -> "Graph":
142
+ """Return the graph with every ``SetInfOrNanToValue`` replaced by its input.
143
+
144
+ The nodes the factor still depends on are kept in their order; a
145
+ graph without a fill is returned as it is. Its missing values are
146
+ the missing values of the data the fills read (the Evaluator's
147
+ coverage).
148
+ """
149
+ if not any(_is_fill(node, self.classes) for node in self.nodes):
150
+ return self
151
+ index: dict[int, int] = {}
152
+ nodes: list[Node] = []
153
+
154
+ def visit(i: int) -> int:
155
+ if i not in index:
156
+ node = self.nodes[i]
157
+ if _is_fill(node, self.classes):
158
+ index[i] = visit(node.inputs[0])
159
+ else:
160
+ parent = visit(node.parent) if node.parent is not None else None
161
+ inputs = tuple(visit(j) for j in node.inputs)
162
+ nodes.append(Node(node.op, node.attrs, inputs, parent))
163
+ index[i] = len(nodes) - 1
164
+ return index[i]
165
+
166
+ visit(len(self.nodes) - 1)
167
+ return type(self)(tuple(nodes), self.classes)
168
+
169
+ def to_json(self) -> list:
170
+ """Return the nodes as JSON-ready lists."""
171
+ return [[n.op, [list(kv) for kv in n.attrs], list(n.inputs), n.parent] for n in self.nodes]
172
+
173
+ @classmethod
174
+ def from_json(cls, data: list, classes: Mapping[str, type]) -> "Graph":
175
+ """Rebuild a graph from ``to_json``; every node must name one of ``classes`` (``Vocabulary.classes``).
176
+
177
+ Raises
178
+ ------
179
+ ValueError
180
+ If a node names a class that is not registered or refers to a
181
+ node that does not precede it.
182
+ """
183
+ nodes = []
184
+ for position, (op, attrs, inputs, parent) in enumerate(data):
185
+ if op not in classes:
186
+ raise ValueError(f"graph node {position} names {op!r}, which is not an operator of the vocabulary")
187
+ refs = list(inputs) + ([] if parent is None else [parent])
188
+ if any(not isinstance(r, int) or not 0 <= r < position for r in refs):
189
+ raise ValueError(f"graph node {position} refers to a node that does not precede it")
190
+ nodes.append(Node(op, tuple((str(k), v) for k, v in attrs), tuple(inputs), parent))
191
+ return cls(tuple(nodes), dict(classes))
192
+
193
+ def fingerprint(self) -> str:
194
+ """Return the SHA-256 of the canonical graph.
195
+
196
+ Two graphs that differ only by the operand order of a commutative
197
+ operator, by constant subexpressions that fold to the same value, or
198
+ by a repeated subexpression get the same fingerprint.
199
+ """
200
+ return canonical_hashes(self)[-1]
201
+
202
+ def ranking(self) -> str:
203
+ """Return the SHA-256 of the canonical graph up to what keeps or reverses its cross-sectional ranking.
204
+
205
+ A rank, a constant offset and a nonzero scale at the root, a negative
206
+ one included, do not change which stocks the factor tells apart, so
207
+ ``x``, ``-x`` and ``Rank(x) * 3 + 1`` share it.
208
+ """
209
+ return hashlib.sha256(term_key(ranked_root(canonical_terms(self)[-1])).encode()).hexdigest()
210
+
211
+
212
+ def _is_fill(node: Node, classes: Mapping[str, type] | None) -> bool:
213
+ """Whether ``node`` is KunQuant's ``SetInfOrNanToValue``, under its own name or a vocabulary's."""
214
+ cls = (classes or {}).get(node.op)
215
+ return node.op == "SetInfOrNanToValue" or (isinstance(cls, type) and issubclass(cls, SetInfOrNanToValue))
216
+
217
+
218
+ def _operator_in_a_number_slot(name: str, key: str) -> str:
219
+ """The reason for an operator passed where ``name`` takes a number, with how to write it."""
220
+ reason = f"{name}'s {key} must be a number, not an operator"
221
+ if name.endswith("Const"):
222
+ reason += f": write {name}(x, 1), or {name.removesuffix('Const')}(x, ConstantOp(1)) to combine two operators"
223
+ return reason
@@ -0,0 +1,383 @@
1
+ """The stage split: a candidate's recorded graph as the graphs it runs as, and the stage rules it breaks.
2
+
3
+ A candidate of an intraday family is one graph in two stages. Every session
4
+ aggregation (``factor_agent.aggregations``) and what it depends on is its
5
+ intraday stage, computed on each session's intraday bars; everything above
6
+ the aggregations is its daily stage, computed on one value per stock and
7
+ day. ``split`` gives the daily graph, each aggregation subtree replaced by
8
+ an ``Input`` of its session column, named after what the subtree computes
9
+ (``session_column``, ``computation_hashes``), and the intraday graphs by
10
+ those names, each ending in its aggregation: two aggregations that compute
11
+ the same values, in one graph or in two, are one column, and two that
12
+ compute different values are two, even when their canonical hashes are one
13
+ (``x + 1e8 - 1e8`` is ``x`` to the Gate but not in float32). The daily family's bar mask, which quantlab's
14
+ NaN-preserving stand-ins add to what they compute, is left out of the
15
+ intraday stage: a bar there is an intraday bar. A graph without an
16
+ aggregation or an intraday field is its own daily graph.
17
+
18
+ Every window of an intraday graph stays within the session: each
19
+ time-series operator outside a loop body, and each reduction of a loop, is
20
+ wrapped in ``WithinSession`` by the bars it reaches back, its inputs'
21
+ windows included, so it is missing on a session's first bars until it
22
+ fits and never reads the session before. A session column is named after
23
+ the intraday graph so rewritten, what runs. Cross-sectional operators rank
24
+ across all the symbols of each bar.
25
+
26
+ The rules ``split`` checks, each broken one a reason with a hint, how to
27
+ write it instead, for the proposer:
28
+
29
+ - an intraday field reaches the daily stage only through an aggregation;
30
+ - the intraday stage reads no daily field;
31
+ - an aggregation's inputs are intraday: no aggregation of a daily value
32
+ (inputs reading no intraday field), none nested in another;
33
+ - each operator is used only in the stages its operator-file entry names
34
+ (``OpSpec.stages``);
35
+ - a window or a lag (``window_kind``) is one of its stage's set: in the
36
+ intraday stage the intraday family's windows and lags
37
+ (``Vocabulary.intraday_windows``, ``intraday_lags``), lags chained
38
+ included; in the daily stage the operator file's set of the attribute.
39
+
40
+ The Gate, the Evaluator and the export all split a graph here; nothing else
41
+ knows how its stages are found.
42
+ """
43
+
44
+ from collections.abc import Callable, Mapping
45
+ from dataclasses import dataclass, field
46
+ from types import SimpleNamespace
47
+ from typing import TYPE_CHECKING
48
+
49
+ from KunQuant.Op import ForeachBackWindow, WindowedTrait
50
+ from KunQuant.ops import Add
51
+ from quantlab.factor.predefined.intraday import SESSION_POSITION
52
+
53
+ from factor_agent.aggregations import SessionAggregation, WithinSession
54
+ from factor_agent.gate._canonical import canonical_hashes, computation_hashes, effective_parameters
55
+ from factor_agent.gate.graph import Graph, Node
56
+
57
+ #: The parameter set of an attribute that is bookkeeping (``vocabulary.FLAG``), never checked.
58
+ FLAG = "flag"
59
+
60
+ if TYPE_CHECKING:
61
+ from factor_agent.gate.vocabulary import Vocabulary
62
+
63
+ #: The stage of the nodes above every aggregation, and of those below one.
64
+ DAILY, INTRADAY = "daily", "intraday"
65
+
66
+ @dataclass(frozen=True)
67
+ class Staged:
68
+ """A graph split into its stages; see the module docstring.
69
+
70
+ ``daily`` reads each session column as an ``Input`` of its name;
71
+ ``intraday`` maps each name to its intraday graph; ``reasons`` are the
72
+ stage rules the graph breaks, empty when it breaks none (and the graphs
73
+ are then the ones it runs as); ``hints`` say how to write the
74
+ candidate instead, for the proposer.
75
+ """
76
+
77
+ daily: Graph
78
+ intraday: Mapping[str, Graph] = field(default_factory=dict)
79
+ reasons: tuple[str, ...] = ()
80
+ hints: tuple[str, ...] = ()
81
+
82
+
83
+ def session_column(subtree_hash: str) -> str:
84
+ """The name of the session column of an aggregation subtree, from the hash of what it computes."""
85
+ return f"session_{subtree_hash}"
86
+
87
+
88
+ def is_aggregation(graph: Graph, node: Node) -> bool:
89
+ """Whether ``node`` of ``graph`` is a session aggregation."""
90
+ cls = (graph.classes or {}).get(node.op)
91
+ return isinstance(cls, type) and issubclass(cls, SessionAggregation)
92
+
93
+
94
+ def is_staged(graph: Graph, vocabulary: "Vocabulary") -> bool:
95
+ """Whether ``graph`` runs in two stages: it holds a session aggregation or reads an intraday field."""
96
+ intraday_columns = vocabulary.intraday_columns
97
+ return any(is_aggregation(graph, node) or _column(node) in intraday_columns for node in graph.nodes)
98
+
99
+
100
+ def window_kind(vocabulary: "Vocabulary", op: str, attribute: str) -> str | None:
101
+ """``"lag"`` for an attribute of ``op`` in the lag set, ``"window"`` for another named ``window``, else None.
102
+
103
+ A staged graph's windows and lags are checked by the stage rules,
104
+ against the set of the stage they are used in.
105
+ """
106
+ kind = vocabulary.spec(op).params.get(attribute)
107
+ if kind is None or kind == FLAG:
108
+ return None
109
+ if kind == vocabulary.lag_set:
110
+ return "lag"
111
+ return "window" if attribute == "window" else None
112
+
113
+
114
+ def split(graph: Graph, vocabulary: "Vocabulary") -> Staged:
115
+ """Return ``graph``'s daily and intraday graphs and the stage rules it breaks; see the module docstring."""
116
+ if not is_staged(graph, vocabulary):
117
+ return Staged(graph)
118
+ aggregations = [is_aggregation(graph, node) for node in graph.nodes]
119
+ masked = _masked(graph, vocabulary)
120
+ stages = _stages(graph, aggregations, masked)
121
+ reasons, hints = _reasons(graph, vocabulary, stages, aggregations, masked)
122
+ if reasons:
123
+ return Staged(graph, {}, reasons, hints)
124
+ intraday = {
125
+ i: _within_session(_rebuilt(graph, i, lambda j: masked.get(j)))
126
+ for i, aggregation in enumerate(aggregations) if aggregation and DAILY in stages[i]
127
+ }
128
+ names = {i: session_column(computation_hashes(subgraph)[-1]) for i, subgraph in intraday.items()}
129
+ daily = _rebuilt(graph, len(graph.nodes) - 1, lambda j: Node("Input", (("name", names[j]),), (), None) if j in names else None)
130
+ return Staged(daily, {names[i]: subgraph for i, subgraph in intraday.items()})
131
+
132
+
133
+ def _column(node: Node) -> str | None:
134
+ return dict(node.attrs)["name"] if node.op == "Input" else None
135
+
136
+
137
+ def _masked(graph: Graph, vocabulary: "Vocabulary") -> dict[int, int]:
138
+ """The nodes that add the daily bar mask to a value, each with the value's node.
139
+
140
+ Inside ``missing_bars_only`` the NaN-preserving stand-ins add the daily
141
+ family's bar mask (``Vocabulary.reference_hashes``) to what they
142
+ compute; in the intraday stage there is no daily bar, so the value is
143
+ taken without it.
144
+ """
145
+ _, mask = vocabulary.reference_hashes
146
+ if not mask:
147
+ return {}
148
+ hashes = canonical_hashes(graph)
149
+ masked = {}
150
+ for i, node in enumerate(graph.nodes):
151
+ cls = (graph.classes or {}).get(node.op)
152
+ if not (isinstance(cls, type) and issubclass(cls, Add)):
153
+ continue
154
+ values = [j for j in node.inputs if hashes[j] not in mask]
155
+ if len(values) == 1 and len(node.inputs) == 2:
156
+ masked[i] = values[0]
157
+ return masked
158
+
159
+
160
+ def _stages(graph: Graph, aggregations: list[bool], masked: dict[int, int]) -> list[dict[str, int | None]]:
161
+ """The stages of each node, each with the aggregation node it is under (None in the daily stage).
162
+
163
+ The root is in the daily stage; an aggregation's inputs, and everything
164
+ they depend on but the daily bar mask (``_masked``), are in the
165
+ intraday stage under it.
166
+ """
167
+ stages: list[dict[str, int | None]] = [{} for _ in graph.nodes]
168
+ stack = [(len(graph.nodes) - 1, DAILY, None)]
169
+ while stack:
170
+ i, stage, under = stack.pop()
171
+ if stage == INTRADAY and i in masked:
172
+ stack.append((masked[i], stage, under))
173
+ continue
174
+ if stage in stages[i]:
175
+ continue
176
+ stages[i][stage] = under
177
+ node = graph.nodes[i]
178
+ below, by = (INTRADAY, i) if aggregations[i] else (stage, under)
179
+ stack += [(j, below, by) for j in node.inputs]
180
+ if node.parent is not None:
181
+ stack.append((node.parent, stage, under))
182
+ return stages
183
+
184
+
185
+ def _reasons(
186
+ graph: Graph, vocabulary: "Vocabulary", stages: list[dict], aggregations: list[bool], masked: dict[int, int],
187
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
188
+ """The stage rules the nodes break, in node order, each once, and how to write each instead."""
189
+ intraday_columns = vocabulary.intraday_columns
190
+ found: dict[str, str] = {}
191
+ daily_values = {
192
+ i for i, aggregation in enumerate(aggregations)
193
+ if aggregation and not _reads_intraday(graph, i, aggregations, masked, intraday_columns)
194
+ }
195
+ for i, node in enumerate(graph.nodes):
196
+ column = _column(node)
197
+ if column is not None:
198
+ name = vocabulary.written(column)
199
+ if DAILY in stages[i] and column in intraday_columns:
200
+ found[f"the intraday field {name} reaches the daily stage without a session aggregation"] = (
201
+ f"reduce each session's {name} to one value per day with a session aggregation first, "
202
+ f"such as SessionMean({name}) or SessionLast({name}), and use that value in the daily stage"
203
+ )
204
+ under = stages[i].get(INTRADAY)
205
+ if INTRADAY in stages[i] and column not in intraday_columns and under not in daily_values:
206
+ found[f"the daily field {name} is read inside the intraday stage, under {graph.nodes[under].op}"] = (
207
+ f"combine the daily field {name} with a session's value outside its aggregation, in the daily "
208
+ f"stage: SessionMean(x) / {name}, not SessionMean(x / {name})"
209
+ )
210
+ continue
211
+ if aggregations[i]:
212
+ if i in daily_values:
213
+ found[f"{node.op} aggregates a daily value: an aggregation reduces the intraday bars of each session"] = (
214
+ f"aggregate a value of the intraday fields, such as {node.op}(bar_close); a daily field or "
215
+ "a daily value is used as it is in the daily stage"
216
+ )
217
+ if INTRADAY in stages[i]:
218
+ outer = graph.nodes[stages[i][INTRADAY]].op
219
+ found[
220
+ f"{node.op} is nested inside {outer}: an aggregation's input is a value of the intraday bars"
221
+ ] = (
222
+ f"aggregate each session once; to combine the values of several sessions, apply a daily "
223
+ f"window to the aggregation, such as WindowedAvg({node.op}(x), 20)"
224
+ )
225
+ continue
226
+ allowed = vocabulary.spec(node.op).stages
227
+ for stage in stages[i]:
228
+ if stage not in allowed:
229
+ found[
230
+ f"{node.op} is used in the {stage} stage, but may be used in the {' and '.join(allowed)} stage only"
231
+ ] = _OUTSIDE_ITS_STAGES[stage].format(op=node.op)
232
+ found.update(dict.fromkeys(_window_reasons(vocabulary, node, stages[i]), _window_hint(vocabulary)))
233
+ for i, aggregation in enumerate(aggregations):
234
+ if aggregation:
235
+ found.update(dict.fromkeys(_chained_lags(vocabulary, _rebuilt(graph, i, masked.get)), _window_hint(vocabulary)))
236
+ return tuple(found), tuple(dict.fromkeys(found.values()))
237
+
238
+
239
+ def _values(values) -> str:
240
+ return ", ".join(f"{v:g}" for v in sorted(set(values)))
241
+
242
+
243
+ def _window_reasons(vocabulary: "Vocabulary", node: Node, stages: dict) -> list[str]:
244
+ """Why each window and lag of ``node`` is outside the set of a stage it is used in."""
245
+ reasons = []
246
+ for key, value in node.attrs:
247
+ kind = window_kind(vocabulary, node.op, key)
248
+ if kind is None:
249
+ continue
250
+ if INTRADAY in stages:
251
+ allowed = vocabulary.intraday_lags if kind == "lag" else vocabulary.intraday_windows
252
+ if not any(value == v for v in allowed):
253
+ reasons.append(f"{node.op}'s {kind} {value:g} is not one of the intraday {kind}s ({_values(allowed)} bars)")
254
+ if DAILY in stages:
255
+ daily = vocabulary.spec(node.op).params[key]
256
+ if not vocabulary.allowed(daily, value):
257
+ reasons.append(
258
+ f"{node.op}'s {kind} {value:g} is not one of the daily {kind}s ({_values(vocabulary.parameters[daily])})"
259
+ )
260
+ return reasons
261
+
262
+
263
+ def _chained_lags(vocabulary: "Vocabulary", subgraph: Graph) -> list[str]:
264
+ """Why lags chained in an aggregation's intraday stage add up to a lag outside the intraday set."""
265
+ allowed = vocabulary.intraday_lags
266
+ # A lag of one BackRef is its own window, refused by _window_reasons when it is outside the set.
267
+ written = {value for node in subgraph.nodes for key, value in node.attrs if window_kind(vocabulary, node.op, key) == "lag"}
268
+ return [
269
+ f"the lags chain to {op} lag {value:g} in the intraday stage, which is not one of the intraday lags "
270
+ f"({_values(allowed)} bars)"
271
+ for op, kind, value in effective_parameters(subgraph)
272
+ if kind == "lag" and not any(value == v for v in allowed) and value not in written
273
+ ]
274
+
275
+
276
+ def _window_hint(vocabulary: "Vocabulary") -> str:
277
+ lags = vocabulary.intraday_lags
278
+ return (
279
+ f"in the intraday stage a window is one of {_values(vocabulary.intraday_windows)} bars and a lag one of "
280
+ f"{_values(lags)} bars; in the daily stage a window or a lag is one of the values of its parameter set, "
281
+ "in days; use one value of the stage's set rather than chaining several"
282
+ )
283
+
284
+
285
+ #: How to write an operator used outside its stages instead, by the stage it was used in.
286
+ _OUTSIDE_ITS_STAGES = {
287
+ INTRADAY: "use {op} on a session's aggregated value in the daily stage, such as {op}(SessionMean(x), ...), "
288
+ "or within the session an operator the intraday stage allows",
289
+ DAILY: "use {op} on the intraday bars, inside a session aggregation, such as SessionMean({op}(x, ...))",
290
+ }
291
+
292
+
293
+ def _reads_intraday(
294
+ graph: Graph, aggregation: int, aggregations: list[bool], masked: dict[int, int], intraday_columns: frozenset,
295
+ ) -> bool:
296
+ """Whether the inputs of ``aggregation`` read an intraday field or hold another aggregation."""
297
+ seen, stack = set(), list(graph.nodes[aggregation].inputs)
298
+ while stack:
299
+ i = stack.pop()
300
+ i = masked.get(i, i)
301
+ if i in seen:
302
+ continue
303
+ seen.add(i)
304
+ node = graph.nodes[i]
305
+ if aggregations[i] or _column(node) in intraday_columns:
306
+ return True
307
+ stack += list(node.inputs) + ([] if node.parent is None else [node.parent])
308
+ return False
309
+
310
+
311
+ def _within_session(graph: Graph) -> Graph:
312
+ """``graph``, an intraday graph, with each window wrapped in ``WithinSession`` by the bars it reaches back.
313
+
314
+ A node reaches back as far as its own window (``BackRef``'s lag, ``w -
315
+ 1`` for a window of ``w`` bars, as KunQuant's ``required_input_window``
316
+ says) and the farthest of its inputs and its loop. A window outside a
317
+ loop body (a time-series operator, or a reduction of a loop's items) is
318
+ wrapped, so it and every node above it are missing until all it reads
319
+ lies within the session; a graph without a window is returned as it is.
320
+ """
321
+ classes = graph.classes or {}
322
+ reach: list[int] = []
323
+ for node in graph.nodes:
324
+ cls = classes.get(node.op)
325
+ own = 0
326
+ if isinstance(cls, type) and issubclass(cls, WindowedTrait):
327
+ own = cls.required_input_window(SimpleNamespace(attrs=dict(node.attrs))) - 1
328
+ under = [reach[j] for j in node.inputs] + ([] if node.parent is None else [reach[node.parent]])
329
+ reach.append(own + max(under, default=0))
330
+
331
+ def windowed(i: int) -> bool:
332
+ node, cls = graph.nodes[i], classes.get(graph.nodes[i].op)
333
+ if node.parent is not None or reach[i] == 0 or (isinstance(cls, type) and issubclass(cls, ForeachBackWindow)):
334
+ return False
335
+ own = reach[i] > max([reach[j] for j in node.inputs], default=0)
336
+ return own or any(graph.nodes[j].parent is not None for j in node.inputs)
337
+
338
+ wrapped = {i for i in range(len(graph.nodes)) if windowed(i)}
339
+ if not wrapped:
340
+ return graph
341
+ # The position's Input first; a graph that reads it too holds it twice, which builds one Input of it.
342
+ out: list[Node] = [Node("Input", (("name", SESSION_POSITION),), (), None)]
343
+ at: dict[int, int] = {}
344
+ for i, node in enumerate(graph.nodes):
345
+ parent = None if node.parent is None else at[node.parent]
346
+ out.append(Node(node.op, node.attrs, tuple(at[j] for j in node.inputs), parent))
347
+ at[i] = len(out) - 1
348
+ if i in wrapped:
349
+ out.append(Node("WithinSession", (("bars", reach[i]),), (at[i], 0), None))
350
+ at[i] = len(out) - 1
351
+ return Graph(tuple(out), {**classes, "WithinSession": WithinSession})
352
+
353
+
354
+ def _rebuilt(graph: Graph, root: int, substitute: Callable[[int], int | Node | None]) -> Graph:
355
+ """The graph of what ``root`` depends on, in node order, with ``substitute``'s replacements.
356
+
357
+ ``substitute(i)`` gives the node to take in node ``i``'s place, a leaf
358
+ node to put there (once, however often it is put) or None to keep it.
359
+ """
360
+ index: dict[int, int] = {}
361
+ leaves: dict[Node, int] = {}
362
+ nodes: list[Node] = []
363
+
364
+ def visit(i: int) -> int:
365
+ if i in index:
366
+ return index[i]
367
+ node, replacement = graph.nodes[i], substitute(i)
368
+ if isinstance(replacement, int):
369
+ index[i] = visit(replacement)
370
+ elif isinstance(replacement, Node):
371
+ if replacement not in leaves:
372
+ nodes.append(replacement)
373
+ leaves[replacement] = len(nodes) - 1
374
+ index[i] = leaves[replacement]
375
+ else:
376
+ parent = visit(node.parent) if node.parent is not None else None
377
+ inputs = tuple(visit(j) for j in node.inputs)
378
+ nodes.append(Node(node.op, node.attrs, inputs, parent))
379
+ index[i] = len(nodes) - 1
380
+ return index[i]
381
+
382
+ visit(root)
383
+ return Graph(tuple(nodes), graph.classes)