用Backtrader测试A股单资产

量化交易的开源回测框架中, Zipline 应该是最流行的,国内也有不少模仿Zipline 的回测框架存在,甚至可以说国内开源或商业化回测平台,几乎都是模仿Zipline和Quantopian。Zipline模式似乎成了业界标杆,但是对于普通开发者入手难度并不低。数据注入(Ingest),data bundle管理异常麻烦,对比各个开源回测工具,我发现 Backtrader 非常灵活,上手难度也很低,也能满足大部分普通回测场景的需求。这篇博客,介绍我使用本地数据库,利用Backtrader进行单资产回测的经验。

均线交叉策略回测单个资产

均线交叉 是一种非常常见的交易策略,在量化和非量化交易中应用广泛。这里我选择的长周期均线是50日MA,短周期均线是10日MA,当10日均线上穿50日均线买入,10日均线下穿50日均线则卖出。回测对象是2016年1月1日到本周五(2020年4月3日)的 万科(000002) ,数据来源是本地数据库中存储的日行情表。

准备

python3, 自2020年1月,PSF已经停止维护python3以下的版本,你必须要在本地安装Python3.6或以上版本。当然也可选择安装最新版的annaconda发行版。确认安装好Python后,在命令行执行python可以进入交互式命令行环境,运行 pip 确认安装成功。

安装软件包, pip install backtrader[plotting] sqlalchemy psycopg2 安装backtrader到你的开发环境。sqlalchemy用于数据库操作,psycopg2是postgresql的Python连接驱动。

logging, 代码中使用Python标准款logging来打印日志,需要了解Python中如何 配置logging

使用本地数据库

相比Zipline,Backtrader可以轻易地集成任何数据格式,这里我使用Postgresql数据库中存储的日行情表作为数据源提取数据,数据格式如下表:

日行情表数据格式
date symbol open high low close volume
2020-04-03 000002 26.75 27.18 26.60 26.77 56945280
2020-04-02 000002 26.50 26.94 26.35 26.88 663097380
2020-04-01 000002 26.45 26.96 26.25 26.63 107433164

参考 文档 ,编写自定义的的DataFeed,DataFeed执行SQL从本地数据库抽取日行情,策略代码将使用这个创建的DataFeed来获取数据。

下面是我们的代码

import logging
import datetime
from typing import Iterator, List, Tuple

import backtrader as bt
from sqlalchemy.engine import Engine, create_engine

log = logging.getLogger(__name__)


class DBDataFeed(bt.feed.DataBase):
    symbol: str
    historical_eod: Iterator
    engine: Engine = None

    params = (
        ("dataname", None),
        ("fromdate", datetime.date(2000, 1, 1)),
        ("todate", datetime.date(2050, 1, 1)),
        ("compression", 1),
        ("timeframe", bt.TimeFrame.Days),
        ("symbol", None),
    )

    def __init__(self, db_uri, *args, **kwargs):
        # db_uri sqlalchemy文档:
        #   https://docs.sqlalchemy.org/en/13/core/engines.html
        super(*args, **kwargs)
        self.db_uri = db_uri

        self.symbol = self.p.dataname

    def start(self):
        super().start()

        if not self.engine:
            log.info("initialize db connection: {}".format(self.db_uri))
            self.__class__.engine = create_engine(self.db_uri)

        # 从数据库load准备数据
        sql = """
            SELECT "date" AS DATETIME
                , "open"
                , high
                , low
                , "close"
                , volume
            FROM cn.historical_daily
            WHERE symbol = '{}'
                AND "open" > 0
                AND high > 0
                AND low > 0
                AND "close" > 0
                AND volume > 0
                AND "date" >= %s
                AND "date" <= %s
            """.format(self.symbol)
        rp = self.engine.execute(sql, (self.p.fromdate, self.p.todate))
        historical_eod = list(rp.fetchall())
        historical_eod.sort(key=lambda x: x.datetime)
        self.historical_eod = iter(historical_eod)

        log.info(
            "load {} rows for {}".format(len(historical_eod), self.symbol)
        )

    def stop(self):
        pass

    def _load(self):
        # 实现 datafeed 的iterator接口函数
        try:
            row = next(self.historical_eod)
        except StopIteration:
            return False

        self.lines.datetime[0] = bt.date2num(row.datetime)
        self.lines.open[0] = float(row.open)
        self.lines.high[0] = float(row.high)
        self.lines.low[0] = float(row.low)
        self.lines.close[0] = float(row.close)
        self.lines.volume[0] = float(row.volume)
        self.lines.openinterest[0] = 0

        return True

编写策略代码

策略代码本身很简单,但需要先了解几个概念:

Broker,Broker中文含义是经纪人,通常代指我们的券商。回测是模拟交易,需要有一个执行交易的模拟Broker,代码中没有指定Broker,默认使用 backtrader.brokers.BackBroker ,调用 broker.buybroker.sell 时会使用下一个交易日的 open price 成交。

Sizer,回测过程中,如果执行买卖操作时没有指定买卖目标数量,Sizer会用于计算买卖数量。代码中使用 AllInSizerInt ,表示若没有买卖目标数量,那么执行全仓买入或卖出。

Analyzer,对回测进行分析非常重要,Backtrader提供多种分析器,代码中使用的分析器是 1)年度收益统计,2)回撤分析,3)交易记录表。

策略回测代码

import datetime

class SMACross(bt.Strategy):
    params = dict(
        sma_lower=10,  # period for lower SMA
        sma_higher=50,  # period for higher SMA
    )

    def __init__(self):
        # 10日SMA计算
        sma1 = bt.ind.SMA(period=self.p.sma_lower)
        # 50日SMA计算
        sma2 = bt.ind.SMA(period=self.p.sma_higher)

        # 均线交叉, 1是上穿,-1是下穿
        self.crossover = bt.ind.CrossOver(sma1, sma2)

    def next(self):
        close = self.data.close[0]
        date = self.data.datetime.date(0)
        if not self.position:
            if self.crossover > 0:
                log.info("buy created at {} - {}".format(date, close))
                self.buy()

        elif self.crossover < 0:
            log.info("sell created at {} - {}".format(date, close))
            self.close()


if __name__ == "__main__":
    cerebro = bt.Cerebro()

    data = DBDataFeed(
        # 本地postgresql数据库
        db_uri="postgresql://user:password@localhost:5432/dbname",
        dataname="000002",
        fromdate=datetime.date(2016, 1, 1),
    )
    cerebro.adddata(data)

    cerebro.addstrategy(SMACross)
    cerebro.addsizer(bt.sizers.AllInSizerInt)
    cerebro.broker.set_cash(100000)

    cerebro.addanalyzer(bt.analyzers.AnnualReturn, _name="annual_returns")
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name="draw_down")
    cerebro.addanalyzer(bt.analyzers.Transactions, _name="transactions")

    results = cerebro.run()

    # 打印Analyzer结果到日志
    for result in results:

        annual_returns = result.analyzers.annual_returns.get_analysis()
        log.info("annual returns:")
        for year, ret in annual_returns.items():
            log.info("\t {} {}%, ".format(year, round(ret * 100, 2)))

        draw_down = result.analyzers.draw_down.get_analysis()
        log.info(
            "drawdown={drawdown}%, moneydown={moneydown}, drawdown len={len}, "
            "max.drawdown={max.drawdown}, max.moneydown={max.moneydown}, "
            "max.len={max.len}".format(**draw_down)
        )

        transactions = result.analyzers.transactions.get_analysis()
        log.info("transactions")

    # 运行结果绘图
    cerebro.plot()

运行Python代码

年度收益统计
Year Return
2016 0.0%
2017 25.7%
2018 1.7%
2019 11.1%
2020 -29.8%

策略运行结果plotting图

000002 plotting

使用backtrader_plotting

Backtrader自带的plotting图形阅读效果和稳定性都较差,建议使用社区开发的基于web的plotting插件: backtrader_plotting 方便读图和分析。

pip install backtrader_plotting 安装插件。

修改代码,在代码文件顶部引入backtrader_plotting

from backtrader_plotting import Bokeh
from backtrader_plotting.schemes import Tradimo

修改代码最后一行代码,使用backtrader_plotting替换自带默认plotting

# cerebro.plot()
b = Bokeh(style="bar", tabs="multi", scheme=Tradimo())
cerebro.plot(b)

再次运行回测代码,查看效果

000002 backtrader_plotting 000002 backtrader_plotting

总结

Backtrader单资产测试的代码量少,可读性高,扩展性高。配合backtrader_plotting,应该能满足大部分开发需求。后面还要思考的是:

1)如何处理股份拆并和配送,以及现金分红

2)如何进行多资产回测

3)如何对回测数据进行更多维度的分析

4)如何对接实盘交易

Creative Commons License
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.