你打开回测报告的第一件事是什么?我以前肯定是先看收益曲线,直到上个月把“月末策略标的”重新拉出来做了一轮复盘,才发现自己之前的回测框架里埋了好几个雷。这次“再研究”不是简单换个参数再跑一遍,而是把整个回测链条——从数据清洗、复权处理、多股组合撮合到绩效归因——全部重构了一遍,用 backtrader 做了多股月末调仓的完整回测。如果你也在做类似的中低频策略,或者正准备从单标的回测跨到多标的组合回测,这篇内容大概率能帮你少踩几个坑。
先说结论:月末策略本身不复杂,复杂的是怎么把“每月最后一个交易日调仓”这件事在回测引擎里真实地表达出来,以及怎么让回测结果不被数据细节骗过去。下面我把这次回测再研究的完整思路拆开讲。
1. 为什么是月末,这次再研究到底想验证什么
1.1 月末调仓这种节奏的底层逻辑
很多人第一次听到“月末策略”会觉得有点玄,觉得调仓频率这么低,能有什么超额收益?但实际上,月末调仓在量化策略里有它非常实在的优势。核心逻辑有两个:一是规则清晰、可复现,二是天然避开了周内情绪波动。
从规则角度讲,每月最后一个交易日收盘后做调仓,是一个极其明确的触发条件。系统不会因为行情大涨大跌就临时改变计划,也不会因为“感觉今天该买”这种主观因素破坏纪律。对于程序化回测来说,这种确定性意味着信号生成、订单执行、绩效统计的每个环节都能严格对齐。
从市场微观结构的角度讲,A股或港美股市场都存在一定程度的“日历效应”,比如月初资金面相对宽松、月末流动性偏紧、财报披露期的窗口效应等。月末调仓实际上是在利用这些规律性波动的“价差窗口”,在月底锁定一批相对便宜的标的,持有到下一个月底。当然,这个逻辑在单个月度里不一定每次都对,但放到十几年、几十年的长周期里,统计优势会被慢慢放大。
这次“再研究”之所以叫“再”,是因为我之前做过一版简单的月末策略:每月末按某几个技术指标打分,取排名前10的股票等权买入。当时跑出来的年化有百分之二十几,回撤也能控制在15%以内,看起来挺美。但后来我逐步发现几个问题:一是数据没有做严格的停牌处理,有些标的在调仓日根本买不进;二是手续费和滑点设置得太乐观;三是打分因子里使用了未来数据。所以这次重构,我给自己定了三个核心验证目标。
1.2 这次回测要验证的三个问题
目标一:验证因子打分在月末这个特定时点上是否真的具备区分度。也就是说,按打分选出来的前10只股票,下个月的表现是否显著优于后10只或全市场平均。这个验证决定了策略是否真的有“选股能力”,而不只是beta在贡献收益。
目标二:验证调仓成本吃掉多少超额收益。月末调仓虽然频率低,但每次调仓涉及换手,如果持仓标的重合度不高,一次调仓可能换掉七八成仓位,双边手续费加滑点加冲击成本,一个月吃掉1%以上很正常。这个成本到底有多大,必须用真实的交易费用模型去测,而不是拍脑袋设个万二。
目标三:验证不同权重分配方式对结果的影响。等权、市值加权、按因子得分加权,三种方式在收益曲线、最大回撤和换手率上会有明显差异。这个问题我过去一直没细究,这次专门做了对比回测。
这三个问题本质上都是在回答:这个策略的超额收益到底从哪来,是靠选股、靠择时、还是靠运气。回测的意义不是让我们看到一个漂亮的数字,而是把收益来源拆解到可以理解的层面。
2. 回测框架选型:为什么还是 backtrader
2.1 backtrader 到底适合哪类回测
现在市面上的回测框架不少,有专门做事件驱动的,有偏向量化的,也有商业软件一键回测的。我这次仍然选择 backtrader,原因很简单:它在“多股组合+规则化调仓”这个场景下,是我用下来最顺手、也最不容易出错的工具。
先解释一下 backtrader 的定位。它是一个事件驱动的回测框架,核心思路是:数据源(Data Feed)驱动引擎向前推进,引擎在每个 bar 上通知策略(Strategy),策略根据当前状态发出订单(Order),订单经经纪商(Broker)撮合后产生成交,最后由分析器(Analyzer)统计绩效。这种设计非常贴合真实交易的流程,尤其是对月末调仓这种低频策略来说,多一个 bar 的偏移、多一个停牌日未成交,都会让结果产生偏差,事件驱动能把这些细节暴露出来,而不是藏在向量化计算的黑箱里。
相比之下,纯向量化回测的优点是速度快,几十行 pandas 就能算完十年数据,但缺点是对交易约束的表达很弱。比如涨跌停不能买入、停牌不能卖出、最小交易单位是100股、资金不足时订单部分成交等,这些在真实交易里都是硬约束,向量化回测里很难精细处理。而 backtrader 的 Broker 模块可以模拟这些约束,虽然速度慢一些,但结果更可信。
另外,backtrader 支持多数据源(多股票)同时加载,每个数据源可以有自己的时间轴、复权因子、交易状态标记。这个特性对多股回测太关键了。你不需要把多只股票的日线数据拼成一个宽表,也不需要自己处理股票停牌导致的日期错位,只要把每只股票作为独立的 Data Feed 塞给大脑(Cerebro),框架会自动按时间对齐事件。
我这次做的是月末多股调仓,10到20只股票,日线级别,周期大概十年。用 backtrader 跑一遍只需要几十秒,完全在可接受范围内。如果你做的标的数量更多,比如几百只股票的全市场筛选,那 backtrader 会有些吃力,建议先做一层预筛选,或者换用向量化框架做初筛再进 backtrader 做精细回测。
2.2 数据准备阶段最容易埋雷
说句实在话,多股回测里,框架选型只是第一步,真正决定回测结果可信度的,是数据准备。很多人一开始学会 backtrader 就急着写策略,结果跑出来的曲线漂亮得不像话,后来一查,数据里全是坑。这里我列几个必须注意的地方。
第一,复权方式要统一。股票的日线数据一般有前复权、后复权和不复权三种形态。回测里最常用的是前复权,因为它保证最后一根K线的价格是真实的市场价格,便于和当前行情对照。但前复权有个问题:随着每次分红除权,历史价格会被重新计算,导致你第二次跑回测时结果和第一次略有差异。如果要做严格的可重复研究,建议用后复权数据做计算,只在最后展示时换算成前复权价格。
第二,停牌处理需要显式标记。多股回测中,一只股票停牌意味着当天没有成交数据,backtrader 默认情况下会把数据视为缺失,而不会自动跳过。这在月末调仓时会很致命:如果你的选股池里有股票在调仓前停牌,数据会短缺,订单撮合也会出问题。我的做法是:在数据预处理阶段,把停牌日的 OHLCV 全部填充为前一个交易日的收盘价,同时加一个 status 字段标记“不可交易”。策略里在调仓时先检查 status,不能交易的标的一律跳过。
第三,上市时间不足导致的新股效应。如果选股池里有次新股,上市初期波动大、容易暴涨暴跌,这会给回测带来很大的噪音。更严重的是一些“借壳上市”的股票,复权后的价格会发生剧烈跳变,甚至出现负价格或极端值。我在数据清洗时设定了一个硬性过滤:上市不足 60 个交易日的股票不进入候选池。
第四,退市股不能直接丢弃。很多人下载历史行情时,习惯性把已经退市的股票删掉,这在多股回测里是非常严重的“幸存者偏差”。如果你只回测今天还活着的股票,等于默认自己在过去每次调仓时都准确避开了未来会退市的垃圾股,这当然会显著拉高回测收益率。正确做法是保留退市股,把它们纳入选股池,让策略在历史中的每一次调仓都面对完整的市场集合,哪怕它在某个时间点以后就再也买不到了。
2.3 手续费、滑点和涨跌停的现实约束
还有一块数据准备之外的关键工作,是把交易成本模型写对。很多网上教程里,手续费就写个 commission=0.0003 完事,这在单标的回测里还能凑合,多股组合回测里完全不够。
我这次把成本模型拆成了三部分:
- 佣金:按成交金额的比例收取,双边都要收,这里我设的是万二点五,最低 5 元。虽然现在很多券商线上佣金能谈到万一点五,但回测里留一点余量更稳妥。
- 印花税:这个只有在卖出时收取,不同市场的税率不一样,我按千分之一的卖出税率估算。
- 滑点与冲击成本:这部分最容易被忽视。月末调仓当天,如果你要买入一只市值偏小的股票,订单量占当日成交量的比例过高,你的实际成交价会明显偏移。backtrader 里我通过
CommissionInfo类的perc参数来模拟这部分成本,比如设置额外 0.001 的冲击费用,相当于买卖双边各 10 个基点。对日内成交量小的标的,我会在选股打分阶段直接对“过去20日平均成交额”做限制,低于某个阈值的股票直接剔除。
涨跌停不仅影响能不能买进卖出,还会影响成交价。真实交易中,一字涨停的股票你是买不进去的,一字跌停的股票你是卖不出的。backtrader 的 Broker 默认不处理涨跌停,需要你在策略里自己判断:如果某只股票当天一字涨停且挂买单,撤单;一字跌停且挂卖单,撤单。我是在next方法里通过比较open == high == low这种条件来识别一字板,这个判断虽然粗略,但实践下来已经能过滤掉绝大多数无效成交。
3. 多股组合回测的核心实现细节
3.1 选股池和月末调仓信号的生成
这次回测我使用的是“动态股票池”,也就是每个调仓日之前重新筛选一次候选标的,而不是固定死 20 只股票从头跑到尾。这么做的好处是贴合真实投资逻辑,策略可以及时纳入次新股、剔除基本面恶化的标的,也能让回测结果反映策略真实的换手率。
数据准备阶段,我先把一个包含约 300 只样本股票的历史日线数据全部加载进来,每只股票保存成单独的 CSV 文件,字段包括日期、开盘、最高、最低、收盘、成交量、成交额、涨跌幅。加载到 backtrader 里时,我用bt.feeds.GenericCSVData逐只导入,然后通过data._name属性区分不同的标的。
月度信号的生成逻辑如下:在每一天的next回调中,先判断当前日期是否是本月最后一个交易日。判断方法很简单,比较下一个交易日的月份是否与当前月份不同:
def is_last_trading_day_of_month(self, dt): try: # 找到当前数据在时间轴上的下一个交易日 next_dt = self.data.datetime.date(0) # 此处为示意 return False except: return True实际实现时,我不会在next里依赖单条数据流,而是用self.datas里所有数据源的最大共同交易日来做判断,确保任何一只股票的下一根K线都还没有到来。简单可靠的做法是:预先把交易日历生成好,用字典映射日期到“是否为月末”标志位。这样无论有多少只股票,信号判断都是 O(1) 的。
到了月末调仓日,我会遍历整个股票池,计算每只股票的打分因子,然后从高到低排序,取前 10 只作为目标持仓。打分因子这次用的是四个:动量(过去20日累计涨幅,剔除最近5日防止短期反转噪音)、波动率倒数、成交活跃度、基本面排雷(剔除账面市值比极端的标的)。每个因子先做百分位排名,再等权合成总分。
3.2 权重计算与订单管理
选好 10 只股票后,接下来要确定每只股票的买入数量。我做了两组对比回测:一组是等权买入,另一组是按因子得分加权。等权最简单,直接target_value = total_cash / 10,然后除以收盘价取整;加权就是先归一化因子得分,再按得分比例分配资金。
回测里有个非常现实的细节:资金分配必须处理“整手”约束。A股、港股的交易单位不同,我这次先以100股为单位取整。比如某只股票目标市值 10 万元,价格 20.35 元,那么理论上可买 4914 股,但实际下单时只能买 4900 股,多出来的几十股现金就留在账户里。这个取整逻辑不处理好,会让回测的资金利用率偏低或者下单数量不合规。
backtrader 里做组合调仓最方便的方法不是直接buy(),而是使用order_target_percent和order_target_value。这两个方法会自动计算当前持仓与目标之间的差额,并生成相应的订单。我会在调仓函数里循环所有目标股票:
for data in self.datas: name = data._name if name in target_positions: target_weight = target_positions[name] self.order_target_percent(data=data, target=target_weight) else: # 不在目标持仓中,清仓 self.order_target_percent(data=data, target=0.0)要注意的是,order_target_percent参数里的target是相对当前总资产的比例,而不是初始本金。如果账户里已经有浮盈,它买入时使用的目标市值会自动放大,这点比较符合真实账户操作。不过它也带来一个副作用:当组合里出现暴亏、总资产缩小时,系统会自动把每个仓位的目标市值调低,产生额外的卖出。这意味着策略的调仓不只在月末发生,遇到单日大幅波动也会在第二天的next回调里被动调整仓位。为了解决这个问题,我会在每个非调仓日的next里直接return,只在月末调仓日才执行order_target_percent,这样就锁定了真实的调仓节奏。
订单提交后,不能撒手不管。我在策略里维护了一个pending_orders列表,在notify_order回调中跟踪订单状态:提交、已接受、部分成交、完全成交、已取消、已拒绝。月末调仓日的一批订单,理论上应该在当天收盘前全部成交,但如果遇到停牌或涨跌停,部分订单会无法成交。我的处理是:当检测到订单已取消或已拒绝时,把这只股票的调仓标记为“失败”,后续不再主动重试。因为月末调仓本身有一定容错性,个别股票买不进就放弃,把资金留在账户里,下个月初再重新分配,这比死磕一个买不进的标的要理智。
3.3 绩效统计与收益归因
回测跑完之后,不能只看一条光秃秃的收益曲线。我这次在 cerebro 里挂了一组分析器,包括:
- 收益率(AnnualReturn、TimeReturn)
- 最大回撤(DrawDown)
- 夏普比率(SharpeRatio,使用无风险利率 1.5% 计算)
- 交易记录与换手率统计
其中换手率是我特别关注的。换手率等于调仓时买卖金额之和除以月初总资产。如果策略的平均月度换手率是 80%,那么一年下来双边换手接近 10 倍,每 10 倍换手对应 1% 的交易成本,可能就是年化收益从 15% 掉到 12% 的差距。backtrader 自带的 Analyzer 不直接给出月度换手率,我是在策略里手动统计每笔成交的金额,累积到一个字典里,回测结束后再计算。
收益归因上,我做了两套对比基准:一是买入持有全市场等权指数,二是“每月末随机选 10 只股票”的随机策略。为什么要做随机基准?因为如果选股池整体就在走牛,那么任何选股方法都能赚钱,你无法判断收益来自策略还是来自市场。随机策略和真实策略用同一个股票池、同一个调仓频率、同一个成本模型,跑 100 次取平均收益,如果真实策略显著跑赢随机基准,才能说明选股因子确实有效。这个对比方法,是我这次“再研究”里最有价值的部分。
4. 回测结果里那些反直觉的东西
4.1 收益曲线好看,但要看回撤位置
第一版重构后的回测结果,年化收益和之前差不多,还是 20% 附近,但最大回撤从 15% 扩大到了 21%。原因很简单:之前省掉了涨跌停约束和停牌处理,最差的行情里,策略可以满仓杀进去,也可以顺利出逃,回撤自然好看。加入现实约束后,碰上连续跌停的月份,持仓出不来,回撤就被动放大了。
我的体会是:回测中最大回撤的绝对值只是参考,更要紧的是看最大回撤发生的时间点和持续时长。如果最大回撤集中在某一次股灾期间,并且在之后的半年内明显修复,那说明策略的恢复能力不错。如果回撤是均匀分布在每一年里的,回头看你的风控模型可能有系统性漏洞,比如行业过度集中、单只股票占比过高。
我这次在结果分析里给每一年单独列了一个收益和回撤的分布表,发现最差的年份不是市场整体下跌的年份,而是个股分化极其剧烈的年份。说明月末策略本身没有择时能力,它只能在市场风格相对稳定时跑出超额。这个结论很关键,它提醒我:如果要实际跑这个策略,不能全仓押注,必须预留一部分现金仓位做保护。
4.2 空仓期其实是超额收益的重要来源
回测里另一个反直觉的发现是:策略的空仓期(手上现金超过 20% 的时间段)不是劣势,反而对最终收益贡献很大。原因在于,月末调仓策略本质上是一个高换手的组合,在市场下跌阶段,调仓买入的标的往往会继续下跌,如果满仓操作,回撤会被不断放大。而我的策略里因为整手取整、涨跌停限购等原因,自然会产生一部分未被使用的现金,这些现金在下跌行情里起到了缓冲垫的作用。
这不是我最初设计的目标,但回测数据让我意识到,一个真实的账户在运行月末策略时,完全没必要强行满仓。保留 5% 到 10% 的现金冗余,既不影响长期收益,还能显著降低尾部回撤。在资金管理上,我会在回测之后再做一个“现金比例敏感性分析”,分别测试 0%、5%、10%、20% 的现金预留对收益回撤的影响,最终选择一个在风险收益比上最舒服的比例。
当然,现金比例太高也会拖累收益。在牛市中段,10% 的现金预留可能会让年化收益降低 1 到 2 个百分点。完全没仓位管理的人容易走极端,要么满仓,要么空仓。正确的做法是通过回测找到“最大回撤容忍度”和“收益目标”之间的平衡点,然后把现金仓位固定下来,不要随意变动。
4.3 换手率和交易成本之间的敏感关系
这次回测我特意做了不同交易费用档位的对比:无费用模型、低费用模型(双边万五)、高费用模型(双边千二)。结果不出意料,费用越高,策略的超额收益越薄,但有趣的是,策略的排序稳定性并没有因为费用模型改变而大幅变化。也就是说,如果一只股票在无费用模型下排第一,那么在千三费用下它大概率仍然是靠前的标的。
不过,换手率高的月份对费用极其敏感。某些月份因为市场风格切换,选股池和上月几乎完全重叠,换手率只有 30%,这时候费用影响微乎其微;但在另一类月份,选股池几乎全部换了一遍,换手率到了 150%,双边费用直接吃掉 1.5% 的收益。如果不控制这种极端月份的发生频率,策略的年度收益方差会很大。
我的应对方法是引入一个“换手率惩罚项”:当新选出的股票和当前持仓重叠度低于 40% 时,打分会乘以一个 0.95 的折扣,尽量让系统在调仓时保持一定的持仓连续性。这个思路在实盘中很常见,基金经理换仓时也会考虑交易成本,不会把组合一锅端。
5. 常见问题与避坑实录
5.1 回测中隐蔽的“未来函数”陷阱
我在这次回测里排查出一个特别隐蔽的未来函数,来源是打分因子的计算。我的动量因子定义是“过去 20 日累计涨幅”,但如果用close(-1)和close(-21)来算,在月末那根K线还没走完时,close(-1)实际上已经包含了收盘价,而信号生成发生在当天盘中或开盘前,这就引入了未来数据。正确的做法是使用close(-2)作为信号基准,或者确保回测引擎只在每根K线收盘后才触发调仓信号。
backtrader 在数据处理顺序上非常严格,正常情况下next是在当前 bar 收盘后调用的,所以直接使用self.data.close[0]作为信号是安全的。但如果你在自定义的 Data Feed 或 Analyzer 里引用了未来数据,比如用self.data.close[1]去计算当前信号,就很容易踩雷。我的排查方法是:把回测中的每个调仓日的信号标的和复盘时的真实行情对照,如果出现“计算因子需要收盘价但信号生成的日期当日已经涨停”这种情况,就说明可能在数据的时序上有偏差。
一个更系统的做法是在回测引擎之外写一个独立的“信号复核”脚本,从历史数据里按规则重新计算每个调仓日的选股结果,再和 backtrader 输出的交易记录做差分对比。如果差异超过 1% 的交易笔数,就说明信号链路中存在未察觉的时间错位。
5.2 复权数据使用不当带来的假信号
复权数据是多股回测中另一个大坑。我举个最简单的例子:某只股票在 6 月 30 日实施了 10 送 10,除权后股价从 50 元变成 25 元。如果你用不复权数据做动量排名,6 月 30 日的累计涨幅会出现一个巨大的假跌幅,该股票直接进入“超跌”区间,可能会被策略选中买入。但真实情况是,只是股本变大了,每股净资产没变,这并不构成买入信号。
应对这件事,最稳妥的办法是在因子计算时使用后复权价格。后复权价格把所有历史分红和拆股都累加到最新价格上,保证历史价格之间的可比性。但后复权数据也有问题:最新价格和真实市场价格偏差很大,不适合用来计算账户市值和下单数量。我的做法是“双轨制”:因子计算用后复权价格,订单的下单价格和账户结算用前复权价格,在策略内部通过一个价格转换函数做换算。
这个细节烦琐,但非常重要。如果图省事全部用前复权,那么每次分红除权后,前复权历史价格都会被整体重算,奖金因子序列也会被“重写”,导致几个月后重新回测时结果对不上。如果你希望自己的回测结论可复现、可跟踪,这关必须过。
5.3 多标的并行时的数据对齐、停牌与最小交易单位
最后一个常见问题是多股票数据源的日期对齐。不同股票可能有不同的节假日停牌日期,比如有些股票因为临时股东大会停牌半天,有些股票因为异动停牌一天,它们的交易日期并不完全重合。在 backtrader 里,所有 Data Feed 的时间轴需要对齐到同一个“交易日历”上,否则引擎会在某只股票数据缺失的日期上直接跳过,导致信号判断错位。
我用的办法是:在数据加载前,先构造一个全市场交易日历作为主时间轴,然后把每只股票的数据 reindex 到这个主时间轴上,缺失的日期填充为NaN或者前值。在next循环里,我始终用主时间轴的日期做信号判断,然后再检查个别股票当前 bar 是否有效。
最后,提醒一下最小交易单位的问题。我这次策略里,100 股的取整逻辑在 backtrader 里并不是默认行为。order_target_percent默认按最大可用资金计算股数,但不一定按整手约束。如果你回测的市场有整手限制,必须在broker的order方法里自己算好size,或者写一个自定义的Order类去强制取整。动作虽小,相差甚远,忽略它会导致回测结果和实盘差异很大,尤其是在小市值股票上。
写在最后的一点建议
如果你准备在自己的策略里引入多股回测,我的建议是:不要一上来就追求复杂的组合优化和机器学习模型,先把最基础的月末等权调仓做扎实,把数据、成本、约束都处理清楚,拿到一个可信的基准结果,再逐步叠加因子和风控逻辑。
回测的本质是帮助我们理解策略在什么环境下有效、什么环境下失效,而不是寻找一个永远赚钱的圣杯。我这次月末策略的重构,最大的收获不是年化提高了多少,而是终于把回测中那些藏在数字背后的假设一个个挖了出来。以后你在看任何一份回测报告时,都可以多问一句:它的数据里有幸存者偏差吗?它的成本模型现实吗?它的信号里有没有未来函数?带着这些问题去审查,你自己做回测的水平会提升一大截。