第四章:表达式引擎原理
表达式引擎,说白了就是因子挖掘的「翻译官」。
你写一个 close - open,它得知道这是要算实体K线长度。你写 rank(volume),它得能调用排序函数。我做了这么多年量化,见过太多团队在表达式引擎上栽跟头——要么解析太慢,要么自定义函数注册一团乱麻。
今天咱们就把这块硬骨头啃下来。
4.1 表达式解析与求值
表达式引擎的第一步,是把字符串变成计算机能理解的结构。这个过程分两步:词法分析和语法分析。
4.1.1 词法分析
词法分析就是把表达式拆成一个个「词素」。比如 close - open * 2 会被拆成:
close— 标识符-— 运算符open— 标识符*— 运算符2— 数字常量
我个人习惯用递归下降解析器,它写起来直观,调试也方便。你想想看,如果表达式里有括号嵌套,递归下降处理起来就像剥洋葱一样自然。
核心要点:词法分析的结果是一串 Token 流,每个 Token 包含类型和值。
4.1.2 语法分析与抽象语法树
Token 流进来后,语法分析器会构建一棵抽象语法树(AST)。AST 的每个节点代表一个操作或值。
举个例子,close - open * 2 的 AST 长这样:
(-)
/ \
close (*)
/ \
open 2
为什么要建 AST?因为求值的时候,我们需要知道运算优先级。乘法优先级高于减法,所以 AST 里 * 在更深的层级。我在项目中遇到过有人直接用栈做表达式求值,结果优先级处理得一塌糊涂——嗯,后来全改成 AST 了。
4.1.3 求值过程
AST 建好后,求值就是深度优先遍历。从叶子节点开始算,逐层向上。叶子节点要么是数据字段(如 close),要么是常量(如 2)。
伪代码大概是这样:
function evaluate(node, data):
if node 是叶子:
if node 是字段: return data[node.name]
if node 是常量: return node.value
else:
left = evaluate(node.left, data)
right = evaluate(node.right, data)
return apply_operator(node.op, left, right)
避坑指南:我曾经在求值时忽略了空值处理,结果回测时某些股票停牌导致数据缺失,整个表达式直接崩了。后来我加了个 null 传播机制——只要有一个操作数是 null,结果就是 null。
4.2 自定义函数注册
光有加减乘除肯定不够。实际挖掘中,我们需要 rank、ts_sum、correlation 这类复杂函数。这就涉及到自定义函数注册机制。
4.2.1 函数注册表
我一般用一个全局字典来维护函数注册表。键是函数名,值是函数指针或可调用对象。
function_registry = {
'rank': rank_function,
'ts_sum': ts_sum_function,
'corr': correlation_function,
'stddev': stddev_function,
'delay': delay_function
}
注册函数时,需要同时声明参数个数和类型。比如 ts_sum(close, 5) 有两个参数:第一个是序列,第二个是窗口长度。
4.2.2 参数校验
函数注册时,我会强制要求参数校验规则。你想想看,如果用户传了个字符串当窗口长度,那不就炸了?
我习惯这样设计:
- 每个函数注册时附带一个校验器
- 校验器检查参数个数、类型、取值范围
- 校验不通过时抛出明确错误信息
注意:我曾经遇到过一个坑——用户注册了同名函数,后注册的覆盖了前面的。后来我改成注册时检查重名,并给出警告。如果你做的是平台型产品,建议加上命名空间机制。
4.2.3 动态加载
对于大型因子挖掘系统,函数库可能成百上千。全部静态加载太慢。我建议用动态加载——按需加载函数模块。
比如用户用到 correlation 时,才去加载相关系数计算模块。这样启动速度快,内存占用也小。
4.3 性能优化:向量化计算
好了,解析和注册都搞定了。但如果你用 Python 的 for 循环去求值,那速度慢得能让你怀疑人生。为什么?因为 Python 的循环开销太大了。
解决方案就是向量化计算。
4.3.1 什么是向量化
向量化,说白了就是一次操作整个数组,而不是一个个元素去算。NumPy 和 Pandas 就是干这个的。
举个例子,计算 close - open:
- 非向量化:
for i in range(len(close)): result[i] = close[i] - open[i] - 向量化:
result = close - open
向量化版本快了多少?我实测过,对于 100 万行数据,向量化比循环快 100 倍以上。
4.3.2 表达式引擎的向量化实现
在表达式引擎里做向量化,核心思路是:每个节点求值时,返回的不是标量,而是整个序列。
比如 close - open 的 AST 求值:
close节点返回整个收盘价序列(一个数组)open节点返回整个开盘价序列-节点对两个数组做逐元素减法
这样一次求值,就得到了所有时间点的结果。
性能关键:向量化计算要避免中间结果的复制。我习惯用 NumPy 的视图机制,尽量不产生新的数组拷贝。
4.3.3 内存布局优化
做向量化时,内存访问模式很重要。连续内存访问比跳跃式访问快得多。
我建议数据按列存储——每列是一个因子序列。这样在计算 close - open 时,两个数组在内存中是连续的,CPU 缓存命中率极高。
对比一下:
- 按行存储:
[close1, open1, close2, open2, ...]— 访问close时跳着读 - 按列存储:
[close1, close2, ..., open1, open2, ...]— 连续读取
按列存储能快 30%-50%,这个优化我在生产环境验证过多次。
4.3.4 并行计算
如果数据量特别大(比如全市场 5000 只股票,每只 10 年日线),单线程向量化也不够。这时候需要并行。
我常用的策略是:
- 按股票分组,每组独立计算
- 用多进程或线程池并行处理
- 最后合并结果
但要注意,并行不是万能的。如果表达式里有跨股票的操作(比如 rank 需要全市场排序),那就不能简单分组了。这种场景我一般用分块+归并的策略。
个人经验:我曾经优化过一个表达式引擎,从单线程向量化改成 8 线程并行,速度提升了 5 倍。但再往上加线程,收益就递减了——因为内存带宽成了瓶颈。所以并行度不是越高越好,要根据数据量和硬件来调。
4.4 整体架构图
下面这张图展示了表达式引擎的完整工作流程,从字符串输入到最终结果输出:
从图上你能看到,整个流程是线性的:字符串 → Token → AST → 求值 → 结果。右侧的函数注册表为求值引擎提供「弹药」,底部的优化策略确保整个过程跑得快。
嗯,表达式引擎的原理就这些。说白了,核心就是三件事:把表达式变成树、把函数注册好、用向量化算得快。我在多个项目中验证过这套方案,稳定性和性能都经得起考验。
公众号:蓝海数据掘金营,微信deep3321