每月最后一个交易日收盘后把持仓清理一遍按既定的几个因子重新筛出一篮子股票等权买进去然后整整一个月不动。这套听起来特别“笨”的月末策略标的我前前后后做了两轮回测。第一轮结果漂亮得不像话年化25%以上最大回撤还不到10%我当时差点真信了自己找到了圣杯。等冷静下来把代码一行一行拆开看才发现至少四个致命问题全踩中了。这篇就写写这次“月末策略标的回测再研究”的完整过程为什么第一轮结果不可信、backtrader多股回测的数据和代码怎么搭、月末调仓信号怎么设计才不吃未来函数以及回测报表里哪些数字能信、哪些是骗你的。1. 为什么做这次“再研究”第一轮回测里的四个可疑点1.1 第一轮到底是怎么测的先说第一轮策略长什么样。股票池用沪深300成分股每个月最后一个交易日收盘后按两个因子筛股息率越高越好和过去20日日收益率标准差越低越好两个因子等权合成总分取前10只股票等权买入持有到下月末。回测区间是2018年1月到2023年12月初始资金100万。框架用的backtrader数据来自某免费数据接口前复权日线。这个方案不算复杂逻辑也很常见很多个人投资者做“低波红利增强”都是这个路子。第一轮跑出来的结果确实好看六年累计收益大约200%年化接近20%最大回撤只有11%左右夏普1.4以上。我第一反应是这策略能直接用。但第二反应是“凭什么”——红利低波风格在2018到2023年确实不错但绝对没有强到这个程度。凡是长这样漂亮曲线的回测基本都要回头审代码。1.2 让我坐不住的四个疑点第一信号生成和成交时间完全重叠。我在月末最后一个交易日收盘后算因子然后同一根bar里就以收盘价买入。可实际上因子值是用这根bar的收盘价算出来的收盘价都没出来怎么可能按收盘价成交这就是教科书级的未来函数。第二成本被严重低估。佣金只按万2.5算没有卖出印花税没有滑点也没有冲击成本。回测里每次调仓等于“想买就能买、想卖就能卖”实盘绝无可能。第三停牌和涨跌停完全没处理。持仓里如果有一半股票正在停牌回测里照样能一键卖出再买入买入标的当天一字涨停回测里照样能挂单成交。数据只告诉我价格不告诉我“根本买不到”。第四复权方式影响了所有因子和历史收益。当时图省事直接用了前复权数据前复权价会随最新分红除权不断变化拿它算历史收益率和波动率短期没大问题但时间拉长后因子排名会失真收益会被系统性抬高。这四个疑点叠加起来第一轮回测的结论基本没法用。所以就有了这次“再研究”。2. backtrader多股回测环境与数据预处理2.1 框架选型为什么还是backtrader个人做多股策略回测可选路子其实就三条在线平台、自研模拟撮合、backtrader。在线平台优点是省心数据、财务指标都给你备好了但缺点也很明显——策略逻辑和平台绑定迁移成本高很多平台对自定义滑点、多周期支持不够灵活。自研撮合能力强可要处理好复权、停牌、涨跌停和资金冻结工作量足够写个毕业论文。backtrader是本地开源、纯Python、社区用户多多股回测虽然有些细节要自己绕但胜在每一步都透明可控参数怎么设我心里有数。“月末调仓”这类低频策略对撮合引擎性能要求不高重点是数据对、逻辑对、成本模型对backtrader完全够用。另外它对target百分比订单支持比较好order_target_percent可以直接按目标权重调仓多头仓位管理写起来很顺手。2.2 数据获取与复权处理这次数据我换了方式。先用akshare或baostock批量拉沪深300成分股的日线字段包含date、open、high、low、close、volume、amount另外单独拉上市日期、退市日期和ST状态。因子计算统一用后复权价格因为后复权价自始至终以某个基准日为锚不会因为后来分红而变化历史收益和波动率算出来是连续一致的。注意前复权适合看图表后复权适合算因子两者混用是回测最常见的隐性错误。比如一个标的历史上大比例分红前复权会把若干年前的close整体拉低用这个价格算波动率、算动量排名结论很容易被扭曲。我这次统一用后复权价格做因子计算同时用后复权价格参与撮合保证整个计算链路口径一致。2.3 多股数据喂入backtrader的完整姿势backtrader的多股回测很简单每只股票一个PandasDatacerebro.adddata逐个加进去再通过data name区分。核心代码如下import backtrader as bt import pandas as pd class PandasData(bt.feeds.PandasData): params ( (datetime, None), (open, open), (high, high), (low, low), (close, close), (volume, volume), (openinterest, None), ) def load_data(stock_data, start_date, end_date): cerebro bt.Cerebro() for code, df in stock_data.items(): df df[(df.index start_date) (df.index end_date)] data PandasData(datanamedf) cerebro.adddata(data, namecode) return cerebro这里有个很容易忽略的坑多只股票的交易日必须对齐。如果一只股票因为停牌少了几天backtrader会按NaN处理但strategy里遍历self.datas时某根bar可能只有部分data有值。做因子排名时必须用pandas在策略外统一对齐日期把停牌日填充为NaN而不是让backtrader自行拼接。推荐的做法是先在外部统一交易日历用reindex把所有股票的DataFrame对齐后再喂给cerebro。2.4 清洗清单先给数据“排雷”取完数据不能直接进回测我每次都会跑一遍清洗顺序很重要。第一步剔除上市不满60个交易日的次新股避免新股初期走势扰动因子。第二步剔除ST、*ST策略文档里最好写清楚怎么识别我是直接拉了个状态表。第三步剔除月度成交额低于5000万的标的这类票流动性太差实盘滑点会吃掉利润。第四步统一索引并检查日期重复、缺失、异常值特别是复牌当天价格跳空大的要人工看一眼有没有送股分红拆并导致的价格断层。清洗完数据后我习惯再跑一个“可用股票数曲线”看看每个月份股票池还剩多少只。如果某些月份不足30只说明清洗太狠选出来的组合代表性就弱如果仍有200只以上说明清洗太松。这个平衡点要根据策略本身调节。3. 月末调仓策略的核心实现信号、交易与成本3.1 精确识别月末交易日别让调仓日差一天识别月末交易日听起来简单实际很容易错。比如“每个月最后一个交易日”不等于“日历上月底最后一天”遇到法定节假日要顺延。更麻烦的是A股每个月的交易日不是固定周期的不能用日期等于月末这种粗暴判断。我的做法是直接用交易日历。在策略外把所有样本股票的日期做并集生成统一交易日序列再用pandas按月份groupby取每个月的min和max得到两个数组month_end_dates每月最后交易日和month_start_dates每月第一个交易日。这样无论是春节、国庆还是临时休市都不会错位。all_dates sorted(set().union(*[set(df.index) for df in stock_data.values()])) cal pd.Series(all_dates) month_end_dates cal.groupby(cal.dt.to_period(M)).max().tolist() month_start_dates cal.groupby(cal.dt.to_period(M)).min().tolist()3.2 因子的时序对齐未来函数的彻底清除这是整篇最重要的一节。月末策略的正确时序应该是T月最后一个交易日收盘后用收盘数据计算因子、选出下个月持仓T1月第一个交易日开盘以开盘价执行买入卖出。也就是说“信号在前、成交在后”中间隔了一个晚上的信息真空。实现时我先在策略外按月度生成一张factor_table把每月月末选出的股票代码提前算好factor_table [] for end_date in month_end_dates: snapshot factor_data.loc[end_date] # 用月末收盘后的截面数据 ranked snapshot.sort_values(byscore, ascendingFalse).head(self.top_n) factor_table.append({ end_date: end_date, start_date: 下月第一个交易日, stocks: ranked.index.tolist() }) factor_df pd.DataFrame(factor_table).set_index(end_date)然后在backtrader策略里这样处理当bar日期出现在factor_df的end_date时只把目标股票列表存下来不下单当bar日期等于该条记录的start_date时才执行调仓。因为backtrader默认市价单会在下一根bar成交我建议开启broker.set_coo(True)让市价单在当根bar开盘价成交正好模拟“开盘即调仓”。class MonthEndRebalanceStrategy(bt.Strategy): def __init__(self): self.target_stocks None def next(self): dt self.datetime.date() if dt in factor_df.index: self.target_stocks factor_df.loc[dt, stocks] if dt in factor_df[start_date].values: self.rebalance()这套设计直接从机制上杜绝了未来函数——信号日不交易交易日用开盘价因子再也不会“偷看”当天的收盘结果。我第一版代码图省事把两步写在一起回测收益凭空多出5到8个点这就是代价。3.3 等权调仓的执行代码与停牌股处理调仓的核心思路很朴素先清掉不在目标池里的持仓再把目标池里没买够的仓位按等权补上。backtrader里直接order_target_percent是最稳的它会自动处理资金的可用余额和持仓调整。def rebalance(self): target self.target_stocks for data in self.datas: pos self.getposition(data).size if data._name in target: weight 1.0 / len(target) self.order_target_percent(datadata, targetweight) elif pos 0: self.order_target_percent(datadata, target0.0)买入时过滤掉不可交易标的。我用两个硬条件当日开盘价相对昨收涨幅超过9.5%视为一字板/秒板排除当日成交量为0视为停牌排除。这两个条件虽然会漏掉一些盘中开板的真实交易机会但回测宁可漏掉、不能虚增否则收益一定是被高估的。卖出端也一样如果持仓股停牌order_target_percent调到0是成交不了的。我的处理是回测里碰到停牌股就跳过卖出等它复牌后的下个月调仓再卖用代码强制“卖不出就是卖不出”。这一步对月末策略影响不小因为月末调仓频率低一个停牌股可能占用两三个月的仓位资金利用率会下降实盘反而更接近真实。3.4 交易成本模型与滑点参数设置成本模型我这次做了三档对比理想档只算佣金标准档加佣金、印花税和固定滑点保守档再增加冲击成本。A股实际成本至少包括买入佣金万2.5最低5元、卖出佣金万2.5、卖出印花税千1、过户费十万分之一。回测里我用万2.5佣金加千1卖出印花税再加千1的固定滑点基本能覆盖大多数小资金账户的真实摩擦cerebro.broker.setcash(1000000) cerebro.broker.setcommission( commission0.00025, commtypebt.CommInfoBase.COMM_PERC, ) cerebro.broker.set_slippage_perc(perc0.001, slip_openTrue, slip_matchTrue)滑点参数开得越大回测越保守但更接近实盘。我建议先按标准档跑出结果再切保守档看差异如果两个结果差距超过20%说明策略本身对交易成本太敏感这种策略实盘要谨慎。4. 结果诊断回测数据好到失真怎么办4.1 这些指标先看哪几个跑完回测先别急着看曲线。我固定看五张表年化收益率、最大回撤、夏普比率、卡玛比率年化收益除以最大回撤、月胜率。月度回撤比总回撤更适合低频策略因为月末调仓策略往往是“平时小幅波动月末集中换仓”的状态只看年度最大回撤容易低估止损风险。backtrader默认输出没有这么全我是把每周的资金曲线导出后自己算。第二版修正后的结果年化15.8%最大回撤-19.6%夏普1.05卡玛0.81月胜率58.3%。比第一版难看不少但这次我反而敢拿去讨论——因为每个环节都有据可查成本、停牌、涨跌停全都体现在数字里了。4.2 超额收益与基准的选择月末策略必须和基准比才有意义。我当时同时对比了沪深300指数和中证红利全收益指数。为什么选中证红利全收益因为这个策略本来就是低波红利风格如果连红利指数都跑不赢那还不如直接买指数基金没必要折腾选股。跑下来我的策略相对红利全收益的超额并不大主要贡献反而来自2018、2022两个熊市年份的低回撤。这说明策略本质是“红利风格加低波控制”而不是绝对alpha。这里还有一个容易踩的坑基准也要用全收益不能用价格指数。分红会漏掉尤其是红利类策略分红贡献经常占三成以上对比价格指数等于自欺欺人。4.3 常见回测Bug速查表回测出问题的时候问题往往不在策略逻辑而在数据或框架细节。我整理了最常遇到的几个现象大概率原因排查方法收益高得离谱未来函数、复权混用检查信号日与成交日是否同bar单月收益特别突出分红/复权跳空被当成交易收益拉出该月的个股价格日志看是否有异常跳空某只股票仓位一直是0日期索引不一致或股票名拼错打印data._name与df索引回测中资金出现负数未冻结可用资金下多笔超过资产检查order_target_percent的调用时机调仓日差一天交易日历没对齐自然月打印调仓日期与前几个交易日关系停牌股卖出后仍持有backtrader无法感知停牌检查成交量等于0的行是否被过滤这份表我每次跑新策略都会重看一遍。多数“神奇策略”最后都能在这里找到归处。4.4 这次回测踩过的三个真实坑第一复权切换导致因子排名震荡。第一版用了前复权后来切后复权时没同步更新因子缓存导致同一只股票在切换日前后排名不连续选出的组合面目全非。解决方法是把因子的“复权口径”写死在配置环境变量里切换时强制清空缓存重算。第二开盘价成交设错了时机。用市价单跑月末调仓默认会延迟一根bar成交结果变成了T1开盘下单、T1收盘成交收益又虚高了一截。开了set_coo(True)以后才真正变成“开盘成交”。这个细节不跑到资金曲线对比根本发现不了。第三抽出了“空壳组合”。做因子回看时发现策略第一年选的股票和预期完全不符后来一查是因子DataFrame尾部有NaN没处理排序时空值全部排到了最后系统以为这些股票“非常差”于是一开头就买了一批因数据缺失被误判的股票。清洗数据时漏了dropna这一步教训很深刻。5. 从回测到下一轮研究几个方向与收尾体会5.1 样本外验证与参数敏感性回测做完还要验证否则只是拟合历史。我把2018到2021设为样本内2022到2025设为样本外重新跑了一遍。结果不出所料样本内年化18%以上样本外年化只剩10%左右超额收益缩水一半。这很正常策略因子的有效性会衰减尤其是低波这种已经被写烂了的因子。接着做参数敏感性top_n从5变成15、回看窗口从20天变成40天、因子权重从五五开到三七开各跑一遍。如果结果在参数小幅扰动下剧烈波动策略就不稳健。我这个策略top_n从10改成15之后年化掉了4个点说明选股数量对结果影响很大后续需要增加分组测试而不是在这里过度调参。5.2 下一轮可以做的几个优化方向第一行业中性化。现在因子选股很容易某个月份全压在银行或煤炭行业暴露太大回测曲线会和板块行情高度相关。可以加一道约束按申万一级行业分组每组最多选2只。第二引入风险平价权重。等权简单但并不是最优换个思路用波动率倒数做权重让低波股票占更大仓位可能平滑回撤。第三把调仓频率从月度改成季度或双月研究一下。频率越低成本和冲击越小但因子衰减也会更明显。用同一套回测框架改几个参数就能对比这种对照实验很有价值。5.3 一点个人体会这两轮回测折腾下来我最大的体会是回测软件不会骗人骗人的永远是代码里的理想化假设。做策略回测尤其是多股、低频、月末调仓这种看似简单的玩法最该花时间的不是调参而是把数据、信号、成交、成本四个环节挨个打磨到“真实”为止。结果难看一点没关系至少你知道这个数字背后发生了什么。最后分享一个小习惯每次跑完回测我会强制自己写一段200字以内的“回测结论”里面必须写清三个东西——什么假设、什么成本、什么限制。写不出来就说明还没研究透继续回去查代码。这次“月末策略标的回测再研究”我就是按这个流程又走了一遍虽然最终收益不如第一版惊艳但至少每一分收益我都能说出它从哪来。
