第 18 篇里,我们用 Pandas 手写了一个向量化回测;第 20 篇更是亲手搭了一个最小的事件驱动框架,一根 K 线一根 K 线地喂给策略。自己造轮子能帮你真正理解回测的运转原理,但你也一定发现了:撮合怎么写、手续费怎么扣、订单没成交怎么办……这些工程细节又琐碎又容易出错,每写一个新策略都要重新操心一遍。
上一篇我们体会了事件驱动的思想,本篇就在这个基础上正式请出量化圈的「老牌轮子」——Backtrader:一个开源的 Python 回测框架。它把数据喂入、指标计算、订单撮合、盈亏统计这些脏活累活全部包办,你只需要专心写策略逻辑本身。这一篇的目标很朴素:把一个双均线策略在 Backtrader 里完整跑通,并且看懂它打印出来的每一行交易通知。
零基础不用慌,Backtrader 的入门用法其实只有四五个零件。我们先讲清这些零件各自干什么,再上完整代码,最后逐段拆解运行结果。
本篇目标
学完本篇,你将能够:
- 用
uv pip install backtrader安装框架,并写出从 import 到出图的最小可运行回测脚本; - 说清 Data Feed、Strategy、Broker、Cerebro 四个核心零件各自的职责,以及一次回测的完整流程;
- 把双均线策略写成 Backtrader 的
Strategy子类,正确区分__init__与next的分工; - 读懂
notify_order与notify_trade打印的订单与交易通知,说出一笔交易从下单到结算的状态流转; - 知道接口细节记不全时去官方文档哪里查证,而不是凭印象瞎写。
核心概念
Backtrader 是什么
Backtrader 是一个开源的 Python 量化回测框架,主打「一套代码既可回测也可实盘」。对学习者来说,它最大的价值有三点:
- 事件驱动:和第 20 篇我们手写的框架思想一致——引擎逐根推送 K 线,策略逐根做决策,天然贴近实盘的思考方式;
- 内置指标与撮合:SMA、RSI 等常用指标开箱即用,现金、持仓、手续费、盈亏由框架统一管理;
- 生态成熟:教程和社区问答丰富,踩坑时容易搜到答案。
写作本篇时 Backtrader 的稳定版本是 1.9.x 系列。框架接口在不同版本间偶有细节差异,所有接口用法请以官方文档为准(入口是 backtrader.com 的 Docu 页面,中文社区也有翻译教程),本文只是教学化的转述。
四个核心零件
把一次回测想象成一家餐厅的后厨,四个零件分工明确:
- Data Feed(数据源):侍者。负责把行情整理成框架认识的格式,一根根端上桌。第 07 篇用 akshare 拉下来的 DataFrame,稍加整理就能直接喂给它。
- Strategy(策略):厨师。你要写的核心逻辑,继承
bt.Strategy,在next()里决定什么时候买、什么时候卖。 - Broker(经纪商模拟器):收银台。模拟券商,管理现金和持仓,负责把你的订单「撮合」成交、扣手续费。
- Cerebro(引擎):后厨主管。名字是西班牙语「大脑」的意思,负责调度上面三者:喂数据、挂策略、跑回测、画图。
划重点:一次回测的固定流程就是四句话——
adddata()喂数据、addstrategy()挂策略、run()跑回测、plot()看结果。所有 Backtrader 脚本都是这个骨架,区别只在策略逻辑。
__init__ 与 next:最重要的分工
写 Strategy 子类时,新手 90% 的报错都源于没分清两个方法的职责:
__init__(self):备菜阶段。整场回测开始前只执行一次。在这里创建指标对象、初始化变量。注意:只是「创建」,不要在这里读指标数值。next(self):炒菜阶段。每根 K 线收盘后执行一次。判断信号、下单、管理持仓,全在这里。
指标的取值语法要专门记一下:self.fast_ma[0] 是当前刚收盘这根 K 线上的均线值,self.fast_ma[-1] 是前一根,self.fast_ma[-2] 再往前。0 表示当下、负数表示过去;框架不允许用正数索引访问「未来」,这个设计本身就是防未来函数的第一道保险。
划重点:指标在
__init__里创建,在next里读取,顺序反了必然报错。把这行写进你的笔记第一页。
双均线策略回顾
双均线是老朋友了:第 10 篇学过 SMA,第 18 篇手写过它的向量化回测。规则一句话——短期均线上穿长期均线(金叉)买入,下穿(死叉)卖出。
写代码时有个细节容易漏:判断「上穿」需要两个条件同时成立——前一根还没金叉(fast ≤ slow),当前这一根金叉了(fast > slow)。只写后半个条件,整个金叉区间里每天都满足,策略会疯狂重复下单。手写框架时我们踩过这个坑,Backtrader 里同理。
一笔交易的一生:订单、交易与 broker 通知
在 Backtrader 里,buy() / sell() 发出的是订单(Order),从发出到结束会流转几个状态:Submitted(已提交)→ Accepted(已接受)→ Completed(已成交),也可能以 Canceled(已撤销)、Margin(资金不足被拒)、Rejected(被拒绝)收场。框架通过 notify_order() 方法把这些状态变化推送给你的策略。
而一笔完整的交易(Trade),指「建仓 → 加减仓 → 清零」的整个过程:买入开仓时 Trade 处于 Created 状态,全部卖出清零后进入 Closed,此时框架会把这笔交易的毛盈亏 pnl 和净盈亏 pnlcomm 推给 notify_trade()。我们在两个通知方法里各写一行打印,就能完整观察一笔交易的一生。
还有一件必须知道的事:默认情况下,你在 next() 里发出的市价单,会在下一根 K 线的开盘价成交。也就是说,你今天收盘后看到金叉下单,实际按明天开盘价买——信号用的是已收盘数据,成交却在更晚的价格上,这正是防止未来函数的保守设计。第 22 篇会专门讲怎么改变这个行为,以及背后的取舍。
划重点:永远不要假设订单百分之百成交。资金不足(Margin)、停牌、涨跌停都可能让订单落空,写策略时要习惯性处理「订单没成交」的分支。
完整代码
依赖安装(Python 3.10+):
uv pip install backtrader akshare pandas matplotlib
数据准备:优先用 akshare 拉沪深 300 指数 2020-01-01 至 2024-12-31 的日线(与全系列口径一致),拉不到就自动降级为本地生成的示意行情,保证脚本任何时候都能跑通:
import akshare as ak
import numpy as np
import pandas as pd
def load_data() -> pd.DataFrame:
"""拉取沪深300日线;失败时退回本地示意数据,保证脚本可运行。"""
try:
raw = ak.index_zh_a_hist(symbol="000300", period="daily",
start_date="20200101", end_date="20241231")
# akshare 返回中文列名,Backtrader 需要小写英文列名
raw = raw.rename(columns={"日期": "date", "开盘": "open", "收盘": "close",
"最高": "high", "最低": "low", "成交量": "volume"})
df = raw[["date", "open", "high", "low", "close", "volume"]].copy()
except Exception:
# 降级方案:本地造一段带漂移的随机游走行情,仅用于跑通流程
rng = np.random.default_rng(seed=42) # 固定种子,结果可复现
dates = pd.bdate_range("2020-01-01", "2024-12-31")
steps = rng.normal(loc=0.0002, scale=0.015, size=len(dates))
close = 4000 * np.exp(np.cumsum(steps)) # 几何随机游走
df = pd.DataFrame({"date": dates, "open": close * 0.999,
"high": close * 1.01, "low": close * 0.99,
"close": close, "volume": 1e8})
df["date"] = pd.to_datetime(df["date"])
return df.set_index("date").round(2)
策略本体——双均线金叉买、死叉卖,配合订单与交易通知:
import backtrader as bt
class SmaCross(bt.Strategy):
"""双均线策略:金叉买入,死叉清仓。"""
params = dict(fast=10, slow=30) # 可调参数,外部可用 addstrategy 传入
def __init__(self):
# 指标只在 __init__ 创建一次,千万不要在这里取值
self.fast_ma = bt.ind.SMA(self.data.close, period=self.p.fast)
self.slow_ma = bt.ind.SMA(self.data.close, period=self.p.slow)
self.order = None # 记录未完成的订单,防止一根 K 线内重复下单
def next(self):
if self.order: # 上一笔订单还没走完流程,本根不做新动作
return
if not self.position: # 空仓:只找买点
golden = (self.fast_ma[0] > self.slow_ma[0]
and self.fast_ma[-1] <= self.slow_ma[-1])
if golden:
print(f"{self.data.datetime.date(0)} 金叉,发出买单")
self.order = self.buy()
else: # 持仓:只找卖点
dead = (self.fast_ma[0] < self.slow_ma[0]
and self.fast_ma[-1] >= self.slow_ma[-1])
if dead:
print(f"{self.data.datetime.date(0)} 死叉,发出卖单")
self.order = self.sell()
def notify_order(self, order):
if order.status in (order.Submitted, order.Accepted):
return # 中间状态不用管
if order.status == order.Completed:
side = "买入" if order.isbuy() else "卖出"
print(f"{self.data.datetime.date(0)} {side}成交:"
f"价格 {order.executed.price:.2f},数量 {order.executed.size:g}")
elif order.status in (order.Canceled, order.Margin, order.Rejected):
print(f"订单未成交,原因:{order.getstatusname()}")
self.order = None # 订单生命周期结束,清空标志位
def notify_trade(self, trade):
if trade.isclosed: # 一买一卖走完,一笔交易正式结算
print(f"交易关闭:毛盈亏 {trade.pnl:.2f},净盈亏 {trade.pnlcomm:.2f}")
引擎组装与运行:
import backtrader as bt # 重复 import 无害,保证本块可独立复制运行
if __name__ == "__main__":
df = load_data()
cerebro = bt.Cerebro() # 引擎:回测的"大脑"
cerebro.adddata(bt.feeds.PandasData(dataname=df)) # 喂入行情
cerebro.addstrategy(SmaCross, fast=10, slow=30) # 挂策略并传参数
cerebro.broker.setcash(100_000) # 初始资金 10 万
# 默认 sizer 每次只买 1 股,这里换成"用 95% 现金买入",下一篇细讲仓位
cerebro.addsizer(bt.sizers.AllInSizerInt, percents=95)
print(f"期初市值:{cerebro.broker.getvalue():,.2f}")
cerebro.run() # 逐根 K 线推演,直到数据走完
print(f"期末市值:{cerebro.broker.getvalue():,.2f}")
cerebro.plot(style="candlestick") # K 线图 + 买卖点 + 资金曲线
代码逐段解释
第 1 段:数据准备函数。 load_data() 先尝试 akshare 的 index_zh_a_hist,拿到的是中文列名的 DataFrame;bt.feeds.PandasData 默认识别 open/high/low/close/volume 这几个小写英文列,所以先 rename 再喂。akshare 拉不到时(断网、接口变动),降级分支用固定随机种子造一段几何随机游走,保证你任何时候复制这段代码都能跑出结果——但请记住降级数据只是示意行情,不是真实历史。运行后 df 长这样(数值为示意,实际以接口当日返回为准):
>>> df = load_data(); print(df.tail(2))
open high low close volume
date
2024-12-30 3912.4 3930.1 3901.7 3925.2 182000000
2024-12-31 3925.9 3941.3 3918.6 3934.9 176000000
第 2 段:Strategy 子类与 params。 params = dict(fast=10, slow=30) 定义了两个可调参数,类内用 self.p.fast 访问,类外用 addstrategy(SmaCross, fast=..., slow=...) 传入。好处是调参不用改代码,第 25 篇的参数寻优会直接复用这个机制。
第 3 段:next 的防重入与金叉判断。 self.order 是防重入标志:只要还有订单没走完状态流转,本根 K 线就不做任何新动作。金叉判断用 [-1] 和 [0] 两个时点配合——「前一根还没金叉 + 这一根金叉了」才发买单。整个决策只用已收盘数据,成交发生在下一根开盘,没有任何未来函数。
第 4 段:两个通知方法。 notify_order 里对 Submitted 和 Accepted 直接 return,因为它们只是中间状态,打印出来全是噪音;真正要处理的是成交(Completed)和三种失败。notify_trade 只在 trade.isclosed 时打印盈亏——本篇还没设置手续费,所以你会看到毛盈亏和净盈亏相等,下一篇加上手续费后两者才会拉开差距。一次运行的控制台输出大概长这样(数值为示意,实际随数据与参数变化):
期初市值:100,000.00
2020-02-28 金叉,发出买单
2020-03-02 买入成交:价格 3944.86,数量 24
2020-04-24 死叉,发出卖单
2020-04-27 卖出成交:价格 3839.62,数量 -24
交易关闭:毛盈亏 -2525.76,净盈亏 -2525.76
期末市值:97,474.24
注意两个细节:第一,2 月 28 日收盘后发出的买单,3 月 2 日才成交——中间隔了周末,且默认按次日开盘价成交;第二,卖出的数量打印成 -24,负号代表卖出方向。24 股来自 AllInSizerInt(percents=95):按下单时的收盘价估算,9.5 万现金能买 24 股(约 9.47 万),剩余零头留在账户。
第 5 段:引擎组装与运行。 run() 之前用 getvalue() 打印期初市值,之后打印期末市值,一进一出就能看到整体盈亏;plot() 会弹出三联图——K 线加买卖点、成交量、账户市值曲线。如果你的环境暂时画不出图,先把最后一行注释掉,把数值部分跑通再说。
常见坑与小练习
常见坑
- 在
__init__里读指标值。 现象:一启动就报IndexError或拿到空数组。原因:__init__整场只执行一次,那时指标还没算出任何值。避免:创建放__init__,取值一律放next。 - 忘了防重入,疯狂下单。 现象:金叉区间每天一条买单日志,现金瞬间打光。原因:金叉条件漏了「前一根还没金叉」,或者没有
self.order标志位。避免:两个条件同时判,加订单标志位。 - 以为按当前收盘价成交。 现象:成交价和信号那根 K 线的收盘价对不上。原因:默认市价单在下一根开盘成交,这不是 bug 而是防未来函数的正确行为;想改成交时机,看第 22 篇的 cheat-on-close 教学说明。
- 中文列名直接喂 PandasData。 现象:报
KeyError或者回测全程无数据。原因:数据源按小写英文列名识别字段。避免:喂之前先rename成open/high/low/close/volume。 cerebro.plot()在部分环境报错。 现象:统计都打印正常,最后画图环节抛异常。原因:matplotlib 后端或版本兼容问题。避免:先注释掉画图跑通数值,再单独解决画图(可尝试cerebro.plot(iplot=False));具体参数以官方文档为准。
小练习
- 把快慢线参数改成 5 和 60 再跑一遍,对比交易次数与期末市值的变化,想一想:参数更敏感意味着什么?
提示:只需要改
addstrategy(SmaCross, fast=5, slow=60)这一行,这正是 params 机制的价值。 - 在
next()里加一行打印,输出当前收盘价与持仓市值(self.data.close[0]、self.position.size),观察下单前后持仓的变化。提示:
self.broker.getvalue()返回账户总市值,self.position.size为 0 说明空仓。 - 在策略里维护一个交易记录列表,回测结束后统计胜率(盈利笔数 ÷ 总笔数)。
提示:在
notify_trade的trade.isclosed分支里把trade.pnlcomm追加进列表;cerebro.run()的返回值就是策略实例列表,从上面能取到你维护的属性。
到这里,你已经完成了从手写框架到工业级框架的切换:同样的事件驱动思想,由 Backtrader 代管了全部工程细节。
风险提示:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。