因子入库前的体检:我把这道工序做成了模块
由qxiao创建,最终由qxiao 被浏览 1 用户
最近在维护自己的因子库。每加工出一批新因子,入库前总要重复同样几件事:覆盖了多少股票、缺多少、 有没有整列都是一个值、日期有没有对错一位。手工查一遍要小半天,还容易漏——漏掉日期错位那一项, 就会把一个测起来准得惊人、实盘一分钱赚不到的因子放进库里。
factor_check 把这套检查固定下来:输入表名,输出一份可归档的指标表。口径写在模块里不用每次 重新定,所以同一个因子隔几个月重跑一次结果可比,不同来源的因子也能放在一张表里横着看。
它有两种模式,本文只讲体检模式,即入库前每张表都要过的那一道,回答「这批数据能不能用」。
这个模块是什么
平台「因子研究」分类下的可视化模块,显示名「因子检验」。输入一张因子表,输出三张指标表——可以拖到 画布里接下游节点,也可以在 notebook 里用 M.factor_check.v2(...) 调。结果自动缓存,参数没变时 重跑是秒回;跑完不在磁盘上留任何文件。
体检模式走三步:
- 契约校验——确认这张表是「每天每只股票一行」的日频面板。不合规就在这里停下,并说清是哪一项
- 因子分类——扫描每个字段,判断它是连续打分、离散标记还是字符串,据此决定查哪些指标
- 数据体检——按天、按列做聚合统计,给每个字段下「通过 / 警告 / 不可用」的结论
日志里的 [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 处;而且比较的是变更点前后各五天的水平,口径变更的特征是位移之后 不回来。单次台阶在长序列上区分不出口径变更和正常波动的尾部,所以要超过三处才报。
只列数字、不参与判定的两列
峰度 和 极端值占比 不告警。肥尾是估值类比率的固有特征,实测 PE、PB、PS、PCF 每一个都有 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% 只用来兜住「几乎没数据」的情况,要入库选股的因子建议按策略的 股票池要求再定一条。
同一张表定期重跑。日期断档、覆盖率断崖、口径突变这三项查的是上游数据有没有悄悄变过,首次入库那 一次跑不出来,得定期跑才有意义;体检的开销支持每天跑。
入库后对候选因子抽样做完整检验,补上体检查不到的两件事:预测能力有多强,以及长持有期上的穿越。