参透了backtrader精华后,仰天长啸,壮怀激烈。
文章目录
Backtrader 是目前用得比较多的一个回测框架,也能接入实盘交易。代码也简洁优美,值得学习借鉴。
Backtrader 是 2015 年开源的 Python 量化回测框架(支持实盘交易),功能丰富,操作方便灵活:
- 品种多:股票、期货、期权、外汇、数字货币;
- 周期全:Ticks 级、秒级、分钟级、日度、周度、月度、年度;
- 速度快:pandas 矢量运算、多策略并行运算;
- 组件多:内置 Ta-lib 技术指标库、PyFlio 分析模块、plot 绘图模块、参数优化等;
- 超灵活:即可以随意搭配组件,又支持扩展自己开发的功能,想怎么玩就怎么玩;
- 社区活跃、帮助文档齐全,官网:https://www.backtrader.com/。
backtrader源码核心逻辑
backtrader两个核心组件: DataFeed和Strategy,前者不断取行情数据,后者根据数据进行指标计算,然后发出买卖指令。cerebro作为指挥大脑,调动二者的运转。cerebro核心逻辑的伪代码:
def run():
while True:
for data in self.datas:
data.load() ## 将调用_load(),每次获得一条行情数据(OHLC bar)
for strat in self.strategies:
strat.next()
对应到源码,看看cerebro中策略被执行的地方:
def run(self, **kwargs):
...
for iterstrat in iterstrats:
runstrat = self.runstrategies(iterstrat)
...
在runstrategies()中,会执行每个策略的_next()方法:
for strat in runstrats:
strat._next()
在_next()方法里,会根据当前数据长度和最小周期的长度判断是否进入next():
minperstatus = self._getminperstatus()
if minperstatus < 0:
self.next()
策略里有个变量self._minperiods,记录着每支股票的最小周期。只有每支股票的数据长度大于最小周期,才会进入策略的next()方法。next()就会执行我们定义的策略的逻辑。
每个data对应一支股票,每个data里持有多个line,一个line是一个LineBuffer对象,里面包含一个array数组,对应时间序列上每点的value。
在live trade中,如果feed不断有数据过来,_runnext就无限循环。
LineBuffer对array的一个封装,实现了对下标,比较运算等的重载。要学会line[]和line()的含义。
下面代码帮你认识LineBuffer对象:
class MyDataSeries(bt.DataSeries):
lines = ('line1','line2','line3',) ## 定义了三条LineBuffer
def __init__(self):
pass
def test_linebuffer():
datas = MyDataSeries()
for x in range(10):
datas.line1.forward()
datas.line1[0] = round(random.uniform(7, 10), 2)
datas.line1.forward()
datas.line1[0] = 100
datas.line1[-1] = datas.line1[0]
datas.line1.backwards()
line = bt.LineBuffer()
line.forward()
line[0] = 1.21
line.forward()
line[0] = 1.22
pass
上面我们创建了一个持有若干条LineBuffer的Data对象,然后填充LineBuffer。
我们完全可以基于LineBuffer、Pandas、talib库实现一个简易的实盘交易系统。
实盘
backtrader在实盘运行的时候是单核处理的,实际上,在回测的时候,也是单核处理的(参数优化除外)。既然是单核处理的,那么,很显然的一个问题就是,CPU的处理能力有限制,为了能在信号出来的时候尽快的下单,并把信号传递到交易所里面,降低交易的滑点,在策略里面进行的数据处理越少越好。
实盘交易中bar的合成不建议在策略里做,最好预先合成好。
为何进不了策略的next()? 请看函数:
def _getminperstatus(self):
# check the min period status connected to datas
dlens = map(operator.sub, self._minperiods, map(len, self.datas))
self._minperstatus = minperstatus = max(dlens)
return minperstatus
也就是最小周期需要小于数据的长度。
类图
Line
backtrader里很多数据都是LineBuffer类型,它代表时间序列上的一组数据,背后就是一个数组。采用Line这个概念容易直观地对数据做对比。每个时间点上的数据必须是数值型,不能是字符串或日期。,所以你会看到bt提供了num2dt和dt2num两个转换函数。
通过line[0]表示当前时间点的数据,line[-1]代表上一个时间点的数据。
line(-1)是延迟索引。通过延迟索引-1获取一个和line一样的line,但是值会向前移动1位。典型示例就是把要对比当前收盘价和上一天的收盘价时,就用data.close和data.close(-1)做对比。
在LineSeries中添加LineBuffer
默认的DataBase已经有六条基本的Line: ‘close’, ‘low’, ‘high’, ‘open’, ‘volume’, ‘openinterest’,有时我们想给自己的DataFeed新增几条Line,可以这么定义:
class MyDatabase(bt.DataBase):
lines = (('haha'),('hoho'), )
上面新增两条line: haha和hoho
resampling
手上只有分钟级别的数据,而我们想要的是日线级别的数据,或者说手上是日线级别的数据,希望变成周线级别的数据。在backtrader中,有很好的的方法解决这样的问题。总而言之,就是timeframe转换的问题。
resampling字面意思看起来是“采样”,准确的来说,是上采样,从小的时间点变成大的时间点。方法很简单,就是在添加数据的时候,不在使用 cerebro.adddata(data),而是使用cerebro.resampledata(data, **kwargs)。后面的参数主要有两个,一个是timeframe,也就是你希望变成的timeframe是多少,day还是week;另外一个是compression,就是对bar进行压缩。
Lines的耦合(coupling)
backtrader对不同的lines进行适配处理,使他们建立对照关系。耦合主要用于将时间窗口不同的两个line建立关系。比如, 不同时间窗口的数据源具有不同的长度,indcator在使用这些数据的时候会复制这个长度。例如:
- 股票的日线数据,每年大约250个(bar,对应250个工作日)
- 股票的周线数据,每年大约52个(bar,对应52周)
现在如果我们要比对日线和周线的移动平均,这两个line一个长度为52个,一个250个,长度都不一样,如何比对呢?在backtrader中,使用一个空括号()来完成,如下示例:
class MyStrategy(bt.Strategy):
params = dict(period=20)
def __init__(self):
# data0 是日线数据
sma0 = btind.SMA(self.data0, period=15) # 15 天的平均
# data1 是周线数据
sma1 = btind.SMA(self.data1, period=5) # 5 周的平均
self.buysig = sma0 > sma1()
def next(self):
if self.buysig[0]:
print('日均线大于周均线!')
Line运算
line之间做加减、比较等运算将会得到新的Line对象,我们以此作为买卖信号。
策略
在next()里接收当前行情,作出买卖判断,通过buy()和sell()函数下单。实际完成买卖是在broker里进行的。
class MyStrategy(bt.Strategy):
# 先在 __init__ 中提前算好指标
def __init__(self):
self.sma1 = btind.SimpleMovingAverage(self.data)
self.ema1 = btind.ExponentialMovingAverage()
self.close_over_sma = self.data.close > self.sma1
self.close_over_ema = self.data.close > self.ema1
self.sma_ema_diff = self.sma1 - self.ema1
# 生成交易信号
self.buy_sig = bt.And(self.close_over_sma, self.close_over_ema, self.sma_ema_diff > 0)
# 在 next 中直接调用计算好的指标
def next(self):
if self.buy_sig:
self.buy()
信号
用来通知买和卖,有空头信号、多头信号、空多头信号之分。
Indicator
backtrader在指标计算这块是矢量化计算,不是利用循环来计算,这也比一些框架速度快。三种使用方式:
- 使用bt.indicators包,也可以简写成bt.ind
- 使用bt.talib模块
- 直接使用talib
你可以使用bt.indicators或talib进行指标计算,bt.indicators并不是基于talib实现的,它自己实现了一遍。基本计算函数在basicops.py中。 - Indicators are always instantiated during
__init__
in the Strategy - Indicator values (or values thereof derived) are used/checked during
next
- Any Indicator (or value thereof derived) declared during init will be precalculated before next is called。指示器的值每次调用next()之前都会计算
import talib
import backtrader as bt
## 三种调用方式
### 直接使用talib库
talib.SMA(self.data.close, timeperiod=5)
### 使用bt.talib,因为bt在一个单独的文件talib.py中导入了talib的所有函数
bt.talib.SMA(self.data.close, timeperiod=5)
### 使用bt自己的实现bt.indicators库
bt.indicators.SMA(self.data, period=5)
# 计算布林带
bt.talib.BBANDS(self.data, timeperiod=25)
bt.indicators.BollingerBands(self.data, period=25)
信号策略
也可以直接定义一个信号作为策略:
class TwoAvgLineSignalStrategy(bt.Indicator):
name = '双均线信号策略'
lines = ('signal',) # 声明 signal 线,交易信号放在 signal line 上
params = dict(
short_period=5,
long_period=20)
def __init__(self):
self.s_ma = bt.ind.SMA(period=self.p.short_period)
self.l_ma = bt.ind.SMA(period=self.p.long_period)
self.macd = btind.MACD()
# 短期均线上穿长期均线,取值为1;反之,短期均线下穿长期均线,取值为-1
# self.lines.signal = bt.ind.CrossOver(self.s_ma, self.l_ma)
self.lines.signal= bt.ind.CrossOver(self.macd.macd, self.macd.signal)
此时,无需实现next()函数,因为我们定义了signal线,它大于0时买进,小于0就卖出
自定义指示器
例如计算N日内上涨天数的指示器可以如下编写:
import backtrader as bt
class NDaysUpIndicator(bt.Indicator):
lines = ('ndaysup',)
params = (('period', 5),)
def __init__(self):
self.addminperiod(self.params.period)
def next(self):
count = 0
for i in range(self.params.period):
if self.data.close[-i] > self.data.close[-i-1]:
count += 1
self.lines.ndaysup[0] = count
class TestStrategy(bt.Strategy):
def __init__(self):
self.ndaysup = NDaysUpIndicator(self.data)
def next(self):
print(f'Date: {self.data.datetime.date(0)}, NDaysUp: {self.ndaysup[0]}')
if __name__ == '__main__':
cerebro = bt.Cerebro()
data = bt.feeds.YahooFinanceData(dataname='AAPL', fromdate=datetime.datetime(2020, 1, 1), todate=datetime.datetime(2020, 12, 31))
cerebro.adddata(data)
cerebro.addstrategy(TestStrategy)
cerebro.run()
多支股票
多支股票运行时,在初始化Indicator和调用sell()/buy()时,一定要记得传data这个参数。
for code in codes:
cerebro.adddata(data, name=code)
## 策略里初始化:
# 移动平均线指标
self.sma={d:bt.ind.SMA(d,period=self.p.period) for d in self.stocks}
## 然后访问
if sma[code][1] > ...
def __init__(self):
self.mas = dict()
#遍历所有股票,计算20日均线
for data in self.datas:
self.mas[data._name] = bt.ind.SMA(data.close, period=self.p.period)
def next(self):
#计算截面收益率
rate_list=[]
for data in self.datas:
if len(data)>self.p.look_back_days:
p0=data.close[0]
pn=data.close[-self.p.look_back_days]
rate=(p0-pn)/pn
rate_list.append([data._name,rate])
#股票池
long_list=[]
sorted_rate=sorted(rate_list,key=lambda x:x[1],reverse=True)
long_list=[i[0] for i in sorted_rate[:10]]
# 得到当前的账户价值
total_value = self.broker.getvalue()
p_value = total_value*0.9/10
for data in self.datas:
#获取仓位
pos = self.getposition(data).size
if not pos and data._name in long_list and \
self.mas[data._name][0]>data.close[0]:
size=int(p_value/100/data.close[0])*100
self.buy(data = data, size = size)
if pos!=0 and data._name not in long_list or \
self.mas[data._name][0]<data.close[0]:
self.close(data = data)
多策略同时运行
一个cerebro里可以添加多个策略,那这么做是否有必要呢?个人认为完全有必要,因为我们不想把各种不同的逻辑混杂在一个策略里。那多个策略同时对多个股票进行买卖是否会存在冲突呢?如果我们是单核运行,基本上不会,我们可以在broker里进行订单管理,防止重复下单。
Cerebro
收盘作弊模式与开盘作弊模式
收盘作弊模式 (cheat_on_close) ,可以以今日收盘价成交。开盘作弊模式(cheat_on_open),相当于上帝模式(提前观察到开盘价),然后以开盘价成交。
当开启了这两种模式后,整个执行逻辑与默认模式很大不同,所以策略类中覆写的方法不再之前的 prenext、next等,backtrader提供了另一套队形的 xxx_open 方法供用户编写业务逻辑。如:next_open、nextstart_open、prenext_open、next_open等。
在这些 xxx_open 方法中:
1、各种技术指标都还未重新计算,还保留着昨天的结果。如 self.sma[0]是昨天的移动均值
2、经纪行broker还未评估未决订单。
订单
backtrader提供了Order相关的类,但Order的创建和处理流程是交给broker的,broker只需要返回一个Order对象给策略即可。
Order状态的处理也是broker自己完成。也就是说,如果自己要实现实盘交易,就需要自己处理Order的完整流程。
Order 包含 order.created 与 order.executed对象。created与executed的区别在于一个记录原始报单, 一个记录实际成交单, 即: created.price和created.size是不会改变的, executed.price和executed.size是随着实际成交结果而变化的。 size的值是可正可负的。
访问订单基本信息:
order.ref, # 订单编号
order.executed.price, # 成交价
order.executed.value, # 成交额
order.executed.comm, # 佣金
order.executed.size, # 成交量
order.data._name,# 股票代码
可以在notify()函数中输出上述基本信息。
notify_order(order):每次订单状态改变会触发回调
notify_trade(trade):任何开仓/更新/平仓交易的通知
notify_cashvalue(cash, value) :通知当前现金和投资组合
notify_store(msg, *args, **kwargs):关于存储的通知
notify_data(self, data, status, *args, **kwargs)::关于数据的通知
notify_timer(self, timer, when, *args, **kwargs):定时器通知,定时器可以通过成员函数add_timer()添加
订单状态值,及流程如下:
- Order.Created 创建
- Order.Submitted 提交给broker
- Order.Accepted broker已接收
- Order.Partial 订单部分被执行 order.executed查看订单
- Order.Complete 订单已完成平均价格
- Order.Rejected 被broker拒绝
- Order.Margin 保证金不足\没有足够的现金执行订单。
- Order.Cancelled 用户取消
- Order.Expired 过期
订单类型
-
Order.Market
市价单,以当时市场价格成交的订单,不需要自己设定价格。市价单能被快速达成交易,防止踏空,尽快止损/止盈;
按下一个 Bar (即生成订单的那个交易日的下一个交易日)的开盘价来执行成交;
例:self.buy(exectype=bt.Order.Market) -
Order.Close
和 Order.Market 类似,也是市价单,只是成交价格不一样;
按下一个 Bar 的收盘价来执行成交;
例:self.buy(exectype=bt.Order.Close) -
Order.Limit
限价单,需要指定成交价格,只有达到指定价格(limit Price)或有更好价格时才会执行,即以指定价或低于指点价买入,以指点价或更高指定价卖出;
在订单生成后,会通过比较 limit Price 与之后 Bar 的 open\high\low\close 行情数据来判断订单是否成交。
如果下一个 Bar 的 open 触及到指定价格 limit Price,就以 open 价成交,订单在这个 Bar 的开始阶段就被执行完成;
如果下一个 Bar 的 open 未触及到指定价格 limit Price,但是 limit Price 位于这个 bar 的价格区间内 (即 low ~ high),就以 limit Price 成交;
例:self.buy(exectype=bt.Order.Limit, price=price, valid=valid) -
Order.Stop
止损单,需要指定止损价格(Stop Price),一旦股价突破止损价格,将会以市价单的方式成交;
在订单生成后,也是通过比较 Stop Price 与之后 Bar 的 open\high\low\close 行情数据来判断订单是否成交。
如果下一个 Bar 的 open 触及到指定价格 limit Price,就以 open 价成交;
如果下一个 Bar 的 open 未触及到指定价格 Stop Price,但是 Stop Price 位于这个 bar 的价格区间内 (即 low ~ high),就以 Stop Price 成交;
例:self.buy(exectype=bt.Order.Stop, price=price, valid=valid) -
Order.StopLimit
止损限价单,需要指定止损价格(Stop price)和限价(Limit Price),一旦股价达到设置的止损价格,将以限价单的方式下单;
在下一个 Bar,按 Order.Stop 的逻辑触发订单,然后以 Order.Limit 的逻辑执行订单;
例:self.buy(exectype=bt.Order.StopLimit, price=price, valid=valid, plimit=plimit) -
Order.StopTrail
跟踪止损订单,是一种止损价格会自动调整的止损单,调整范围通过设置止损价格和市场价格之间的差价来确定。
差价即可以用金额 trailamount 表示,也可以用市价的百分比 trailpercent 表示;
如果是通过 buy 下达了买入指令,就会“卖出”一个跟踪止损单,在市场价格上升时,止损价格会随之上升;
若股价触及止损价格时,会以市价单的形式执行订单;若市场价格下降或保持不变,止损价格会保持不变;
如果是通过 sell 下达卖出指令,就会“买入”一个跟踪止损单,在市场价格下降时,止损价格会随之下降;
若股价触及止损价格时,会以市价单的形式执行订单;但是当市场价格上升时,止损价格会保持不变;
例:self.buy(exectype=bt.Order.StopTrail, price=xxx, trailamount=xxx) -
Order.StopTrailLimit
跟踪止损限价单,是一种止损价格会自动调整的止损限价单,订单中的限价 Limit Price 不会发生变动,
止损价会发生变动,变动逻辑与上面介绍的跟踪止损订单一致;
例:self.buy(exectype=bt.Order.StopTrailLimit, plimit=xxx, trailamount=xxx)
Trade
一个交易可能由多个订单组成。
- 仓位从0变为正值(100),系统打开一个交易,会触发notify_trade方法
- 仓位从100变为0,关闭交易,触发notify_trade
不能做空的情况下,self.close()命令会一次性市价卖出全部仓位,关闭交易。在卖空操作中,self.close()默认会市价买回所有卖空仓位,关闭交易。
当前持仓
采用如下方式获得持仓信息:
print('当前可用资金', self.broker.getcash())
print('当前总资产', self.broker.getvalue())
print('当前持仓量', self.broker.getposition(self.data).size)
print('当前持仓成本', self.broker.getposition(self.data).price)
# 也可以直接获取持仓
print('当前持仓量', self.getposition(self.data).size)
print('当前持仓成本', self.getposition(self.data).price)
扫地僧给力系列
他推出了一个概念:策略迭代表。我以为是什么新东西,看了他的解释后,哑然失笑了。