上一期聊完数据处理,后台和评论区就一直有人在问“接下来策略怎么写”。这个问题的确很典型:环境装好了、数据能读了,结果对着编辑器半天不知道第一行代码该写什么。BackTrader的策略开发跟普通脚本完全是两套思维,这篇文章我就把这个环节彻底拆开讲透——从策略类的骨架、双均线信号的落地,到手续费、滑点、仓位这些决定回测可信度的细节,再聊聊我写策略这些年踩过的坑。已经装好环境但还没写过自己策略的朋友,或者写过几个Demo却总觉得哪里不对劲的同学,这篇应该能帮你把思路理顺。
1. 策略类的运行节奏:先搞清楚引擎怎么“叫醒”你的代码
BackTrader的核心抽象是Strategy类。你写一个继承bt.Strategy的子类,把它交给Cerebro引擎后,引擎会沿着时间轴逐根K线驱动你的策略。驱动方式集中在两个方法:__init__和next。新手最常见的误解,就是把这两个方法当成普通类的初始化和处理函数,其实它们在回测引擎里有极其严格的分工。
1.1__init__:声明指标,而不是执行逻辑
__init__里做两件事:声明数据引用、声明指标。比如:
import backtrader as bt class DoubleMAStrategy(bt.Strategy): def __init__(self): self.sma_fast = bt.indicators.SMA(self.data.close, period=10) self.sma_slow = bt.indicators.SMA(self.data.close, period=30) self.cross = bt.indicators.CrossOver(self.sma_fast, self.sma_slow)这里有个关键机制:BackTrader把价格序列、指标输出都抽象成“lines”(线),一条线就是一整列随时间推进的数值。你在__init__里声明指标,相当于告诉引擎“我要追踪这组序列”,引擎会用内部加速模块把整条线的数值提前算好,后续在next里每次取值都是O(1)的读取,而不是重新计算。所以__init__不是写“此刻该不该买”的地方,而是搭建整个策略计算图的地方。
1.2next:每一根K线的决策现场
next会在每根K线上被调用一次,你的买入卖出决策都在这里完成。读取方式常让新手犯迷糊:data.close[0]是当前K线的收盘价,data.close[-1]是上一根的收盘价,以此类推。看这段典型的判断:
def next(self): if self.cross[0] > 0 and not self.position: self.buy()cross[0] > 0表示快线刚刚上穿慢线(金叉);接着检查not self.position,防止持仓状态下重复开仓。先理解运行节奏,很多“策略写不出来”的问题就解决了一半——BackTrader不是“代码从头到尾跑一遍”,而是“每来一根新K线,next从头到尾执行一遍”。你在next里写的每一行,本质上都是“当前时点的状态判断”,不需要自己维护K线循环和索引。
1.3 指标放__init__的第三个理由:性能和时序一致
有人问,指标能不能在next里现算?语法上能,但长周期、多品种回测时,每根K线重算一遍指标会明显拖慢速度。更重要的是,BackTrader指标对象自带缓冲机制,你在next里用sma[0]拿到的当前值,和引擎内部计算完全一致,不会出现“自己算的指标和引擎时序对不上”的诡异问题。
还值得一提的是多数据源的情况。策略同时看多个品种或周期时,用self.datas[0]、self.datas[1]来区分,或者用self.data、self.data1这种快捷引用。声明指标时最好显式指定数据源,比如bt.indicators.SMA(self.data1.close, period=20),否则指标默认绑定到第一个数据源上,信号源头搞混了,回测结果会让你怀疑人生。
2. 从想法到可回测代码:写一个双均线交叉策略的完整过程
骨架讲完,我们把上面的思路组装成一个完整可跑的策略。双均线虽然简单,但它把信号定义、下单执行、订单状态这三个核心问题全部覆盖了,是理解BackTrader机制的绝佳样本。
2.1 信号定义:用CrossOver而不是手写比较
最直觉的均线交叉判断是“当前快线大于慢线、上一根快线小于慢线”。你完全可以这么写:
def next(self): if self.sma_fast[0] > self.sma_slow[0] and self.sma_fast[-1] <= self.sma_slow[-1]: self.buy()这个写法逻辑没错,但藏着一个隐患:如果这波行情里快线始终在慢线上方,那么每一根K线都会满足“当前快线大于慢线”这个条件,你需要额外的持仓判断来挡住重复开仓。而用CrossOver指标,它的取值只有几个状态:1表示上穿、-1表示下穿、0表示无交叉,天然把“交叉瞬间”和“趋势持续”区分开。两种方案对比:
| 对比维度 | 手写比较 | CrossOver指标 |
|---|---|---|
| 交叉瞬间判断 | 需要同时对比t和t-1 | 内置状态机 |
| 趋势持续期 | 需要额外持仓判断 | 不会重复触发 |
| 可读性 | 逻辑分散 | 一目了然 |
我实际开发中倾向于用CrossOver,不是因为它多高级,而是裸比较很容易在连续趋势里产生意外加仓。策略信号的定义越明确,后续排错越省心。
2.2 下单:buy、sell、close与订单生命周期
下单方法的核心参数是size(数量)。如果不传size,就由Sizer插件决定仓位,这个后面细说。buy()会返回一个订单对象,把它存在self.order里,这是防止重复下单的关键:
def next(self): if self.order: return # 有未完成订单时,不做新决策 if self.cross[0] == 1 and not self.position: self.order = self.buy() elif self.cross[0] == -1 and self.position: self.order = self.close()close()是清仓方法,效果等于按当前持仓方向卖出或回补相同数量。平多单可以用sell(),但手动传size容易传错,用close()更稳妥。注意buy()只代表“提交订单”,不代表立刻成交,默认情况下订单会在下一根K线开盘时以市价撮合。订单从提交到结束会经历多个状态,想看清策略真实的成交过程,必须实现notify_order:
| 订单状态 | 含义 | 处理建议 |
|---|---|---|
| Submitted | 已提交 | 忽略,等待 |
| Accepted | 经纪商已接受 | 忽略,等待 |
| Completed | 已成交 | 记录成交价格、数量 |
| Margin | 保证金不足被拒 | 检查账户资金 |
| Rejected | 被拒绝 | 查明原因 |
| Canceled | 被取消 | 确认是否符合预期 |
我自己的notify_order模板长这样:
def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: return if order.status == order.Completed: if order.isbuy(): self.log(f'买入 执行价={order.executed.price:.2f} 数量={order.executed.size}') else: self.log(f'卖出 执行价={order.executed.price:.2f} 数量={order.executed.size}') elif order.status == order.Margin: self.log('订单因保证金不足被拒') elif order.status == order.Rejected: self.log('订单被拒绝') self.order = None每次处理完订单后把self.order重置为None,这一步绝对不能省。如果订单完成后还握着旧引用不放,后续下单逻辑会一直卡在“有未完成订单”的分支里,策略从第二笔交易开始就罢工了——这是我见过新手最容易踩的坑,没有之一。
2.3 完整可运行的双均线策略
把上面这些片段拼起来,再加上日志辅助方法,就是一个能直接跑的双均线策略:
import backtrader as bt class DoubleMAStrategy(bt.Strategy): params = ( ('fast', 10), ('slow', 30), ) def __init__(self): self.sma_fast = bt.indicators.SMA(self.data.close, period=self.p.fast) self.sma_slow = bt.indicators.SMA(self.data.close, period=self.p.slow) self.cross = bt.indicators.CrossOver(self.sma_fast, self.sma_slow) self.order = None self.bar_count = 0 def log(self, txt, dt=None): dt = dt or self.datas[0].datetime.date(0) print(f'{dt.isoformat()} {txt}') def next(self): self.bar_count += 1 if self.order: return if self.cross[0] == 1 and not self.position: self.order = self.buy() elif self.cross[0] == -1 and self.position: self.order = self.close() def notify_order(self, order): if order.status in [order.Submitted, order.Accepted]: return if order.status == order.Completed: if order.isbuy(): self.log(f'买入 执行价={order.executed.price:.2f} 数量={order.executed.size}') else: self.log(f'卖出 执行价={order.executed.price:.2f} 数量={order.executed.size}') elif order.status == order.Margin: self.log('订单因保证金不足被拒') elif order.status == order.Rejected: self.log('订单被拒绝') self.order = None if __name__ == '__main__': cerebro = bt.Cerebro() cerebro.addstrategy(DoubleMAStrategy) data = bt.feeds.GenericCSVData( dataname='daily_data.csv', dtformat='%Y-%m-%d', openinterest=-1, ) cerebro.adddata(data) cerebro.broker.setcash(100000.0) cerebro.broker.setcommission(commission=0.0003, percabs=False) cerebro.addsizer(bt.sizers.PercentSizer, percents=95) results = cerebro.run() cerebro.plot()notify_trade也是推荐实现的,它在一次完整的开仓到平仓周期结束时触发:
def notify_trade(self, trade): if trade.isclosed: self.log(f'交易关闭 净利润={trade.pnlcomm:.2f}')trade.pnlcomm是扣除手续费后的净利润。订单日志看执行细节,交易日志看策略整体的盈亏节奏,两者都要有。
3. 回测可信度的底线:手续费、滑点和仓位模型
双均线策略跑出收益曲线只是第一步。如果回测环境里没有手续费、没有滑点、仓位想买多少就买多少,那条漂亮曲线在实盘里大概率要变形。这个环节是“回测和实盘差距”最集中的来源。
3.1 手续费与滑点怎么设置才不被怀疑
手续费设置在Broker层面:
cerebro.broker.setcommission(commission=0.0003, percabs=False) cerebro.broker.set_slippage_perc(perc=0.001)这里必须多说一句:BackTrader的setcommission对百分数有两种解释方式,不显式指定percabs很容易把“万分之三”写成“十万分之三”而不自知。我习惯用percabs=False,直接传十进制比例,0.0003就是万分之三,意思最直观。set_slippage_perc(perc=0.001)表示滑点按成交价的千分之一计入,它的兄弟方法是set_slippage_fixed,按固定点数模拟。高频策略对滑点极其敏感,我通常会把滑点设得比实盘统计略高一点再回测,给自己留出安全边际。
另一个细节:一笔完整的买卖会产生两笔手续费,也就是双边成本。很多人回测时只算单边,成本低估一半,盈亏比和最优参数全被带偏。我的做法是按双边成本折算进单边参数里,心里有数就好。
3.2 仓位模型:把Sizer用起来
如果策略里手动指定size,每笔开仓都是固定数量,资金利用率很差。BackTrader的Sizer就是为这个问题准备的。最常用的是PercentSizer,按当前资金的比例下单:
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)95%看起激进,但PercentSizer内部会把计算出来的股数向下取整,实际不会真把95%资金全部押上。想要更精细的控制,可以实现自己的Sizer,核心是重写getsizing方法:
class RiskSizer(bt.Sizer): def getsizing(self, comminfo, cash, data, isbuy): position = self.strategy.getposition(data) price = data.close[0] if not isbuy: return position.size # 平仓时清掉现有持仓 max_loss_per_trade = 0.02 * cash stop_distance = price * 0.05 size = int(max_loss_per_trade / stop_distance) return max(0, size)这个例子的逻辑是:每次开仓的潜在亏损不超过账户资金的2%,止损距离按价格的5%估算,倒推出该买多少。int()保证结果是最小交易单位的整数倍。真实的仓位管理比这复杂得多,但思路是一致的:下单手数不是自由发挥,而是被风险预算约束出来的结果。
3.3 整手约束与最小变动价位:不起眼但影响实盘模拟精度
容易被忽略的模拟细节是最小交易单位。比如某些股票类品种一手是100股,某些期货品种一手对应固定合约乘数。BackTrader的Broker默认允许小数仓位,不做处理的话,回测里可能出现买入0.7手这种实盘根本无法成交的订单。处理办法有两个:在自定义Sizer里用int()向下取整,或者在下单时自己控制size的取整逻辑。
另一个细节是数据精度。K线价格跳动要符合品种的最小变动价位,比如某个品种最小变动是0.01,你喂进去的数据却出现0.015这种价格,滑点和止盈止损的模拟就会失真。单独看每个细节影响都不大,但叠加起来就是回测和实盘的鸿沟。
4. 让策略自己开口说话:日志、图像与绩效指标的正确读法
回测跑完只输出一个最终收益率,基本等于什么都得不到。策略开发阶段最重要的工作不是写信号,而是建立一套能让你“看见”策略行为的验证手段。
4.1 逐K线日志:回到每一个决策现场
我在next里加一个计数器,在关键节点把状态打出来:
def next(self): self.bar_count += 1 if self.bar_count % 50 == 0 or self.cross[0] != 0: self.log(f'Bar {self.bar_count} 收盘={self.data.close[0]:.2f} ' f'持仓={self.position.size} 现金={self.broker.getcash():.2f}')这样回测跑完,你能按时间顺序回看每一个关键节点:交叉发生时价格是多少、当时持仓什么状态、为什么下单、成交之后资金变成多少。我几乎每次调试策略都会先把日志完整过一遍,确认前20个信号完全符合我的预想,再继续往下优化。跳过这一步直接看绩效指标,等于让一个粗心的员工直接交报告,中间全是盲区。
4.2 图表定制:别让默认图的信息密度骗了你
cerebro.plot()确实方便,但默认图信息太杂,容易干扰判断。常用的定制参数:
cerebro.plot(style='candle', barup='green', bardown='red', volume=False)红涨绿跌还是绿涨红跌,看个人偏好,关键是保持全局一致。plotlines可以控制指标线的样式,plotinfo可以把指标挪到独立子图,观察量价配合时很好用。图形的价值在于快速发现“异常”:某段净值曲线异常陡峭、某段交易频率突然飙升,通常意味着订单执行或信号逻辑有bug,这时回到日志里定位,比盯着指标猜有效率得多。另一个我常用的配置是把volume=False关掉,让主图只保留价格和均线,减少视觉干扰。
4.3 绩效指标的阅读顺序:先看回撤,再看收益
BackTrader自带一组分析器,常用的有Returns、SharpeRatio、DrawDown、TradeAnalyzer。挂载方式:
cerebro.addanalyzer(bt.analyzers.Returns, _name='returns') cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown') cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')跑完从策略对象里取结果:
strats = cerebro.run() strat = strats[0] print(f"年化收益: {strat.analyzers.returns.get_analysis()['rnorm100']:.2f}%") print(f"最大回撤: {strat.analyzers.drawdown.get_analysis()['max']['drawdown']:.2f}%")| 指标 | 它的作用 | 我的关注点 |
|---|---|---|
| 总收益率 | 看结果 | 容易被极端单笔交易拉高 |
| 最大回撤 | 看风险 | 决定持仓时扛不扛得住 |
| 夏普比率 | 看收益风险比 | 不只看数值,看计算假设 |
| 盈亏比 | 看盈利质量 | 交易样本数是否足够 |
我给策略做体检的顺序是:先看最大回撤,问自己能不能扛得住;再看盈亏比和交易次数,判断策略是不是靠少数几次大赚撑起来;最后才看总收益。一个年化40%但最大回撤30%的策略,和一个年化15%但回撤只有5%的策略,对多数人的体感是完全不同的。TradeAnalyzer里还有一个很有价值的维度:平均盈利、平均亏损、最大连续亏损次数,这些比单一收益率信息量大得多。如果交易次数只有二十来次,任何指标都没有统计意义,先扩充样本再说。
5. 策略开发中我反复踩过的坑和现在的检查清单
最后聊聊策略开发里最容易出问题、也最影响回测可信度的几个坑。这些教训不少是用真金白银试错换来的,希望能帮你绕开。
5.1 未来函数:回测里最漂亮的东西往往最危险
BackTrader默认的订单执行顺序是:在t这根K线里基于当前数据做决策,订单在t+1这根K线的开盘价成交。这个设计本身就是防未来函数的,它保证了“决策时点能看到的信息”不会新于“成交时点的信息”。
但有个开关必须提醒:cheat_on_close。如果把它设成True,订单会在当前K线收盘价成交,相当于你用这一根K线的收盘价做决策,又用同一根K线的收盘价成交——在只拿到整根K线数据的前提下,这就引入了轻微的未来信息。我实盘验证时绝不开启这个选项,宁可回测成交价差一点,也要保证逻辑干净。判断一个回测有没有未来函数,最简单的提问是:如果交易发生在今天开盘,那么今天收盘之后才知道的信息,是否影响了这笔交易?如果是,这个策略回测再漂亮也不能要。
5.2 参数过拟合:策略在参数寻优后变得“太聪明”
双均线的周期参数一寻优,很容易出现某个参数组合在测试区间收益极高、换到另一个区间立刻垮掉的情况,这是典型的过拟合。不是说不能优化参数,而是要控制自由度:参数越少越好,寻优区间和验证区间要分开,有条件的话做滚动的前向验证。我自己有一条底线:如果最优参数是拿大量组合硬试出来的,而且对参数微调极其敏感——比如快线从10改成11收益就腰斩——这个策略基本可以放弃。一个经不起参数扰动的策略,也经不起实盘的意外行情。
5.3 一个策略不合格时,我的复盘顺序
当回测结果不理想,我不会急着改代码,而是按固定顺序排查:
- 先查硬bug:未来函数、订单重复、仓位计算错误这些。
- 再手工核对前几十个信号,用日志逐条对照策略行为是否符合设计。
- 然后看交易笔数,样本太少的结果没有统计意义。
- 最后才怀疑策略逻辑本身,考虑加约束条件或换思路。
这个顺序帮我找到了很多“表面逻辑没问题”背后的隐藏问题。有一段时间某个策略回测收益高得异常,排查后才发现是订单在未完成状态下被重复下单,导致仓位翻倍。日志里订单状态一展开,问题立刻现形,根本不需要猜。
写到这里,BackTrader策略开发最核心的内容基本说完了。最后再分享一个我个人的习惯:每写完一个策略,我会在独立的数据区间上做一次最小化的手工复盘——把自己当成一根K线一根K线地手动模拟交易,拿第一笔交易信号去对照代码逻辑。这个过程很慢,但它的价值不在于验证收益,而在于逼着我重新思考每一个交易决策背后的假设。很多时候策略没有bug,只是假设本身就不成立。希望这篇能帮你在策略开发这条路上少走几步弯路。