
1. 估值因子单测的完整拆解从数据获取到因子清洗估值因子大概是所有量化因子库里最“古老”也最“顽固”的一类。说它古老是因为从格雷厄姆那会儿开始大家就在用PE、PB这些东西给股票定价说它顽固是因为哪怕在因子挖掘已经卷到用遗传算法和神经网络的今天估值因子依然稳稳地待在各大机构的多因子模型里没有被淘汰。但问题也恰恰出在这里——越是看起来简单的因子越容易在数据获取和处理的环节上翻车。我自己在做单因子测试框架的时候估值类因子是第一批被纳入的也是踩坑最多的。原因很简单估值因子的计算高度依赖财务数据和行情数据的对齐而这两类数据的时间戳逻辑完全不同。行情数据是日频的、连续的财务数据是季频的、离散的而且还有披露延迟、追溯调整、报表口径变更等一系列让人头疼的问题。如果这些细节处理不好你算出来的PE可能跟真实值差出好几倍回测出来的IC看着漂亮实盘一跑就废。这篇文章主要面向两类人一是刚开始搭建因子测试框架的量化研究员二是想系统梳理估值因子处理流程的从业者。我会把估值类因子从数据获取、清洗、计算到最终入库的完整链路拆开来讲重点放在那些文档里不会写、但实操中一定会遇到的坑上。整个流程基于我自己的单因子测试框架实践涉及到的工具主要是Python生态里的pandas、numpy和常见的数据库操作不涉及任何特定平台的绑定。2. 估值因子的核心逻辑与数据源选择2.1 为什么估值因子值得单独做一套处理流程估值因子的核心逻辑其实就一句话用价格去衡量基本面。PE是市值除以净利润PB是市值除以净资产PS是市值除以营收股息率是股息除以市值。看起来都是简单的除法但每一个分母都涉及到财务数据的选取问题。举个最直接的例子你在2024年5月10日要计算某只股票的PE_TTM你应该用哪个净利润用2023年年报的净利润用2024年一季报的净利润还是用最近四个季度的滚动净利润这三种选择算出来的PE可能相差30%以上。更麻烦的是2024年一季报在4月底才披露完毕5月10日的时候你确实能拿到一季报数据但如果你回测的时间点是2024年4月20日那时候一季报还没出完你就只能用2023年年报的数据。这种“当时能看到什么数据”的问题就是所谓的Point-in-Time问题是估值因子处理中最核心的难点。所以估值因子不能像动量因子那样拿收盘价直接算个收益率就完事。它需要一套完整的数据对齐机制确保在任何一个历史时间点上你用的财务数据都是当时市场上真实可获取的。这套机制如果没搭好后面的因子测试全是空中楼阁。2.2 数据源的选择与获取方式做估值因子你需要两类基础数据行情数据和财务数据。行情数据相对简单日频的收盘价、总市值、流通市值这些从常见的金融数据接口都能拿到。关键是市值数据的口径要统一——有些数据源给的是总市值有些是流通市值有些是自由流通市值。做估值因子一般用总市值因为净利润和净资产都是全口径的分子分母要匹配。如果你用流通市值去除以总净利润那算出来的东西就没有经济含义了。财务数据的获取就复杂得多。你需要至少三张表的数据利润表里的净利润、资产负债表里的净资产和总资产、现金流量表里的经营现金流。而且每张表都有多个口径——净利润有归母净利润和净利润含少数股东损益净资产有归母净资产和所有者权益合计。做估值因子通常用归母口径因为市值是归母股东享有的。注意不同数据源的财务数据字段命名和口径可能不同在开始计算之前一定要做一次全面的字段映射和口径核对。我见过有人把“净利润”和“归母净利润”搞混算出来的PE差了20%以上。数据获取的方式取决于你的技术栈。如果用的是本地数据库一般是通过SQL查询按股票代码和报告期拉取如果用的是在线接口通常是按交易日或按报告期批量获取。不管哪种方式核心原则是一致的行情数据按交易日对齐财务数据按公告日期对齐而不是按报告期对齐。2.3 财务数据的Point-in-Time对齐逻辑这是整个估值因子处理中最关键的一步也是最容易出错的地方。财务数据的对齐不能用报告期必须用公告日期。什么意思2023年年报的报告期是2023年12月31日但它的公告日期可能是2024年3月15日。如果你在2024年1月1日就用这份年报的数据去算PE那就是用了未来信息回测结果会严重高估。正确的做法是对于每个交易日找到在该交易日之前含当日已经公告的最近一期财务数据。如果2024年3月14日之前只有2023年三季报的数据那就用三季报到了2024年3月15日年报公告了就切换到年报数据。这个逻辑用伪代码表示大概是这样的# 假设financial_data包含stock_code, report_period, announce_date, net_profit等字段 # 假设trade_dates是交易日列表 for trade_date in trade_dates: # 找到该交易日之前已公告的最近一期财报 available financial_data[ (financial_data[announce_date] trade_date) (financial_data[stock_code] stock_code) ] if not available.empty: latest available.sort_values(announce_date).iloc[-1] # 用latest的财务数据计算估值因子实际实现的时候用pandas的merge_asof或者SQL的窗口函数会更高效但逻辑是一样的。还有一个细节财报的追溯调整。有些公司会在后续的财报中调整之前报告期的数据比如2023年年报里把2022年的净利润做了修正。这时候你需要决定是用原始公告的数据还是用调整后的数据。一般来说做回测应该用原始公告的数据因为那才是当时市场看到的信息。但很多数据源只提供调整后的数据这就需要你额外注意。3. 估值因子的计算细节与常见陷阱3.1 PE因子的三种口径与选择PE是估值因子中最基础的一个但它的计算口径至少有三种静态PEPE_LYR市值除以最近一个完整财年的净利润。比如2024年5月用2023年年报的净利润。滚动PEPE_TTM市值除以最近四个季度的滚动净利润。比如2024年5月用2023Q2到2024Q1的净利润之和。动态PEPE_FY1市值除以分析师一致预期的未来一年净利润。这个需要预期数据不在本文讨论范围内。做单因子测试最常用的是PE_TTM因为它兼顾了时效性和稳定性。PE_LYR太滞后PE_FY1又依赖预期数据只有PE_TTM是纯客观的、可复现的。计算PE_TTM的时候滚动净利润的算法需要注意。如果最近四个季度是2023Q2、2023Q3、2023Q4、2024Q1那么滚动净利润就是这四个季度单季净利润之和。但很多数据源只提供累计净利润比如2023Q2给的是2023年1-6月的累计净利润2023Q3给的是1-9月的累计净利润。这时候你需要先把累计值转换成单季值# 假设df包含stock_code, report_period, cumulative_profit # 将累计净利润转换为单季净利润 df[single_quarter_profit] df.groupby(stock_code)[cumulative_profit].diff() # 对于每个股票的第一条记录通常是Q1单季值等于累计值 df.loc[df.groupby(stock_code).head(1).index, single_quarter_profit] \ df.loc[df.groupby(stock_code).head(1).index, cumulative_profit]这个转换看起来简单但实际操作中经常遇到问题。比如某只股票2023Q1的累计净利润是负的2023Q2的累计净利润还是负的但亏损减少了diff之后单季净利润是正的这在逻辑上没问题但如果你没注意到这种情况可能会觉得数据有问题。提示在计算滚动净利润之前一定要检查每个季度是否有缺失。如果某只股票缺少2023Q3的数据那它的PE_TTM在2024年5月就没法算需要标记为缺失值而不是用其他季度的数据凑。3.2 PB因子的净资产口径问题PB的计算公式是市值除以归母净资产。看起来比PE简单因为净资产是时点数据不需要滚动求和。但净资产的坑在于它也会被追溯调整而且不同报告期的净资产口径可能不一致。比如某公司在2023年年报中进行了会计政策变更把原本计入负债的某项科目重分类到了权益导致2023年末的净资产比2022年末大幅增加。这种变更在公告日期之前你是不知道的所以回测的时候必须用原始公告的数据。另外净资产有正有负。对于净资产为负的公司PB是负的这种股票在因子测试中通常需要特殊处理。有些做法是直接剔除有些做法是取绝对值有些做法是单独分一组。我个人的经验是净资产为负的股票数量很少直接剔除对整体结果影响不大但如果你的股票池里这类股票占比超过1%就需要单独分析一下。3.3 PS和股息率因子的特殊处理PS是市值除以营业收入计算逻辑跟PE类似也需要滚动四个季度的营收。但营收的数据质量通常比净利润好因为营收的操纵空间相对小一些追溯调整的情况也少一些。股息率因子比较特殊它是股息除以市值。这里的股息通常用最近12个月的现金分红总额。但分红的数据获取比较麻烦因为分红方案有多个时间节点预案公告日、股东大会通过日、除权除息日、派息日。做回测的时候应该用除权除息日来确认分红是否已经发生因为在那之前分红还没有真正到账。# 股息率计算示例 # dividend_data包含stock_code, ex_date, dividend_per_share # 对于每个交易日找到过去12个月内除权除息的分红总额 for trade_date in trade_dates: start_date trade_date - timedelta(days365) dividends dividend_data[ (dividend_data[ex_date] start_date) (dividend_data[ex_date] trade_date) (dividend_data[stock_code] stock_code) ] total_dividend dividends[dividend_per_share].sum() # 股息率 总股息 / 收盘价 dividend_yield total_dividend / close_price这个逻辑看起来简单但实际操作中会遇到分红数据缺失、分红币种不同、特别分红和普通分红混在一起等问题。我的建议是如果数据源提供了计算好的股息率字段优先用数据源的如果没有自己算的时候一定要做交叉验证。4. 数据清洗与异常值处理的实操方案4.1 估值因子的极值问题与处理方法估值因子的分布有一个显著特点右偏严重。大部分股票的PE在10到50之间但总有那么几只股票的PE是几百甚至几千。这些极值如果不处理会严重扭曲因子测试的结果比如IC的计算会被少数极值股票主导。处理极值的方法主要有三种Winsorize缩尾处理把超过某个分位数的值替换为该分位数的值。常用的分位数是1%和99%或者5%和95%。中位数绝对偏差法MAD用中位数加减若干倍的MAD来界定合理范围超出范围的值替换为边界值。直接剔除把超过阈值的股票直接从样本中删掉。我个人的做法是先做MAD去极值再做Winsorize。MAD的好处是对极值不敏感能更稳健地识别异常值。具体参数上我通常用5倍MAD作为阈值然后把超出范围的值缩尾到边界。def mad_winsorize(series, n5): median series.median() mad (series - median).abs().median() upper median n * mad lower median - n * mad return series.clip(lower, upper)注意去极值应该在每个截面上独立进行而不是在时间序列上做。因为不同时期的估值水平不同用全样本的极值阈值去处理每个截面的数据是不合理的。4.2 缺失值的处理策略估值因子的缺失值主要有两个来源一是财务数据缺失比如某只股票没有2023Q3的财报二是行情数据缺失比如某只股票在某个交易日停牌了。对于财务数据缺失如果缺失的是最近一个季度的数据可以用上一个季度的数据代替但需要标记出来。如果连续缺失两个季度以上建议直接剔除该股票在该时间段的样本。对于行情数据缺失停牌期间的收盘价通常是沿用上一个交易日的价格但市值可能没有更新。这时候计算出来的估值因子是失真的。我的做法是停牌超过5个交易日的股票在停牌期间不参与因子测试。# 标记停牌股票 df[is_suspended] df[volume] 0 # 对于停牌超过5天的股票标记为不参与 df[suspend_days] df.groupby(stock_code)[is_suspended].transform( lambda x: x.rolling(5).sum() ) df.loc[df[suspend_days] 5, valid] False4.3 财务数据与行情数据的对齐检查数据对齐是估值因子处理中最容易出错的环节。我见过太多人因为对齐没做好导致回测结果虚高。这里分享一个我常用的对齐检查方法取10只股票手动查它们在某几个交易日应该用的财务数据然后跟程序算出来的结果对比。如果一致说明对齐逻辑没问题如果不一致就逐条排查。具体检查点包括财报公告日期是否准确有些数据源的公告日期是报告期的最后一天这是错的滚动净利润的四个季度是否正确市值数据是否与收盘价和股本一致除权除息日的分红数据是否已经纳入这个检查看起来笨但能帮你省下大量调试时间。5. 因子入库与单因子测试的衔接5.1 因子数据的存储格式与更新机制估值因子算完之后需要存储到因子库中供后续的单因子测试使用。存储格式通常有两种宽表和长表。宽表是每行一个交易日每列一只股票单元格是因子值。这种格式适合做截面分析但股票数量多的时候列数会很大。长表是每行一个股票-交易日组合包含股票代码、交易日、因子值等字段。这种格式更灵活适合做各种维度的分析。我一般用长表存储因为估值因子的股票池会随时间变化宽表处理起来不方便。存储的时候除了因子值本身还会存一些辅助字段是否有效、是否停牌、所属行业等。更新机制上估值因子的更新频率取决于财务数据的更新频率。行情数据每天更新但财务数据只在财报季更新。所以我的做法是每天更新行情相关的部分市值、收盘价财报季的时候批量更新财务数据然后重新计算所有估值因子。5.2 单因子测试前的数据准备因子入库之后在跑单因子测试之前还需要做几件事股票池筛选剔除ST股票、上市不足60天的次新股、停牌股票。这些股票的估值因子往往失真纳入测试会干扰结果。行业中性化估值因子在不同行业之间的差异很大银行股的PE普遍在5到10之间科技股的PE可能在50以上。如果不做行业中性化因子测试的结果可能只是在反映行业差异而不是估值因子本身的选股能力。市值中性化大市值的股票估值通常比小市值的低这也是一个需要控制的变量。行业中性化的做法是在每个截面内对每个行业的因子值减去该行业的中位数或者做行业哑变量的回归取残差。我通常用中位数去均值的方法简单且稳健。# 行业中性化示例 df[pe_neutral] df.groupby([trade_date, industry])[pe_ttm].transform( lambda x: x - x.median() )5.3 因子测试的评估指标与解读估值因子的单因子测试核心看几个指标IC值、IC_IR、多空收益、换手率。IC值是因子值与未来收益的截面相关系数。估值因子通常是负向因子——PE越低未来收益越高所以IC值一般是负的。看IC的时候主要看绝对值绝对值越大说明因子的预测能力越强。IC_IR是IC的均值除以标准差衡量的是因子预测能力的稳定性。多空收益是把股票按因子值分成若干组做多因子值最低的一组做空因子值最高的一组看这个组合的收益。估值因子的多空收益通常不会特别高年化在5%到10%之间就算不错了。但它的优势在于稳定性好回撤小适合作为多因子模型中的底仓因子。换手率是衡量因子交易频率的指标。估值因子的换手率通常较低因为财务数据更新频率低因子值的变化主要来自价格波动。低换手率意味着交易成本低这是估值因子的一个天然优势。提示估值因子的IC衰减通常比较慢持有期可以适当放长。我测试下来持有20个交易日的IC通常比持有5个交易日的IC更高说明估值因子的信息释放需要时间。6. 常见问题排查与实操避坑指南6.1 数据获取阶段的典型问题问题一财报公告日期不准确。有些数据源把公告日期统一设为报告期的最后一天比如2023年年报的公告日期写成2023年12月31日。这会导致严重的未来信息泄露。排查方法是随机抽取几只股票手动查它们的实际公告日期跟数据源对比。问题二财务数据字段缺失。小市值股票或次新股的财务数据经常有缺失。处理方法是对于缺失的字段先用同行业同市值区间的中位数填充如果还是缺失就标记为无效样本。问题三市值数据口径不一致。有些数据源的市值是总市值有些是流通市值有些是自由流通市值。做估值因子必须用总市值因为净利润和净资产是全口径的。排查方法是用收盘价乘以总股本看是否等于数据源提供的市值。6.2 因子计算阶段的常见错误错误一滚动净利润的季度数不对。比如用了2023Q1到2023Q4的净利润但实际上是2023Q2到2024Q1。这种错误在代码里很难发现因为数字看起来都正常。排查方法是手动计算一只股票的PE_TTM跟程序结果对比。错误二净资产用了含少数股东权益的口径。归母净资产和所有者权益合计的差异可能很大尤其是对于有大量子公司的企业。排查方法是检查数据源的字段说明确认用的是归母净资产。错误三股息率的分红数据没有按除权除息日对齐。用了预案公告日的分红数据导致提前纳入了未实施的分红。排查方法是检查分红数据的日期字段确认用的是除权除息日。6.3 因子测试阶段的异常结果排查异常一IC值异常高。如果估值因子的IC绝对值超过0.1大概率是数据有问题。正常来说估值因子的IC在-0.05到-0.02之间。排查方向检查是否有未来信息泄露检查财务数据的对齐是否正确。异常二多空收益曲线过于平滑。如果多空收益曲线几乎没有回撤说明因子可能包含了未来信息。正常的估值因子多空收益曲线应该有明显的波动和回撤。异常三换手率异常低。如果换手率接近0说明因子值几乎没有变化。排查方向检查财务数据是否在更新检查市值数据是否在更新。6.4 常见问题速查表问题类型典型表现排查方法解决方案公告日期错误IC异常高手动核对公告日期用正确的公告日期重新对齐滚动净利润错误PE值与实际不符手动计算对比检查季度数和累计值转换净资产口径错误PB值偏小核对字段说明改用归母净资产分红数据未对齐股息率偏高检查日期字段用除权除息日对齐极值未处理IC波动大检查因子分布做MAD去极值行业未中性化因子收益集中在某行业分行业看IC做行业中性化7. 我个人的实操体会与后续扩展方向估值因子的处理流程看起来繁琐但一旦搭好后续的维护成本其实很低。因为财务数据的更新频率低行情数据的处理逻辑也相对固定。我自己的框架跑了一年多除了财报季需要检查一下数据质量平时基本不用怎么管。踩过的最大的坑是公告日期的对齐。早期我用的是报告期对齐回测结果好得离谱年化多空收益超过20%。后来发现是未来信息泄露修正之后收益直接掉到6%左右。这个教训让我后来在所有因子处理中都把Point-in-Time对齐作为第一优先级。另一个体会是估值因子不适合单独使用。它的IC不高多空收益也一般但它的优势在于与其他因子的相关性低。把估值因子和动量、质量、成长因子放在一起组合的IC_IR会有明显提升。所以做单因子测试的时候不要因为估值因子的单独表现一般就放弃它要看它在多因子模型中的边际贡献。后续扩展的话可以考虑几个方向一是加入分析师预期数据做PE_FY1和PEG因子二是做估值因子的时序标准化比如用过去3年的PE分位数来代替原始PE值三是做估值因子的行业中性化改进比如用行业PE中位数来标准化个股PE。这些方向我都试过一些效果各有优劣有机会再单独展开聊。