codestr 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.
- codestr/__init__.py +15 -0
- codestr/compiler.py +159 -0
- codestr/engine.py +364 -0
- codestr/errors.py +71 -0
- codestr/parser.py +194 -0
- codestr/syntax.py +227 -0
- codestr/tokens.py +33 -0
- codestr/udf/__init__.py +9 -0
- codestr/udf/base_udf.py +389 -0
- codestr/udf/cs_udf.py +166 -0
- codestr/udf/registry.py +118 -0
- codestr/udf/ts_udf.py +98 -0
- codestr-0.1.0.dist-info/METADATA +157 -0
- codestr-0.1.0.dist-info/RECORD +16 -0
- codestr-0.1.0.dist-info/WHEEL +4 -0
- codestr-0.1.0.dist-info/licenses/LICENSE +21 -0
codestr/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .engine import CodeStr as CodeStr
|
|
2
|
+
from .syntax import (
|
|
3
|
+
Call as Call,
|
|
4
|
+
)
|
|
5
|
+
from .syntax import (
|
|
6
|
+
Column as Column,
|
|
7
|
+
)
|
|
8
|
+
from .syntax import (
|
|
9
|
+
ExprNode as ExprNode,
|
|
10
|
+
)
|
|
11
|
+
from .syntax import (
|
|
12
|
+
Literal as Literal,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = ["Call", "CodeStr", "Column", "ExprNode", "Literal"]
|
codestr/compiler.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
from codestr.errors import CompileError
|
|
8
|
+
from codestr.syntax import Call, Column, ExprNode, Literal
|
|
9
|
+
from codestr.udf.registry import UDFRegistry
|
|
10
|
+
|
|
11
|
+
# Canonical window defaults for when the caller does not provide ts_over/cs_over.
|
|
12
|
+
# These match the default time_col="datetime", asset_col="asset" convention.
|
|
13
|
+
# When called through the CodeStr engine, these are ALWAYS overridden by the
|
|
14
|
+
# engine's per-instance _ts_over / _cs_over (derived from time_col/asset_col).
|
|
15
|
+
_TS_DEFAULT_OVER = {"partition_by": ["asset"], "order_by": ["datetime"]}
|
|
16
|
+
_CS_DEFAULT_OVER = {"partition_by": ["datetime"], "order_by": ["asset"]}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compile(
|
|
20
|
+
node: ExprNode,
|
|
21
|
+
registry: UDFRegistry | None = None,
|
|
22
|
+
dims: list[int] | None = None,
|
|
23
|
+
ts_over: dict[str, list[str]] | None = None,
|
|
24
|
+
cs_over: dict[str, list[str]] | None = None,
|
|
25
|
+
) -> pl.Expr:
|
|
26
|
+
"""Compile an AST node to a Polars expression.
|
|
27
|
+
|
|
28
|
+
Pure function — no side effects, no state mutation.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
node: The root AST node to compile.
|
|
32
|
+
registry: UDF registry for function lookup. Uses the global singleton by default.
|
|
33
|
+
dims: Dimension info (e.g. [num_datetimes, num_assets]) for operators that need
|
|
34
|
+
array reshaping context.
|
|
35
|
+
ts_over: Window config for time-series operators
|
|
36
|
+
(``{"partition_by": [...], "order_by": [...]}``).
|
|
37
|
+
cs_over: Window config for cross-section operators.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
A Polars expression with the node's alias applied.
|
|
41
|
+
"""
|
|
42
|
+
if registry is None:
|
|
43
|
+
registry = UDFRegistry.get_instance()
|
|
44
|
+
return _compile(node, registry, dims, ts_over, cs_over).alias(node.alias)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve(
|
|
48
|
+
node: ExprNode,
|
|
49
|
+
registry: UDFRegistry,
|
|
50
|
+
dims: list[int] | None,
|
|
51
|
+
ts_over: dict[str, list[str]] | None = None,
|
|
52
|
+
cs_over: dict[str, list[str]] | None = None,
|
|
53
|
+
) -> pl.Expr | int | float | str:
|
|
54
|
+
"""Compile an AST node to a Polars expression or resolve to a Python scalar.
|
|
55
|
+
|
|
56
|
+
Literal nodes return bare Python values (int/float), Column and Call nodes
|
|
57
|
+
return pl.Expr. This is used so that UDF functions receive appropriate types
|
|
58
|
+
for positional and keyword arguments.
|
|
59
|
+
"""
|
|
60
|
+
if isinstance(node, Column):
|
|
61
|
+
return pl.col(node.name)
|
|
62
|
+
if isinstance(node, Literal):
|
|
63
|
+
return node.value
|
|
64
|
+
if isinstance(node, Call):
|
|
65
|
+
return _compile(node, registry, dims, ts_over, cs_over)
|
|
66
|
+
raise TypeError(f"Unknown node type: {type(node)}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _compile(
|
|
70
|
+
node: ExprNode,
|
|
71
|
+
registry: UDFRegistry,
|
|
72
|
+
dims: list[int] | None = None,
|
|
73
|
+
ts_over: dict[str, list[str]] | None = None,
|
|
74
|
+
cs_over: dict[str, list[str]] | None = None,
|
|
75
|
+
) -> pl.Expr:
|
|
76
|
+
"""Recursively compile an AST node to a Polars expression.
|
|
77
|
+
|
|
78
|
+
Column → pl.col, Literal → pl.lit, Call → UDF invocation.
|
|
79
|
+
|
|
80
|
+
Engine context is injected automatically by inspecting the UDF signature:
|
|
81
|
+
* ``dims`` — injected if the function accepts it and dims is available.
|
|
82
|
+
* ``partition_by`` / ``order_by`` — injected based on the UDF category
|
|
83
|
+
(``"ts"`` → ts_over, ``"cs"`` → cs_over), only if the function
|
|
84
|
+
signature includes those parameter names.
|
|
85
|
+
"""
|
|
86
|
+
from toolz import partial
|
|
87
|
+
|
|
88
|
+
if isinstance(node, Column):
|
|
89
|
+
return pl.col(node.name)
|
|
90
|
+
|
|
91
|
+
if isinstance(node, Literal):
|
|
92
|
+
return pl.lit(node.value)
|
|
93
|
+
|
|
94
|
+
if isinstance(node, Call):
|
|
95
|
+
if node.fn_name not in registry:
|
|
96
|
+
raise CompileError(f"Unknown function: {node.fn_name}")
|
|
97
|
+
|
|
98
|
+
meta = registry[node.fn_name]
|
|
99
|
+
func = meta.fn
|
|
100
|
+
|
|
101
|
+
args: list = []
|
|
102
|
+
kwargs: dict = {}
|
|
103
|
+
|
|
104
|
+
sig_params = list(inspect.signature(func).parameters.keys())
|
|
105
|
+
|
|
106
|
+
# Inject dims if the operator accepts it
|
|
107
|
+
if "dims" in sig_params and dims is not None:
|
|
108
|
+
func = partial(func, dims=dims)
|
|
109
|
+
|
|
110
|
+
# Inject over config based on UDF category
|
|
111
|
+
_inject_over(meta.category, sig_params, ts_over, cs_over, kwargs)
|
|
112
|
+
|
|
113
|
+
for arg in node.args:
|
|
114
|
+
if isinstance(arg, dict):
|
|
115
|
+
for k, v in arg.items():
|
|
116
|
+
kwargs[k] = (
|
|
117
|
+
_resolve(v, registry, dims, ts_over, cs_over)
|
|
118
|
+
if isinstance(v, ExprNode)
|
|
119
|
+
else v
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
args.append(_resolve(arg, registry, dims, ts_over, cs_over))
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
return func(*args, **kwargs)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
raise CompileError(
|
|
128
|
+
f"{node.fn_name}({', '.join(str(a) for a in node.args)})\n{e}"
|
|
129
|
+
) from e
|
|
130
|
+
|
|
131
|
+
raise TypeError(f"Unknown node type: {type(node)}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _inject_over(
|
|
135
|
+
category: str,
|
|
136
|
+
sig_params: list[str],
|
|
137
|
+
ts_over: dict[str, list[str]] | None,
|
|
138
|
+
cs_over: dict[str, list[str]] | None,
|
|
139
|
+
kwargs: dict,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Inject partition_by/order_by into kwargs based on operator category.
|
|
142
|
+
|
|
143
|
+
TS/CS operators ALWAYS receive window config. The priority is:
|
|
144
|
+
1. Caller-provided ts_over/cs_over (from the engine)
|
|
145
|
+
2. Module-level canonical defaults (_TS_DEFAULT_OVER / _CS_DEFAULT_OVER)
|
|
146
|
+
|
|
147
|
+
Math/user operators have no window concept and are skipped.
|
|
148
|
+
"""
|
|
149
|
+
if category == "ts":
|
|
150
|
+
over_config = ts_over if ts_over is not None else _TS_DEFAULT_OVER
|
|
151
|
+
elif category == "cs":
|
|
152
|
+
over_config = cs_over if cs_over is not None else _CS_DEFAULT_OVER
|
|
153
|
+
else:
|
|
154
|
+
return # "math" and "user" have no window config
|
|
155
|
+
|
|
156
|
+
if "partition_by" in sig_params:
|
|
157
|
+
kwargs["partition_by"] = over_config["partition_by"]
|
|
158
|
+
if "order_by" in sig_params:
|
|
159
|
+
kwargs["order_by"] = over_config["order_by"]
|
codestr/engine.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
from loguru import logger
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
|
|
11
|
+
from codestr.compiler import compile as _pure_compile
|
|
12
|
+
from codestr.errors import CompileError, FailError, PolarsError
|
|
13
|
+
from codestr.parser import parse as _parse
|
|
14
|
+
from codestr.syntax import (
|
|
15
|
+
Call,
|
|
16
|
+
)
|
|
17
|
+
from codestr.syntax import (
|
|
18
|
+
depth as _depth,
|
|
19
|
+
)
|
|
20
|
+
from codestr.syntax import (
|
|
21
|
+
node_count as _node_count,
|
|
22
|
+
)
|
|
23
|
+
from codestr.syntax import (
|
|
24
|
+
to_rpn as _to_rpn,
|
|
25
|
+
)
|
|
26
|
+
from codestr.udf.registry import UDFRegistry
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CodeStr:
|
|
30
|
+
"""Expression compute engine with DSL → Polars translation.
|
|
31
|
+
|
|
32
|
+
State invariants
|
|
33
|
+
----------------
|
|
34
|
+
- ``data`` : the most recently materialised DataFrame. Only ``None`` before
|
|
35
|
+
the first ``sql()`` call on a ``pure_lazy`` engine.
|
|
36
|
+
- ``_data_`` : lazy compute graph that accumulates ``with_columns`` during
|
|
37
|
+
a single ``sql()`` call. Reset to ``None`` on ``clear_cache()``.
|
|
38
|
+
- ``_last_query_cache`` : result of the last eager ``sql()``, used to merge
|
|
39
|
+
new columns back into ``data`` on the next call.
|
|
40
|
+
- ``_expr_cache`` : persistent cross-query cache (ExprNode → alias).
|
|
41
|
+
- ``_cur_expr_cache`` : per-query cache, merged into ``_expr_cache`` after
|
|
42
|
+
a successful eager ``sql()``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
data: pl.LazyFrame | pl.DataFrame,
|
|
48
|
+
index: tuple[str, str] = ("datetime", "asset"),
|
|
49
|
+
partition_by: list[str] | None = None,
|
|
50
|
+
order_by: list[str] | None = None,
|
|
51
|
+
align: bool = True,
|
|
52
|
+
pure_lazy: bool = False,
|
|
53
|
+
):
|
|
54
|
+
"""Initialize the CodeStr engine.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data: Input Polars DataFrame or LazyFrame.
|
|
58
|
+
index: A 2-tuple ``(time_col, entity_col)`` used for panel alignment
|
|
59
|
+
and result column selection.
|
|
60
|
+
partition_by: Entity-axis columns for window grouping.
|
|
61
|
+
Defaults to ``[index[1]]`` (e.g. ``["asset"]``).
|
|
62
|
+
order_by: Time-axis columns for window ordering.
|
|
63
|
+
Defaults to ``[index[0]]`` (e.g. ``["datetime"]``).
|
|
64
|
+
align: If True (default), perform cross-join alignment to fill
|
|
65
|
+
missing index combinations with nulls.
|
|
66
|
+
pure_lazy: If True, never materialize data — keep everything as
|
|
67
|
+
LazyFrame. ``sql()`` calls will not update ``self.data``.
|
|
68
|
+
|
|
69
|
+
Window Semantics
|
|
70
|
+
----------------
|
|
71
|
+
* **TS (time-series)** — ``over(partition_by=partition_by, order_by=order_by)``.
|
|
72
|
+
Each entity's rolling window runs independently along the time axis.
|
|
73
|
+
* **CS (cross-section)** — ``over(partition_by=order_by, order_by=partition_by)``.
|
|
74
|
+
At each time slice, operators compute across all entities.
|
|
75
|
+
"""
|
|
76
|
+
assert isinstance(data, (pl.LazyFrame, pl.DataFrame)), (
|
|
77
|
+
"data 必须是 polars DataFrame 或 LazyFrame"
|
|
78
|
+
)
|
|
79
|
+
self.failed: list = []
|
|
80
|
+
self._expr_cache: dict = {}
|
|
81
|
+
self._cur_expr_cache: dict = {}
|
|
82
|
+
|
|
83
|
+
self.data: pl.DataFrame | None = None
|
|
84
|
+
self.index: tuple[str, str] = index
|
|
85
|
+
self._data_: pl.LazyFrame | None = None
|
|
86
|
+
self._last_query_cache: pl.DataFrame | None = None
|
|
87
|
+
|
|
88
|
+
# Over-window config: user provides partition / order columns explicitly
|
|
89
|
+
_partition = partition_by if partition_by is not None else [self.index[1]]
|
|
90
|
+
_order = order_by if order_by is not None else [self.index[0]]
|
|
91
|
+
|
|
92
|
+
self._ts_over = {
|
|
93
|
+
"partition_by": _partition, # entity columns
|
|
94
|
+
"order_by": _order, # time columns
|
|
95
|
+
}
|
|
96
|
+
self._cs_over = {
|
|
97
|
+
"partition_by": _order, # time columns (swapped)
|
|
98
|
+
"order_by": _partition, # entity columns (swapped)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if pure_lazy:
|
|
102
|
+
self._data_ = data
|
|
103
|
+
else:
|
|
104
|
+
self.data = data.with_columns(pl.col(pl.Decimal).cast(float))
|
|
105
|
+
if isinstance(self.data, pl.LazyFrame):
|
|
106
|
+
self.data = self.data.collect()
|
|
107
|
+
|
|
108
|
+
if align:
|
|
109
|
+
self.align()
|
|
110
|
+
|
|
111
|
+
def align(self, on=None):
|
|
112
|
+
"""数据对齐
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
on: 2-tuple of columns to align on. Defaults to ``self.index``.
|
|
116
|
+
"""
|
|
117
|
+
if on is None:
|
|
118
|
+
on = (self.index[0], self.index[1])
|
|
119
|
+
lev_vals: list[pl.DataFrame] = [self.data.select(name).drop_nulls().unique() for name in on]
|
|
120
|
+
full_index = lev_vals[0].unique()
|
|
121
|
+
for lev_val in lev_vals[1:]:
|
|
122
|
+
full_index = full_index.join(lev_val.unique(), how="cross")
|
|
123
|
+
self.data = full_index.join(self.data, on=on, how="left").sort(self.index)
|
|
124
|
+
|
|
125
|
+
self.dims = [self.data[name].drop_nulls().n_unique() for name in on]
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def cache_columns(self) -> list[str]:
|
|
129
|
+
"""Currently available column names (from materialised data or lazy graph)."""
|
|
130
|
+
if self.data is not None:
|
|
131
|
+
return self.data.columns
|
|
132
|
+
if self._data_ is not None:
|
|
133
|
+
return self._data_.collect_schema().names()
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
def __str__(self):
|
|
137
|
+
return str(self.data)
|
|
138
|
+
|
|
139
|
+
def __repr__(self):
|
|
140
|
+
return str(self.data)
|
|
141
|
+
|
|
142
|
+
def register_udf(self, func: Callable, name: str | None = None):
|
|
143
|
+
"""Register a user-defined function into the UDF registry."""
|
|
144
|
+
from codestr.udf.registry import UDFMeta, UDFRegistry
|
|
145
|
+
|
|
146
|
+
UDFRegistry.get_instance().register(
|
|
147
|
+
UDFMeta(
|
|
148
|
+
name=name if name is not None else func.__name__,
|
|
149
|
+
fn=func,
|
|
150
|
+
category="user",
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def check_expr(
|
|
155
|
+
self,
|
|
156
|
+
expr: str,
|
|
157
|
+
max_depth: int | None = None,
|
|
158
|
+
max_nodes: int | None = None,
|
|
159
|
+
check_rpn: bool = True,
|
|
160
|
+
check_redundant: bool = True,
|
|
161
|
+
):
|
|
162
|
+
"""Validate an expression string before execution.
|
|
163
|
+
|
|
164
|
+
Does NOT execute the expression — only parses and runs structural checks.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
expr: The DSL expression string to validate.
|
|
168
|
+
max_depth: If set, reject expressions exceeding this AST depth.
|
|
169
|
+
max_nodes: If set, reject expressions exceeding this node count.
|
|
170
|
+
check_rpn: Validate reverse Polish notation stack balance.
|
|
171
|
+
check_redundant: Detect redundant sub-expressions (e.g. ``a - a``).
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
A dict with keys ``valid`` (bool), ``reasons`` (list[str]), and
|
|
175
|
+
``expr`` (str representation of the parsed node, or None on error).
|
|
176
|
+
"""
|
|
177
|
+
result = {"valid": True, "reasons": [], "expr": None}
|
|
178
|
+
try:
|
|
179
|
+
node = _parse(expr)
|
|
180
|
+
result["expr"] = str(node)
|
|
181
|
+
if max_depth is not None and _depth(node) > max_depth:
|
|
182
|
+
result["reasons"].append(f"max_depth:{_depth(node)}")
|
|
183
|
+
if max_nodes is not None and _node_count(node) > max_nodes:
|
|
184
|
+
result["reasons"].append(f"max_nodes:{_node_count(node)}")
|
|
185
|
+
if check_redundant:
|
|
186
|
+
self._check_redundant(node, result["reasons"])
|
|
187
|
+
if check_rpn:
|
|
188
|
+
self._check_rpn(_to_rpn(node), result["reasons"])
|
|
189
|
+
except Exception as e:
|
|
190
|
+
result["reasons"].append(str(e))
|
|
191
|
+
result["valid"] = len(result["reasons"]) == 0
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
def _check_redundant(self, node: Call, reasons: list[str]):
|
|
195
|
+
if not isinstance(node, Call):
|
|
196
|
+
return
|
|
197
|
+
if node.fn_name in ("sub", "div") and len(node.args) == 2:
|
|
198
|
+
left, right = node.args
|
|
199
|
+
if str(left) == str(right):
|
|
200
|
+
reasons.append(f"redundant:{node.fn_name}")
|
|
201
|
+
for arg in node.args:
|
|
202
|
+
if isinstance(arg, Call):
|
|
203
|
+
self._check_redundant(arg, reasons)
|
|
204
|
+
|
|
205
|
+
def _check_rpn(self, rpn, reasons: list[str]):
|
|
206
|
+
from codestr.tokens import TokenType
|
|
207
|
+
|
|
208
|
+
stack = 0
|
|
209
|
+
for token in rpn:
|
|
210
|
+
if token.type in (
|
|
211
|
+
TokenType.FEATURE,
|
|
212
|
+
TokenType.CONSTANT,
|
|
213
|
+
TokenType.WINDOW,
|
|
214
|
+
TokenType.BINS,
|
|
215
|
+
TokenType.PARAM,
|
|
216
|
+
):
|
|
217
|
+
stack += 1
|
|
218
|
+
elif token.type == TokenType.OPERATOR:
|
|
219
|
+
args_num = token.arity
|
|
220
|
+
if stack < args_num:
|
|
221
|
+
reasons.append(f"rpn_args:{token.value}")
|
|
222
|
+
return
|
|
223
|
+
stack = stack - args_num + 1
|
|
224
|
+
if stack != 1:
|
|
225
|
+
reasons.append("rpn_invalid")
|
|
226
|
+
|
|
227
|
+
def compile(self, expr: str) -> pl.Expr:
|
|
228
|
+
"""Purely compile an expression string to a Polars Expression.
|
|
229
|
+
|
|
230
|
+
No side effects. The returned expression is bound to the column names
|
|
231
|
+
and over-window config of this CodeStr instance.
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
node = _parse(expr)
|
|
235
|
+
return _pure_compile(
|
|
236
|
+
node,
|
|
237
|
+
registry=UDFRegistry.get_instance(),
|
|
238
|
+
dims=getattr(self, "dims", None),
|
|
239
|
+
ts_over=self._ts_over,
|
|
240
|
+
cs_over=self._cs_over,
|
|
241
|
+
)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
raise CompileError(f"Pure compilation failed for {expr}: {e}") from e
|
|
244
|
+
|
|
245
|
+
def _compile_expr(self, expr: str, cover: bool):
|
|
246
|
+
"""str表达式 -> polars 表达式"""
|
|
247
|
+
if self._data_ is None:
|
|
248
|
+
self._data_ = self.data.lazy()
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
node = _parse(expr)
|
|
252
|
+
alias = node.alias
|
|
253
|
+
current_cols = set(self.cache_columns)
|
|
254
|
+
|
|
255
|
+
if alias in current_cols and not cover:
|
|
256
|
+
return pl.col(alias), alias
|
|
257
|
+
if node in self._expr_cache and not cover:
|
|
258
|
+
expr_pl = pl.col(self._expr_cache[node]).alias(alias)
|
|
259
|
+
self._data_ = self._data_.with_columns(expr_pl)
|
|
260
|
+
return pl.col(alias), alias
|
|
261
|
+
if node in self._cur_expr_cache and not cover:
|
|
262
|
+
expr_pl = pl.col(self._cur_expr_cache[node]).alias(alias)
|
|
263
|
+
self._data_ = self._data_.with_columns(expr_pl)
|
|
264
|
+
return pl.col(alias), alias
|
|
265
|
+
|
|
266
|
+
expr_pl = _pure_compile(
|
|
267
|
+
node,
|
|
268
|
+
registry=UDFRegistry.get_instance(),
|
|
269
|
+
dims=getattr(self, "dims", None),
|
|
270
|
+
ts_over=self._ts_over,
|
|
271
|
+
cs_over=self._cs_over,
|
|
272
|
+
)
|
|
273
|
+
self._data_ = self._data_.with_columns(expr_pl.alias(alias))
|
|
274
|
+
self._cur_expr_cache[node] = alias
|
|
275
|
+
return pl.col(alias), alias
|
|
276
|
+
|
|
277
|
+
except Exception as e:
|
|
278
|
+
raise CompileError(message=f"[表达式]: {expr}\n[编译器外层]\n{e}") from e
|
|
279
|
+
|
|
280
|
+
def sql(
|
|
281
|
+
self,
|
|
282
|
+
*exprs: str,
|
|
283
|
+
cover: bool = False,
|
|
284
|
+
lazy: bool = False,
|
|
285
|
+
) -> pl.LazyFrame | pl.DataFrame:
|
|
286
|
+
"""Execute one or more DSL expressions interactively.
|
|
287
|
+
|
|
288
|
+
This is the **stateful** API — results are cached in the engine and
|
|
289
|
+
may be reused across subsequent ``sql()`` calls.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
exprs: DSL expression strings, e.g. ``"ts_mean(close, 5) as ma5"``.
|
|
293
|
+
cover: If True, recompute even if the alias already exists.
|
|
294
|
+
If False (default), skip computation on cache hits.
|
|
295
|
+
lazy: If True, return a ``pl.LazyFrame`` without materializing.
|
|
296
|
+
If False (default), collect and return a ``pl.DataFrame``.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
A DataFrame or LazyFrame containing the index columns and all
|
|
300
|
+
requested expression aliases.
|
|
301
|
+
"""
|
|
302
|
+
self.failed = list()
|
|
303
|
+
exprs_to_add = list()
|
|
304
|
+
exprs_select = list()
|
|
305
|
+
self._cur_expr_cache = {}
|
|
306
|
+
|
|
307
|
+
# Snapshot _data_ so we can roll back on lazy-return or failure
|
|
308
|
+
_data_saved = self._data_
|
|
309
|
+
|
|
310
|
+
if self._last_query_cache is not None:
|
|
311
|
+
if self.data is None:
|
|
312
|
+
self.data = self._last_query_cache
|
|
313
|
+
else:
|
|
314
|
+
self.data = self.data.with_columns(
|
|
315
|
+
self._last_query_cache.select(
|
|
316
|
+
*[i for i in self._last_query_cache.columns if i not in self.data.columns]
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
for expr in exprs:
|
|
321
|
+
try:
|
|
322
|
+
compiled, alias = self._compile_expr(expr, cover)
|
|
323
|
+
if compiled is not None:
|
|
324
|
+
exprs_to_add.append(compiled)
|
|
325
|
+
exprs_select.append(alias)
|
|
326
|
+
except Exception as e:
|
|
327
|
+
self.failed.append(FailError(expr, e))
|
|
328
|
+
if self.failed:
|
|
329
|
+
logger.warning(f"CodeStr.sql 失败:{len(self.failed)}/{len(exprs)}: \n {self.failed}")
|
|
330
|
+
if self._data_ is None:
|
|
331
|
+
self._data_ = self.data.lazy()
|
|
332
|
+
self._data_ = self._data_.with_columns(*exprs_to_add)
|
|
333
|
+
|
|
334
|
+
if lazy:
|
|
335
|
+
self._expr_cache.update(self._cur_expr_cache)
|
|
336
|
+
result = self._data_.select(*self.index, *exprs_select)
|
|
337
|
+
self._data_ = _data_saved # roll back: don't accumulate in lazy mode
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
current_cols = set(self._data_.collect_schema().names())
|
|
341
|
+
new_expr_cache = dict()
|
|
342
|
+
try:
|
|
343
|
+
self._last_query_cache = self._data_.select(*self.index, *exprs_select).collect()
|
|
344
|
+
self._expr_cache.update(self._cur_expr_cache)
|
|
345
|
+
for k, v in self._expr_cache.items():
|
|
346
|
+
if v in current_cols:
|
|
347
|
+
new_expr_cache[k] = v
|
|
348
|
+
self._expr_cache = new_expr_cache
|
|
349
|
+
|
|
350
|
+
return self._last_query_cache
|
|
351
|
+
except Exception as e:
|
|
352
|
+
for k, v in self._expr_cache.items():
|
|
353
|
+
if v in current_cols:
|
|
354
|
+
new_expr_cache[k] = v
|
|
355
|
+
self._expr_cache = new_expr_cache
|
|
356
|
+
self._data_ = _data_saved # roll back failed with_columns
|
|
357
|
+
raise PolarsError(message=f"LazyFrame.collect() 阶段出错\n{e}") from e
|
|
358
|
+
|
|
359
|
+
def clear_cache(self):
|
|
360
|
+
"""清除缓存,重置计算图"""
|
|
361
|
+
self._data_ = None
|
|
362
|
+
self._expr_cache = {}
|
|
363
|
+
self._cur_expr_cache = {}
|
|
364
|
+
self._last_query_cache = None
|
codestr/errors.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class ParseError(Exception):
|
|
6
|
+
"""解析错误"""
|
|
7
|
+
|
|
8
|
+
message: str
|
|
9
|
+
|
|
10
|
+
def __str__(self):
|
|
11
|
+
return self.message
|
|
12
|
+
|
|
13
|
+
def __repr__(self):
|
|
14
|
+
return self.__str__()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class CalculateError(Exception):
|
|
19
|
+
"""计算错误"""
|
|
20
|
+
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
def __str__(self):
|
|
24
|
+
return self.message
|
|
25
|
+
|
|
26
|
+
def __repr__(self):
|
|
27
|
+
return self.__str__()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class CompileError(Exception):
|
|
32
|
+
"""编译错误"""
|
|
33
|
+
|
|
34
|
+
message: str
|
|
35
|
+
|
|
36
|
+
def __str__(self):
|
|
37
|
+
return self.message
|
|
38
|
+
|
|
39
|
+
def __repr__(self):
|
|
40
|
+
return self.__str__()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class PolarsError(Exception):
|
|
45
|
+
"""Polars 引擎错误"""
|
|
46
|
+
|
|
47
|
+
message: str
|
|
48
|
+
|
|
49
|
+
def __str__(self):
|
|
50
|
+
return self.message
|
|
51
|
+
|
|
52
|
+
def __repr__(self):
|
|
53
|
+
return self.__str__()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class FailError:
|
|
58
|
+
"""失败错误信息容器"""
|
|
59
|
+
|
|
60
|
+
expr: str
|
|
61
|
+
error: Exception
|
|
62
|
+
|
|
63
|
+
def __str__(self):
|
|
64
|
+
return f"""
|
|
65
|
+
[失败表达式]: {self.expr}
|
|
66
|
+
[错误类型]: {self.error.__class__.__name__}
|
|
67
|
+
[错误信息]: \n{self.error}
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __repr__(self):
|
|
71
|
+
return self.__str__()
|