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,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)
|