如何用backtrader对股票组合进行量化回测?

01 引言

backtrader是功能非常强大的量化回测框架之一,得到欧洲很多银行、基金等金融机构的青睐,并应用于实盘交易中。公众号Python金融量化针对backtrader的入门和应用已连续发布了四篇推文:《

【手把手教你】入门量化回测最强神器backtrader(一)

》、《

【手把手教你】入门量化回测最强神器backtrader(二)

》、《

【手把手教你】入门量化回测最强神器backtrader(三)

》和《

backtrader如何加载股票因子数据?以换手率、市盈率为例进行回测【附Python代码】

》,分别介绍了backtrader整个框架的组成部分、回测系统的运行,策略模块交易日志的编写、策略参数的寻优,Analyzers模块的分析、策略业绩评价指标可视化分析以及扩张数据加载模块对市盈率因子进行回测等。上述文章有一个共同点是回测实例均以个股为交易标的,那么如何对股票组合进行回测呢?本文将重点介绍如何加载多只股票数据,并构建交易组合进行量化回测


02股票组合回测实例


数据获取

第一步是数据获取和加载,A股数据个人一般使用tushare来获取,由于对多只股频繁获取容易出现接口报错,因此个人在本地搭建了一个股票数据库(关于数据库搭建请参照:《

【手把手教你】Python面向对象编程入门及股票数据管理应用实例

》)。注意,下面导入的update_sql和base是自己写的本地脚本,使用自己数据运行时请将其注释掉。


import backtrader as bt
import pandas as pd

#以下引入脚本是个人的数据库文件,导入其他数据请注释掉

from update_sql import update_sql
#更新数据库
update_sql(table_name='daily_data')

from base import sql_engine,ts_pro
pro=ts_pro()
engine = sql_engine()
def get_data(code):
    sql=f"select * from daily_data where trade_date>'20150201' and ts_code='{code}'"
    data=pd.read_sql(sql,engine)
    data=data.sort_values('trade_date')
    #前复权
    data['adjclose']=(data.close*data.adj_factor/data.adj_factor.iloc[-1]).values
    data['adjvol']=(data.vol*data.adj_factor/data.adj_factor.iloc[-1]).values
    data['adjopen']=(data.open*data.adj_factor/data.adj_factor.iloc[-1]).values
    data['adjhigh']=(data.high*data.adj_factor/data.adj_factor.iloc[-1]).values
    data['adjlow']=(data.low*data.adj_factor/data.adj_factor.iloc[-1]).values

    data=data[['trade_date','adjopen','adjhigh','adjlow','adjclose','adjvol']]
    n1=['open','high','low','close','volume']
    n2=['adjopen','adjhigh','adjlow','adjclose','adjvol']
    data.rename(columns=dict(zip(n2,n1)),inplace=True)
    data.index=pd.to_datetime(data.trade_date)
    data=data.sort_index()
    data['openinterest']=0
    data['datetime']=data.index
    data=data[['datetime','open','high','low','close','volume']]
    return data


如果不会搭建数据库,也可以使用tushare pro直接在线获取数据,并转化为backtrader能接受的数据格式。




import tushare as ts

#tushare pro需到官网注册并获取token才能用
token='输入你的token'
pro=ts.pro_api(token)def get_data2(code,date='20150101'):
    data=ts.pro_bar(ts_code=code, adj='qfq', start_date=date)
    data.index=pd.to_datetime(data.trade_date)
    data=data.sort_index()
    data['volume']=data.vol
    data['openinterest']=0
    data['datetime']=pd.to_datetime(data.trade_date)
    data=data[['datetime','open','high','low','close','volume','openinterest']]
    data=data.fillna(0)
    return data


由于A股全市场有三千多只股票,如果对所有股票进行遍历构建交易策略,

Python循环起来会非常慢。为了节省时间,下面先对市场个股进行一次筛选,

根据个人偏好,通过条件设置过滤掉大部分股票。


def get_code_list(date='20150202'):
    #默认2010年开始回测
    dd=pro.daily_basic(trade_date=date)
    x1=dd.close<100
    #流通市值低于300亿大于50亿
    x2=dd.circ_mv>500000
    x3=dd.circ_mv<3000000
    #市盈率低于80
    x4=dd.pe_ttm<80
    #股息率大于2%
    x5=dd.dv_ttm>3
    x=x1&x2&x3&x4&x5
    stock_list=dd[x].ts_code.values
    return stock_list


通过价格、市值、市盈率和股息率指标的设置,选择了24只个股进行量化回测。

len(get_code_list())

24


策略编写


下面以一个简单的“动量+趋势跟踪”策略作为示例。策略思路为:计算24只股票过去30日的收益率并进行排序,选择前10只股票加入选股池(动量),逐日滚动计算和判断:如果选股池中某只个股满足股价位于20均线以上且没有持仓时买入(以20日均线为生命线跟踪趋势);如果某只个股已持仓但判断不在选股池中或股价位于20均线以下则卖出。每次交易根据十只个股平均持仓(注意:最多交易10只个股)。


class MyStrategy(bt.Strategy):
    # 策略参数
    params = dict(
        period=20,  # 均线周期
        look_back_days=30,
        printlog=False
    )

    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)                        

    def log(self, txt, dt=None,doprint=False):
        if self.params.printlog or doprint:
            dt = dt or self.datas[0].datetime.date(0)
            print(f'{dt.isoformat()},{txt}')

    #记录交易执行情况(可省略,默认不输出结果)
    def notify_order(self, order):
        # 如果order为submitted/accepted,返回空
        if order.status in [order.Submitted, order.Accepted]:
            return
        # 如果order为buy/sell executed,报告价格结果
        if order.status in [order.Completed]: 
            if order.isbuy():
                self.log(f'买入:\n价格:{order.executed.price:.2f},\
                成本:{order.executed.value:.2f},\
                手续费:{order.executed.comm:.2f}')

                self.buyprice = order.executed.price
                self.buycomm = order.executed.comm
            else:
                self.log(f'卖出:\n价格:{order.executed.price:.2f},\
                成本: {order.executed.value:.2f},\
                手续费{order.executed.comm:.2f}')

            self.bar_executed = len(self) 

        # 如果指令取消/交易失败, 报告结果
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log('交易失败')
        self.order = None

    #记录交易收益情况(可省略,默认不输出结果)
    def notify_trade(self,trade):
        if not trade.isclosed:
            return
        self.log(f'策略收益:\n毛收益 {trade.pnl:.2f}, 净收益 {trade.pnlcomm:.2f}')


数据加载和回测系统设置


写一个循环遍历24只个股数据并加载到回测系统中,将初始本金设置为10万元,手续费为千分之一,回测结束打印出交易日记。

#加载数据

cerebro = bt.Cerebro()
for s in get_code_list():
    feed = bt.feeds.PandasData(dataname = get_data(s))
    cerebro.adddata(feed, name = s)

#回测设置
startcash=100000.0
cerebro.broker.setcash(startcash)
# 设置佣金为千分之一
cerebro.broker.setcommission(commission=0.001)
 # 添加策略
cerebro.addstrategy(MyStrategy,printlog=True) 
cerebro.run() 
#获取回测结束后的总资金
portvalue = cerebro.broker.getvalue()
pnl = portvalue - startcash
#打印结果
print(f'总资金: {round(portvalue,2)}')
print(f'净收益: {round(pnl,2)}')


输出结果:

2015-04-27,买入:
价格:14.54,成本:8724.53, 手续费:8.72
2015-04-27,买入:
价格:22.34,成本:8934.14, 手续费:8.93
2015-04-28,卖出:
价格:23.06,成本: 8934.14, 手续费9.22
2015-04-28,策略收益:
毛收益 287.97, 净收益 269.82
......
2020-05-26,策略收益:
毛收益 624.00, 净收益 591.71
2020-05-26,策略收益:
毛收益 570.00, 净收益 537.05
2020-05-26,策略收益:
毛收益 40.00, 净收益 7.37
2020-05-26,策略收益:
毛收益 561.00, 净收益 527.87
2020-05-27,买入:
价格:20.00,成本:16000.00,手续费:16.00
总资金: 182914.68
净收益: 82914.68


策略回测结果可视化


cerebro = bt.Cerebro()

for s in get_code_list():
    feed = bt.feeds.PandasData(dataname = get_data(s))
    cerebro.adddata(feed, name = s)
#回测设置
startcash=100000.0
cerebro.broker.setcash(startcash)
# 设置佣金为千分之一
cerebro.broker.setcommission(commission=0.001)
# 添加策略
cerebro.addstrategy(MyStrategy) 


注意,plot_result是自己写的对回测结果进行可视化的脚本文件,代码比较长,此处省略,完整代码分享在“金融量化知识星球中(文末)。


bt.plot_result(cerebro)






03 结语

本文着重介绍了如何使用backtrader进行股票组合量化回测,回测实例仅供参考,不构成任何交易建议。文中构建的“动量+趋势跟踪”策略,并没有对相关参数进行优化,而且股票组合的选股范围较小(待选股票只有24只,而每次交易组合不超过10只),不同交易周期、不同标的参数阈值设置可能存在较大差异。从回测结果的评价指标来看,该策略并不是很理想,年化收益只有11%,最大回撤21%,夏普比率只有0.95。当然,本文的目的不在于结果而是过程。总之,兄弟我先抛块砖,有玉的尽管砸过来


参考资料:

1. backtrader官方文档和安装包原生代码

    https://www.backtrader.com/docu/

2. 知乎:《【干货】backtrader单因子回测框架--以上证50为例》,作者:云金杞。


关于Python金融量化

专注于分享Python在金融量化领域的应用。加入知识星球,可以免费获取量化投资视频资料、量化金融相关PDF资料、公众号文章Python完整源码、量化投资前沿分析框架,与博主直接交流、结识圈内朋友等。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267