量化金融里的因子研究走到一定阶段,最明显的感受是:跑通一个单因子已经不难,难的是让这个因子从一次性的探索脚本变成可持续更新、可复测、可被下游策略稳定调用的数据资产。这个阶段通常被称为“因子阶段”,而它的收官标志不是某个IC指标达标,而是从数据流到因子库的整条链路已经闭合。原始行情进入系统后,经过清洗、对齐、因子计算、单因子检验、入库发布,最终由因子库对外提供稳定查询。这篇内容是对“365天量化金融”系列进入第90篇时的阶段整理,核心话题就是这条链路。
这里讨论的“因子”,指量化投资研究中的 alpha 因子,也就是一种对股票未来收益具有区分能力的可度量特征。它与统计学里的“因子分析法(factor analysis)”不是一回事,后者是一类降维和解释性统计方法。很多初学者容易把两个“因子”混在一起,所以先说明边界。整篇文章围绕“从数据流到因子库”展开,适合已经会写单因子脚本、但还没建立工程化因子研究流程的同学,也适合准备把 notebook 经验迁移到团队协作场景的量化工程人员。
1. 因子阶段收官的真正标准:不只是回测通过
1.1 因子研究在量化体系中的位置
量化研究通常可以拆成四层:数据层、因子层、策略层、执行层。数据层负责把原始行情和基本面数据整理成可计算的样本;因子层负责把样本加工成有预测含义的特征;策略层负责在因子上做组合和仓位管理;执行层负责交易接入。因子阶段处于数据层和策略层之间,承上启下。如果只重视单因子验证,而忽略因子层的数据管道和存储,一旦因子数量从几个增加到几十个,脚本之间的依赖会迅速失控。
因子的本质可以这样理解:面对一批股票,我们希望找到一个可观察、可计算、可复现的特征,使得历史上这个特征值较高的股票,在未来一段时间的收益率,整体上区别于特征值较低的股票。它不要求对每只股票都准确,只要求在统计意义上具备区分度。正因为追求的是统计规律,单因子检验才必须严谨。
1.2 收官时应该具备的五个能力
到因子阶段结束,至少应该具备五种能力:
- 数据流可自动更新:原始行情、财务、成分等数据能按交易日或更短频率增量更新,并有失败重跑机制。
- 因子可复现:给定因子ID和参数,能重新计算出完全一致的结果,而不是依赖某个 notebook 的内存状态。
- 检验可追溯:每轮检验都有输入因子版本、收益窗口、样本区间、处理方式等元数据。
- 因子库可查询:因子值不散落在 CSV 文件里,而是有统一 Schema 和查询入口。
- 下游可依赖:策略或组合模块通过 API 或数据库读取因子值,不直接接触原始计算脚本。
可以用一张表来对照不同成熟度的研究习惯:
| 阶段 | 常用做法 | 主要风险 |
|---|---|---|
| 探索期 | Jupyter notebook 手工计算 | 结果不可复现,依赖内存状态 |
| 脚本期 | Python 脚本 + CSV 输出 | 路径混乱,无法多人协作 |
| 工程期 | 数据管道 + 因子库 + 检验报告 | 需要引入调度、存储、权限设计 |
| 产品期 | 实时因子服务 + 监控告警 | 需要更严格的版本兼容和性能治理 |
对大多数个人研究者来说,不必一上来就追求产品期,但至少要落地到工程期。这也决定了后面技术选型会偏“轻量而完整”。
注意:阶段收官不要求所有模块都做到生产级高可用,但要求“从数据到因子再到查询”的链路能独立运行,并且每一步都有日志和验证点。
1.3 常见误区:notebook 里跑通不等于收官
最常见的收官误区是:在 notebook 里数据能读取、因子能计算、IC 大于零、画了几张分层图,就认为因子阶段结束。实际问题往往出在数据依赖上。比如换一台电脑、换一个数据目录,脚本马上失效;比如数据源某天字段名变了;比如一次全量重算后,因子值因为前复权价格变化而整体变化,但旧的检验报告还挂在因子名下面。
更隐藏的问题是“计算口径”没有被显式记录。动量因子是使用 20 个自然日还是 20 个交易日,收益是百分比还是对数,停牌日是否剔除,极值是否缩尾,这些口径只要不写进元数据,下次复算基本无法得到同样结果。所以“可复现”不是偏好,而是因子能否进入后续流程的最低门槛。因子阶段收官的判断标准,就应该从“指标好不好看”调整成“链路是否完整、结果是否可追溯”。
2. 数据流设计:从原始行情到因子计算所需的面板数据
2.1 先设计原始数据层,而不是直接设计因子
因子计算前,最需要投入时间的往往不是因子公式,而是数据流。数据流的起点是明确原始层的数据表。以日频因子研究为例,常见表有行情表、财务表、行业与成分表、交易日历表。核心表结构可以按下面的字段设计:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| daily_quote | asset_code, trade_date, open, high, low, close, volume, amount | 保存原始行情,复权方式要单独记录 |
| stock_basic | asset_code, list_date, delist_date, industry | 上市状态,避免退市股进入样本 |
| trade_calendar | trade_date, is_open | 交易所交易日历 |
| financial_indicator | asset_code, report_date, 净利率, 营收同比等 | 基本面因子原材料 |
| index_member | index_code, asset_code, in_date, out_date | 指数成份,用于股票池和中性化 |
这个设计里,最能避免后期返工的是trade_calendar。很多因子计算失败不是公式问题,而是把非交易日当成交易日,或者把停牌日当成正常零收益日处理。交易日历应该来自交易所数据,而不是用pd.bdate_range()简单生成。不同市场、不同交易所的节假日规则不同,尤其是中国 A 股市场的节假日调休,和普通工作日并不完全一致。
2.2 清洗、对齐与复权处理
原始数据进入面板数据前,需要做四件事:去重、排序、复权处理、缺失值归类。下面是一段最小清洗函数:
import pandas as pd def clean_daily_quote(raw: pd.DataFrame, stock_basic: pd.DataFrame) -> pd.DataFrame: df = raw.copy() df["trade_date"] = pd.to_datetime(df["trade_date"]) df = df.drop_duplicates(subset=["asset_code", "trade_date"]) df = df.sort_values(["asset_code", "trade_date"]).reset_index(drop=True) df["pct_chg"] = df.groupby("asset_code")["close"].pct_change() df = df.merge( stock_basic[["asset_code", "list_date", "delist_date"]], on="asset_code", how="left", validate="many_to_one", ) df = df[df["trade_date"] >= df["list_date"]] df = df[df["delist_date"].isna() | (df["trade_date"] <= df["delist_date"])] return df这段代码有几个关键点。第一,pct_change()默认按行顺序计算,如果排序出错,收益率就会错位。第二,上市日和退市日的过滤,能避免在股票未上市或已退市的日期计算虚假因子。第三,复权方式必须和因子定义绑定。例如“使用后复权收盘价计算动量”,这个说明要写入因子元数据,否则一次全量重算会因为价格复权变化导致因子历史值变化。
2.3 从长表变宽表,再对齐交易日历
因子计算大量使用横截面操作,比如某一天所有股票的因子值排序。所以需要把长表转成宽面板:行是交易日,列是股票代码,值是价格或成交额。
def to_wide_panel(df: pd.DataFrame, value_col: str = "close") -> pd.DataFrame: panel = df.pivot_table( index="trade_date", columns="asset_code", values=value_col, ) panel = panel.reindex(calendar) return panel宽表在日频研究中非常直观,但要注意它的短板。股票规模增长后,宽表会引入大量缺失值;沪深两市的股票数量到几千只时,宽表依然能接受,但已经不适合分钟级计算。实务中,很多团队会用长表加多级索引来保存因子值,计算时再临时转宽。这个选择没有绝对对错,关键是“面板数据的交割格式”要统一。
停牌、未上市等缺失数据,常见处理有三种:前向填充,适用于有成交的价格序列;置空并在计算中跳过,适用于收益率或因子滚动窗口;按零收益处理,适用于指数或组合层面的近似计算。需要特别注意,不要直接在原始行情里用ffill()补齐成交量。成交量为 0 表示没有成交,成交量为 NaN 表示数据缺失,两者在因子计算里不应混为一谈。
2.4 数据质量检查应嵌入数据流
数据质量检查不能等到因子异常再补。最小检查项包括:
- 交易日是否有重复或缺失。
- 单日股票数量是否少于预期,例如指数成份数量偏差超过 5%。
- 收盘价为 0 或负数的记录是否被过滤。
- 复权价格单日跳变是否异常。
- 财务数据报表日期是否重复。
可以写一个极简检查函数,输出检查报告:
def check_panel(panel: pd.DataFrame) -> dict: report = { "shape": panel.shape, "missing_ratio": float(panel.isna().mean().mean()), "zero_close": int((panel == 0).sum().sum()), "last_date": str(panel.index.max()), } return report检查报告的格式不必复杂,但每次入库和重算都要生成。后面排查“因子对不上”问题时,第一件事就是对比当天的数据检查报告和因子计算日志。
3. 因子计算与计算框架:从公式到可复现任务
3.1 因子的常见分类
量化研究里的因子按数据来源可以分成三类:
| 因子类型 | 数据来源 | 典型例子 | 更新频率 |
|---|---|---|---|
| 价量技术因子 | 行情、成交量 | 动量、反转、波动率、换手率 | 日频/分钟频 |
| 基本面因子 | 财务报表 | 毛利率、ROE、营收同比 | 季频/月频 |
| 另类因子 | 文本、舆情、供应链等 | 舆情情绪、供应链数据 | 视数据源而定 |
对个人研究者而言,最容易从价量因子入手,因为数据公开、口径容易验证。基本面因子则要注意财报发布日期和报告期,不能简单使用最新报告期,否则会有前视偏差。另类因子的数据获取和清洗成本高,更适合作为已有因子库的补充。
3.2 因子计算的最小示例
以动量因子为例。经典动量因子可以定义为过去 N 个交易日内的累计收益率。更严格一些,要排除最近一个交易日,避免与短期反转混在一起。不同定义对应不同因子ID,应该分开管理。
def compute_momentum( panel: pd.DataFrame, lookback: int = 20, skip: int = 1 ) -> pd.DataFrame: # 过去 lookback 个交易日的累计收益,跳过最近 skip 个交易日 momentum = panel.shift(skip) / panel.shift(skip + lookback) - 1.0 momentum.name = f"momentum_{lookback}d_skip{skip}" return momentum这段计算依赖panel已经是按交易日排序的宽表。若面板里存在 NaN,比如停牌日,滚动计算会根据 NaN 传播,因此要在计算前决定是剔除、填充还是保持 NaN。这也是一个典型的“口径”问题,决定之后要写进因子元数据。
3.3 向量化、并行化和缓存
因子计算最容易犯的错误是用 Python 循环逐股票逐日计算。日频研究里,宽表的向量化操作就能满足大部分需求;当数据量达到分钟频,或者样本股票数量很大时,才需要引入并行计算。选择顺序建议是:
- 先用 pandas/numpy 向量化实现。
- 用 groupby 或 pivot 处理横截面和时序维度。
- 计算复杂度可控后,再用
multiprocessing或dask做并行。 - 对频繁重算的因子,按“因子ID + 参数 + 数据版本 + 数据截止日期”做缓存。
其中“数据截止日期”很重要。每天新增行情后,前复权价格会变化,旧版本因子的历史值可能被重算,所以缓存键需要明确。下面的示例展示一个计算任务的元信息:
factor_run = { "factor_id": "momentum_20d_skip1", "calc_version": "v3", "data_version": "2025-05-20", "source_table": "daily_quote", "adjust": "post", }运行任务时,把这个字典写入因子库的元数据表,后续任何人查看因子值,都能知道它是用什么数据版本计算出来的。
3.4 因子计算阶段的常见坑
第一个坑是滚动窗口内自然日和交易日混用。pd.Series.rolling(20)默认按固定窗口大小滚动,和“20个交易日”并不完全一样,尤其当序列中间有缺失日期时。处理方式是用交易日历重采样后再 rolling,或者使用rolling(window, min_periods=...)并保证索引没有缺失交易日。
第二个坑是极值没有处理就进入统计检验。部分因子天然包含极端值,比如单日涨幅因子。若不做缩尾或去极值,均值、方差和 F 检验都可能被少数样本带偏。常见处理包括 MAD 法和分位数缩尾。
第三个坑是因子计算和收益预测窗口错位。计算因子的时间是 T 日收盘,研究未来收益通常对应 T+1 到 T+N。若用 T 日当天收益作为预测目标,就产生了前视偏差。收益对齐时应使用shift把未来收益移动到当前因子行。前后两条数据如果错一位,检验结论可能完全不同。
4. 单因子检验:从 F 检验到分层回测
4.1 检验的意义不是只看 IC 符号
单因子检验回答的问题是:这个因子值对未来收益是否有统计意义上的区分能力。常看的指标包括 IC、IR、分组收益差、F 检验 p 值、分层单调性。IC 是因子值与未来收益的 Spearman 秩相关,计算简单,但它只描述整体秩相关关系,无法体现因子值分组之间的非线性差异。
分层分析是更贴近策略的方式:把每个截面上的因子值按大小分成若干组,计算各组未来收益的均值或累计净值,观察组间收益是否存在单调梯度。分层结果通常用柱状图或净值图观察,但工程链路中还需要把分层结论数字化,例如最高组收益减最低组收益的均值和 t 统计量。
4.2 单因子方差分析 F 检验的含义
单因子方差分析(one-way ANOVA)在这里用于检验:按因子值分成的多个组,其未来收益均值是否显著不同。原假设是各组未来收益均值相同;如果 F 统计量足够大、p 值足够小,说明至少有一个组和其他组存在差异,因子具备进一步研究的价值。
用代码实现时,可以直接按因子分组后调用 F 检验:
from scipy import stats import pandas as pd def run_anova( factor: pd.Series, forward_return: pd.Series, n_groups: int = 5 ) -> dict: data = pd.DataFrame({"factor": factor, "ret": forward_return}).dropna() try: data["group"] = pd.qcut( data["factor"], n_groups, labels=False, duplicates="drop" ) except ValueError: return {"status": "failed", "reason": "factor has too many duplicate values"} groups = [ data.loc[data["group"] == g, "ret"] for g in sorted(data["group"].unique()) ] f_stat, p_value = stats.f_oneway(*groups) means = data.groupby("group")["ret"].mean().to_dict() return { "status": "ok", "f_stat": round(float(f_stat), 4), "p_value": float(p_value), "group_mean": means, }这里有几个细节。第一,pd.qcut会尽量把样本分成等频区间,但如果因子值大量重复,会出现分位点重叠,需要用duplicates="drop"处理。第二,dropna()非常重要,否则缺失值会参与分组,导致统计口径混乱。第三,分组数n_groups通常取 3 到 10,日频因子常用 5 组,既能观察单调性,也保证每组样本量足够。
4.3 IC、IR 和分层检验一起看
F 检验描述“组间均值差异是否显著”,IC 描述“因子与未来收益的秩相关”,IR 描述“IC 的均值与波动之比”,三者各有侧重。推荐至少输出以下指标到检验报告:
| 指标 | 含义 | 常见参考范围 |
|---|---|---|
| IC均值 | 因子与未来收益秩相关的历史均值 | 绝对值大于 0.02 算弱有效,大于 0.05 算较明显 |
| ICIR | IC 均值除以 IC 标准差 | 绝对值大于 0.3 可作为候选 |
| F检验p值 | 分组收益均值是否显著不同 | p 小于 0.01 或 0.05 |
| 分层单调性 | 组均值是否随因子值单调变化 | 单调或近似单调更理想 |
| 最高组最低组收益差 | 多空组合的原始收益差 | 需结合波动率看显著性 |
这里的参考范围只是传统日频因子的经验观察,不构成任何保证。实际使用时,样本区间、股票池和收益窗口都会影响数值。不要因为一个固定阈值不达标就直接否定因子,也不要因为一个 p 值很小就立即入库。
4.4 检验报告结构
工程化之后,检验结果应该写成结构化文件,和因子版本绑定。一个最小化 JSON 报告可以这样:
{ "factor_id": "momentum_20d_skip1", "calc_version": "v3", "sample_start": "2018-01-01", "sample_end": "2024-12-31", "universe": "csi300", "horizon": 5, "ic_mean": 0.036, "icir": 0.28, "f_stat": 4.12, "p_value": 0.002, "layer_mean": {"0": -0.004, "1": 0.001, "2": 0.003, "3": 0.006, "4": 0.009} }有了这个报告,后续查问题就能直接定位:是因子版本变了,还是数据版本变了,还是检验参数变了。没有元数据的检验报告,本质上只是数字集合,无法用于复盘。
5. 因子库建设:存储、版本、权限和查询
5.1 存储选型要先考虑查询模式
因子库的存储选型取决于查询模式。日频宽表适合用 Parquet 文件加分区目录存储;需要按股票和日期随机查询时,可以用 SQLite 或 PostgreSQL;更高频、更大规模时,ClickHouse 或 DuckDB 是常见选择。个人学习阶段,推荐先用 Parquet 文件作为主存储,配合轻量数据库记录元数据。进入团队协作或生产环境后,再迁移到列式数据库。
为了统一,本文示例把因子值存到 SQLite 或 PostgreSQL 的factor_value表。建表 SQL 如下:
CREATE TABLE factor_value ( trade_date DATE NOT NULL, asset_code VARCHAR(20) NOT NULL, factor_id VARCHAR(64) NOT NULL, value DOUBLE PRECISION NOT NULL, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (factor_id, trade_date, asset_code) );因子元数据单独建表:
CREATE TABLE factor_meta ( factor_id VARCHAR(64) PRIMARY KEY, factor_name VARCHAR(128) NOT NULL, category VARCHAR(32), calc_version VARCHAR(32), data_version VARCHAR(32), params TEXT, owner VARCHAR(64), status VARCHAR(16) DEFAULT 'active', update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里把主键设计成(factor_id, trade_date, asset_code),天然适合“按因子、按时间区间查询”的场景。当日所有因子快照查询时,通过trade_date索引也能较好处理。
5.2 因子命名与版本管理
因子库最容易出现的混乱是:同一个因子有多个名字,或者同一个因子名下换了计算口径。建议命名规则采用“类型_参数_口径”的格式,例如momentum_20d_skip1、volatility_20d_log。因子计算代码一经发布,不允许修改同名因子的默认口径;如果必须修改,就生成新因子ID,或者在元数据中递增calc_version。
每次入库写入前,要检查factor_meta里是否已经有同名因子。若存在,调用方需要显式传入版本号,避免覆盖。版本管理不需要复杂工具,先把规范和约定执行到位,比引入分布式配置中心更有价值。
5.3 入库流程
入库流程可以分成三步:读取最新因子宽表、转长表、批量写入factor_value表。以 pandas 为例:
import sqlite3 import pandas as pd def publish_factor( panel: pd.DataFrame, factor_id: str, conn: sqlite3.Connection ) -> None: long_df = panel.stack().rename_axis(["trade_date", "asset_code"]).reset_index() long_df.columns = ["trade_date", "asset_code", "value"] long_df["factor_id"] = factor_id long_df = long_df.dropna(subset=["value"]) long_df.to_sql("factor_value", conn, if_exists="append", index=False)这段代码在个人项目里够用,但生产环境要注意幂等性。如果任务重复执行,同一(factor_id, trade_date, asset_code)会写入两次。因此入库前最好按主键删除旧分区,或者使用INSERT OR REPLACE。以 SQLite 为例:
INSERT OR REPLACE INTO factor_value (trade_date, asset_code, factor_id, value) VALUES (?, ?, ?, ?);5.4 查询 API
因子库最终要能被下游策略或研究平台调用。最小查询接口可以用 FastAPI 实现:
from fastapi import FastAPI, Query import pandas as pd import sqlite3 app = FastAPI() @app.get("/factor/{factor_id}") def get_factor( factor_id: str, start: str = Query(..., description="start date YYYY-MM-DD"), end: str = Query(..., description="end date