资讯动态

Python+pandas搭建A股因子挖掘系统:数据清洗、IC检验与分层回测

发布时间:2026/10/27 2:14:37 来源:尧图企业网站定制
做量化研究时卡住进度的往往不是策略想法而是数据接口对不上、因子算完不知道对不对、回测跑出来的曲线又不知道可信不可信。这篇就把“从拉数据到因子检验”这条链路完整串一遍用 Python pandas 搭一套最小可运行的 A 股因子挖掘系统包含数据清洗、因子构造、IC/IR 检验、分层回测四个环节全部代码可复制运行。零基础可以照着跑通有经验的开发者可以直接把里面的模块换成自己的实现。需要先说明本文的定位这是一篇技术实现教程讨论的是数据处理、统计检验和代码组织方式不构成任何投资建议也不承诺任何收益。文中所有因子方向和结论都只是待验证的假设样本区间、股票池一换结果就会变请务必自己在本地验证。1. 先搞清楚一套 A 股量化系统到底由哪几块组成1.1 从“因子”这个词说起通俗地讲因子就是把一只股票压缩成一个数字的规则。比如“过去 20 个交易日的涨跌幅”就是一个动量因子“过去 20 天日收益率的标准差”就是一个波动率因子。把全市场几千只股票在同一天都算出这个数字再排序就得到了一张当日的因子截面表。专业一点的定义是因子是一个横截面映射函数输入是某只股票在某一时点可获取的全部信息输出是一个可比较的标量。因子挖掘的本质是寻找那些对未来收益具有稳定预测能力的标量。这里要区分两个容易混淆的概念。因子factor是做选股打分的输入变量特征feature在机器学习语境下几乎等价但更强调喂给模型指标indicator通常指技术分析里的 MACD、KDJ 这类往往是单只股票时间序列上的信号不强调横截面可比性。量化选股关心的是横截面排序所以因子必须保证“同一时点、不同股票之间可比”这一点决定了后面必须做标准化。1.2 因子挖掘全链路的四个环节一套完整的因子挖掘流程可以拆成下面四步缺一环结论就不可信数据层拿到行情数据做复权、去重、缺失值处理拼成“日期 × 股票”的面板。因子层基于面板计算原始因子值做极值处理与横截面标准化。检验层计算 IC、RankIC、ICIR再做分层回测看单调性。决策层把通过检验的因子合成进入组合构建和风险控制。本文覆盖前三层第四层只做简单说明。很多教程的问题在于只写了第二步给了个df[factor] ...就结束了读者拿到因子值根本不知道好坏这套系统就白跑了。1.3 本文示例系统的能力边界必须提前把边界讲清楚避免误用能力是否支持说明日频行情拉取支持通过公开数据接口获取字段需按版本适配因子计算与标准化支持示例给三个因子可自行扩展IC / ICIR 检验支持基于 Spearman 秩相关分层回测支持等权分组不含交易成本交易成本、滑点不支持需要自行扩展涨跌停、停牌处理部分支持只在数据清洗阶段做基础过滤实盘下单不支持本文不涉及任何交易接口再强调一次分层回测得到的净值曲线是统计意义上的分组收益不含手续费、印花税、冲击成本也没有考虑流动性和涨跌停无法成交的情况。看到一条漂亮的曲线就以为找到了圣杯是新手最容易踩的坑。2. 环境准备与项目结构2.1 Python 环境与依赖版本本文示例使用 Python 3.9 及以上版本依赖主要集中在数据处理侧。具体版本请以你本地实际安装为准不同版本在groupby的行为上存在差异后文会专门提示。操作系统Windows / macOS / Linux 均可 Python3.9 pandas1.5 或 2.x numpy1.23 akshare用于行情数据获取请安装较新版本这里特别提醒pandas的版本问题。pandas 2.x对DataFrameGroupBy.apply增加了include_groups参数的弃用警告如果直接照抄老写法控制台会刷一屏警告。本文的代码统一改成用transform和显式选列的方式尽量兼容两个大版本。2.2 依赖安装新建一个干净的虚拟环境避免和本机其他项目冲突# 创建并激活虚拟环境 python -m venv venv # Windows venv\Scripts\activate # macOS / Linux source venv/bin/activate # 安装依赖 pip install pandas numpy akshare如果你的网络环境下载较慢可以加国内镜像源pip install pandas numpy akshare -i https://pypi.tuna.tsinghua.edu.cn/simpleakshare的接口签名会随版本迭代调整安装完成后建议先跑一个最小验证确认接口可用再往下走# check_env.py import akshare as ak df ak.stock_zh_a_hist( symbol600519, perioddaily, start_date20230101, end_date20231231, adjustqfq, ) print(df.shape) print(df.columns.tolist()) print(df.head())如果这段能打印出 DataFrame 和列名说明数据通道没问题。请把你实际看到的列名记下来后面第 3 节要据此调整映射表。2.3 项目目录结构把代码按职责拆成几个文件比全部塞进一个.py更容易维护和排查。推荐结构如下quant_factor_demo/ ├── config.py # 全局配置股票池、时间区间、参数 ├── data_loader.py # 数据获取、清洗、缓存 ├── factors.py # 因子构造与标准化 ├── evaluate.py # IC 检验与分层回测 ├── main.py # 主流程入口 └── cache/ # 本地缓存目录自动生成拆文件的另一个好处是当你想换数据源时只需要改data_loader.py因子和检验代码完全不用动。这个“数据源可替换”的设计在真实项目里价值很高因为数据供应商更换是很常见的事。3. 数据层行情数据的获取与清洗3.1 数据源选型与复权口径示例使用akshare的stock_zh_a_hist接口获取 A 股日频历史行情。这个接口支持按股票代码、起止日期和复权方式查询返回的字段大致包含日期、开高低收、成交量、成交额、涨跌幅、换手率等。关于复权一定要用前复权adjustqfq。原因很直接如果一只股票发生过送转或分红除权未复权的价格会出现一个巨大缺口20 日收益率会被这个缺口彻底扭曲动量因子算出来就是垃圾。前复权把历史价格按最新股本折算保证价格序列连续这是计算收益率型因子的前提。后复权hfq也不是错它的特点是历史价格不变、最新价格被放大适合做长期收益统计。但对“滚动窗口内收益率”这类计算前复权的数值更贴近真实交易视角。本文统一用前复权。3.2 列名兼容与字段规范化数据接口返回的列名是中文的而后续代码统一用英文列名所以需要一层映射。不同版本的接口可能少一两个字段映射表要做成“存在才映射”的容错写法不能硬写死。# data_loader.py import os import time import pandas as pd try: import akshare as ak except ImportError: # 允许在无数据源的机器上导入其他模块 ak None # 中文列名到英文列名的映射按实际接口返回增删 COLUMN_MAP { 日期: date, 股票代码: code, 开盘: open, 收盘: close, 最高: high, 最低: low, 成交量: volume, 成交额: amount, 振幅: amplitude, 涨跌幅: pct_chg, 涨跌额: change, 换手率: turnover, } KEEP_COLUMNS [date, code, open, high, low, close, volume, amount, turnover] def _normalize(df: pd.DataFrame, code: str) - pd.DataFrame: 统一列名、类型与列顺序保证后续计算不因版本差异报错。 df df.rename(columnsCOLUMN_MAP).copy() df[code] code df[date] pd.to_datetime(df[date]) # 缺失字段补空列避免 KeyError for col in KEEP_COLUMNS: if col not in df.columns: df[col] pd.NA df df[KEEP_COLUMNS].sort_values(date).reset_index(dropTrue) numeric_cols [open, high, low, close, volume, amount, turnover] for col in numeric_cols: df[col] pd.to_numeric(df[col], errorscoerce) return df这段代码里有几个细节值得说。pd.to_numeric(..., errorscoerce)会把无法转换的值变成NaN而不是抛异常这在处理脏数据时更稳妥先补空列再选列是为了防止接口少返回某个字段时直接崩掉sort_values(date)必须做滚动计算强依赖时间顺序顺序错了结果全错。3.3 批量拉取与本地缓存逐只股票拉取时两个问题必须处理一是接口限流二是重复拉取浪费时间。加本地 CSV 缓存和请求间隔是最简单的解法。def fetch_one(code: str, start_date: str, end_date: str, adjust: str qfq) - pd.DataFrame: 拉取单只股票日频行情。 if ak is None: raise RuntimeError(未安装 akshare请先执行 pip install akshare) raw ak.stock_zh_a_hist( symbolcode, perioddaily, start_datestart_date, end_dateend_date, adjustadjust, ) if raw is None or raw.empty: return pd.DataFrame() return _normalize(raw, code) def fetch_pool(stock_pool, start_date, end_date, adjustqfq, cache_dircache, sleep_sec0.3): 批量拉取股票池命中缓存则直接读取。 os.makedirs(cache_dir, exist_okTrue) frames [] for code in stock_pool: cache_file os.path.join(cache_dir, f{code}_{start_date}_{end_date}_{adjust}.csv) if os.path.exists(cache_file): df pd.read_csv(cache_file, parse_dates[date]) frames.append(df) print(f[CACHE] {code} 命中缓存{len(df)} 条) continue try: df fetch_one(code, start_date, end_date, adjust) except Exception as exc: print(f[WARN] {code} 拉取异常: {exc}) continue if df.empty: print(f[WARN] {code} 返回空数据) continue df.to_csv(cache_file, indexFalse) frames.append(df) print(f[OK] {code} 拉取 {len(df)} 条) time.sleep(sleep_sec) if not frames: raise RuntimeError(股票池未获取到任何数据请检查代码或网络) panel pd.concat(frames, ignore_indexTrue) return panel.sort_values([code, date]).reset_index(dropTrue)把缓存文件名带上起止日期和复权方式是为了避免“换了区间还读到旧数据”这种隐蔽 bug。sleep_sec不要设成 0公开接口连续高频请求很容易被限0.2 到 0.5 秒是比较稳妥的间隔。4. 因子层三个可复现的经典因子实现4.1 动量因子过去 20 日收益率动量因子的经济直觉是“趋势延续”过去一段时间表现强势的股票短期内可能继续强势。实现上就是当前收盘价除以 20 个交易日前的收盘价再减一。import numpy as np import pandas as pd def add_price_derived(panel: pd.DataFrame, window: int 20, min_periods: int 15) - pd.DataFrame: 基于价格与换手率派生基础因子。 df panel.sort_values([code, date]).copy().reset_index(dropTrue) by_code df.groupby(code) # 日收益率 df[ret_1d] by_code[close].transform(lambda s: s.pct_change()) # 动量因子过去 window 日累计收益 df[mom_20] by_code[close].transform(lambda s: s / s.shift(window) - 1) # 波动率因子过去 window 日收益率标准差 df[vol_20] df.groupby(code)[ret_1d].transform( lambda s: s.rolling(window, min_periodsmin_periods).std() ) # 换手率因子过去 window 日平均换手率 df[turn_20] by_code[turnover].transform( lambda s: s.rolling(window, min_periodsmin_periods).mean() ) return dfmin_periods15这个参数容易被忽略。它表示窗口内至少有 15 个有效值才计算结果剩下的允许缺失。如果不设新股上市不满 20 天时整个因子都是NaN白白浪费样本如果设得太小比如 5那算出来的“20 日波动率”其实只有 5 天数据噪声极大。15 是一个折中值你可以根据自己的股票池调整。4.2 波动率因子收益率标准差波动率因子的假设来自“低波动异象”长期看低波动股票的风险调整后收益往往不差。计算方式就是滚动标准差。要注意的是波动率因子和动量因子都基于收益率但捕捉的信息完全不同。动量看的是方向波动率看的是离散程度。两只股票可能涨幅一样但一只天天小幅上涨另一只暴涨暴跌它们的动量因子接近而波动率因子差异巨大。这正是多因子体系的价值用不同维度描述同一只股票。# 计算示例观察波动率因子的分布 print(df[vol_20].describe()) # 检查缺失比例缺失过高说明 min_periods 设置或数据有问题 print(vol_20 缺失率:, df[vol_20].isna().mean())跑完后先看describe()的输出。如果max出现几十甚至上百这种离谱数值基本可以断定是数据里有异常收益率比如未复权导致的除权缺口、或者单日数据错误。这时候不要急着调因子先去查数据。4.3 换手率因子流动性维度换手率反映交易活跃度常见的假设是“低换手股票存在流动性溢价”也就是被市场忽视的股票未来可能有更好表现。这个假设在 A 股历史上被讨论得很多但它在不同市值分组、不同市场阶段的表现差异很大必须自己验证。# 换手率因子的横截面分布检查 print(df.groupby(date)[turn_20].mean().tail(10))这里要注意某些数据源返回的换手率单位是百分比比如 3.5 表示 3.5%有些是小数0.035。单位不统一不影响排序类检验但会影响你对数值的直观判断建议在数据层就统一口径并写注释说明。4.4 横截面标准化与极值处理原始因子值不能直接比较。动量因子的量纲是收益率波动率因子的量纲也是收益率但分布形态不同换手率则是百分比。要放在一起看必须先做横截面标准化在每一个交易日内部把所有股票的因子值减均值除标准差。同时要做极值处理winsorize把超出 1% 和 99% 分位数的值截断。原因是财务造假、数据错误、极端行情会产生离群值一个 500% 的收益率能把整个截面的均值和标准差都带偏。def cs_winsorize_zscore(s: pd.Series, lower: float 0.01, upper: float 0.99) - pd.Series: 横截面去极值 Z-Score 标准化。 valid s.dropna() if valid.shape[0] 10: return pd.Series(np.nan, indexs.index) q_low valid.quantile(lower) q_high valid.quantile(upper) clipped s.clip(q_low, q_high) std clipped.std() if not np.isfinite(std) or std 0: return pd.Series(0.0, indexs.index) return (clipped - clipped.mean()) / std标准化必须按日期分组做绝不能对整个面板一次性做。对整个面板做等于把不同时间的行情混在一起牛市期间的因子值和熊市期间的因子值被强行拉到同一尺度这在逻辑上就是错的。def build_factor_panel(panel: pd.DataFrame, forward_days: int 5) - pd.DataFrame: 构造完整因子面板含前视收益与标准化因子。 df add_price_derived(panel) # 前视收益未来 forward_days 个交易日的收益率 df[fwd_ret] df.groupby(code)[close].transform( lambda s: s.shift(-forward_days) / s - 1 ) # 三个因子分别做横截面标准化 for col in [mom_20, vol_20, turn_20]: df[f{col}_z] df.groupby(date)[col].transform(cs_winsorize_zscore) return dffwd_ret这个字段是后面所有检验的基础必须理解清楚它用的是未来的价格所以只用于评估绝对不能出现在因子计算里。如果在构造因子时不小心用了shift(-n)就会造成未来函数回测结果会好得离谱实盘必然失效。这是量化开发里最致命的一类 bug没有之一。5. 因子检验层IC、IR 与分层回测5.1 前视收益与调仓周期前视收益的窗口长度要和调仓周期匹配。如果你打算每 5 个交易日调一次仓那前视收益就用 5 日如果每 20 天调一次就用 20 日。两者不匹配会导致检验结论和实际策略脱节。还有一个非常关键的细节重叠的前视收益不能直接累乘。假设你每天都算 5 日前视收益相邻两天的收益窗口有 4 天重叠把这些数值当成独立期间去累乘会严重夸大收益和波动。正确做法是每隔 5 个交易日取一个截面让样本互不重叠。def pick_rebalance_dates(panel: pd.DataFrame, freq: int 5): 按固定间隔抽取调仓日避免前视收益重叠。 dates sorted(panel[date].unique()) return set(dates[::freq])这一步在后面的主流程里会用到所有 IC 计算和分层回测都只在调仓日截面上进行。5.2 IC / RankIC / ICIR 的计算ICInformation Coefficient衡量的是“因子排序”和“未来收益排序”的一致性。最常用的是 RankIC也就是因子值和前视收益的 Spearman 秩相关系数。选 Spearman 而不是 Pearson原因是 Pearson 对极端值非常敏感而且假设线性关系Spearman 只看排序对因子的单调变换不敏感更符合“因子是用来排序选股”的使用场景。# evaluate.py import numpy as np import pandas as pd def calc_ic_series(df: pd.DataFrame, factor_col: str, fwd_col: str fwd_ret, min_stocks: int 10) - pd.Series: 计算逐期 RankIC 序列。 sub df.dropna(subset[factor_col, fwd_col]).copy() if sub.empty: return pd.Series(dtypefloat) counts sub.groupby(date)[factor_col].transform(count) sub sub[counts min_stocks] if sub.empty: return pd.Series(dtypefloat) ic sub.groupby(date)[[factor_col, fwd_col]].apply( lambda x: x[factor_col].corr(x[fwd_col], methodspearman) ) return ic.dropna() def ic_summary(ic_series: pd.Series) - dict: 把 IC 序列汇总成可读指标。 if ic_series.empty: return {IC均值: np.nan, IC标准差: np.nan, ICIR: np.nan, IC0占比: np.nan, 样本期数: 0} mean_ic ic_series.mean() std_ic ic_series.std() icir mean_ic / std_ic if std_ic and np.isfinite(std_ic) and std_ic ! 0 else np.nan return { IC均值: round(float(mean_ic), 4), IC标准差: round(float(std_ic), 4), ICIR: round(float(icir), 4) if np.isfinite(icir) else np.nan, IC0占比: round(float((ic_series 0).mean()), 4), 样本期数: int(ic_series.shape[0]), }怎么判断一个因子“有没有用”给几条经验参考注意只是参考区间不是硬标准IC 均值绝对值长期稳定在 0.03 以上算是有点东西0.05 以上比较理想ICIR 大于 0.3 说明稳定性尚可IC 大于 0 的期数占比超过 55% 说明方向一致性较好。如果 IC 均值只有 0.005 且正负各半那这个因子基本没有 alpha。还要看 IC 的时间序列形态。如果一个因子只在某一年表现好其他年份都很差那它很可能只是拟合了那一年的市场风格不是稳定的 alpha 来源。5.3 分层回测与单调性判断IC 只告诉你“排序是否相关”分层回测告诉你“差距有多大”。做法是每个调仓日按因子值把股票分成 N 组通常是 5 组或 10 组计算每组的等权平均前视收益然后看组间收益是否单调。def layer_backtest(df: pd.DataFrame, factor_col: str, fwd_col: str fwd_ret, n_groups: int 5, direction: int 1, min_stocks: int 10): 分层回测返回每组逐期收益与累计净值。 sub df.dropna(subset[factor_col, fwd_col]).copy() if sub.empty: return pd.DataFrame(), pd.DataFrame() counts sub.groupby(date)[factor_col].transform(count) sub sub[counts max(min_stocks, n_groups)] if sub.empty: return pd.DataFrame(), pd.DataFrame() # direction1 表示因子值越大越看好-1 表示越小越看好 sub[raw_factor] sub[factor_col] * direction sub[group] sub.groupby(date)[raw_factor].transform( lambda s: pd.qcut(s.rank(methodfirst), n_groups, labelsFalse) ) group_ret sub.groupby([date, group])[fwd_col].mean().unstack() group_ret group_ret.sort_index() nav (1 group_ret.fillna(0)).cumprod() return group_ret, navpd.qcut前先做.rank(methodfirst)是为了处理因子值重复的情况。如果直接对含大量重复值的序列做qcut会报 Bin edges must be unique 的错误尤其是换手率这类容易出现相同值的因子。direction参数用于统一方向。因为不同因子的“好”方向不同动量是越大越好direction1波动率和换手率是先验假设越小越好direction-1。乘上方向后所有因子的第 N-1 组都代表“最看好组”方便横向对比。6. 完整实战三分钟跑通全链路6.1 config.py把参数集中管理# config.py # 股票池先用 20 只流动性较好的标的跑通流程 STOCK_POOL [ 600519, 601318, 600036, 000858, 000333, 600276, 601166, 600030, 000651, 002415, 600887, 601888, 600900, 000001, 601012, 300750, 002594, 600309, 601899, 600028, ] START_DATE 20220101 END_DATE 20231231 ADJUST qfq FORWARD_DAYS 5 # 前视收益窗口 REBALANCE_FREQ 5 # 每 5 个交易日取一次截面 N_GROUPS 5 # 分层组数 MIN_STOCKS 10 # 单个截面最少股票数 FACTORS [mom_20_z, vol_20_z, turn_20_z] # 先验方向1 表示因子值越大越看好-1 表示越小越看好 FACTOR_DIRECTION { mom_20_z: 1, vol_20_z: -1, turn_20_z: -1, } CACHE_DIR cache股票池只放 20 只是刻意的。全市场 5000 只股票拉一遍要好几个小时第一次跑通流程完全没必要。等流程验证没问题再逐步扩充股票池到几百只、上千只。这个“先小后大”的调试习惯能省掉大量无谓等待。6.2 main.py把四步串起来# main.py import pandas as pd import config from data_loader import fetch_pool from factors import build_factor_panel from evaluate import calc_ic_series, ic_summary, layer_backtest, pick_rebalance_dates def main(): print( * 64) print(Step 1/4 拉取行情数据) panel fetch_pool( config.STOCK_POOL, config.START_DATE, config.END_DATE, adjustconfig.ADJUST, cache_dirconfig.CACHE_DIR, ) print(f面板规模: {panel.shape[0]} 行, {panel[code].nunique()} 只股票, f{panel[date].nunique()} 个交易日) print( * 64) print(Step 2/4 构造因子面板) factor_df build_factor_panel(panel, forward_daysconfig.FORWARD_DAYS) print(f因子面板: {factor_df.shape[0]} 行, {factor_df.shape[1]} 列) print( * 64) print(Step 3/4 按调仓周期抽样计算 IC) reb_dates pick_rebalance_dates(panel, config.REBALANCE_FREQ) bt_df factor_df[factor_df[date].isin(reb_dates)].copy() print(f调仓截面数: {bt_df[date].nunique()}) summary_rows [] ic_store {} for factor in config.FACTORS: ic calc_ic_series(bt_df, factor, fwd_ret, min_stocksconfig.MIN_STOCKS) ic_store[factor] ic stat ic_summary(ic) stat[因子] factor summary_rows.append(stat) ic_table pd.DataFrame(summary_rows).set_index(因子) print(\n 因子 IC 汇总 ) print(ic_table.to_string()) print( * 64) print(Step 4/4 分层回测) for factor in config.FACTORS: direction config.FACTOR_DIRECTION.get(factor, 1) group_ret, nav layer_backtest( bt_df, factor, fwd_ret, n_groupsconfig.N_GROUPS, directiondirection, min_stocksconfig.MIN_STOCKS, ) if group_ret.empty: print(f[WARN] {factor} 分层回测无有效数据) continue avg_ret group_ret.mean().round(4) print(f\n----- {factor} (direction{direction}) 各组平均{config.FORWARD_DAYS}日收益 -----) print(avg_ret.to_string()) if nav.shape[1] 2: top nav.columns[-1] bottom nav.columns[0] print(f最看好组末值: {nav[top].iloc[-1]:.4f}, f最不看好组末值: {nav[bottom].iloc[-1]:.4f}) print( * 64) print(全链路执行完成) return ic_table, ic_store if __name__ __main__: main()注意pick_rebalance_dates需要pick_rebalance_dates函数在evaluate.py里前面已经定义过直接导入即可。如果放在别处记得调整 import。6.3 运行与预期输出在项目根目录执行python main.py首次运行会输出类似这样的日志 Step 1/4 拉取行情数据 [OK] 600519 拉取 486 条 [OK] 601318 拉取 486 条 ... 面板规模: 9720 行, 20 只股票, 486 个交易日 Step 2/4 构造因子面板 因子面板: 9720 行, 16 列 Step 3/4 按调仓周期抽样计算 IC 调仓截面数: 98 因子 IC 汇总 IC均值 IC标准差 ICIR IC0占比 样本期数 因子 mom_20_z 0.0xxx 0.xxxx 0.xxxx 0.xxxx 98 vol_20_z 0.0xxx 0.xxxx 0.xxxx 0.xxxx 98 turn_20_z 0.0xxx 0.xxxx 0.xxxx 0.xxxx 98具体数值取决于你选的股票池和时间区间本文不给出任何具体数字因为任何数字都可能被误读成“收益预期”。你要关注的是量级和符号IC 均值是不是稳定为正或稳定为负、ICIR 是不是足够大、IC 大于 0 的期数占比是不是明显偏离 50%。缓存命中后再次运行通常十几秒就能跑完这就是标题里“3 分钟跑通”的实际含义前提是数据已在本地。首次拉取 20 只股票大约需要 10 到 20 秒拉几百只就会明显变慢。6.4 结果怎么读拿到输出后按下面顺序读第一步看 IC 汇总表的样本期数。如果只有几期统计意义很弱说明你的时间区间太短或者缺失太多先把数据补齐。第二步看 IC 均值和 ICIR 的符号是否一致。如果 IC 均值正、ICIR 也正说明因子的正向预测在多数期间成立如果两者符号打架通常意味着 IC 序列波动极大有少数几期极端值在拉高均值这种因子稳定性差。第三步看分层回测的各组平均收益。理想的因子应该呈现单调性从第 0 组到第 4 组平均收益递增或递减。如果只有首尾两组拉开了差距中间三组乱序说明因子只在极端值区域有效中间区域是噪声实盘使用时要注意分组方式。第四步对比不同因子。三个因子的 IC 都很低是正常的单因子 IC 到 0.05 已经不错了。多因子的价值就在于把几个弱因子组合起来。7. 常见问题与排查清单7.1 数据拉取类问题第一次跑最容易卡在数据这一步。下面这张表覆盖了几种典型情况问题现象常见原因排查与解决ModuleNotFoundError: akshare未安装或装错环境确认虚拟环境已激活重新pip install akshare接口返回空 DataFrame代码不存在、区间非法或该股停牌换一只股票试确认起止日期格式为YYYYMMDDKeyError: 收盘接口版本变更列名或字段不同打印df.columns.tolist()按实际列名改COLUMN_MAP请求频繁被拒未加请求间隔调大sleep_sec建议 0.3 到 0.8 秒部分股票缺数据上市时间晚于起始日期属正常现象后续dropna会处理价格序列有明显跳空未复权或复权参数错误统一用adjustqfq7.2 计算类问题问题现象常见原因排查与解决因子值大量为 NaN窗口长度大于样本长度检查window与min_periods缩短窗口或延长区间Bin edges must be unique因子值大量重复qcut无法分箱先.rank(methodfirst)再qcutgroupby.apply一堆警告pandas 2.x 行为变更改用transform或显式选列后apply标准差为 0 导致除零某日截面所有股票因子值相同标准化函数里加std 0判断返回全 0计算结果与手算对不上数据顺序未按日期排序计算前必须sort_values([code, date])7.3 结果异常类问题这一类最危险因为代码不报错但结论是错的。问题现象常见原因排查与解决IC 高得离谱 0.3因子计算用了未来数据检查所有shift的符号shift(-n)只允许出现在fwd_ret净值曲线过于平滑前视收益重叠导致重复累计按调仓周期抽样确保样本不重叠分层收益完全不单调股票池太小分组噪声大扩大股票池或增加到 10 组观察某一年表现极好其他年份很差拟合了特定市场风格分年度看 IC判断稳定性换了时间区间结论完全反转因子本身不稳定属于正常发现说明该因子不可靠特别提醒第一条。判断有没有未来函数有个简单办法把所有shift(-搜一遍除了构造fwd_ret的那一处其他位置出现就是 bug。养成这个检查习惯能避免绝大多数回测虚高。8. 最佳实践与工程建议8.1 数据层缓存、校验、可替换数据层的第一原则是可替换。所有数据获取逻辑收敛到data_loader.py一个文件里对外只暴露fetch_pool一个函数返回固定的字段结构。这样无论是换数据供应商、换接口版本还是从 CSV 读离线数据改动范围都被限制在一个文件内。第二原则是入仓即校验。数据进到系统里先检查三件事行数是否合理比如 20 只股票 2 年应该有接近一万行、日期是否连续有没有大段缺失、价格是否为正出现 0 或负数说明数据有问题。校验代码不用复杂几行assert和print就够但能挡掉大量后续疑难杂症。第三原则是缓存文件命名要带全参数。本文用{code}_{start}_{end}_{adjust}.csv一旦你改了区间或复权方式会自动拉新数据而不是误读旧文件。8.2 因子层命名、方向、可解释性因子命名建议带上窗口长度比如mom_20、vol_20、turn_20看到名字就知道参数。标准化后的版本加_z后缀。这样一眼就能区分原始值和标准化值不会在代码里混用。每个因子都要写清楚经济含义和先验方向。不要出现factor_1、factor_2这种命名三个月后你自己都忘了它是什么。方向信息集中放在FACTOR_DIRECTION里回测时统一读取避免每处代码各写一遍导致不一致。因子的可解释性比复杂度重要。一个能说清楚逻辑的简单因子比一个 IC 略高但你说不清为什么有效的黑盒因子更值得投入。因为前者失效时你能判断原因后者失效时你只能干瞪眼。8.3 回测层成本、容量、幸存者偏差本文的分层回测是最简化版本真实使用前至少要补三件事交易成本A 股卖出有印花税加上佣金和冲击成本双边成本通常在千分之一到千分之三之间。这个量级足以吃掉大部分低换手因子的收益不加成本的回测没有参考价值。容量约束小市值股票的流动性极差回测里能买入不代表实盘能成交。至少要用成交额做一次过滤剔除日均成交额过低的标的。幸存者偏差本文的股票池是“当前还在交易的股票”已经退市的股票不在其中。这会让回测结果系统性偏乐观因为退市的往往都是表现差的。严格做法是使用包含退市股票的历史成分股列表。8.4 生产化日志、版本、可复现如果这套代码要从“跑着玩”变成“每天跑”还需要补几样东西。日志不要用print换成logging按级别输出把每次运行的参数、数据区间、因子列表、关键指标都记录下来。这样当某天结果突变时能快速定位是数据变了还是代码变了。因子计算逻辑要做版本管理。给每个因子加一个版本号比如mom_20_v1一旦修改了计算逻辑就升版本不要原地改。否则历史结果和新结果无法对比回测结论就失去了可追溯性。随机性要固定。如果后续引入随机抽样、模型训练务必设置random_state保证同样输入得到同样输出。可复现性是量化研究的基本要求一个跑两次结果不同的系统没有讨论价值。最后是权限与合规。数据接口通常有使用条款商用前请确认授权范围涉及账户、交易的模块必须做最小权限控制任何自动下单逻辑都要在测试环境充分验证后再考虑且必须有人工熔断机制。9. 这套系统还能往哪些方向扩展跑通上面这条链路之后你已经有了一个可以持续迭代的骨架。接下来几个方向的投入产出比相对较高。因子中性化。当前算出来的因子受市值和行业影响很大。比如动量因子可能只是在选大市值股票波动率因子可能只是在选某个行业。做法是把因子值对市值对数、行业哑变量做横截面回归取残差作为新因子。这一步做完很多因子的 IC 会明显变化有的变好有的变差正好用来判断它是不是“真因子”。多因子合成。有了几个通过检验的因子可以用等权打分、IC 加权或者机器学习模型合成为一个综合分。最简单的做法是z-score直接相加

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑