1. Backtrader简介
Backtrader 是 2015 年开源的 Python 量化回测框架(支持实盘交易),功能丰富,操作方便灵活:
- 品种多:股票、期货、期权、外汇、数字货币;
- 周期全:Ticks 级、秒级、分钟级、日度、周度、月度、年度;
- 速度快:pandas 矢量运算、多策略并行运算;
- 组件多:内置 Ta-lib 技术指标库、PyFlio 分析模块、plot 绘图模块、参数优化等;
- 超灵活:即可以随意搭配组件,又支持扩展自己开发的功能,想怎么玩就怎么玩。
2. INSIGHT免费数据源简介
INSIGHT为华泰提供的行情数据源,其中level1数据是免费的,包含沪深市场tick,日k,分钟k,月k,etf,各类指数,金融资讯数据以及融券通数据。
提供python,java,c++,cs 等多种语言,同时提供SDK和可视化金融数据下载终端,方便快捷的访问数据。本文数据将以可视化金融数据下载终端为例。
详情可访问(社区版免费): https://findata-insight.htsc.com:9151/help/sdk/SDKDownload/
3. 如何使用两者进行回测
本文将介绍如何使用INSIGHT和Backtrader实现回测,【点击demo下载】,最终结果如:
3.1 回测流程:
通常我们使用Backtrader回测时,流程如下:
-
Step 1:构建策略 (1) 确定策略潜在的可调参数; (2) 计算策略中用于生成交易信号的指标; (3) 按需打印交易信息; (4) 编写买入、卖出的交易逻辑。 -
Step 2:结合行情数据,实例化策略引擎 cerebro,由 cerebro 来驱动回测 (1) 由 DataFeeds 加载数据,再将加载的数据添加给 cerebro; (2) 将上一步生成的策略添加给 cerebro; (3) 按需添加策略分析指标或观测器; (4) 通过运行 cerebro.run() 来启动回测; (5) 回测完成后,按需运行 cerebro.plot() 进行回测结果可视化展示。
Backtrader 最基础的回测代码编写流程如下:
import backtrader as bt
import backtrader.indicators as btind
import backtrader.feeds as btfeeds
class TestStrategy(bt.Strategy):
params = (
(...,...),
)
def log(self, txt, dt=None):
'''可选,构建策略打印日志的函数:可用于打印订单记录或交易记录等'''
dt = dt or self.datas[0].datetime.date(0)
print('%s, %s' % (dt.isoformat(), txt))
def __init__(self):
'''必选,初始化属性、计算指标等'''
pass
def notify_order(self, order):
'''可选,打印订单信息'''
pass
def notify_trade(self, trade):
'''可选,打印交易信息'''
pass
def next(self):
'''必选,编写交易策略逻辑'''
sma = btind.SimpleMovingAverage(...)
pass
cerebro = bt.Cerebro()
data = btfeeds.BacktraderCSVData(...)
cerebro.adddata(data)
cerebro.broker.setcash(...)
cerebro.addsizer(...)
cerebro.broker.setcommission(...)
cerebro.addstrategy(TestStrategy)
cerebro.addanalyzer(...)
cerebro.addobserver(...)
cerebro.run()
cerebro.plot()
下面就参照上面的模板,一步步教大家如何用 Backtrader 进行选股回测。
3.2 如何进行回测
在此我们以一个策略进行示范讲解~
3.2.1 策略说明
本文省去了选股过程,直接提供最终的选股结果,然后对选股结果做回测,具体的回测条件如下: 股票池:600466.SH,603866.SH,688088.SH,603816.SH。 回测区间:2020-02-03至2021-01-28。 持仓周期:月度调仓,每月第一个交易日,以开盘价买入或卖出。 持仓权重:流通市值占比。 总资产:100,000,000元。 佣金:0.0003双边。 滑点:0.0001双边。
3.2.2 策略逻辑
假设已经在每个月最后一个交易日基于选股规则选出了表现最优的4只的股票作为下一个月的持仓成分股,然后在下个月的第一个交易日,卖出已有持仓,买入新的持仓
3.2.3 数据准备
测试用到 2 个数据集,一个是日度历史行情数据,另一个是股票数据集 。
-
日度行情数据集 日度行情数据集来源华泰的 可视化金融数据下载终端。 免费下载地址为https://findata-insight.htsc.com:9151/help/terminal/download/。 下载成功之后,需要先申请一个账号(如何申请),登陆后终端提供了多类数据的查询和下载。 本篇文章我们用到的是日度行情数据,可在 【金融数据下载终端】-> 【行情数据】->【基本面指标】中获取需要的数据,可将查询的数据导出成csv(点击下载数据)至本地供backertrader使用。 注意!!! 日度行情数据集 daily_price.csv 对应的是4只股票各自从 2020-02-03至2021-01-28的日度行情数据(后复权),共有 18 个字段: daily_price=pd.read_csv(r'D:\terminalfindata\daily_price.csv',encoding='gbk',parse_dates=['MDDate'])
此处为csv中的具体数据数据 2. 月末调仓成分股数据集 测试用的数据集 trade_info.csv 就是最终的选股结果,共包含 3 个字段:trade_date 调仓期(每月最后一个交易日)、sec_code 持仓成分股代码、weight 持仓权重 。 trade_info = pd.read_csv("trade_info.csv", parse_dates=['trade_date'])
3.2.4 常规数据导入
将数据导入 backtrader,构建“大脑”
导入 backtrader 时,约定俗成的将其缩写为 bt 。由于回测用到的各种原材料都是需要被添加给“大脑” cerebro的,所以最开始可以先实例化大脑:
import backtrader as bt
cerebro = bt.Cerebro()
如果没啥感觉,可以运行如下代码小试一下,若返回下面的结果,恭喜你!成功完成一个“空”回测 ~
import backtrader as bt
cerebro = bt.Cerebro()
print('Starting Portfolio Value: %.2f' % cerebro.broker.getvalue())
cerebro.run()
print('Final Portfolio Value: %.2f' % cerebro.broker.getvalue())
结果如下展示:
Starting Portfolio Value: 10000.00
Final Portfolio Value: 10000.00
3.2.5 多只股票的历史行情数据导入
Backtrader 通过 DataFeeds 模块来导入各式各样的数据。由于读取 daily_price.csv 文件后就生成了 DataFrame 表格,所以选用 DataFeeds 的 PandasData() 方法来导入,导入的 DataFrame 有默认的格式要求:
以交易日 ‘datetime’ 为 index,
列为’open’、‘high’、‘low’、‘close’、‘volume’、‘openinterest’ 字段。
首先我们要将daily_price.csv中的字段对应backtrader的默认格式要求,这里我们定义了一个函数用来修改字段名,因为我们只需要用到这几个数据,所以只修改这几个就好:
def change_column_name(data):
name_clomuns = data.columns.tolist()
new_name_dict = {}
for name in name_clomuns:
if name == 'MDDate':
new_name_dict[name] = 'datetime'
if name == 'OpenPx':
new_name_dict[name] = 'open'
if name == 'ClosePx':
new_name_dict[name] = 'close'
if name == 'HighPx':
new_name_dict[name] = 'high'
if name == 'LowPx':
new_name_dict[name] = 'low'
if name == 'TotalVolumeTrade':
new_name_dict[name] = 'volume'
if name == 'HTSCSecurityID':
new_name_dict[name] = 'sec_code'
data.rename(columns=new_name_dict, inplace=True)
return data
调用这个函数修改字段名,并筛选需要的数据
daily_price = change_column_name(data)
daily_price = data[["datetime", "sec_code", "open", "high", "low", "close", "volume"]]
daily_price.set_index('datetime', inplace=True)
接下来该如何导入本次回测用到的4只股票的数据,并让 Backtrader 知道这是哪只股票的数据?我们采用的是循环导入的方式,每次循环导入一只股票的数据并将数据名称命名为股票名,如下所示:
for stock in daily_price['sec_code'].unique():
data = pd.DataFrame(index=daily_price.index.unique())
df = daily_price.query(f"sec_code=='{stock}'")[['open','high','low','close','volume']]
data_ = pd.merge(data, df, left_index=True, right_index=True, how='left')
data_.loc[:,['volume',]] = data_.loc[:,['volume']].fillna(0)
data_.loc[:,['open','high','low','close']] = data_.loc[:,['open','high','low','close']].fillna(method='pad')
data_.loc[:,['open','high','low','close']] = data_.loc[:,['open','high','low','close']].fillna(0)
datafeed = btfeeds.PandasData(dataname=data_, fromdate=datetime.datetime(2020,2,3), todate=datetime.datetime(2021,1,28))
cerebro.adddata(datafeed, name=stock)
print(f"{stock} Done !")
在导入多只股票数据时需注意以下细节:
- 各股交易日不统一:上市日期不一致、退市日期不一致、回测区间内出现停牌等,都会使得不同股票各自的交易日数量不统一,所以要以回测区间内所有交易日为基础,对每只股票缺失的交易日进行补齐;
- 行情数据缺失:在补齐交易日过程中,会使得补充的交易日缺失行情数据,需对缺失数据进行填充。比如将缺失的 volume 填充为 0,表示股票无法交易的状态;将缺失的高开低收做前向填充;将上市前缺失的高开低收填充为 0 等;
- 股票与行情数据的匹配:通过设置 adddata() 方法中 name 参数,来实现数据集与股票的一 一对应关系。
3.2.6 如何配置回测条件
Backtrader 通过 Broker 模块来模拟证券交易中的“经纪商”角色(比如大家熟悉的证券公司),所以像初始资金、手续费等与经纪商相关的各种信息是通过 Broker 模块来配置的:
cerebro.broker.setcash(100000000.0)
cerebro.broker.setcommission(commission=0.0003)
cerebro.broker.set_slippage_perc(perc=0.0001)
此外,还可以通过 analyzers 策略分析模块和 observers 观测器模块提前配置好要返回的回测结果,比如想要返回策略的收益率序列、常规的策略评价指标,就可以提前将指标添加给大脑:
cerebro.addanalyzer(bt.analyzers.TimeReturn, _name='pnl')
cerebro.addanalyzer(bt.analyzers.AnnualReturn, _name='_AnnualReturn')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='_SharpeRatio')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='_DrawDown')
3.2.7 如何编写交易策略
所有的交易策略都是写在自定义的策略类里,如下面的 TestStrategy 类,自定义的策略类名称可以任意取,但必须继承 Backtrader 内置的 Strategy 类,即 bt.Strategy 。
相当于是给大家提供了一个策略接口,大家只需调用这个接口,专心编写自己的策略,而无需关心接口的具体内容。
class TestStrategy(bt.Strategy): # 类的名字可以随意取
那如何基于 trade_info.csv 的调仓信息在构建的 TestStrategy 里实现买卖操作呢?在TestStrategy 里至少需要定义 init() 和 next() 方法。其中, init() 用于初始化各类属性,next() 用于下单交易,如下所示:
class MyStrategy(bt.Strategy):
def __init__(self):
'''必选,策略中各类指标的批量计算或是批量生成交易信号都可以写在这里'''
pass
def next(self):
'''必选,在这里根据交易信号进行买卖下单操作'''
pass
具体到选股策略:1. trade_info.csv 里的调仓日和持仓列表就可以定义在 init() 里,方便 next() 函数调用;2. 在 next() 里,判断每个交易日是否为调仓日,如果是调仓日就按调仓权重卖出旧股,买入新股。
具体代码如下:
class TestStrategy(bt.Strategy):
'''选股策略'''
def __init__(self):
self.buy_stock = trade_info
self.trade_dates = pd.to_datetime(self.buy_stock['trade_date'].unique()).tolist()
self.order_list = []
self.buy_stocks_pre = []
def next(self):
dt = self.datas[0].datetime.date(0)
if dt in self.trade_dates:
print("--------------{} 为调仓日----------".format(dt))
if len(self.order_list) > 0:
for od in self.order_list:
self.cancel(od)
self.order_list = []
buy_stocks_data = self.buy_stock.query(f"trade_date=='{dt}'")
long_list = buy_stocks_data['sec_code'].tolist()
print('long_list', long_list)
sell_stock = [i for i in self.buy_stocks_pre if i not in long_list]
print('sell_stock', sell_stock)
if len(sell_stock) > 0:
print("-----------对不再持有的股票进行平仓--------------")
for stock in sell_stock:
data = self.getdatabyname(stock)
if self.getposition(data).size > 0 :
od = self.close(data=data)
self.order_list.append(od)
print("-----------买入此次调仓期的股票--------------")
for stock in long_list:
w = buy_stocks_data.query(f"sec_code=='{stock}'")['weight'].iloc[0]
data = self.getdatabyname(stock)
order = self.order_target_percent(data=data, target=w*0.95)
self.order_list.append(order)
self.buy_stocks_pre = long_list
cerebro.addstrategy(TestStrategy)
3.2.8 策略细节说明
- init() 函数在回测过程中只会在最开始的时候调用一次,而 next() 会每个交易日依次循环调用多次;
- 为了提高回测效率,对于策略用到的辅助数据、一次性就能计算完成的指标等,都建议在 init() 里生成或计算;对于复杂的选股策略,建议参考本文的方式,事先确定好调仓日期、成分、权重,再将结果导入 Backtrader 做回测;
- Backtrader 默认情况下是:在 t 日运行下单函数,然后在 t+1 日以开盘价成交
- 交易函数说明:
self.close() 平仓; self.buy() 买入、做多; self.sell() 卖出、做空; self.cancel() 取消订单; self.order_target_percent() 按持仓百分比下单,“多退少补”原则, 对于股票当前无持仓或持有的是多单(size>=0)的情况,若目标占比 target > 当前持仓占比,买入不够的部分;若目标占比 target < 当前持仓占比,卖出多余的部分。
3.2.9 如何打印回测日志
在 TestStrategy 里还可以定义许多打印日志的函数,常用的有 notify_order() 订单日志、notify_trade() 交易日志、notify_cashvalue() 资金信息、notify_store() 交易事件说明等等。
比如再往上面的 TestStrategy 里添加 notify_order() ,用于打印具体的订单信息:
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed, order.Canceled, order.Margin]:
if order.isbuy():
self.log(
'BUY EXECUTED, ref:%.0f,Price: %.2f, Cost: %.2f, Comm %.2f, Size: %.2f, Stock: %s' %
(order.ref,
order.executed.price,
order.executed.value,
order.executed.comm,
order.executed.size,
order.data._name))
else:
self.log('SELL EXECUTED, ref:%.0f, Price: %.2f, Cost: %.2f, Comm %.2f, Size: %.2f, Stock: %s' %
(order.ref,
order.executed.price,
order.executed.value,
order.executed.comm,
order.executed.size,
order.data._name))
3.2.9 如何提取回测结果
想要提取回测结果,首先要确保已经启动并完成回测,然后再从返回的 result 中提取事先配置好的回测结果:
result = cerebro.run()
strat = result[0]
daily_return = pd.Series(strat.analyzers.pnl.get_analysis())
print("--------------- AnnualReturn -----------------")
print(strat.analyzers._AnnualReturn.get_analysis())
print("--------------- SharpeRatio -----------------")
print("--------------- DrawDown -----------------")
print(strat.analyzers._DrawDown.get_analysis())
cerebro.plot()
最终打印出来的原始结果如下所示,也可以按需对结果的数据结果做进一步的处理:
--------------- AnnualReturn -----------------
OrderedDict([(2020, 0.21542275632539853),(2021, 0.017567210073598405)])
--------------- SharpeRatio -----------------
OrderedDict([('sharperatio', 1.5512121051534207)])
--------------- DrawDown -----------------
AutoOrderedDict([('len',136),('drawdown',6.655064560818994),('moneydown', 10952970.349310666),('max', AutoOrderedDict([('len', 206), ('drawdown', 20.374812759676267), ('moneydown', 27705182.493407518)]))])
附录:本章所涉及的完整代码:
import backtrader as bt
import backtrader.feeds as btfeeds
import pandas as pd
import datetime
import warnings
warnings.filterwarnings("ignore")
cerebro = bt.Cerebro()
def change_column_name(data):
name_clomuns = data.columns.tolist()
new_name_dict = {}
for name in name_clomuns:
if name == 'MDDate':
new_name_dict[name] = 'datetime'
if name == 'OpenPx':
new_name_dict[name] = 'open'
if name == 'ClosePx':
new_name_dict[name] = 'close'
if name == 'HighPx':
new_name_dict[name] = 'high'
if name == 'LowPx':
new_name_dict[name] = 'low'
if name == 'TotalVolumeTrade':
new_name_dict[name] = 'volume'
if name == 'HTSCSecurityID':
new_name_dict[name] = 'sec_code'
data.rename(columns=new_name_dict, inplace=True)
return data
data= pd.read_csv(r'D:\terminalfindata\daily_price.csv', encoding='gbk',parse_dates=['MDDate'])
data = change_column_name(data)
daily_price = data[["datetime", "sec_code", "open", "high", "low", "close", "volume"]]
daily_price.set_index('datetime', inplace=True)
trade_info=pd.read_csv(r'D:\terminalfindata\trade_info.csv',encoding='gbk',parse_dates=['trade_date'])
for stock in daily_price['sec_code'].unique():
data = pd.DataFrame(index=daily_price.index.unique())
df = daily_price.query(f"sec_code=='{stock}'")[['open','high','low','close','volume']]
data_ = pd.merge(data, df, left_index=True, right_index=True, how='left')
data_.loc[:,['volume',]] = data_.loc[:,['volume']].fillna(0)
data_.loc[:,['open','high','low','close']] = data_.loc[:,['open','high','low','close']].fillna(method='pad')
data_.loc[:,['open','high','low','close']] = data_.loc[:,['open','high','low','close']].fillna(0)
datafeed = btfeeds.PandasData(dataname=data_, fromdate=datetime.datetime(2020,2,3), todate=datetime.datetime(2021,1,28))
cerebro.adddata(datafeed, name=stock)
print(f"{stock} Done !")
class TestStrategy(bt.Strategy):
def __init__(self):
'''必选,初始化属性、计算指标等'''
self.buy_stock = trade_info
self.trade_dates = pd.to_datetime(self.buy_stock['trade_date'].unique()).tolist()
self.order_list = []
self.buy_stocks_pre = []
def next(self):
'''必选,编写交易策略逻辑'''
dt = self.datas[0].datetime.date(0)
if dt in self.trade_dates:
print("--------------{} 为调仓日----------".format(dt))
if len(self.order_list) > 0:
for od in self.order_list:
self.cancel(od)
self.order_list = []
buy_stocks_data = self.buy_stock.query(f"trade_date=='{dt}'")
long_list = buy_stocks_data['sec_code'].tolist()
print('long_list', long_list)
sell_stock = [i for i in self.buy_stocks_pre if i not in long_list]
print('sell_stock', sell_stock)
if len(sell_stock) > 0:
print("-----------对不再持有的股票进行平仓--------------")
for stock in sell_stock:
data = self.getdatabyname(stock)
if self.getposition(data).size > 0:
od = self.close(data=data)
self.order_list.append(od)
print("-----------买入此次调仓期的股票--------------")
for stock in long_list:
w = buy_stocks_data.query(f"sec_code=='{stock}'")['weight'].iloc[0]
data = self.getdatabyname(stock)
order = self.order_target_percent(data=data, target=w * 0.95)
self.order_list.append(order)
self.buy_stocks_pre = long_list
cerebro.broker.setcash(100000000.0)
cerebro.broker.setcommission(commission=0.0003)
cerebro.broker.set_slippage_perc(perc=0.0001)
cerebro.addstrategy(TestStrategy)
cerebro.addanalyzer(bt.analyzers.TimeReturn, _name='pnl')
cerebro.addanalyzer(bt.analyzers.AnnualReturn, _name='_AnnualReturn')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='_SharpeRatio')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='_DrawDown')
result = cerebro.run()
strat = result[0]
daily_return = pd.Series(strat.analyzers.pnl.get_analysis())
print("--------------- AnnualReturn -----------------")
print(strat.analyzers._AnnualReturn.get_analysis())
print("--------------- SharpeRatio -----------------")
print(strat.analyzers._SharpeRatio.get_analysis())
print("--------------- DrawDown -----------------")
print(strat.analyzers._DrawDown.get_analysis())
cerebro.plot()
|