因子入库前的体检:我把这道工序做成了模块

由qxiao创建,最终由qxiao 被浏览 1 用户

最近在维护自己的因子库。每加工出一批新因子,入库前总要重复同样几件事:覆盖了多少股票、缺多少、 有没有整列都是一个值、日期有没有对错一位。手工查一遍要小半天,还容易漏——漏掉日期错位那一项, 就会把一个测起来准得惊人、实盘一分钱赚不到的因子放进库里。

factor_check 把这套检查固定下来:输入表名,输出一份可归档的指标表。口径写在模块里不用每次 重新定,所以同一个因子隔几个月重跑一次结果可比,不同来源的因子也能放在一张表里横着看。

它有两种模式,本文只讲体检模式,即入库前每张表都要过的那一道,回答「这批数据能不能用」。

这个模块是什么

平台「因子研究」分类下的可视化模块,显示名「因子检验」。输入一张因子表,输出三张指标表——可以拖到 画布里接下游节点,也可以在 notebook 里用 M.factor_check.v2(...) 调。结果自动缓存,参数没变时 重跑是秒回;跑完不在磁盘上留任何文件。

体检模式走三步:

  1. 契约校验——确认这张表是「每天每只股票一行」的日频面板。不合规就在这里停下,并说清是哪一项
  2. 因子分类——扫描每个字段,判断它是连续打分、离散标记还是字符串,据此决定查哪些指标
  3. 数据体检——按天、按列做聚合统计,给每个字段下「通过 / 警告 / 不可用」的结论

日志里的 [1/8]、[2/8] 是完整检验那条八步流程的编号,体检模式只走前两步再加体检这一步。

它查哪些项

类别 查什么 产出的列
时间覆盖 有数据的交易日数、首末日期、中间有没有断档 起始日 结束日 交易日数 日期断点数
截面覆盖 平均每天覆盖了全市场多少股票、有效股票数有没有断崖 横截面覆盖率 缺失率 覆盖异常点数
区分度 有多少种取值、多少交易日整个截面没有差异 唯一值数 零方差期占比
数值健康 无穷值、量级大到会让统计溢出的值 inf数 inf占比 异常量级数
分布形态 均值、标准差、偏度、峰度、五个分位点 均值 标准差 偏度 峰度 p1~p99
口径稳定 日中位数有没有出现持续性台阶 口径突变点数
数据穿越 因子里有没有混进未来数据 IC_0 IC_1 数据穿越嫌疑 当日信息主导

最后一项是这套检查里最要紧的,单独一节说。

最小示例

from bigmodule import M

outs = M.factor_check.v2(
    table="cn_stock_valuation",
    start="2024-01-01",
    end="2024-02-29",
    mode="体检",
    show_report=True,
)

输出区打印每一步进度:

[2026-09-18 16:30:25] [info     ] factor_check.v2 开始运行 ..
[1/8] 契约校验通过:37 个交易日 2024-01-02~2024-02-29,日均 5345 只股票,14 个候选因子
[2/8] 因子分类完成,14 个连续因子 + 0 个离散因子(连续 14、主键 2)
[体检] 完成(通过 14)
[2026-09-18 16:30:32] [info     ] factor_check.v2 运行完成 [7.243s].

取端口数据:

outs.summary.read()      # 总表,一行一个因子
outs.quality.read()      # 体检指标全量,32 列
outs.metrics.read()      # 指标明细,体检模式下与 quality 同内容

outs.summary.read()的实际结果:

十四个字段全部通过,覆盖率最低的 ps_trailing 是 93.89%,没有穿越嫌疑。

除穿越检查外每项指标都是按天或按列的聚合,不需要逐行明细,全程在平台端算完,取回来只有几千行。 上例 37 个交易日 × 14 个字段共 7.2 秒。1126 万行的表做一次按天聚合实测 0.2 秒、峰值 0.3 GB, 取回本地做同样的事要 40 秒、峰值 8.5 GB。所以体检可以对全区间、全部表定时跑。

参数

体检模式下用得上的只有前四个和 show_report:

参数 类型 默认 说明
table 字符串 必填 因子表名。只支持「每天每只股票一行」的日频表
start 字符串 2024-01-01 样本起始日期
end 字符串 留空表示取到最新
mode 枚举 体检 写 体检 或内部名 quick 都认
show_report 布尔 是否在输出区内联展示完整报告
periods 字符串 1,5,10,20 体检模式下不生效
quantiles 整数 10 体检模式下不生效

mode另有完整检验选项,在体检之外再算 IC、分档收益、多空组合。它要把整张面板取回本地, 慢得多也吃内存,不适合放进流水线,本文不展开。用法是体检过了之后,对候选因子抽一段短区间 单独跑一次。

三个输出端口

端口 内容 上例的形状
quality 体检指标全量。被跳过的字段也在,附「类型」和「跳过原因」 14 行 × 32 列
summary 总表,一行一个因子,只挑最关键的九列 14 行 × 9 列
metrics 指标明细。体检模式下与 quality 同内容 14 行 × 30 列

summary 的九列就是上面截图那些:因子 / 类型 / 体检结论 / 横截面覆盖率 / 缺失率 / 唯一值数 / 交易日数 / Ω据穿越嫌疑 / 备注。人看这一张就够。

quality 是上面「它查哪些项」那 26 列,加上 因子 类型 体检结论 备注 跳过原因,再加一个 在体检模式下恒为空的 极端值占比,共 32 列,适合归档进因子库元数据。体检模式下 metrics 与它同内容,区别只在少了 类型 和 跳过原因 两列; 完整检验模式下 metrics 才会变成「因子 × 持有期」的明细。

三个端口在画布上可以各接各的下游:summary 接展示,quality 接入库表,metrics 接对比分析。

内联报告

show_report=True 时输出区展开一份报告,四部分:体检总表、每一列是什么意思、完整体检指标、 附录(表结构校验明细、因子分类、各步骤耗时)。体检模式没有图表,报告只有几十 KB。

列说明是报告自带的,不用另外查文档。比如 数据穿越嫌疑 那一行:

最该看的一列。因子的预测准度高得不真实,基本可以断定是日期对齐错了一位、把未来的数据当成了 当期。这种因子测出来准得惊人,实盘一分钱赚不到。查的是「错位一天」这种最常见的情形——如果 偷看的是 20 天以后的数据,这里查不出来。

批量跑时设 show_report=False,只取端口。

全是标记的表也能体检

状态类表(停牌、ST、涨跌停)不是打分型因子,但数据质量照样要查:

outs = M.factor_check.v2(
    table="cn_stock_status",
    start="2024-01-01",
    end="2024-02-29",
    mode="体检",
    show_report=False,
)
outs.summary.read()
[2/8] 因子分类完成,0 个连续因子 + 5 个离散因子(离散 5、主键 2)
[体检] 完成(通过 4、警告 1)

exdr(除权除息标记)被判警告,原因是 73% 的交易日整个截面没有区分度。绝大多数交易日没有股票 除权,这是事件标记的正常形态,不是数据错误——警告要人看一眼再决定,不等于有问题。其余四个字段 覆盖率 100%、缺失率 0,可以当过滤条件用。

对输入表的要求

只认「每天每只股票一行」的日频面板表。第一步就校验四项,不满足就在那里停下,并说清是哪一项、 具体数字是多少:

校验项 要求 不满足时的典型原因
主键列 有 date(时间类型)、instrument(字符串类型) 提交的是宽表或已经聚合过的截面
主键唯一 (date, instrument) 不重复 date 带时分秒(新闻、公告流水);或存在第三个主键列(行业标准、报表类型)
日频 相邻日期间隔的众数 ≤ 3 天 提交的是周频、月频、季频表
代码格式 形如 000001.SZ(6 位 + .SZ/.SH/.BJ) 代码没带交易所后缀

主键重复这一项会区分两种原因分别报,因为改法不同:一天有多个不同时间戳 → date 带时分秒, 本质上不是面板,要先按天聚合;平均每只股票每天 N 行 → 表里还有第三个主键列,要先收敛成单一 口径或筛出一个切片。

代码格式这一项之所以要在第一步拦住:格式不一致会导致关联行情表时匹配不上,而平台不会报错,只会 静默少掉大量样本,最后产出一份看着正常、实际基于很少数据的报告。

因子命名只有一条限制,不能以 fc__ 开头。模块自己生成的列一律带这个前缀,所以 ret_1、 st_status、list_days 这些名字都可以用。

字段分类:哪些字段查什么

第 2 步扫描全表,按唯一值数把每个字段分流。除主键外的全部字段都在体检范围内:

类型 判定 体检范围
连续 唯一值 > 100 全部指标
准离散 唯一值 ≤ 100 全部指标
离散 唯一值 ≤ 10 全部指标
字符串 / 元数据 非数值列、name 一类名称列 只查覆盖率、缺失率、唯一值数
空列 / 无信息 整列为空、或只有一个取值 判死并写明原因
主键 date、instrument 排除

字符串列也在体检范围内。行业、板块字段排不了序,但覆盖率和缺失率照样要查,一个 40% 缺失的行业 字段是数据问题。这类列的均值、偏度、分位数和穿越检查留空,不填 0。零方差期占比 同样留空—— 按 NULL 当 0 算会得到 1.0,读起来是「100% 的交易日没有区分度」,对一个有五千多种取值的证券 简称是错的。

判定标准

结论分三档:通过 / 警告 / 不可用。

判死(不可用)

条件 含义
abs(IC_1) > 0.30 预测准度高得不真实,几乎必是数据穿越
缺失率 = 100% 整列全为空
唯一值数 ≤ 1 整列一个取值,没有区分度
有效交易日 < 2 做不了时序统计

后三条会互相蕴含(全空的列必然只有一个取值),只报最根本的那一条,避免一个问题刷三遍。

告警(警告)

条件 默认阈值 说明
横截面覆盖率过低 < 30% 池子小,结论代表性不足
截面零方差期占比过高 > 5% 这些交易日该因子没有区分度
含量级异常值 绝对值 ≥ 1e70 必定是算错了,已排除在统计之外。数量再少也报
inf 占比过高 > 1% 已按空值处理
日期断档 间隔 > 12 天 跨春节的正常间隔已排除
日中位数台阶 > 3 处 分布不稳定,常见于按预测或定期报告离散更新的指标,也可能是口径中途改过
有效股票数断崖 环比跌 > 30% 通常是上游数据缺了一块

其中三条的阈值是调出来的,命中时可以对照着判断严重程度:

inf 按占比判而不是按有没有判。实测估值表里 ps_trailing 有 74 个 inf,是 18 万条里的 0.04%,都是分母为零的个别记录,对下游没有影响。超过 1% 才值得查上游。

量级异常和 inf 分开计数。有限但极大的数一样会让统计溢出,实测某张因子表的 alpha_017 最大值 是 7.6e288,而中位数只有 1.47。这类值数量再少也报。

口径突变判的是持续性台阶。用日中位数而不是日均值——PE/PS 这类肥尾因子一只极端股票就能把当天均值 拉飞,实测 726 天里误报 145 处;而且比较的是变更点前后各五天的水平,口径变更的特征是位移之后 不回来。单次台阶在长序列上区分不出口径变更和正常波动的尾部,所以要超过三处才报。

只列数字、不参与判定的两列

峰度 和 极端值占比 不告警。肥尾是估值类比率的固有特征,实测 PEPBPSPCF 每一个都有 6%~17% 的样本偏离中位数超过 5 倍 MAD,14 个字段里 9 个会触发;把成熟的生产数据标成九成有问题, 说明错的是判定标准。体检模式不做去极值,所以 极端值占比 这一列本身也是空的。

当日信息主导(IC_0 远大于 IC_1)也不告警。成交量、成交额、当日涨跌幅这几个字段永远会命中 ——它们就是当天的成交结果。在「T 日收盘出信号、T+1 开盘买入」的口径下,当日数据在收盘时早就知道, 用它是合法的,短期反转因子就是这么做的。这一列只陈述事实,供参考。

数据穿越检查

自己加工因子最常见、也最难自查的错误是日期对齐错了一位,把未来的数据当成了当期。这种因子测出来 准得惊人,实盘一分钱赚不到,所以这是体检里最要紧的一项。

做法是算两个秩相关,都在平台端算完,只取回逐日结果:

收益定义 含义
IC_0 close / open - 1(当日) 因子与当日涨跌的相关性
IC_1 T+1 开盘买、T+2 开盘卖 因子与次日收益的相关性

T 日收盘出信号的因子并不知道当天的涨跌,所以两者应当相当。真实因子的日频 Rank IC 在 0.02~0.05 量级,0.1 已属罕见,abs(IC_1) 超过 0.30 在 A 股不可能靠真本事做到,只能是混进了未来数据。命中 就判死,数据穿越嫌疑 列写 True,备注 里写明 IC 是多少。

排名时空值是挑出去的。SQL 的 rank() 会给 NULL 也排一个名次,不处理的话缺失的股票会被当成「排在 最后」参与相关性计算。

覆盖范围只有 1 日:能发现错位一天这种最常见的情形,如果穿越的是 T+10 的数据,IC_1 会显示正常, 要靠完整检验把 1/5/10/20 全算一遍才查得出。体检通过不代表一定没穿越,只代表没有最常见的那种。 字符串列不做这项检查,排不了序也就谈不上预测准度。

覆盖率和缺失率的分母不同

横截面覆盖率 的分母是当日全市场股票数(取自行情表),不是这张表自己的行数,回答的是「这个因子 覆盖了全市场百分之多少」。用表自己的行数当分母的话,一张只收录两千只股票的表永远是 100%。

缺失率 的分母才是表自己的行数。所以两列要一起看:上例 ps_trailing 是 93.89% 和 6.11%,加起来 正好 100%,说明这张表每天收录了全市场股票,两个口径重合。而一张只收录两千只股票的表,缺失率 可能是 0(有的行都有值),横截面覆盖率 却只有 37%,该看的是后者。

批量入库:状态码

表结构不合规、无待检因子、取数失败这几种情况不抛异常,免得定时任务里一张表的问题阻塞后面所有表。 这时三个端口会发同一张单行状态表,列是 表名 / 状态 / 状态码 / 状态说明。下游按 状态码 判断,它是稳定的 ASCII 字面量;中文说明是给人看的,不要拿去解析。

状态码 状态 值得告警 含义
ok 正常 有检验结果
invalid_schema 表结构不合规 缺主键列、主键重复、非日频、代码格式不一致。重试无用
query_failed 取数失败 表不存在、查询超时、连接中断。值得重试
column_conflict 因子名冲突 因子名以 fc__ 开头
no_factor 无待检因子 不是错误。表里的字段全被跳过(整列为空、只有一个取值、缺失率超过 90%、或只有字符串列)时就是这个结果,属于正常结论,告警会变成噪音

批量跑一批表的写法:

from bigmodule import M

TABLES = ["cn_stock_valuation", "cn_stock_status", "my_factor_table"]

for table in TABLES:
    outs = M.factor_check.v2(
        table=table,
        start="2024-01-01",
        end="2024-02-29",
        mode="体检",
        show_report=False,
    )
    frame = outs.summary.read()

    if "状态码" in frame.columns:          # 单行状态表:检验没走完
        print(table, "跳过:", frame.loc[0, "状态码"],
              frame.loc[0, "状态说明"].splitlines()[0])
        continue

    fail = frame[frame["体检结论"] == "不可用"]
    warn = frame[frame["体检结论"] == "警告"]
    print(table, f"共 {len(frame)} 个字段,不可用 {len(fail)},警告 {len(warn)}")
    for row in fail.itertuples():
        print("   退回:", row.因子, row.备注)

判断有没有正常出结果,看端口里有没有 状态码 列,正常结果的表里没有这一列。表名写错时拿到的是 query_failed,状态说明 写的是「平台上没有名为 'xxx' 的表。请检查表名拼写,或确认当前账号有 该表的访问权限。」

建议的准入规则

不可用 一律退回加工方,附上 备注 列的原因。其中穿越嫌疑是加工逻辑错误而非数据瑕疵,放进库里 会污染后面所有用到它的策略。

警告 人工过一眼。大部分警告是这类数据本来就这样(事件标记天然稀疏、估值比率天然肥尾),少部分 是真问题(覆盖率断崖、口径突变),区别在备注写的是哪一条。

覆盖率另设自己的下限。默认的 30% 只用来兜住「几乎没数据」的情况,要入库选股的因子建议按策略的 股票池要求再定一条。

同一张表定期重跑。日期断档、覆盖率断崖、口径突变这三项查的是上游数据有没有悄悄变过,首次入库那 一次跑不出来,得定期跑才有意义;体检的开销支持每天跑。

入库后对候选因子抽样做完整检验,补上体检查不到的两件事:预测能力有多强,以及长持有期上的穿越。

{link}