
量化金融里的因子研究走到一定阶段最明显的感受是跑通一个单因子已经不难难的是让这个因子从一次性的探索脚本变成可持续更新、可复测、可被下游策略稳定调用的数据资产。这个阶段通常被称为“因子阶段”而它的收官标志不是某个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_quoteasset_code, trade_date, open, high, low, close, volume, amount保存原始行情复权方式要单独记录stock_basicasset_code, list_date, delist_date, industry上市状态避免退市股进入样本trade_calendartrade_date, is_open交易所交易日历financial_indicatorasset_code, report_date, 净利率, 营收同比等基本面因子原材料index_memberindex_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(dropTrue) df[pct_chg] df.groupby(asset_code)[close].pct_change() df df.merge( stock_basic[[asset_code, list_date, delist_date]], onasset_code, howleft, validatemany_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( indextrade_date, columnsasset_code, valuesvalue_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 fmomentum_{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 日收盘研究未来收益通常对应 T1 到 TN。若用 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, labelsFalse, duplicatesdrop ) 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会尽量把样本分成等频区间但如果因子值大量重复会出现分位点重叠需要用duplicatesdrop处理。第二dropna()非常重要否则缺失值会参与分组导致统计口径混乱。第三分组数n_groups通常取 3 到 10日频因子常用 5 组既能观察单调性也保证每组样本量足够。4.3 IC、IR 和分层检验一起看F 检验描述“组间均值差异是否显著”IC 描述“因子与未来收益的秩相关”IR 描述“IC 的均值与波动之比”三者各有侧重。推荐至少输出以下指标到检验报告指标含义常见参考范围IC均值因子与未来收益秩相关的历史均值绝对值大于 0.02 算弱有效大于 0.05 算较明显ICIRIC 均值除以 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_existsappend, indexFalse)这段代码在个人项目里够用但生产环境要注意幂等性。如果任务重复执行同一(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(..., descriptionstart date YYYY-MM-DD), end: str Query(..., descriptionend date