上一篇我们把双均线策略在 Backtrader 里跑通了,但你有没有留意到一个细节:notify_trade 打印的毛盈亏和净盈亏完全相等——因为那篇回测里,买卖是不花钱的。真实交易里每一次成交都要付费:券商佣金、印花税,还有更隐蔽的一项——你想按 10.00 元买,真正成交时往往已经变成了 10.01 元,这叫滑点(Slippage)。回测里不装上这两项成本,策略收益就像不加运费的商品标价,看着便宜,结账时才知道另一回事。
上一篇我们跑通了最小回测,本篇给它装上三件「仿真外设」:用 setcommission 按全系列统一的「双边 0.1%」口径收费,用 set_slippage_perc 模拟成交价差,再把「每次买多少」从内置的简陋默认值升级成可自定义的 Sizer(仓位管理器)。最后我们初识 Backtrader 的两个统计工具——Observer(观察器)与 Analyzer(分析器),让回测跑完自动给出胜率和最大回撤。成本、仓位、统计,这三件事正是新手回测「虚高」的三大来源,一篇补齐。
本篇目标
学完本篇,你将能够:
- 用
setcommission(commission=0.001)实现「双边 0.1%」收费口径,并说清它与全系列口径的换算关系; - 用
set_slippage_perc给回测加入滑点,解释滑点对买入价、卖出价的不同影响方向; - 编写自定义 Sizer 子类,在「全仓」「固定金额」「总市值百分比」三种仓位模式间一键切换;
- 说明 cheat-on-close(收盘价成交开关)改变的是什么、什么时候该用、什么时候不该用;
- 用
TradeAnalyzer与DrawDown两个分析器在回测结束后输出胜率与最大回撤。
核心概念
成本第一项:手续费与口径换算
全系列统一口径是双边 0.1%(万 10):每一笔成交,无论买入还是卖出,都按成交金额的 0.1% 收费;一买一卖一个完整来回,合计约占成交金额的 0.2%。现实中 A 股的费用结构更复杂(券商佣金分档、印花税只在卖出时收、还有过户费),教学里用 0.1% 一个数覆盖近似,原则是宁可高估成本,不要低估。
换算到 Backtrader 只需一行:
cerebro.broker.setcommission(commission=0.001) # 0.1% = 0.001
关键在于理解 commission 的语义:它是每笔成交的费率,框架在买入时扣一次、卖出时再扣一次,所以直接填 0.001 就是双边各 0.1%,不需要除以 2。这是新手最常见的换算错误——以为要「摊到两边」各写 0.0005,结果把成本凭空少算了一半。
划重点:先在纸上写清你的口径是「每边 0.1%」还是「来回合计 0.1%」,再去填参数。口径差一倍,回测结论可能差一个方向。
成本第二项:滑点
滑点指下单价格与实际成交价格的差。市价单天然存在这个问题:你看到信号下单到真正撮合之间,价格已经走了。Backtrader 里加滑点:
cerebro.broker.set_slippage_perc(perc=0.0005) # 0.05% 滑点
perc=0.0005 表示成交价按对你不利的方向偏移 0.05%:买入时更贵,卖出时更便宜。它和手续费的区别在于——手续费是明码标价的账单,滑点是对市场冲击和延迟的模拟,量级通常比佣金更能杀死高频策略。第 23 篇我们会算一笔账:同样的策略,换手频率越高,这两项成本侵蚀得越狠。
仓位管理:Sizer 三种模式
「买多少」和「什么时候买」同等重要。Backtrader 把这个问题交给 Sizer:每次下单时,框架调用它的 _getsizing() 方法问一句「该买几股」。内置的 bt.sizers.FixedSize 每次固定买 1 股(上一篇开头如果不加 sizer,你看到的成交就只有 1 股),明显不合用;常用的三种思路是:
- 全仓:现金几乎全押。信号对时赚得最多,但一次回撤就是满额挨打,且资金不足时容易拒单;
- 固定金额:每次只投固定的钱(比如 3 万)。天然实现「盈利后仓位自动变轻、亏损后变重」的反向效果——注意这通常是不想要的特性,但它简单直观,适合教学起步;
- 总市值百分比:每次投入当前账户总市值的一定比例(比如 50%)。账户增值时仓位水涨船高,回撤时自动收缩,是复利机器的标准姿势。
自定义 Sizer 只需继承 bt.Sizer、实现 _getsizing(self, comminfo, cash, data, isbuy)、返回股数。资金不够买一股时返回 0,框架会自动撤单——这比手动 if 判断干净得多。
成交时机:cheat-on-close 的教学说明
上一篇说过,默认市价单在下一根 K 线开盘价成交:收盘后看到信号、次日开盘才成交,保守但真实。Backtrader 还提供一个开关:
cerebro.broker.set_coc(True) # Cheat-On-Close:用信号当根的收盘价成交
打开后市价单直接按当根收盘价撮合,省去了隔夜跳空的不确定性。教学上它有个好处:回测盈亏和信号价严格对应,逐段核对数字非常方便。但要诚实地说清它的性质——它假设你恰好能以自己刚看到的那个收盘价成交,现实中只有尾盘集合竞价附近勉强接近,长期用它会让回测偏乐观。结论:默认关着它,对比验证时才临时打开。此外框架还有 cheat-on-open(信号当根开盘成交)等更细的玩法,参数以官方文档为准。
划重点:成交时机的选择本身就是一条回测假设。「次日开盘」是保守假设,「当根收盘」是乐观假设,论文和研报里看到漂亮曲线时,先问一句它用的是哪种。
Observer 与 Analyzer:回测的仪表盘
跑完回测只会打印期末市值是不够的,我们还需要结构化统计。Backtrader 把这类工具分两类:
- Observer(观察器):挂在回测过程上的仪表,实时记录现金、持仓、每笔交易,默认就加了几个,
cerebro.plot()图上的资金曲线、买卖箭头就是它们画的; - Analyzer(分析器):回测结束后一次性输出的报表。本篇用两个——
TradeAnalyzer(把平仓交易分成盈利/亏损两组,统计笔数、平均盈亏、连胜连败)和DrawDown(最大回撤 Maximum Drawdown 及其持续时间)。
用法三步:addanalyzer(..., _name="别名") 注册 → cerebro.run() → 从返回的策略实例上 strat.analyzers.别名.get_analysis() 取结果字典。
完整代码
依赖安装:
uv pip install backtrader akshare pandas matplotlib
数据准备与上一篇相同(先 akshare 后降级):
import akshare as ak
import numpy as np
import pandas as pd
def load_data() -> pd.DataFrame:
"""沪深300日线(2020-2024),接口失败时退回本地示意行情。"""
try:
raw = ak.index_zh_a_hist(symbol="000300", period="daily",
start_date="20200101", end_date="20241231")
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")
close = 4000 * np.exp(np.cumsum(rng.normal(0.0002, 0.015, len(dates))))
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)
自定义 Sizer:三种仓位模式装进一个类,用参数切换:
import backtrader as bt
class FlexSizer(bt.Sizer):
"""仓位管理器:mode 取 all / fixed / percent 三选一。"""
params = dict(mode="fixed", amount=30_000, percent=50)
def _getsizing(self, comminfo, cash, data, isbuy):
if not isbuy: # 卖出时不做仓位设计,直接返回全部持仓用于清仓
return self.broker.getposition(data).size
price = self.data.close[0] # 以当前收盘价估算可买股数
if self.p.mode == "all": # 全仓模式:现金的 98% 折算为股数
budget = cash * 0.98
elif self.p.mode == "fixed": # 固定金额模式:每次只投 amount 元
budget = min(self.p.amount, cash)
else: # 百分比模式:投入当前账户总市值的 percent%
budget = self.broker.getvalue() * self.p.percent / 100
return int(budget / price) # 向下取整;买不起一股时返回 0 自动撤单
策略沿用上一篇的双均线(通知方法精简),主程序挂上成本、滑点与分析器:
import backtrader as bt # 保证本块可独立复制运行
class SmaCross(bt.Strategy):
"""双均线:金叉全信号买入,死叉清仓。"""
params = dict(fast=10, slow=30)
def __init__(self):
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
def next(self):
if self.order: # 防重入:上一笔订单没走完不开新仓
return
if not self.position:
if (self.fast_ma[0] > self.slow_ma[0]
and self.fast_ma[-1] <= self.slow_ma[-1]):
self.order = self.buy()
elif (self.fast_ma[0] < self.slow_ma[0]
and self.fast_ma[-1] >= self.slow_ma[-1]):
self.order = self.sell()
def notify_order(self, order):
if order.status in (order.Submitted, order.Accepted):
return
if order.status == order.Completed:
print(f"{self.data.datetime.date(0)} 成交 @ {order.executed.price:.2f}"
f",数量 {order.executed.size:g},佣金 {order.executed.comm:.2f}")
else:
print(f"订单未成交:{order.getstatusname()}")
self.order = None
if __name__ == "__main__":
cerebro = bt.Cerebro()
cerebro.adddata(bt.feeds.PandasData(dataname=load_data()))
cerebro.addstrategy(SmaCross)
cerebro.addsizer(FlexSizer, mode="fixed", amount=30_000) # 每次固定投 3 万
cerebro.broker.setcash(100_000)
cerebro.broker.setcommission(commission=0.001) # 双边各 0.1%
cerebro.broker.set_slippage_perc(perc=0.0005) # 滑点 0.05%
cerebro.broker.set_coc(True) # 教学演示:按信号当根收盘价成交
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name="ta")
cerebro.addanalyzer(bt.analyzers.DrawDown, _name="dd")
print(f"期初市值:{cerebro.broker.getvalue():,.2f}")
strat = cerebro.run()[0]
print(f"期末市值:{cerebro.broker.getvalue():,.2f}")
ta = strat.analyzers.ta.get_analysis()
closed = ta.get("total", {}).get("closed", 0)
won = ta.get("won", {}).get("total", 0)
print(f"平仓 {closed} 笔,胜率 {won / closed * 100 if closed else 0:.1f}%")
dd = strat.analyzers.dd.get_analysis()
print(f"最大回撤 {dd['max']['drawdown']:.2f}%,最长持续 {dd['max']['len']} 根K线")
代码逐段解释
第 1 段:数据准备。 与上一篇逐字一致,不再展开——akshare 拉沪深 300 日线,中文列名改小写英文,失败则退回固定种子的随机游走示意数据。
第 2 段:FlexSizer。 核心是 _getsizing 的返回值:买入股数。卖出分支直接返回当前全部持仓(self.broker.getposition(data).size),配合死叉清仓的策略恰好成立。买入分支先定预算再除以股价取整:all 模式留 2% 现金余量防止「算出来的钱刚好买不起」;fixed 模式用 min(amount, cash) 兜底,现金不足时自动降额;percent 模式的基数是 getvalue()(现金 + 持仓市值),所以盈利后每次投入自动变大——这正是百分比模式「复利感」的来源。运行时把 addstrategy 行换成 mode="all" 或 mode="percent", percent=50 即可对比三种模式,期末市值和回撤会明显不同(固定金额模式仓位最轻,波动最小;全仓模式最颠簸)。
第 3 段:主程序的成本三件套。 setcommission(0.001) 即双边各 0.1%;set_slippage_perc(0.0005) 让每次成交价向不利方向偏 0.05%;set_coc(True) 让成交价等于信号当根收盘价,便于对账。notify_order 里新增了 order.executed.comm 的打印——成交后你能直接看到这笔被扣了多少佣金。示例输出如下(数值为示意,实际随数据与参数变化;若用降级示意数据,笔数与盈亏会不同):
期初市值:100,000.00
2020-02-28 成交 @ 3945.98,数量 7,佣金 21.64
2020-04-24 成交 @ 3841.05,数量 -7,佣金 21.23
...(中间省略若干笔)
2024-11-15 成交 @ 3866.74,数量 7,佣金 21.19
期末市值:106,800.31
平仓 9 笔,胜率 44.4%
最大回撤 8.87%,最长持续 143 根K线
第 4 段:统计数字怎么读。 胜率 44.4% = 9 笔平仓交易里 4 笔盈利——趋势策略低胜率、靠盈亏比吃饭,这个组合很常见,别看到不到一半就慌。可以再自算一笔账:假设盈利单平均毛盈亏约 2600 元、亏损单平均约 -612 元,毛盈亏合计 4×2600 − 5×612 = 7340 元;而期末市值 106,800.31 说明净赚 6800.31 元,中间约 540 元的差额正是 18 次成交(9 个来回)的手续费与滑点损耗——成本这一关先扣掉了约 7% 的利润。另外注意:若回测结束时仍持仓,浮动盈亏不计入「平仓统计」,所以期末市值与平仓盈亏之和允许对不上。
第 5 段:分析器的取数路径。 TradeAnalyzer 返回多层嵌套字典:ta["total"]["closed"] 是平仓笔数,ta["won"]["total"] 是盈利笔数,ta["won"]["pnl"]["average"] 是盈利单平均毛盈亏,ta["lost"] 下是亏损侧的同款统计;DrawDown 的 dd["max"]["drawdown"] 是最大回撤百分比,dd["max"]["len"] 是从峰值到谷底持续的 K 线根数。字段名记不住没关系,print(ta) 先看整体结构,再按需取键。
常见坑与小练习
常见坑
- 手续费口径除错了 2。 现象:以为「双边 0.1%」要写
commission=0.0005,回测成本少一半。原因:Backtrader 的commission本来就是每笔成交的费率,买卖各收一次已经天然是「双边」。避免:动手前先写明口径定义。 - 忘了滑点,回测虚高。 现象:策略毛利薄如纸,加了 0.05% 滑点后由盈转亏。原因:市价单的成交价从来不是你看到的价格。避免:任何回测默认带上滑点,做敏感性测试时从 0 到 0.1% 多档对比。
- 全仓模式频繁拒单。 现象:日志里大量
Margin状态,账户现金纹丝不动。原因:按现价估算刚好买得起,开盘成交价稍高就资金不足;或没有预留佣金余量。避免:预算乘个 0.98,或直接用固定金额/百分比模式。 - cheat-on-close 常开导致乐观偏差。 现象:打开
set_coc(True)后收益明显变好,误以为策略变强了。原因:它假设你能按刚看到的收盘价精确成交。避免:只在对照验证时开启,正式回测用默认的次日开盘成交。 - 分析器取键报 KeyError。 现象:
ta["won"]["pnl"]报错。原因:一次交易都没平仓时,won/lost键可能不存在。避免:先用ta.get("total", {}).get("closed", 0)判断笔数,再取深层字段。
小练习
- 把 sizer 依次改成
mode="all"、mode="fixed", amount=30_000、mode="percent", percent=50各跑一遍,把三组的期末市值与最大回撤记进一张表,对比风险收益的排序。提示:只需要改
cerebro.addsizer(...)一行;百分比模式会复利放大,回撤也同比放大。 - 把滑点从 0 逐步加到 0.001(0.1%),记录期末市值的变化曲线,找出「策略由盈转亏」的滑点临界值。
提示:改
set_slippage_perc(perc=...);这个临界值就是策略对交易质量的容忍上限。 - 给
TradeAnalyzer的结果加一行打印:亏损单的平均毛盈亏(ta["lost"]["pnl"]["average"]),并计算本策略的盈亏比(盈利单均值 ÷ 亏损单均值绝对值)。提示:盈亏比乘以胜率大于 1 时,策略期望为正;用示例数字验证一下 2600÷612×44.4% 是否大于 1。
成本与仓位装上了,统计仪表也亮了。但成本到底在多大程度上吃掉收益?凯利公式给出的「最优仓位」为什么新手不能照抄?下一篇我们把这些数字算给你看。
风险提示:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。