回测年化30%的模型,放到实盘里跑三个月就开始亏钱,这种剧本我在量化社群见过太多回了。大多数人的第一反应是策略过拟合、滑点太大、手续费没算够,但真正的问题往往藏在一个最不起眼的地方——复权。如果你正在用 backtrader 跑多股回测,或者刚把一套历史收益很漂亮的策略接到实盘,我强烈建议先花十分钟看完这篇记录。前复权、后复权、不复权这三套价格体系,只要有一处混用,回测结果和实盘表现就能差出十万八千里,甚至完全反向。
我不打算写教科书式的复权定义,而是用一个真实的踩坑场景切入:为什么回测里能成交的价位,实盘根本挂不上单;为什么前复权数据会在你的策略里埋入“未来函数”;以及一套我目前一直在用的“真实价成交、复权价计算”双视图方案,配 backtrader 多股回测的具体落地方法。
1. 复权是什么?回测里为什么绕不开它
1.1 三种价格形态:不复权、前复权、后复权
先把概念捋清楚。股票分红送转之后,股价会向下跳空。最典型的例子是10送10,股权登记日收盘20元,第二天除权,理论上参考价变成10元。如果直接看不复权K线,这一天就是一个巨大的向下跳空缺口,像“暴跌”一样。问题是股票并没有跌,只是股本变大了、每股含金量减半了。
- 不复权:就是真实成交价,有多少钱就是多少钱,适合判断涨跌停、计算实际市值、做下单撮合。
- 前复权:以最新价格为基准,把历史价格按照复权因子向下压缩,让整条K线连起来。图形漂亮,但历史价格会随着“最新价”的变化而不断重算。
- 后复权:以上市首日或某个固定基准为基准,把之后的价格向上放大,历史序列一旦生成就是固定的,不受新数据影响。
后复权的价格会高得离谱,比如茅台后复权能到几万块,看着吓人,但它才是量化计算里最稳定的那个视图。前复权适合看图,后复权适合计算,不复权适合下单——这句话基本可以概括全文。
1.2 除权除息与复权因子的本质
任何股票数据源里,复权因子(adj_factor)才是灵魂。它本质上是一个缩放倍数,记录每次分红送转带来的价格修正。常见的数据约定是:
后复权价 = 真实价 × 复权因子
举个例子,某股票上市时复权因子是1.0,第一次10送10之后因子变成2.0。除权前真实价20元,后复权价是20×1.0=20元;除权后真实价10元,后复权价是10×2.0=20元。价格连续了,对不对?
所以你最容易踩的第一个坑,是拿接口直接返回的 qfq(前复权)K线喂给回测引擎。因为前复权K线已经把真实价格这个信息丢掉了,只剩下一个被压缩过的虚拟价格。后面所有回测、信号、撮合都建立在这个虚拟价格之上,和实盘的真实成交价完全脱节。更麻烦的是,前复权价格还会随着行情更新不断漂移,这是下一章的重点。
2. 前复权数据里的隐形杀手:未来函数与信号失真
2.1 前复权是“会变”的历史
很多人没意识到,前复权的历史价格并不是一个固定事实。它采用的基准是“最新一个交易日”,所以每次行情更新,前复权因子就会变,整段历史K线都会跟着重算。这意味着你回测时看到的2019年K线,是站在2024年最新基准下重新调整过的K线,2019年那一刻的交易者根本看不到这个价格。
这算不算未来函数?算,而且是很隐蔽的一种。你并没有显式地用到未来数据,但历史价格本身已经被“未来信息”改写了。对均线突破、动量这类相对价格关系,影响可能不大;但只要策略里涉及绝对价格水平,比如“股价低于5元买入”“价格区间网格”“低价股轮动”,灾难就来了。
2.2 价格阈值策略被前复权悄悄改写
我见过一个最典型的案例。某只股票在2020年做了一次10送20的高送转,前复权之后,2019年的历史价格被压缩到原来的三分之一。一只原本28元的股票,在前复权K线上变成了9.3元。策略逻辑是“股价低于10元且20日均线向上”就买入,于是回测在2019年触发了一大堆买入信号,看似年化收益30%。但实盘那位交易者在2019年面对的是一只28元的股票,条件根本不成立。
这类策略在回测里越是“精准命中”,实盘越是亏得惨,因为回测中每一次买入都是被未来基准重写过的假信号。排查方法也很简单:把同一策略分别用不复权、前复权、后复权三种数据跑一遍,如果收益差异大到盈亏逆转,十有八九就是绝对价格逻辑被复权坑了。
2.3 涨跌停与撮合价失真
涨跌停价格是按真实价格计算的,不是按复权价。除权日当天,真实K线上有一个向下的价格跳空,而前复权K线把这个缺口补平了。问题在于很多策略会用“跌幅超过某阈值”或者“触及跌停”作为买入条件,前复权之后,除权日看起来就像一个“抄底机会”,回测引擎会愉快地在跌停价附近给你成交。
但实盘呢?除权参考价、真实跌停价、集合竞价价格全部基于真实价格,你看好的那个“低位”根本不存在,或者就算存在,真实盘口上的买单和卖单也不是回测系统里那个虚拟价格能成交的。回测里那些漂亮的低吸点位,放到实盘里要么抢不到筹码,要么买在了一个完全不同的位置。这是“回测稳、实盘拉胯”最常见的来源之一。
3. 多股回测的复权陷阱:backtrader 里最容易翻车的地方
3.1 多股票组合的“基准错位”
单只股票用错复权数据,影响还局限在个券上;多股回测里,复权基准错位会被组合层放大。每只股票的除权除息日期不同、送转比例不同、累计因子不同。如果全部用前复权数据,各只股票的价格水平就完全不可比了。A股票高送转多次,前复权后价格被压到5块钱;B股票从不分红,价格一直是80块。你的组合策略如果按“价格等权”“价格区间分配资金”来配置,资金分配比例会被严重扭曲。
更隐蔽的是组合净值计算。很多人习惯把组合里各股票的价格直接加总或者用固定股数乘以价格求市值,前复权价根本不是真实市值,算出来的权重自然也是歪的。高位送转的股票实际权重被低估,不分红的股票被高估,最后跑出来的组合曲线完全不是你想表达的策略。
3.2 backtrader 多股回测的典型错误
结合我自己的使用经验,backtrader 里跑多股回测最容易出现下面几类问题:
- 错误一:所有股票用 tushare 的 qfq 数据直接喂给 backtrader,然后按固定股数算组合市值。不同股票的复权基准不一致,市值权重天然就是错的。
- 错误二:数据用了 qfq,但下单价格直接取 self.data.close[0]。因为 close 已经是前复权后的虚拟价格,backtrader 的 broker 就会按虚拟价格撮合,和实盘价格完全对不上。
- 错误三:每次重新下载历史数据,由于前复权因子随最新价漂移,同一套策略前后两次回测结果对不上。我见过有人反复查代码,最后发现是数据版本变了。
- 错误四:多股停牌日期不同,复权因子列在停牌区间缺失,有人直接前向填充,导致除权日附近的因子错乱,信号全部错位。
3.3 为什么后复权是多股回测更稳的主数据
后复权的核心优势是确定性和可复现性。只要真实价格不变、复权因子规则不变,历史后复权序列就永远不变。对多股回测来说,用后复权价做横截面比较、计算动量因子、构建收益率矩阵,都不会被基准漂移干扰。
而且后复权价有一个重要的数学性质:它的日度收益率约等于含分红的真实收益率。这意味着你可以直接用后复权价格序列计算策略净值,不用在每次除权日手工处理跳空。唯一的注意点是,后复权价格数值大小和真实价格不是一个量级,不适合做“绝对值相关的价格策略”,但适合做“相对收益类”的横截面策略。记住一个公式随时切回真实价格:
真实价 = 后复权价 / 复权因子
4. 解决方案:双视图回测架构与回测-实盘映射
4.1 数据分层原则
我的做法是把数据分成四层,每层各司其职,互不污染。
第一层是原始数据层,只保存不复权的 OHLCV 和对应的复权因子 adj_factor。第二层是派生数据层,需要后复权时实时计算,后复权价 = 真实价 × adj_factor。第三层是指标计算层,策略里的均线、动量、波动率、突破信号全部基于后复权价生成。第四层是撮合执行层,所有下单价格、涨跌停判断、滑点模拟一律使用真实价格完成。
这样设计的好处是,信号计算和交易执行两个环节彻底解耦。信号告诉你要买,执行层只负责按真实市场价格成交,不会出现“策略在虚拟价格上开仓、实盘在真实价格上挨打”的错位。
4.2 回测-实盘信号映射
在 backtrader 里,一个最直接的做法是在 next 回调里手动计算下单价格。假设你用的是带 factor 字段的自定义数据源:
class PandasDataExt(bt.feeds.PandasData): lines = ('factor', 'real_close',) params = ( ('factor', -1), ('real_close', -1), )这里我在数据源里额外增加了 factor 列和 real_close 列。策略逻辑里,信号指标用 self.data.close[0] * self.data.factor[0] 算出的后复权价;而真正下买单时,用真实价格:
real_price = self.data.real_close[0] self.buy(exectype=bt.Order.Limit, price=real_price * 0.98)这样 broker 撮合时使用的是真实价格体系,不会被后复权价格误导。有一个我踩过的坑要提醒:除权日当天,factor 的值是当天收盘后才知道的,盘中下单时如果用当天的 factor 反推真实价,可能出现数据错位。稳妥的处理是提前维护一份除权除息计划表,或者在撮合之后加一道校验:成交价不能低于当日真实最低价、不能高于当日真实最高价,否则丢弃重打。
4.3 股息与红利再投资
后复权收益率里通常包含了分红再投资的假设,但真实账户里现金分红不会自动变成股票,到账有延迟,还要扣红利税。为了让回测更贴近实盘,比较严谨的做法是把“价格收益”和“现金分红”拆开处理:用真实价格计算价格涨跌,分红日单独记录一笔现金流入,按除权日或实际到账日计入组合现金。
backtrader 里可以通过 notify_order 或自定义 Analyzer 处理分红到账事件。如果你的策略本身不依赖分红,直接用后复权收益率当然简单,但心里要清楚:这个收益率比真实“股价涨幅”多了一部分分红收益,时间一长,净值曲线的水分不小。
4.4 Backtrader 多股回测实操要点
多股回测我现在的标准流程是这样的。
第一步,下载不复权日线数据,包含 ts_code、trade_date、open、high、low、close、pre_close、vol、amount,再单独下载 adj_factor 表,合并成一张 DataFrame。第二步,每一只股票生成三列核心数据:真实价、复权因子、后复权价(真实价 × factor)。第三步,自定义 PandasDataExt 传入 backtrader。第四步,使用字典管理多标的的 data 引用,方便在 next 里遍历:
self.dnames = {d._name: d for d in self.datas} for name, d in self.dnames.items(): hfq_close = d.close[0] * d.factor[0] real_price = d.real_close[0] # 在这里写你的信号逻辑和下单逻辑第五步,回测结束后导出订单明细,重点人工检查除权日附近的订单价格是否落在真实价格区间内。这一步看起来很笨,但确实能抓住大部分复权问题。
5. 5分钟自查清单:你的回测有没有踩复权坑
5.1 快速验证的五个检查点
- 检查回测订单里有没有“买入价低于当日真实最低价、卖出价高于当日真实最高价”的成交。有,就是价格视图混用了。
- 把回测信号的触发日期和除权除息日历对齐,如果信号密集出现在除权日附近,基本可以判定是复权缺口被策略误读。
- 用不复权、前复权、后复权三种数据分别跑同一套策略,收益差异大到年化几个点以上,数据层一定有问题。
- 确认你的数据链路里保存了复权因子。如果你的数据只有 qfq K线,没有 adj_factor,那就无法做真实价映射,建议立刻换数据源。
- 实盘前做一次“除权日模拟”,在股权登记日和除权日分别跑一笔模拟单,看信号和成交价是否稳定。
5.2 我排查过的两个真实案例
案例一,某个“跌停反转”策略,回测年化28%,一到实盘就频繁止损。我把回测订单导出来,发现大量买单出现在“10送10除权日”的跌停价附近。复盘后确认,回测系统用的是前复权数据,除权日的价格跳空被当作“超跌”信号,买入价根本不是真实盘口能成交的价格。切换成“后复权计算信号、真实价格撮合”之后,年化降到12%,虽然没那么惊艳,但实盘终于不再乱开仓了。
案例二,backtrader 多股回测,组合里有一只高送转股票和一只从不分红股票。用 qfq 数据直接加总市值时,高送转股票前复权价被压到很低,导致它的实际市值权重被严重低估。同一个策略,仅仅把数据切到后复权+真实市值权重,组合净值的形状就完全变了。后来我固定了一套规则:所有横截面权重一律用“真实收盘价×股本”计算,所有因子计算一律用后复权收益率,所有下单价格一律用真实价。
我的个人体会是,量化里“数据正确”永远排在“模型聪明”前面。现在我做任何回测,数据链路里必须同时存在真实价和复权因子这两个字段。接口自带的 qfq 数据,我只拿来看图,从不直接喂给回测引擎。多股回测要复现,先检查复权因子版本是不是一致。这套习惯改过来之后,回测和实盘的偏差肉眼可见地缩小了,至少“回测30%、实盘亏钱”这种乌龙,已经很久没发生过了。