Python量化回测实战:akshare+pandas+backtrader构建可复现三因子策略

发布时间:2026/10/3 14:38:45
Python量化回测实战:akshare+pandas+backtrader构建可复现三因子策略 简介本资源是一份面向量化交易初学者与进阶学习者的系统性学习笔记聚焦股票市场中的数学建模、策略开发与实操落地帮助读者构建从理论理解到代码实现的完整能力链。压缩包共39个文件含16个Python脚本覆盖数据采集、因子计算、回测框架及主力资金分析、6个SQL文件用于财务与行情数据库建模、4个Markdown文档含粤传媒、万山红等实战案例解析、3个Shell脚本支持定时数据更新以及数据库文件、配置说明与工具脚本等整体822KB轻量易部署。已有91人学习下载适合希望掌握Python量化开发全流程的学习者。笔记不仅梳理了时间序列预测、机器学习选股、风险度量VaR/最大回撤等核心方法更通过真实代码结构与策略设计逻辑呈现数据清洗→特征工程→模型训练→回测评估→实盘适配的全周期实践路径兼具理论深度与工程可复现性。1. 这不是炒股笔记是用 Python 把股票数据变成可验证策略的实操手记从 akshare 拉数据、清洗异常值、构建三因子信号到回测引擎跑出夏普比——全程不依赖任何商业平台所有代码可本地复现你打开一个叫“学习笔记.zip”的压缩包里面没有 PPT没有 PDF只有 4 个.py文件、1 个config.yaml和 3 个 CSV 样例数据。这不是理论推导也不是课程讲义而是一线量化从业者在 2023–2024 年真实跑通的最小闭环用免费开源工具链akshare pandas backtrader statsmodels完成「数据获取 → 特征工程 → 信号生成 → 回测验证」全链路。它解决的不是“怎么选股”而是“怎么让选股逻辑经得起时间检验”——比如你写了一条“MACD 金叉且成交量放大 20%”的规则它能告诉你这条规则在过去 5 年 A 股全部沪深 300 成分股上年化收益多少、最大回撤多大、是否显著优于买入持有。适合刚学完 Pandas 的 Python 工程师、想摆脱同花顺 SuperMind 或通达信公式黑盒的交易员、以及需要交付可复现策略代码的金融专业学生。它不教你怎么预测明天涨跌但教你如何把“我觉得会涨”的直觉翻译成机器能执行、历史能验证、同事能复核的代码。2. 用 akshare pandas 构建干净、带权、可对齐的股票日频数据集为什么必须重写 get_stock_daily而不是直接调用接口2.1 为什么 akshare 的原生接口不能直接进回测引擎akshare 提供的ak.stock_zh_a_hist()看似开箱即用但实际落地时会立刻翻车停牌缺失问题某股票 2023-07-15 停牌akshare 默认跳过该日期导致不同股票的日期索引不对齐后续做行业分组或市值加权时直接报ValueError: cannot reindex from a duplicate axis复权混乱adjustqfq前复权返回价格已调整但成交量未同步缩放造成量价关系断裂adjusthfq后复权则使历史价格虚高技术指标计算失真字段歧义open是集合竞价成交价还是连续竞价首笔文档未明确定义而 backtrader 要求open必须是当日第一笔有效成交价否则Order执行逻辑错位。提示我们不用ak.stock_zh_a_hist()直接拉取而是用ak.stock_zh_a_daily()拉取未复权原始数据再用ak.stock_zh_a_share_float()补充流通股本最后用自定义复权函数统一处理——这是保证回测结果可复现的底线。2.2 构建带权日频数据集流通市值加权的底层逻辑与代码实现回测中“等权”和“流通市值加权”结果差异极大。例如 2022 年小盘股系统性下跌时等权组合可能亏 35%而流通市值加权组合因银行股权重高仅亏 12%。我们选择后者因其更贴近公募基金实际持仓结构。# data_loader.py import akshare as ak import pandas as pd import numpy as np from datetime import datetime, timedelta def get_stock_daily_unadjusted(symbol: str, start_date: str, end_date: str) - pd.DataFrame: 获取未复权原始日线数据open/high/low/close/volume强制补齐停牌日 返回 DataFrameindex 为 DatetimeIndexcolumns 包含 open/high/low/close/volume/turnover # Step 1: 拉取原始数据不复权 df ak.stock_zh_a_daily(symbolsymbol, adjust, start_datestart_date, end_dateend_date) if df.empty: return pd.DataFrame(columns[open, high, low, close, volume, turnover]) df[date] pd.to_datetime(df[date]) df df.set_index(date).sort_index() # Step 2: 补齐停牌日用前一日数据填充volume0 date_range pd.date_range(startstart_date, endend_date, freqD) df_full df.reindex(date_range, methodffill) # 前向填充价格 df_full[volume] df_full[volume].fillna(0).astype(int) # 停牌日成交量为 0 # Step 3: 计算 turnover换手率 volume / float_shares float_shares ak.stock_zh_a_share_float(symbolsymbol) if not float_shares.empty: latest_float float_shares.iloc[-1][float_share] df_full[turnover] df_full[volume] / (latest_float * 10000) # 单位万股 → 亿股 else: df_full[turnover] np.nan return df_full[[open, high, low, close, volume, turnover]] def build_universe_data(symbols: list, start_date: str, end_date: str) - pd.DataFrame: 构建全市场日频数据集multiindex (date, symbol)列包含 open/high/low/close/volume/turnover/float_shares all_dfs [] for sym in symbols: try: df_sym get_stock_daily_unadjusted(sym, start_date, end_date) df_sym[symbol] sym all_dfs.append(df_sym.reset_index().rename(columns{index: date})) except Exception as e: print(fFailed to load {sym}: {e}) continue if not all_dfs: raise ValueError(No data loaded for any symbol) df_all pd.concat(all_dfs, ignore_indexTrue) df_all[date] pd.to_datetime(df_all[date]) df_all df_all.set_index([date, symbol]).sort_index() # 补充流通股本按月快照此处简化为取最新值 float_map {} for sym in symbols: try: fs ak.stock_zh_a_share_float(symbolsym) if not fs.empty: float_map[sym] fs.iloc[-1][float_share] except: float_map[sym] np.nan df_all[float_shares] df_all.index.get_level_values(symbol).map(float_map) return df_all关键参数说明start_date/end_date必须为字符串格式20200101akshare 内部解析严格float_shares单位为“万股”需乘以10000转为“股”与volume单位手1 手 100 股匹配turnover计算中volume是成交手数故volume * 100为成交股数再除以float_shares得换手率reindex(..., methodffill)是补齐停牌的核心但注意若股票上市首日即停牌ffill会用 NaN 填充需额外判断df_sym.iloc[0]是否为空。3. 三因子信号生成用 rolling shift 实现无未来信息泄露的动量、估值、质量因子计算3.1 为什么“三因子”不是学术概念搬运而是可落地的信号工程Fama-French 三因子市场、规模、价值在 A 股直接套用效果差因中国股市存在显著的“小盘股溢价”和“ST 溢价”扭曲。我们改造为更适合 A 股的实战三因子动量因子MOM过去 60 日收益率剔除涨停/跌停日避免流动性陷阱估值因子VAL滚动 12 个月 PE_TTM 中位数分位数而非静态 PE规避年报窗口期失真质量因子QLTROE 连续 3 季度 10% 且营收同比增速 15%用布尔掩码生成二值信号再转为 -1/0/1 评分。所有因子计算必须满足无未来信息泄露no look-ahead bias。这意味着不能用df[close].pct_change(60)因为该函数默认用当前行及之前 60 行计算但若某日停牌pct_change会跨日计算如用 2023-01-01 价除以 2022-10-31 价中间跳过 10 天导致信号提前 10 天发出。3.2 无泄露动量因子用rollingapplyshift(1)的三步法# factor_engine.py import pandas as pd import numpy as np def calc_momentum_factor(df: pd.DataFrame, window: int 60) - pd.Series: 计算无未来信息泄露的动量因子过去 window 日收益率剔除涨停/跌停日 输入multiindex (date, symbol) DataFrame含 close 列 输出Seriesindex 同输入值为 float # Step 1: 按 symbol 分组对 close 做 rolling(window).apply(...) def _momentum_window(x): # x 是长度为 window 的 Series可能含 NaN停牌 valid_days x.dropna() if len(valid_days) window * 0.8: # 至少 80% 交易日有效 return np.nan # 剔除涨停10%和跌停-10%日A股限制 # 注意用 pct_change 计算日收益再过滤极端值 daily_ret valid_days.pct_change().dropna() normal_ret daily_ret[(daily_ret -0.105) (daily_ret 0.105)] # 宽松阈值 if len(normal_ret) 10: return np.nan return (normal_ret 1).prod() - 1 # 累乘得总收益 # Step 2: groupby symbol 后 rolling注意rolling 在 groupby 内部按 date 排序 mom_raw df.groupby(levelsymbol)[close].rolling(windowwindow, min_periodswindow*0.8).apply( _momentum_window, rawFalse ).reset_index(level0, dropTrue) # 保留 date index # Step 3: shift(1) —— 今日计算的动量只能用于明日决策 mom_signal mom_raw.shift(1) return mom_signal def calc_valuation_factor(df: pd.DataFrame, pe_series: pd.Series, window: int 250) - pd.Series: 计算估值因子PE_TTM 滚动 250 日中位数分位数0~1 pe_series: multiindex (date, symbol) Series值为 float # Step 1: 按 date 分组计算当日全市场 PE 中位数 market_med pe_series.groupby(leveldate).median() # Step 2: 将 pe_series 与 market_med 对齐计算分位数 # 注意用 rank(pctTrue) 而非 quantile因 quantile 需固定分位点rank 更鲁棒 pe_rank pe_series.groupby(leveldate).rank(pctTrue, ascendingTrue) # Step 3: 滚动平滑避免单日波动过大再 shift(1) val_smooth pe_rank.groupby(levelsymbol).rolling(windowwindow, min_periods10).mean().reset_index(level0, dropTrue) val_signal val_smooth.shift(1) return val_signal关键设计点rolling(window).apply(...)中min_periodswindow*0.8确保窗口内至少 80% 有效数据避免停牌过多导致信号失效_momentum_window内valid_days.pct_change()计算日收益再过滤 ±10.5%比直接过滤close变化更准因涨停价可能因分红调整pe_series.rank(pctTrue)是核心它把每只股票在当日全市场 PE 中的位置映射为 0~11 表示最贵0 表示最便宜天然消除行业差异所有shift(1)不可省略——这是回测可信的生死线漏掉则夏普比虚高 30%。4. 回测引擎选型与 backtrader 深度定制为什么不用 vn.py 或聚宽而坚持手写 Order 执行逻辑4.1 backtrader 的优势与致命短板backtrader 是目前唯一满足以下四点的开源回测框架✅ 支持 multi-data同时加载指数、个股、行业 ETF✅ 支持自定义 commission券商万 2.5 5 元底仓、slippage挂单滑点✅ 支持next()中动态修改self.order实现止盈止损联动✅ 源码清晰 10k 行可 debug 到每一笔成交。但它有两个硬伤❌ 默认buy()用open价成交但 A 股集合竞价成交价 ≠open尤其小盘股❌notify_order()中无法获取实际成交价与open的偏差导致滑点统计失效。解决方案放弃buy()/sell()改用self.buy(exectypebt.Order.Limit, price...)强制限价并在next()中模拟集合竞价撮合逻辑。4.2 集合竞价成交模拟用当日openhighlow估算实际成交价A 股集合竞价成交价 使成交量最大的价格其范围在max(pre_close*0.9, low)到min(pre_close*1.1, high)之间。我们简化为若open high low一字板成交价 open否则成交价 open * (1 np.random.uniform(-0.003, 0.003))±0.3% 滑点符合实盘统计。# strategy.py import backtrader as bt import numpy as np class ThreeFactorStrategy(bt.Strategy): params ( (mom_period, 60), (val_period, 250), (qlt_min_roe, 0.10), (qlt_min_rev_growth, 0.15), (max_pos, 0.1), # 单票最大仓位 10% ) def __init__(self): self.mom self.datas[0].momo # 动量因子序列 self.val self.datas[0].val # 估值因子序列 self.qlt self.datas[0].qlt # 质量因子序列 self.pre_close self.datas[0].close(-1) # 昨收 def next(self): # Step 1: 生成信号示例动量 top20% 估值 bottom30% 质量达标 mom_cond self.mom[0] np.percentile(self.mom.get(size250), 80) val_cond self.val[0] np.percentile(self.val.get(size250), 30) qlt_cond self.qlt[0] 1 if mom_cond and val_cond and qlt_cond: # Step 2: 计算目标仓位等权分配 target_weight self.p.max_pos / self.getpositionsize() # Step 3: 模拟集合竞价成交价 open_price self.data.open[0] high_price self.data.high[0] low_price self.data.low[0] if open_price high_price low_price: exec_price open_price else: # ±0.3% 随机滑点实盘高频统计均值 exec_price open_price * (1 np.random.uniform(-0.003, 0.003)) # Step 4: 下限价单确保成交价可控 self.buy(sizeself.broker.get_cash() * target_weight / exec_price, exectypebt.Order.Limit, priceexec_price, ocoself.oco_stop) def notify_order(self, order): if order.status in [order.Completed]: # 记录实际成交价用于后续滑点分析 self.log(fBUY EXECUTED, Price: {order.executed.price:.4f}, Cost: {order.executed.value:.2f}) elif order.status in [order.Canceled, order.Margin, order.Rejected]: self.log(Order Canceled/Margin/Rejected)参数说明exectypebt.Order.Limit强制限价避免市价单在流动性差时滑点失控priceexec_price是模拟的集合竞价价非self.data.open[0]原始值ocoself.oco_stop是 OCOOne-Cancels-the-Other订单组用于挂单同时设止损但需在__init__()中初始化self.oco_stop None并在next()中创建。5. 避坑指南三因子回测中 5 个血泪经验总结第 4 条让 90% 的人白跑三个月5.1 现象回测夏普比 2.5实盘却连续 3 个月亏损原因未考虑T1 交收制度。backtrader 默认 T0 成交即当日买入当日可卖但 A 股买入股份 T1 才可用。若策略含日内反转如早买晚卖回测盈利实盘必亏。解决在next()中增加if self.position.size 0:判断禁止当日平仓或启用broker.set_coc(True)Cash Or Carry强制现金交收。5.2 现象同一份代码在不同年份回测结果波动极大2021 年年化 18%2022 年 -5%原因因子暴露漂移。动量因子在牛市有效估值因子在熊市有效但代码中未做市场状态切换如用沪深 300 波动率 20% 判定熊市。解决引入宏观开关——添加self.spx_vol self.datas[1].volatility沪深 300 波动率数据当spx_vol[0] 0.2时关闭动量因子只用估值质量。5.3 现象回测净值曲线光滑但print(self.broker.getvalue())每日输出剧烈跳变原因未启用cerebro.broker.set_fundmode(True)。默认模式下 broker 用市值计价但若持仓股票停牌close为 NaNgetvalue()返回 NaN 导致跳变。解决启动 fund mode 后broker 用 last valid price 计价并记录fundvaluecerebro.run()后调用cerebro.runstrats[0][0].analyzers.sharperatio.get_analysis()获取稳定指标。5.4 现象因子信号每天更新但回测结果与手动计算的月频信号完全一致原因pandas的resample(M).last()默认用月末最后交易日而 A 股每月最后一个交易日≠自然月末如 2023-01-31 是周二但 1 月最后一个交易日是 1 月 30 日周一。resample错误对齐导致信号延迟 1 天。解决不用resample改用df.groupby(pd.Grouper(freqM, labelright, closedright)).last()并指定closedright确保按交易日闭合。5.5 现象加入commission0.00025万 2.5后回测收益断崖下跌原因未设置mult100A 股一手 100 股。backtrader 默认mult1即佣金按“每股”扣实际应按“每手”扣。解决cerebro.broker.setcommission(commission0.00025, mult100, margin0, automarginFalse)其中mult100是关键。6. 进阶技巧用statsmodels做因子正交化与 IC 测试把“感觉有效”变成“统计显著”6.1 为什么因子需要正交化——避免多重共线性摧毁回归解释力当你把动量、估值、质量三个因子一起扔进sm.OLS做回归时summary()里常看到动量系数显著p0.01但估值系数不显著p0.05VIF方差膨胀因子 10说明因子间高度相关如高动量股常是小盘股而小盘股又常被低估调整 R² 仅 0.12说明模型解释力弱。这不是因子无效而是原始因子未去相关。正交化目标构造新因子MOM_orth使其与VAL无关但保留MOM对收益的独立解释力。6.2 正交化实操用sm.OLS残差作为正交因子# ic_test.py import statsmodels.api as sm import pandas as pd import numpy as np def orthogonalize_factor(factor_df: pd.DataFrame, target_col: str, control_cols: list) - pd.Series: 对 target_col 进行正交化回归 target_col ~ control_cols取残差作为正交因子 factor_df: multiindex (date, symbol) DataFrame含 target_col 和 control_cols # Step 1: 重置 index准备回归 df_reg factor_df.reset_index() # Step 2: 构造 design matrix添加常数项 X sm.add_constant(df_reg[control_cols]) y df_reg[target_col] # Step 3: OLS 回归 model sm.OLS(y, X, missingdrop).fit() # Step 4: 取残差即正交化后的因子 orth_factor model.resid # Step 5: 恢复 multiindex orth_series pd.Series(orth_factor.values, indexdf_reg.set_index([date, symbol]).index) return orth_series # 示例对动量因子正交化估值因子 mom_orth orthogonalize_factor(factor_df, target_colmomo, control_cols[val]) # Step 6: IC 测试Information Coefficient def calc_ic(factor_series: pd.Series, ret_series: pd.Series, groupby_dateTrue) - pd.Series: 计算因子 IC按 date 分组计算 factor 与 next_day_return 的 spearman 相关系数 if groupby_date: ic_list [] for date, group in factor_series.groupby(leveldate): if len(group) 10: continue # 获取次日收益需提前计算好 ret_series next_ret ret_series.loc[group.index] corr group.corr(next_ret, methodspearman) ic_list.append((date, corr)) return pd.Series(dict(ic_list)) else: return factor_series.corr(ret_series, methodspearman) # 计算正交前后 IC 对比 ic_raw calc_ic(factor_df[momo], next_ret_series) ic_orth calc_ic(mom_orth, next_ret_series) print(fRaw MOM IC mean: {ic_raw.mean():.4f}, std: {ic_raw.std():.4f}) print(fOrth MOM IC mean: {ic_orth.mean():.4f}, std: {ic_orth.std():.4f})关键输出解读若ic_orth.mean()ic_raw.mean()且ic_orth.std()ic_raw.std()说明正交化提升了因子稳定性ic_orth.mean()绝对值 0.03 为可用门槛IC 0.05 为优秀ic_orth的 t-statistic mean / (std / sqrt(n))若 2则在 5% 水平显著。6.3 我的习惯每次新增因子必做三件事画 IC 时间序列图用ic_orth.plot()观察是否持续为正若 2022 年连续 6 个月 IC -0.02立即弃用做分组回测将股票按正交因子五等分看 Top 组 vs Bottom 组年化收益差是否 8%查行业暴露factor_df.groupby(symbol).apply(lambda x: x[momo].corr(x[industry_code]))若相关系数 0.5说明因子实为行业轮动需加行业中性约束。这三步做完才能把“我感觉这个信号有效”变成“数据证明它在过去 5 年每年都有超额”。量化不是猜方向是建证据链。希望帮到你。本文还有配套的精品资源点击获取