公式化因子:一句表达式就是一个因子,代价是你能写出几万个
你脑子里冒出一个挺朴素的想法:最近五天涨得猛的股票,接下来一段时间会不会继续猛。
想验证它,第一步不是写策略,也不是画图。第一步是把这句人话变成一个数——一个能对着三千只股票、三千个交易日同时算出来的数。一只股票你在表格里手工算得出来,三千只就不行了。
而且你很快会发现,你真正想问的不是一个问题,是一族问题:五天还是二十天?涨幅要不要除以价格才好横向比?要不要先剔掉整个市场的涨跌?每换一个说法,就是一个新的公式。这时候你需要的已经不是「算一个因子」的能力,而是「一口气造一批因子」的能力。
公式化因子这套东西,就是为了回答这个需求出现的。
先说人话:它是一台公式生成器
先把「因子」这个词落地:所谓因子,就是把「为什么会涨」这种模糊直觉,压缩成一个每天每只股票都能算出来的数字,然后拿这个数字去排序、去比较。这个概念本身在 因子:把「为什么涨」变成一个可计算的数 里讲得更细,这篇只管一件事——这个数是怎么被批量造出来的。
想象你在表格软件里干活。A 列是每天的收盘价,你在 B 列写下 =A3/A2-1,鼠标往下一拖,整列的日涨幅就出来了。你写的是一个公式,得到的是一整列结果。这已经是公式化的雏形了。
但投资里的表格不是二维的,是三维的:几千只股票 × 几千个交易日 × 十几个字段(开盘、最高、最低、收盘、成交量……)。你没法在一张平面表格里拖鼠标。更麻烦的是,你想试的不是一个公式,是几百个长得差不多、只有参数不同的公式。
于是有人做了这么一件事:发明一小套专门写这类公式的语言,让「一个因子」正好等于「一个字符串」。
字符串这个选择是整件事的关键。字符串可以被程序拼出来,可以塞进配置文件,可以存进数据库当缓存的键,可以被循环成千上万次。你从「手写一个因子」变成了「写一个会写因子的程序」——就像从自己按菜谱做菜,变成了教会一台机器读菜谱。菜谱写得再多,成本也只是多敲几行字。
这就是公式化因子(formulaic alpha):能被一条数学表达式完整表示的因子。qlib 的文档里对它的说法很简洁——顾名思义,就是能写成一个公式的那类 alpha。
投资层:它改变的是成本结构,而成本结构会改变你
对你的决策来说,这套东西真正的影响不在「算得更准」,而在「试错变得极其便宜」。
以前你想验证一个想法,要写取数、对齐日期、处理停牌、算指标、跑回测,一整天没了。所以你会挑着试——只试那些你真心相信的想法。现在同一件事变成改一行配置,一分钟出结果。
问题就在这儿。当试错成本从一天降到一分钟,你不会只是「试得更快」,你会开始试那些你根本不相信的东西。 反正也不花钱,多试几个呗。而这个行为上的转变,恰恰是量化研究里最危险的那条路的起点——试得越多,你越必然能从纯粹的随机里捞出一个看起来很漂亮的东西。这一层后面单独展开。
先看它在真实系统里长什么样,看完再回来谈风险,你才会明白为什么这个风险几乎是必然的。
系统层:一个字符串是怎么变成一列数的
qlib 的文档里给了一个具体例子。MACD 这个大家眼熟的指标,写成表达式是这样(取自其文档快照):
MACD_EXP = '2 * ((EMA($close, 12) - EMA($close, 26))/$close - EMA((EMA($close, 12) - EMA($close, 26))/$close, 9))'
一眼看过去有三种成分:$close 这样的字段引用,EMA(...) 这样的算子(对一列数据做某种运算的函数),以及加减乘除括号这些普通算术。就这三样,拼起来能表达的东西比你想的多得多。
字符串怎么变成计算
这一步很朴素,朴素到有点好笑。qlib 用三条正则替换,把你写的字符串改写成合法的 Python 代码: $$ 开头的换成时点字段对象,$ 开头的换成普通字段对象,凡是「名字后面跟着左括号」的一律在前面补上算子表的前缀。改写完,直接交给 Python 求值:
expression = eval(parse_field(field))
所以 $close 会变成 Feature("close"),EMA(...) 会变成 Operators.EMA(...)。最终你得到的不是一个数,是一棵对象树——最底下是字段,往上一层层套着算子。真正取数据、算结果,是这棵树被要求「加载某只股票某段时间」的时候才发生的。
这个设计的好处很直接:表达式是字符串,就能被拼接、被配置、被当作缓存的键复用。坏处也很直接:你把算子名字拼错了,语法检查阶段什么都不会说,要等到求值那一刻才炸出来。
算子分成四个家族
翻一遍算子清单,会看到它们大致分成四类,理解了这个分类,你基本就能自己读懂任何一条表达式:
| 家族 | 干什么 | 例子 |
|---|---|---|
| 逐元素 | 对每个数各算各的 | 绝对值、取符号、对数、逻辑非 |
| 成对 | 两列数逐位对齐算 | 加减乘除、乘方、取较大/较小、各种比较 |
| 滚动 | 在一个时间窗口里算 | 均值、标准差、最大最小、排名、斜率、拟合优度 |
| 成对滚动 | 两列数在同一窗口里算 | 相关系数、协方差 |
有意思的是,Ref——也就是「取 N 期之前的值」——在源码里是滚动算子的子类。往回看几天这件事,本质上和「在一个窗口里算点什么」是同一类操作,只不过窗口的结果就是端点那一个值。
负数的 Ref 就是未来,而这不是 bug
Ref 的文档写得毫不含糊:N 大于零取 N 期之前的数据,N 小于零取未来的数据。
一个专门用来读未来的算子,摆在一个号称严防前视的框架里,看着挺矛盾。但看一眼 qlib 默认的标签配置就明白了:
def get_label_config(self):
return ["Ref($close, -2)/Ref($close, -1) - 1"], ["LABEL0"]
这是标签,不是特征。所谓标签,就是你希望模型学会预测的那个东西——在这里是「未来的收益率」。要训练模型,你必须把正确答案喂给它,而正确答案天然就在未来。所以这里读未来是完全正当的。
顺带看一眼它读的是哪一段:分子是两天后的收盘价,分母是一天后的收盘价。它算的不是「今天到明天」,而是「明天到后天」,中间明确空出了一天。这个位移是可配置的——同一份源码里就有另一个版本,把收盘价换成了成交均价。你自己接手的时候,第一件该确认的事就是这条标签到底在预测哪一段收益,因为它直接决定了模型学到的是什么。
真正的红线在于:负数的 Ref 绝不能出现在特征列里。 特征是模型做决策时能看的东西,里面混进一个未来值,整套结果就废了,而且废得非常好看——历史成绩会漂亮得让你不忍心怀疑。这类错误怎么发生、为什么极难自查,前视偏差 那篇讲得很透;标签和特征各自的职责边界,则属于 特征与标签 的地盘。
系统自己知道要多取多少历史
这是表达式树带来的一个漂亮红利。
每个算子都会回答两个问题:我一共要往回看多远?我的输入需要向左右各多延伸几格?滚动算子的答案是「我的输入需要多少,再加上我的窗口长度减一」;Ref 的答案是「我的输入需要多少,再加上 N」,而且当 N 是负数时,它会往右边多要几格。
这些需求逐层向上冒泡,一条嵌套三层的表达式,总共该多取多少历史数据,是自动算出来的,不用你操心。这正是 指标启动长度 那篇讲的问题在工程上的解法。
但这里藏着一个坑,值得单独拎出来。滚动计算用的最小周期数被设成了 1,也就是窗口还没填满也照样给你一个值。源码注释里说得很坦白:空值检查被移除了,现在由使用者自己决定要不要用前面那几天的数据。
后果是:一条二十日均值的表达式,第一行给你的是一天的均值,第二行是两天的均值……它们和第二十行之后的数字长得一模一样,都是正常的浮点数,没有任何标记。你如果不主动裁掉开头那一段,等于在用一批「半成品」参与训练和检验。
还有个更极端的特例:窗口设成 0 时,滚动会退化成「用到目前为止的全部历史」,源码里对此直接打了警告,说这种情况算不准。
批量:几百个因子,只要几十行循环
前面铺垫的所有东西,都是为了这一步。
qlib 里有一套按配置生成因子的工具。你给它一个配置字典,它按块吐出表达式列表和对应的名字。配置分成几块,各管一摊:
- K 线形态块:九条硬编码的表达式,把一根 K 线的几何形状量化。比如
($close-$open)/$open是实体相对开盘价的比例,($high-$low)/$open是整根 K 线的相对长度,(2*$close-$high-$low)/$open描述收盘价在高低区间里的偏移位置。这些不是玄学,是把「长上影」「光头阳线」这类看图形容词翻译成了数。 - 价格块与成交量块:把 N 天前的价格除以今天的收盘价,得到一批无量纲的相对值。除以当前值这个动作很关键——它让不同价位的股票能放在一起比。
- 滚动块:一个双重循环,算子清单 × 窗口清单。默认窗口是 5、10、20、30、60 这几档(当前默认值,配置里可改),另外提供了 include 和 exclude 两个开关,决定哪些算子参与。
还有一套更直白的:六个字段(开、高、低、收、成交均价、成交量)各取过去六十个交易日的滞后值,全部除以最新的收盘价或成交量做归一化,凑成三百六十列。源码就是一串 for i in range(59, 0, -1) 的循环,几十行写完。
这就是「表达式即字符串」的全部意义所在。 造三百六十个因子和造三个因子,工程上的差别只是循环次数。你甚至可以让程序去穷举算子和窗口的组合,一口气生成上万条表达式——这条路是通的,而且技术上毫无障碍。
如果内置算子不够用,还能自己写一个类注册进算子表。注册时如果名字和内置的撞了,系统会打一条警告告诉你默认定义被覆盖了——这个提醒很有必要,不然你以为在用标准的均值,实际用的是你三个月前改过的版本。
对照:freqtrade 压根没搞这一套
值得拿另一种设计做个对照,才能看清表达式语言到底适合什么场合。
freqtrade 的策略里没有表达式语言。你在一个叫 populate_indicators 的方法里,直接写 pandas 和技术指标库的代码,往数据表上加列。想算什么就写什么,完全是普通 Python。它的示例策略文件里甚至专门提醒了一句:出于性能考虑,指标别加太多,用不上的就注释掉,否则白白浪费内存和 CPU。
两种设计的分歧不在技术品味,在面对的问题不一样:
- freqtrade 主要处理单个交易对的时间序列,策略数量有限,但单个策略的逻辑可能很曲折——什么时候进、什么时候加仓、遇到什么情况提前撤。这种「少而深」的需求,用普通代码写最舒服,硬套一套表达式语言反而处处受限。
- qlib 面对的是横截面——同一天里几千只股票放在一起排序比较。单个因子往往很简单,但数量极大。这种「多而浅」的需求,字符串化的表达式才是对的抽象。
所以你看到哪种写法,基本就能反推出这个系统想干什么。反过来说,如果你的想法只有三五个但每个都很复杂,硬要塞进表达式语言里,是自找麻烦。
便宜的代价:造得越多,假的越多
现在回到前面留下的那个尾巴。
一个纯粹随机、毫无道理的因子,在某一段特定历史上碰巧表现不错的概率,不是零。这不是运气问题,是数学问题。你试五个,可能一个都不像样;你试五千个,几乎必然会有一批看起来相当漂亮。
问题在于,从五千个里挑出来的那个「最好的」,它好在哪里?它未必是最有道理的那个,但它一定是最会讨好这段历史的那个。历史只有一份,你在同一份数据上翻来覆去地筛选,筛出来的就是最贴合这份数据噪音的形状。这件事有个专门的名字,机制和识别办法在 数据窥探 那篇里。
表达式语言在这里扮演的角色很微妙:它本身没有错,它只是把试错的边际成本压到了几乎为零,于是让这个陷阱变得极其容易掉进去。 以前你受体力限制,一天试五个;现在你一晚上能试五万个。搜索空间大了一万倍,撞见假信号的概率也大了一万倍,而你的判断力一点没变。
还有两层更隐蔽的:
第一层,模板生成的因子彼此高度相关。 同一个量的五天版、十天版、二十天版,说的其实是同一件事,只是清晰度不同。一百多个因子摆在你面前,看着像一百多份独立的证据,实际独立信息远少于这个数。你以为的「多个因子互相印证」,很可能只是同一个观点被复述了一百多遍。人被同一句话重复一百遍就会信,模型也差不多。
第二层,表达式好写,也就好雕。 把窗口从二十改成二十一,把除数从收盘价换成成交均价,在别的系统里可能要改一段代码,在这里只是敲一个字符。改到曲线好看为止——这个动作太轻了,轻到你根本不会意识到自己在做一件严重的事。
「我试了三百个因子,选出了表现最好的那个」——这句话本身就是问题。 选择这个动作发生在哪段数据上,那段数据就不再是干净的检验集了。更常见的情况是,报告里只写胜出者的表现,完全不提一共试过多少个。而「试过多少个」恰恰是判断这个结果值不值钱的核心信息。看别人的因子研究时,这一条是我建议你最先问的。
那该怎么办?没有能一劳永逸的招,但有几个方向是明确的:
- 先立假设,再写表达式。 顺序反过来——先批量生成再回头找哪个好看——那不叫研究,那叫抽奖。你至少要能说清楚,这个因子如果有效,背后的经济逻辑是什么。
- 给自己记账。 老老实实记下你一共试了多少个想法。这个数字本身就是你结果可信度的折扣系数。
- 把检验数据真正隔离出来。 用于挑选的那段数据,不能再拿来证明挑得对。
- 看一眼因子之间的相关性。 一百个高度相关的因子,不如五个真正不同的因子。
失效边界与常见误用
表达式跑通不等于因子有意义。 语法完全正确、数值也算得出来的表达式,可以毫无经济含义。系统不会替你判断这一点,它只负责算。
别把生成器当研究。 在配置里的算子清单多加两项,因子数量可能翻倍,但你对市场的理解一点没增加。产出的数量和研究的深度是两回事,混淆这两者是这套工具最常见的误用。
别忽略窗口没填满的那几行。 前面说过,最小周期数是 1,开头那一段是半成品,长得和正常值一模一样。检验区间的起点如果没留够缓冲,你的头几笔结论是建立在残缺数据上的。
表达式的简洁会掩盖数据成本。 $close 只有六个字符,可它背后是一整套问题:复权怎么处理、停牌那几天填的什么、财报数据是哪一版。表达式语言不解决这些,它只是把它们藏进了那个美元符号后面。一条写得再优雅的表达式,喂进去的数据不干净,出来的就是不干净的结论。
特征列里出现负数的 Ref。 再说一遍,因为这是最贵的那个错误。
小结
- 公式化因子就是「一个因子等于一个字符串」。字符串能被程序拼接、配置、批量生成,这是整套设计的全部秘密。
- 系统层三件事:字符串被正则改写后求值,变成一棵算子树;算子分逐元素、成对、滚动、成对滚动四个家族;每个算子自报所需窗口并逐层累加,所以要多取多少历史是自动算出来的。
- 负数的
Ref读的是未来。它在标签里天经地义,在特征里就是灾难。 - 配置驱动的批量生成让「造几百个因子」的成本约等于「造三个」,这是红利,也是全部风险的来源。
- 不是所有系统都需要这套东西。多而浅的横截面研究适合表达式语言,少而深的单品种策略用普通代码更顺手。
- 试得越多,从随机里捞出漂亮结果的概率越高。「一共试了多少个」是评估任何因子研究时最该先问的一个数。
一句话记住这篇:把想法写成公式,是为了让你一次能试一千个;而真正难的不是想出这一千个,是承认其中九百九十九个只是碰巧。
本文所引源码与文档均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么;本站内容为投资教育,不构成任何投资建议,边界见 免责声明。
本文为投资教育整理,关键数据与结论请结合下列权威来源验证。