☰
Backtrader双均线回测全流程:策略、参数与实盘衔接
2026/10/2 3:01:20 网站建设 项目流程

双均线策略大概是量化交易里被讨论最多、也最容易被小看的策略了。两行均线,一根快线一根慢线,金叉买入、死叉卖出,听起来简单到不像能赚钱的样子,但真正把它跑完一遍回测、把参数、手续费、滑点、复权方式都摆上台面之后,你会发现这个策略反而是检验一个人回测水平最好的试金石。这篇文章我想把用 backtrader 做经典双均线策略回测的完整过程拆开来讲——从最基础的代码骨架,到多股回测的正确姿势,再到怎么判断一份回测报告到底可不可信,最后聊聊从回测往模拟盘、实盘过渡时那些回测曲线里看不见的坑。无论你是刚接触量化想找个入门实践,还是已经在做回测但总觉得报告差点意思,这篇内容都值得你花十分钟慢慢看。

1. 双均线策略为什么到现在还值得认真跑一遍

先说一个可能有点反直觉的观点:越是简单到人人都会写的策略,越值得你亲手完整实现一遍回测。双均线策略的逻辑一句话就能说清楚——短期均线上穿长期均线时买入,下穿时卖出。但这句话背后藏着一整套完整的问题链:数据怎么取、均线周期选多少、买卖价格按什么算、手续费怎么扣、资金怎么分配、多只股票怎么处理、回测结果怎么评估。任何一个环节处理方式不同,最后的收益曲线可能差出一大截。把这套问题在双均线这种简单策略上全部过一遍,你才真正理解回测这件事的本质,而不是只会往代码里填参数。

1.1 双均线策略的逻辑本质

双均线策略的完整逻辑如下:计算短期均线(比如 5 日线)和长期均线(比如 20 日线),当短均线从下方穿越长均线时形成"金叉",这是买入信号;当短均线从上方穿越长均线时形成"死叉",这是卖出信号。用代码表达就是short_sma > long_sma持仓,否则空仓。

这个逻辑背后的直觉是:短期均线代表近期价格趋势,长期均线代表较长时间的趋势,金叉意味着短期趋势向上突破了长期趋势,顺势而为。它本质上是一个趋势跟踪系统,在单边上涨行情中表现很好,但在震荡行情中会频繁发出假信号,反复被打脸。

这里有一个新手常犯的理解误区:双均线策略不是一个"预测型"策略,而是一个"应对型"策略。它不预测明天涨不涨,它只是在均线关系发生变化后及时跟随。理解这一点很重要,否则你看到它在震荡市里亏损就会怀疑策略写错了,其实震荡市亏损是这类策略的固有特性,不是 bug。

1.2 回测到底在验证什么

很多人对回测的理解是"看我这个策略能不能赚钱",这个理解太浅了。回测真正验证的东西有三个层面:

第一,策略逻辑是否成立。双均线逻辑在历史上能否产生正收益预期,这需要足够长的历史数据和真实条件下的测算。

第二,策略在不同市场环境下的表现边界。比如用 5/20 参数在 2015 年到 2024 年跑下来收益不错,但拆开看会发现收益主要来自某几次大的趋势行情,大部分时间是横盘甚至小亏。这种认知能帮你建立正确的心理预期。

第三,策略对参数的敏感度。换成 5/21、6/20、10/30 之后结果有多大变化,如果稍微改一个数字收益就从翻倍变成腰斩,那这个策略大概率是过拟合出来的,不可靠。

把这些层面验证完,才算一次合格的回测。这也是为什么我一直建议新手上手量化先做双均线回测——它简单到你只需要关注回测本身的质量,不需要被复杂的策略逻辑干扰。

2. 用 backtrader 搭一个可复现的双均线回测骨架

backtrader 是目前社区里用得比较多的 Python 回测框架,优点是对新手友好、实盘对接能力强、内置了完整的交易流水撮合机制。我下面给的这套代码是经过多次实战改版后的骨架,直接复制就能跑,每一步我都会解释为什么这么写。

2.1 数据准备:用什么数据、什么周期

第一步先把环境装好,pip install backtrader pandas matplotlib,这三件套足够起步。数据方面,建议从你用券商接口、tushare、baostock 或 yfinance 都行,关键是拿到日线级别的 OHLC 数据。

我个人的建议是第一份数据用一只流动性好、历史足够长的标的,比如沪深 300 的成分股或者指数本身,时间段尽量覆盖一轮完整的牛熊周期,至少 5 年起步。数据准备阶段最容易踩的坑是复权问题,这里先提醒一句:回测必须用前复权数据,后面第五节详细展开为什么。

2.2 策略类的核心逻辑与完整代码

这是完整的、可运行的策略代码:

import backtrader as bt import pandas as pd from datetime import datetime class DoubleSMAStrategy(bt.Strategy): params = ( ("short_period", 5), ("long_period", 20), ) def __init__(self): self.short_sma = bt.indicators.SimpleMovingAverage( self.data.close, period=self.params.short_period ) self.long_sma = bt.indicators.SimpleMovingAverage( self.data.close, period=self.params.long_period ) # 金叉死叉信号,1 代表金叉,-1 代表死叉 self.crossover = bt.indicators.CrossOver( self.short_sma, self.long_sma ) def next(self): # 未入场且出现金叉,买入 if not self.position and self.crossover > 0: self.buy() # 已持仓且出现死叉,清仓 elif self.position and self.crossover < 0: self.close() class CommissionScheme(bt.CommissionInfo): def _getcommission(self, size, price, pseudoexec): return abs(size) * price * 0.0003 # 万三手续费

这里有几个关键点要说明:

第一,bt.indicators.CrossOver返回的是三个状态:金叉时返回 1,死叉时返回 -1,其他情况返回 0。不要在next里手动写short_sma > long_sma来判断金叉,因为均线值每天都有大小关系,你没法区分"今天刚穿越"和"已经穿越好几天了",CrossOver 帮你把信号做了事件化处理。

第二,策略的买卖默认按次日开盘价撮合。这个行为由broker的下单模式决定,backtrader 默认的bar模式下,当天信号当天以收盘价成交。如果策略用的是日线数据,当天收盘信号当天收盘价成交是合理的,因为均线交叉在收盘时才能确定。

第三,手续费通过自定义CommissionScheme实现。我上面写的是万三,实际交易中还有印花税(卖出时额外收取,常见费率是千分之 0.5 或更高)和最低收费,后面在回测质量部分我会给一个更完整的费用模型。

策略类写完之后,需要配置 cerebro 引擎来运行:

cerebro = bt.Cerebro() cerebro.addstrategy(DoubleSMAStrategy) # 加载数据,用本地 CSV 为例 df = pd.read_csv("stock_5y.csv", parse_dates=["date"], index_col="date") data_feed = bt.feeds.PandasData( dataname=df, datetime=None, # 使用索引作为时间 open="open", high="high", low="low", close="close", volume="volume" ) cerebro.adddata(data_feed) # 初始资金与佣金 cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission=0.0003) # 添加分析器,用于输出关键指标 cerebro.addanalyzer(bt.analyzers.Returns, _name="returns") cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown") cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe", riskfreerate=0.02) results = cerebro.run()

cerebro.broker.setcash(100000.0)设置初始资金 10 万,这决定了仓位规模和每笔交易数量。riskfreerate=0.02是夏普比率计算时用的无风险利率,国内一般取 2% 的年化。

2.3 引擎配置中的几个关键参数

有四个经常被忽略但影响巨大的配置项:

一是cerebro.broker.set_coc(True)或cerebro.broker.set_oco(False),控制订单是否按收盘价立刻成交。二是cerebro.broker.set_slippage_perc(0.001),设置滑点比例。三是cerebro.broker.set_checksubmit(False),跳过一些订单提交检查,实盘模拟时最好保留默认。四是cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="trade"),输出每次交易的明细,用来统计胜率。

如果你只是想知道策略大概能赚多少钱,跑完cerebro.run()之后调用cerebro.plot()看曲线就行了。但要想真正评估一个策略,这几个分析器必须全部打开,下一节展开讲怎么看这些数字。

3. 回测报告里的核心指标怎么看,哪些数字最容易骗人

跑完回测,backtrader 会给出一堆数字,但很多人只看最后的资金曲线和总收益率,这其实是最大的误区。一份完整的回测评估至少要看六个维度的数字:总收益率、年化收益率、最大回撤、夏普比率、胜率、盈亏比、交易次数。下面我逐个说清楚它们的含义和容易误读的地方。

3.1 用一张表看懂核心指标

我整理了一个常见回测指标的解读表,方便对照参考:

指标正常范围(参考)含义最容易犯的误判
总收益率越高越好期末资金 / 期初资金 - 1不区分时间长短,5 年翻倍和 1 年翻倍完全不同
年化收益率10%-25% 就算不错将总收益率折算到每一年,考虑了复利牛市背景下的高年化不可持续,需和年份对照
最大回撤15%-30% 以内账户净值从高点跌到低点的最大幅度只看数值不看持续时间,横盘阴跌远比快速回撤难受
夏普比率大于 1 算及格,大于 2 优秀每承担一单位风险获得的超额收益样本太短时夏普失真,至少有 100 次以上交易才有参考价值
胜率趋势策略 35%-50% 正常盈利交易笔数占总交易笔数的比例只看胜率,不看盈亏比,70% 胜率可能照样亏钱
盈亏比大于 1.5 较好平均盈利金额 / 平均亏损金额单次极端盈利会拉高值,要看中位数而非平均数
交易次数越多越接近统计可靠总开仓次数次数太少(小于 30)任何指标都没统计意义

这里重点说一下最大回撤和胜率这两个最容易误读的指标。

最大回撤不是"亏了多少钱",而是从净值高点算起,一度跌了多少。比如初始 10 万,涨到 15 万,然后跌到 12 万,最大回撤是 20%,而不是 30%。回撤控制是量化策略的生命线,因为回撤幅度决定了实际投入仓位时要预留多少心理和资金缓冲,一个年化收益 30% 但回撤 40% 的策略,实际可用性远远不如年化 15% 回撤 8% 的策略。

胜率则要永远搭配盈亏比一起看。双均线这种趋势策略典型特征是"多次小亏、偶尔大赚",胜率往往只有 40% 甚至更低,但只要单次盈利能覆盖两三次亏损,整体就是赚钱的。反过来,如果一个策略胜率高达 80% 却还在亏钱,那一定是有某几次巨亏把前面的小盈利全部吃掉了,这种策略的实际价值也很低。

3.2 双均线策略回测中那些绕不开的数字特征

我的实际经验是,经典双均线(比如 5/20)在日线上跑一只股票 5 年,常见的特征是:年化收益率 10%-18% 的样子,最大回撤 20%-30%,夏普比率 0.8-1.5,胜率 35%-50%,一年交易次数大概 10-20 次。如果跑出来的结果远超这个范围,比如胜率 80%、夏普 5,那我建议你先怀疑回测代码是不是出了问题,而不是高兴策略太强。

有一个很容易踩的实际问题:双均线在单边趋势强的标的上表现极好,在震荡型标的上基本是反复磨损。同样是 5/20 参数,跑同一时间段的贵州茅台和某创业板题材股,结果可能一个年化 20%、一个年化 -5%。这不是策略错了,而是策略风格和市场状态不匹配。所以做双均线回测时,最好固定时间段、同时在多只标的上跑,这才有比较意义,也能看出策略失效的边界在哪里。这也自然引出了下一节的话题——多股回测。

4. 从单股扩展到多股回测:backtrader 多股实现与常见坑

单标的回测只能证明策略在某一标的上有效,但这离"策略可用"还差得很远。真正实盘里你不可能把全部资金压在一只股票上,所以多标的回测是必然会遇到的需求。backtrader 对多股的支持比较直接,但实际跑起来之后会有几个容易忽略的问题,我逐个说明。

4.1 多股数据准备与对齐问题

多股回测的第一步是往 cerebro 里添加多个数据源:

cerebro = bt.Cerebro() cerebro.addstrategy(DoubleSMAStrategy) for ticker, df in stock_data.items(): data_feed = bt.feeds.PandasData( dataname=df, datetime=None, open="open", high="high", low="low", close="close", volume="volume" ) cerebro.adddata(data_feed, name=ticker)

这里要注意的关键点是:backtrader 要求所有数据源的时间轴对齐,村的数据缺失会自动让策略next停等,等所有数据都有新一轮再执行。这意味着如果一只股票停牌几天,其他股票的买卖信号也会被推迟到停牌股票恢复的那一天才执行。对于日线级别回测来说,这种机制虽然保证了信号的准确性,但也会让回测结果与实盘产生细微差异。

处理停牌数据时,比较实用的做法是提前填充缺失数据——用前收盘价填充或者用ffill。绘图时你会看到价格出现平台,但信号计算逻辑不受影响。千万不要直接用空值就丢给 backtrader,会报错或产生不可预期的行为。

另一个对齐问题是不同股票的上市时间和交易日历差异。如果你的数据池里既有 2010 年上市的股票,又有 2020 年上市的股票,回测早期阶段上市晚的股票数据为空,策略在那些时间点的资金分配会有被低估的问题。我建议人为统一一个回测起始日期,并且只保留那些从起始日就有数据的股票做样本池。

4.2 组合仓位管理的正确姿势

多股回测里最核心的问题不是"信号怎么来",而是"信号来了之后,资金怎么分配"。这一步决定了组合层面的风险和收益特征,比单股信号本身重要得多。

backtrader 默认的买入策略是:每次信号到来时,用全部现金买入该股票。如果同时在 10 只股票上出现金叉,资金会被平均分给这 10 只吗?并不是。backtrader 的buy()如果不指定size,默认用broker.getcash()全部资金买当前这只。这样在多信号并发时会出问题:第一只股票买完,现金已经大幅减少,第二只的信号买入仓位就很小,资金分配极度不均。

正确的做法是用固定比例仓位控制。比如每只股票分配 10% 的资金,代码需要这样写:

class DoubleSMAStrategy(bt.Strategy): params = ( ("short_period", 5), ("long_period", 20), ("target_percent", 0.10), ) def next(self): # 根据当前现金和目标仓位比例计算买入数量 for data in self.datas: pos = self.getposition(data) if not pos and self.crossover(data).get(0, 0) > 0: target_value = self.broker.getvalue() * self.params.target_percent size = target_value // data.close[0] self.buy(data=data, size=size) elif pos and self.crossover(data).get(0, 0) < 0: self.close(data=data)

这样做的好处是把资金分配规则显式化,既避免了并发信号下的资金倾斜,也方便后面调整仓位比例做敏感性测试。但要注意,self.crossover(data)这种写法在 backtrader 里对应的是每个数据源分别创建指标对象,需要在__init__中用一个列表记录每个数据源对应的 CrossOver 指标,上面的简化写法只是为了展示思路,实际实现时建议用dict存储指标:

def __init__(self): self.crossovers = {} for data in self.datas: short_sma = bt.indicators.SMA(data.close, period=self.params.short_period) long_sma = bt.indicators.SMA(data.close, period=self.params.long_period) self.crossovers[data] = bt.indicators.CrossOver(short_sma, long_sma)

多股回测还有一个容易被忽略的细节:不同股票的价格高低不同,同样 1 万资金在 10 元股和 100 元股上买的股数也不同,这在整股交易下会产生资金浪费。A 股一手是 100 股,backtrader 默认支持整手限制,但你需要确认commission的设定不影响这个约束,否则会出现持仓数量大小不一的混乱。

5. 回测质量评估:从数据到参数的全面质检

回测跑出来一条漂亮的收益曲线并不难,难的是这条曲线经得起推敲。我见过太多人拿着过拟合出来的高收益率曲线自信心爆棚,实盘一个月就原形毕露。这一节我详细拆解回测质量最重要的三个层面:数据质量、费用模型、参数可靠性。

5.1 数据质量:复权方式决定了曲线的真实性

先说复权。股票分红送股会带来除权除息,价格会出现跳空。如果不用复权数据,均线计算会被跳空价格破坏,金叉死叉信号在除权日附近会大量失真。比如一只股票 10 送 10,除权日价格直接减半,均线会出现一个巨大的向下跌破,触发死叉信号,而实际持有人并没有亏钱。

处理方式有三种:前复权、后复权、不复权。回测建议使用前复权数据,因为前复权以当前价格为基准,将历史价格按分红送股比例倒推调整,这样回测的历史价格是连续的,信号计算不跳变,收益曲线也与真实账户体验最接近。后复权适合做长周期收益研究,但看图时价格和真实价格对不上,容易直觉错乱。关于除权除息当日是否产生实际收益差异,不妨用一个小表说明:

复权方式历史价格表现适用场景
前复权历史价格因分红而降低,当前价格为真实价回测策略信号
后复权历史价格因分红而抬高,当前价格失真长期收益率统计
不复权保持真实历史报价,除权日现跳空技术分析画线参考

另一个数据质量问题是幸存者偏差。如果你用今天依然在市的股票做回测,那你天然过滤掉了那些曾经上市但后来退市的股票,收益率会被系统性高估。这个问题在选股池较大的多股回测里尤其严重。解决方案是构建历史成分股池,比如用指数在某时点的全部成分股做回测,而不是只用当下存活的股票集合。

5.2 费用模型:手续费、滑点、印花税一个都不能少

很多人做回测只设一个万三佣金就完事了,结果收益曲线比实盘好看一大截。真实交易的成本远不止佣金,至少还有三块:

第一,印花税。A 股卖出时单边征收,费率交易金额的 0.05%(目前阶段常有调整),这一项绝对不能漏。第二,滑点。买的时候你很可能以比收盘价更高的价格成交,卖的时候以比收盘价更低的价格成交,回测中一般按 0.1%-0.2% 的滑点比例来模拟。第三,平台最低佣金。很多券商最低收 5 元一笔,如果每次只买几千块,实际佣金率远超万三。

我推荐的完整费用模型是这样的:

class RealisticCommission(bt.CommissionInfo): params = ( ("commission", 0.0003), ("stamp_duty", 0.0005), # 印花税单边,买入为0,卖出收取 ("min_commission", 5.0), ) def _getcommission(self, size, price, pseudoexec): amount = abs(size) * price fee = amount * self.params.commission if fee < self.params.min_commission: fee = self.params.min_commission # stamp_duty 只在卖出方向加。backtrader 的 size 符号可以区分买卖 if size < 0: fee += amount * self.params.stamp_duty return fee

把这个费用模型换上,很多边缘策略会直接从盈利变成亏损。这不是坏事,它帮你提前排掉了实盘里大概率亏钱的策略。

还有一个经验:如果你跑了不同初始资金的回测,费用占比差别会很大。10 万本金和 100 万本金,固定的最低佣金和印花税占比完全不同,回测结果对资金规模非常敏感。所以回测前先想清楚你实盘打算放多少钱,按这个资金量来设定费用模型。

5.3 参数可靠性检查:用参数高原来识别过拟合

双均线只有两个参数(短周期、长周期),过拟合风险相对可控,但仍然需要做检查。我的做法是跑一组参数网格,看一下收益率在参数空间里的分布。如果好的参数是孤立的"孤峰",比如只有(5,20)这个组合特别好,周围参数全部明显恶化,那大概率是过拟合。反之如果是一个"高原",即(4,20)(5,20)(5,21)(6,20)结果都接近,彼此差异不大,说明策略逻辑本身稳健,参数只是在合理区间内波动。

用 backtrader 做参数扫描其实很简单,不需要写多层循环。用cerebro.optstrategy即可:

cerebro.optstrategy( DoubleSMAStrategy, short_period=range(3, 11), long_period=range(15, 31), )

跑完后分别计算每组参数的年化收益、最大回撤、夏普,然后画出二维热力图,一眼就能看出是孤峰还是高原。我自己常用的判断标准是:最优参数和周围参数的年化收益差距在 20% 以内且回撤没有显著差异,就算稳健;如果最优比次优高出 50% 以上,直接拉黑这个策略。

此外还有一个非常必要的检查——样本外测试。把 5 年数据切成前 4 年训练参数、后 1 年验证参数,或者更严格的用滚动窗口反复验证。如果参数在训练段表现好、验证段就崩盘,那这个参数组合是不可用的。样本内过拟合是回测质量的最大杀手,尤其当你发现"最优参数"那张收益曲线漂亮得不像话时,一定要冷静做一次样本外验证。

6. 从回测走向模拟盘与实盘:backtrader 的实盘扩展思路与看不见的坑

回测做得再漂亮,最终都要面对一个现实问题:真金白银上去之后,策略还能不能按回测的剧本走。backtrader 的一大好处是它的架构天然支持从回测平滑切换到模拟盘和实盘,但这个过程里有几个回测曲线完全反映不出来的差异,很多人都是吃了亏才反应过来。

6.1 回测与实盘之间的三个主要差异

第一个差异是信号时效性。回测用的是历史数据,信号产生的那一刻数据已经确定,backtrader 在下一根 bar 模拟撮合。实盘里,你的数据源是实时推送的,均线金叉信号可能在收盘那一刻才成立,但你下单的时机和成交价格会受到网络延迟、交易所撮合规则的影响。如果你用的是日线级策略,这个问题不明显,但如果你打算把同样思路切到分钟级,时效性差异就会急剧放大。

第二个差异是市场冲击成本。回测中的滑点假设是一个固定比例,但实盘中如果你的资金量大,买入动作本身会推高价格,这在你回测时几乎不可能精确模拟。资金越大,冲击成本越明显。对于个人投资者 10 万-100 万的规模,冲击成本基本可以忽略,但如果你是管理千万级资金,回测的收益率在实盘中要打个折扣。

第三个差异是交易环境可靠性。回测中,策略代码运行在你自己电脑上,数据是完整的,信号是不可能漏掉的。实盘中,你依赖的是券商接口、服务器、网络、程序运行环境的稳定性,任何一个环节出问题都会导致信号没执行或者重复执行。这个问题和策略本身无关,但它的风险同样致命。

6.2 用 backtrader 做模拟交易的实践建议

从回测框架过渡到模拟交易,backtrader 有官方支持的 live trading 扩展,比如通过backtrader-live或bt-live这类桥接库对接不同券商的 API。思路通常是将 cerebro 的 broker 替换成实盘 broker 实现,让next里的买卖信号直接触发真实账户的委托。

我的个人建议是:别急着一步到实盘,先做一个月到一个半月的模拟盘。模拟盘的价值不在于验证策略是否赚钱——它验证的是你的程序和交易链路是否可靠。我给自己定过几个模拟盘验收标准:

  • 程序连续 5 个交易日零人工干预正常运行;
  • 每个信号的执行情况都能在交易 API 回调里看到并将明细完整落库;
  • 盘中临时断网或接口故障时,程序能自动重连或者安全退出,不会重复下单;
  • 模拟盘的成交价与回测假设价差不超过 0.3%。

这四点全部满足,才考虑用真实资金跑一个最小单元。很多人跳过这个环节直接实盘,结果策略本身没问题,程序的问题却把策略彻底毁掉了。

另外,从回测切换到模拟盘时,建议把回测的仓位比例砍半起步。原因很简单:实盘中的心理波动会导致你中途修改参数、加仓减仓、甚至提前止损,这些都是回测无法预测的。先用半仓跑一段时间,等心态稳定了、对回撤有了实感,再逐步恢复到回测设定的仓位水平。

我自己的体会是,双均线这类趋势策略在实盘中最难克服的不是技术问题,而是"连续七八笔小亏损之后还敢不敢继续按规则执行"的心理问题。回测可以一目十行地看到完整曲线,实盘却是一天一天熬过来的。先用模拟盘把这种"熬"提前体验一遍,比什么都重要。

最后再分享一个小经验:所有基于日线的趋势策略,在实盘切换时尽量减少对盘中数据的依赖。由于双均线信号本质上在收盘前无法完全确定(你只能等收盘确认均线关系),所以最稳妥的做法是每天收盘后跑一次策略逻辑,如果出现信号,第二天开盘按竞价或开盘价下单。这样既避免了盘中实时判断的不确定性,又把回测和实盘的撮合逻辑对齐到了同一个时间尺度上。这个策略我沿用至今,在回测框架和实盘执行之间几乎不需要额外校准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询