因子引擎:先定契约,再谈依赖图

29 阅读 2000 字 · 约 7 分钟

立项的时候,因子引擎这块的设计稿写得很满:注册表登记依赖,依赖连成有向无环图,按拓扑序计算,避免重复;算完的结果按「因子名 + 参数 + 数据版本」做缓存 key,一个因子一个文件;数据版本一变,缓存自动失效;单个因子算挂了,不影响其他因子。

实装的时候,四件里我落了两件。注册表落了,契约定了。依赖图和版本化缓存留在设计稿上。

注册表:一个列表

因子在代码里就是一个冻结的数据类,五个字段:名字、说明、分类、参数、计算函数。全部因子放在一个模块级列表里,现在 18 个。

  • 名字是出去的键,后续所有环节靠它认因子。
  • 分类有六类:动量、趋势、波动、量价、估值、流动性。分类不是给人看的标签,是合成和评估时的分组依据——同一类的因子底层逻辑接近,相关性天然偏高。
  • 参数带默认值。5 日动量就写 window=5,写在定义里,不散在调用处。
  • 计算函数的输入是行情,输出是一张表。

估值和流动性那批因子是用工厂生成的:同一套变换(取对数、过滤掉负的市盈率)配不同字段,一批产出。省的是重复代码,代价是容易越生成越多,一堆长得像的因子堆在一起,最后靠相关性阈值互相砍。

用列表而不是装饰器自动注册,图的是能一眼看全所有因子。靠 import 的副作用拼清单,读代码看不出到底注册了什么。列表摊在明处,加一个因子就是加一条记录,顺序就是执行顺序。

契约:输出只有三列

因子引擎最容易松掉的地方是输出。让每个因子自己决定返回什么,下游就得给每个因子写一套适配:IC 计算一套、分层回测一套、合成一套、界面一套。加一个因子改四处,做几次就没人愿意加因子了。

我把输出定死成一张长表,三列:交易日、标的、因子值。因子内部读什么、怎么算、过滤什么,随它。出了函数,只有这三列。

好处是下游零适配。加因子,下游一行不用改。

代价在内存上。长表比宽表占地方,18 个因子的长表如果全驻留,接近 1.8GB。跑研究的容器是 1GiB,顶不住。所以研究段现在只在子集模式跑(票数 1500 以内),全市场那档跳过,只做回测。

这个取舍是清楚的:用一个明确的资源上限,换下游接口的稳定。反过来放开形状,内存宽松了,适配代码就是无底洞,每个因子还都可能有自己的一套坑。

为什么没做依赖图

设计稿里因子有「依赖」字段,实装里没有。一个因子需要什么数据,都写在计算函数内部:多数只吃行情,估值类的直接去读日频指标那张 parquet 表。

后果是引擎不知道一个因子读了什么。知道的话能做三件事:按依赖排序、并行算互不依赖的因子、只增量重算受影响的因子。不知道,就只能全量。

现在的做法是全量重算:每个因子一个 parquet,跑一次覆盖写一次,没有 key 化缓存。

有人第一反应是「太浪费」。按规模算,浪费可控:一天一次,18 个因子。贵的是那层缓存逻辑本身。key 要拼因子名、参数、数据版本,版本一变全盘失效;这套东西要写、要测,还会出错。它出错的形式很难看:拿着过期数据算出一张形状正常的表,没人看得出来。

数据版本我记了,但它只做一件事:报告头里写清这轮用的数据版本,因子引用也带上它,形如某个因子在某个版本上的取值。任何一份报告都能回到当时的数据。它不当缓存 key。

什么时候该把依赖补上?两个信号。因子涨到几十上百,全量重算的时间开始影响当天节奏。或者出现特别重的因子,比如要拼财务大表,全量算一次的成本明显高于其他因子。这两个信号出现之前,依赖图和增量重算是给一个还不痛的问题提前上机器。

一个藏在合成里的问题

因子算完,还有一步:压成一个信号。做法是按 IC 绝对值降序贪心挑,遇到与已选因子截面相关性超过 0.6 的直接跳过。

不做组合优化,是因为协方差矩阵的估计误差常常比优化带来的收益还大。用一版估偏的相关性矩阵算最优权重,很容易算出个漂亮但站不住的结果。贪心加一个阈值糙,但每一步都能解释:选它因为 IC 高,跳它因为跟已选的太像。

这步顺手暴露了一件更麻烦的事。有些因子的原始多空收益很漂亮,中性化之后大幅缩水:20 日波动率从 5.99% 掉到 1.42%,20 日动量从 3.44% 掉到 0.62%。

缩掉的那部分来自市值和行业的暴露,本来就不属于这个因子。因子引擎只管把信号算准、把重复的挡掉;谁在赚 beta,是更上层要回答的问题。

一个坑

IC 用 spearman 算。小样本截面上,它返回 NaN,不是 null。

NaN 会一路传下去,把 IC 均值污染成 NaN,全程不报错。表是正常的,字段都在,数字「有」,只是全都不对。发现的时候,一整轮评估结果全是 NaN。

修法是在入口显式过滤掉非数值。

这跟因子没关系。统计函数的边界行为不能指望它按契约来:NaN 和 null 在传递上的表现不一样,前者会静默传染。

小结

因子引擎的复杂度落在形状和边界上。输出定死成一张长表,看着是牺牲自由,换来的是下游零适配。注册表摊在列表里,读代码能看全,代价是没有依赖声明,只能全量重算。

全量重算现在能接受,前提是 18 个因子、一天一次。前提变了,依赖图和增量重算就该补上。