news 2026/9/24 22:31:22

backtrader月末调仓策略回测:避开未来函数与成本失真陷阱

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
backtrader月末调仓策略回测:避开未来函数与成本失真陷阱

每月最后一个交易日收盘后,把持仓清理一遍,按既定的几个因子重新筛出一篮子股票,等权买进去,然后整整一个月不动。这套听起来特别“笨”的月末策略标的,我前前后后做了两轮回测。第一轮结果漂亮得不像话:年化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的多股回测很简单:每只股票一个PandasData,cerebro.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(dataname=df) cerebro.adddata(data, name=code) 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月最后一个交易日收盘后,用收盘数据计算因子、选出下个月持仓;T+1月第一个交易日开盘,以开盘价执行买入卖出。也就是说“信号在前、成交在后”,中间隔了一个晚上的信息真空。

实现时,我先在策略外按月度生成一张factor_table,把每月月末选出的股票代码提前算好:

factor_table = [] for end_date in month_end_dates: snapshot = factor_data.loc[end_date] # 用月末收盘后的截面数据 ranked = snapshot.sort_values(by='score', ascending=False).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(data=data, target=weight) elif pos > 0: self.order_target_percent(data=data, target=0.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( commission=0.00025, commtype=bt.CommInfoBase.COMM_PERC, ) cerebro.broker.set_slippage_perc(perc=0.001, slip_open=True, slip_match=True)

滑点参数开得越大,回测越保守,但更接近实盘。我建议先按标准档跑出结果,再切保守档看差异,如果两个结果差距超过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成交,结果变成了T+1开盘下单、T+1收盘成交,收益又虚高了一截。开了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字以内的“回测结论”,里面必须写清三个东西——什么假设、什么成本、什么限制。写不出来就说明还没研究透,继续回去查代码。这次“月末策略标的回测再研究”,我就是按这个流程又走了一遍,虽然最终收益不如第一版惊艳,但至少每一分收益我都能说出它从哪来。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 22:31:11

电子设备EMC整改:从源头抑制到路径阻断的系统化实战指南

1. 电子设备EMC整改的核心逻辑与整体思路1.1 为什么EMC整改总是“按下葫芦浮起瓢”干过硬件的人都有体会&#xff1a;EMC测试挂了&#xff0c;整改的时候改一个地方&#xff0c;原来通过的频段又冒出来了。这不是运气问题&#xff0c;而是因为EMC本质上是系统性问题——干扰源、…

作者头像 李华
网站建设 2026/9/24 22:30:38

YOLOv11智慧工地实战(二):工程车行为分析与作业区域识别算法详解及系统整合部署全流程开发指南

🎪 摸鱼匠:个人主页 🎒 个人专栏:《YOLOv11实战专栏》 🥇 没有好的理念,只有脚踏实地! 文章目录 四、工程车行为分析与作业区域识别 4.1 工程车行为分析基础 4.1.1 运动状态分类 4.1.2 作业区域识别 4.1.3 工作模式分析 4.2 运动状态识别算法 4.3 作业区域识别算法…

作者头像 李华
网站建设 2026/9/24 22:29:14

训练速度慢不一定是显卡的锅:GPU租用平台选型与迁移实战

“训练速度慢”这四个字&#xff0c;几乎是每个搞深度学习的人都会撞上的墙。模型迭代到第三版&#xff0c;loss曲线死活下不去&#xff1b;明明加了数据增强&#xff0c;一个epoch却从20分钟变成40分钟&#xff1b;转头看任务管理器&#xff0c;GPU占用率在60%和90%之间反复横…

作者头像 李华
网站建设 2026/9/24 22:29:11

Java常用类核心要点:包装类、BigDecimal精度与随机数实战

1. 包装类到底解决什么问题——先聊设计思路1.1 基本类型不是对象&#xff0c;集合又只收对象Java有两套类型体系&#xff1a;一套是基本类型&#xff08;int、double、boolean这些&#xff09;&#xff0c;另一套是引用类型&#xff08;String、数组、各种类对象&#xff09;。…

作者头像 李华
网站建设 2026/9/24 22:29:00

情绪Alpha:如何把市场情绪变成可交易的量化因子

量化圈子里聊“因子”&#xff0c;聊到后来基本就两类&#xff1a;一类是量价&#xff0c;一类是基本面。但最近几年&#xff0c;大家开始高频地提一个词叫“情绪 Alpha”。第一次看到这个标题的时候&#xff0c;我第一反应是&#xff1a;这不就是量化里那句老话“别人贪婪我恐…

作者头像 李华