量化投资与机器学习微信公众号,是业内垂直于量化投资、对冲基金、Fintech、人工智能、大数据领域的主流自媒体公众号拥有来自公募、私募、券商、期货、银行、保险、高校等行业30W+关注者,曾荣获AMMA优秀品牌力、优秀洞察力大奖,连续4年被腾讯云+社区评选为“年度最佳作者”。

量化投资与机器学习公众号 独家撰写
量化投资与机器学习公众号为全网读者带来的Backtrader系列,自推出以来收获无数好评!我们是真的在用心做这个内容。
QIML针对这个系列的宗旨就是:
免费!
做最好、最清晰的Bt教程!
让那些割韭菜的课程都随风而去吧!
为此,QIML为大家多维度、多策略、多场景来讲解Backtrader

预定系列(点击可阅读)
同时,我们对每段代码都做了解读说明,愿你在Quant的道路上学有所获!

QIML官方Github也已上线
相关数据、代码一并同步
https://github.com/QuantWorld2022/backtrader
希望大家多Follow,多给星

常见问题
1、如何直接从Mysql数据库中加载数据?
Backtrader的DataFeeds数据模块提供了各种加载数据的方法,之前的文章有介绍如何加载CSV文件或DataFrame中的数据,今天就补充介绍如何直接从Mysql数据库中加载数据。
下面的例子就是在继承了DataBase父类的基础上,修改相关方法的操作逻辑,“改装”得到了一个新的DataFeeds类,类名为 PsqlDatabase:
import
 datetime
as
 dt

import
 backtrader
as
 bt

from
 backtrader
import
 DataBase, date2num


classPsqlDatabase(DataBase):
'''

    默认数据库表格字段如下:

            ticker char(5),

            date date,

            high numeric(10,4),

            low numeric(10,4),

            open numeric(10,4),

            close numeric(10,4),

            volume integer,

            unique (ticker, date)

    '''

    params = (

# 数据库连接信息
        (
'user'
,
None
),

        (
'password'
,
None
),

        (
'host'
,
None
),

        (
'port'
,
None
),

        (
'dbname'
,
None
),

        (
'table'
,
None
),

# 证券信息
        (
'ticker'
,
None
),
# 要提取的证券代码
        (
'fromdate'
,
None
),
# 提取数据的起始时间(包含)
        (
'todate'
,
None
),
# 提取数据的截止时间(包含)
# 每条线对应的提取出来的数据的列索引
        (
'datetime'
,
0
),

        (
'high'
,
1
),

        (
'low'
,
2
),

        (
'open'
,
3
),

        (
'close'
,
4
),

        (
'volume'
,
5
),

        (
'openinterest'
,
-1
),
# -1 表示不存在该列数据
    )


defstart(self):
        conn = self._connect_db()

        query = (
"""SELECT date, high, low, open, close, volume """
"""FROM {table} """
"""WHERE ticker = '{ticker}' """
                 .format(table=self.p.table,

                         ticker=self.p.ticker))

if
 self.p.fromdate
isnotNone
:

            query +=
" AND date >= '{fromdate}' "
.format(fromdate=dt.datetime.strftime(self.p.fromdate,
'%Y-%m-%d'
))

if
 self.p.todate
isnotNone
:

            query +=
" AND date <= '{todate}' "
.format(todate=dt.datetime.strftime(self.p.fromdate,
'%Y-%m-%d'
))

        query +=
"""ORDER BY date asc"""

        self.result = conn.execute(query)

        self.price_rows = self.result.fetchall()

        self.result.close()

        self.price_i =
0
        super(PsqlDatabase, self).start()


def_load(self):
if
 self.price_i >= len(self.price_rows):

returnFalse
# 每循环一次_load(),填充一个 bar 的数据
        row = self.price_rows[self.price_i]

        self.price_i +=
1
for
 datafield
in
 self.getlinealiases():
# 查看 Data Feeds 包含哪些线
if
 datafield ==
'datetime'
:

                self.lines.datetime[
0
] = date2num(row[self.p.datetime])

elif
 datafield ==
'volume'
:

                self.lines.volume[
0
] = row[self.p.volume]

else
:

                colidx = getattr(self.params, datafield)
# 获取列索引
if
 colidx <
0
:
# 列索引小于0,表示不存在该列
continue
                line = getattr(self.lines, datafield)
# 将数据赋值给对应的线
                line[
0
] = float(row[colidx])

returnTrue

# 设置数据库连接逻辑
def_connect_db(self):
from
 sqlalchemy
import
 create_engine

        url =
'mysql+mysqldb://{user}:{password}@{host}:{port}/{dbname}'
.format(user=self.p.user,

                                                                         password=self.p.password,

                                                                         host=self.p.host,

                                                                         port = self.p.port,

                                                                         dbname=self.p.dbname)

        engine = create_engine(url, echo=
False
)

        conn = engine.connect()

return
 conn


defpreload(self):
# 负责循环调用load()(_load()是被 load() 调用的)
        super(PsqlDatabase, self).preload()

# self.price_rows 的数据都存入lines后,清除 self.price_rows 中的数据,释放资源
        self.price_rows =
None

cerebro = bt.Cerebro()

# 调用 MysqlData 类,得到实例
data = PsqlDatabase(user=
'xxxxx'
,

                    password=
'xxxx'
,

                    host=
'xxx'
,

                    port=
'xxxx'
,

                    dbname=
'xxxx'
,

                    table=
'xxxx'
,

                    ticker=
'xxxxx'
,

                    fromdate=
'xxxxx'
,

                    todate=
'xxxxx'
)

cerebro.adddata(data, name=
'xxxx'
)
# 将数据传给大脑
  • params 属性对应的是加载数据时涉及的各种参数,主要是新增了一部分和数据库有关的信息,7 条基础 lines 的索引需要与 sql 语句中字段的顺序相一致;
  • start() 方法用于启动数据加载,连接数据库、从数据库中读取数据等操作逻辑会写在该方法中;
  • stop() 方法用于关闭数据加载,断开数据库连接的操作逻辑可以写在该方法中(上例未涉及stop());
  • _load() 方法负责将加载的数据,一个个赋值给 7 条基础 lines,直到所有数据都已填充进 lines 为止(返回 False);
  • preload() 方法负责不断的循环调用 load()(_load()是被 load() 调用的)直到下载完所有数据;
  • 上面这些方法都是底层 DataBase 类中的方法,想要具体了解可以看底层代码 backtrader/feed.py at master · mementum/backtrader (github.com);
  • 上面这个案例参考的 Github 中的 PSQL feed implementation by dolanwill · Pull Request #393 · mementum/backtrader (github.com),以及 Backtrader 社区中的讨论 SQLite example | Backtrader Community;
  • Backtrader 的 DataFeeds 数据模块提供的 InfluxDB 类也是类似的实现逻辑:backtrader/influxfeed.py at master · mementum/backtrader (github.com);
  • 如果想连接不同的数据库,只需修改数据库连接方法 _connect_db()、start() 中的查询语句等逻辑即可。
2、出现 AttributeError: 'int' object has no attribute 'to_pydatetime' 报错?
大家在用PandasData往大脑cerebro中adddata基础行情数据时,如果遇到AttributeError: 'int' object has no attribute 'to_pydatetime' 报错,是因为:没有将 datetime 设置为 index, 或者是没有指定 datetime 所在的列。
.
..

    params = (

# Possible values for datetime (must always be present)
# None : datetime is the "index" in the Pandas Dataframe
# -1 : autodetect position or case-wise equal name
# >= 0 : numeric index to the colum in the pandas dataframe
# string : column name (as index) in the pandas dataframe
        (
'datetime'
,
None
),

...


# PandasData 默认是将 DataFrame 的索引作为 datetime
# 如果你已经将 datetime 设置为 index ,可以直接用下面的语句导入数据:
data = bt.feeds.PandasData(dataname=price)

# 如果 datetime 只是 DataFrame 中的一列,且列名称也一致(不区分大小写),则需要设置参数:
data = bt.feeds.PandasData(dataname=price, datetime=
-1
)

# 或是指定 datetime 在第几列,比如在 DataFrame 的第 7 列,则令 datetime=6
data = bt.feeds.PandasData(dataname=price, datetime=
6
)
3、出现create_full_tear_sheet() got an unexpected keyword argument 'gross_lev' 报错?
在回测完成后,我们可以借助Backtrader的策略分析器模块analyzer返回诸多的策略收益评价指标,而且Backtrader还集成了Quantoption的Pyfolio模块。Backtrader中的PyFolio分析器是由TimeReturn、PositionsValue、Transactions、GrossLeverage4个子分析器构成的,PyFolio分析器会一次性返回上述4个自分析器的计算结果,分析结果的可视化展示还是通过调用Quantoption的Pyfolio模块来实现:
...

# 添加 PyFolio 分析器
cerebro.addanalyzer(bt.analyzers.PyFolio, _name=
'pyfolio'
)

...

results = cerebro.run()

strat = results[
0
]

# 一次性获取 4 个子分析器的计算结果
pyfoliozer = strat.analyzers.getbyname(
'pyfolio'
)

returns, positions, transactions, gross_lev = pyfoliozer.get_pf_items()

...

...

# 利用 Quantoption 的 Pyfolio 模块来绘制图形
# 需要提前安装好该模块 pip install pyfolio==0.5.1
import
 pyfolio
as
 pf

pf.create_full_tear_sheet(

    returns,

    positions=positions,

    transactions=transactions,

    gross_lev=gross_lev,

    live_start_date=
'2005-05-01'
,
# This date is sample specific
    round_trips=
True
)
如果出现 create_full_tear_sheet() got an unexpected keyword argument 'gross_lev' 报错,是因为后期版本更新后的 create_full_tear_sheet 不再支持 gross_lev 这个参数,官方文档给出的解释如下:
As of (at least) 2017-07-25 the pyfolio APIs have changed and create_full_tear_sheet no longer has a gross_lev as a named argument.
所以在使用 create_full_tear_sheet 事,不要设置 gross_lev 参数,以及令 round_trips 为 False:
import
 pyfolio
as
 pf

fig = pf.create_full_tear_sheet(

            returns,

            positions=positions,

            transactions=transactions,

# gross_lev=gross_lev,
            live_start_date=
'2020-05-01'
,

            round_trips=
False
,

            return_fig =
True# 后期用于存储
            )


# fig.savefig('returns_tear_sheet.pdf')
如果遇到新的报错:AttributeError: ‘numpy.int64’ object has no attribute ‘to_pydatetime’,建议卸载 pyfolio 重新从 git 上拉代码安装:
pip uninstall pyfolio

pip install git+https://github.com/quantopian/pyfolio
4、如何添加业绩基准Benchmark?
Backtrader中与业绩基准相关的操作主要有 2 种方式:
  • 一种是通过 bt.analyzers.TimeReturn 返回业绩基准的收益率,在此之前,需要确保已经将业绩基准的行情数据adddata给大脑,还要给 bt.analyzers.TimeReturn 指定 data 参数;
  • 另一种是通过 bt.observers.Benchmark 添加业绩基准的观测器,plot绘图时展示的收益率曲线就是 bt.analyzers.TimeReturn 返回的收益率。
# 实例化大脑
cerebro = bt.Cerebro()

# 初始资金 1,000,000
cerebro.broker.setcash(
1000000.0
)

# 读取行情数据
daily_price = pd.read_csv(
"./data/daily_price.csv"
, parse_dates=[
'datetime'
])

stock_price = daily_price.query(
f"sec_code=='600718.SH'"
).set_index(
'datetime'
)

datafeed1 = bt.feeds.PandasData(dataname=stock_price,

                                fromdate=pd.to_datetime(
'2019-01-02'
),

                                todate=pd.to_datetime(
'2021-01-28'
))

cerebro.adddata(datafeed1, name=
'600718.SH'
)

benchmark_price = daily_price.query(
f"sec_code=='600728.SH'"
).set_index(
'datetime'
)

datafeed2 = bt.feeds.PandasData(dataname=benchmark_price,

                                fromdate=pd.to_datetime(
'2019-01-02'
),

                                todate=pd.to_datetime(
'2021-01-28'
),

                                )

cerebro.adddata(datafeed2, name=
'600728.SH'
)


# 将编写的策略添加给大脑,别忘了 !
cerebro.addstrategy(TestStrategy)

cerebro.addanalyzer(bt.analyzers.TimeReturn,_name=
'stock_returns'
)

# 返回 benchmark 的收益率
cerebro.addanalyzer(bt.analyzers.TimeReturn, data=datafeed2, _name=
'benchmark_returns'
)

# 添加业绩基准的观测器
cerebro.addobserver(bt.observers.Benchmark, data=datafeed2)

cerebro.addobserver(bt.observers.TimeReturn)

result = cerebro.run()

cerebro.plot(iplot=
True
)
相关参考:https://www.backtrader.com/blog/posts/2016-07-22-benchmarking/benchmarking/
5、如何设置非整数型的成交数量?
Backtrader在撮合成交订单时,订单上的购买数量都是算的整数,但是像比特币这类加密货币的交易是会出现小数的成交数量的,比如交易 0.5 个比特币,那如何设置非整型的成交数量呢?只需通过继承 bt.CommissionInfo 重新定义获取成交量 getsize 即可:
classCommInfoFractional(bt.CommissionInfo):
defgetsize(self, price, cash):
'''Returns fractional size for cash operation @price'''
return
 self.p.leverage * (cash / price)


# 然后通过 addcommissioninfo 将设置传递给 broker
cerebro.broker.addcommissioninfo(CommInfoFractional())
默认情况下的 getsize 的定义如下所示,其实只需将取整相关的逻辑(int、整除)删除即可:
# 默认情况下的 getsize 的定义如下,只需
defgetsize(self, price, cash):
'''Returns the needed size to meet a cash operation at a given price'''
ifnot
 self._stocklike:

return
 int(self.p.leverage * (cash // self.get_margin(price)))


return
 int(self.p.leverage * (cash // price)
相关参考:https://www.backtrader.com/blog/posts/2019-08-29-fractional-sizes/fractional-sizes/
6、Backtrader 如何处理股票拆分合并、分红配股的情况?
当股票发生拆分合并或是分红配股时,股票价格会发生较大的变动,使得当前价格变得不连续而出现断层现象,为了保持价格的连续性,都会对价格做复权处理。
回测时遇到上述情况,最符合现实的操作是:交易时仍用真实价格(不复权)作为委托价进行下单,计算交易数量;但在计算涨跌或收益时,会考虑股价的连续性(使用复权后的价格),防止价格断层扭曲真实收益。
目前Backtrader还无法处理股票拆分合并、分红配股带来的影响,但常规的处理方式是在导入行情数据时,就直接导入复权后的行情数据(一般选择后复权),保证收益的准确性。
结语
至此,本次
Backtrader系列已全部更新完毕。

QIML会在今后的日子里,在全网发布一系列好用、实用、你绝对爱不释手的量化开源工具包!
公众号希望给国内量化投资圈贡献一份自己的力量!
希望影响更多人了解量化、学习量化、找到属于一条属于自己的路!
如果你希望我们分享些什么,也欢迎在评论区留言~

继续阅读
阅读原文