【Backtrader】专治割韭菜(一)——定投靠谱吗

1,396 阅读9分钟

前言

不管是刷短视频,还是各种基金科普,「定投」这个策略经常会被推荐。但是本着科学的怀疑(总有奸臣想要谋害朕)精神,作为一个追求实事求是的码农,必须用数据说话。

我们就以「上证指数」为基础,用 Backtrader 来回测一下「定投策略」是否真的能赚钱。另外,估计大家更关心数据和结论,所以为了提升用户体验,代码和数据详情将会放到文末的【附录】中,请自行取用。

如果想更细致的研究代码,推荐先阅读《【Backtrader】统计上证指数历年收益率》一文,会有一些工具代码的详细说明。

勘误本文的定投策略测试的是定量定投,即每次买都是 100 股。更多的是定价定投,即每次用 10000 元来买入,后面可能会再测一下这种策略。

数据与分析

单年定投

规则如下:

  1. 不考虑佣金,货币时间价值等因素;
  2. 以年为单位,分别跑出:上证指数涨幅和周期分别为 5、10、15、20、25、30 天的定投策略的收益率,单位 %
  3. 为了突出对比,绿色越深表示在同组中表现越好红色越深表示在同组中表现越差

image.png

初步浏览全局,我们大概能够得到以下结论:

  1. 大盘下跌的年份,定投可以减少损失,减少幅度大概在 40%~50%;
  2. 大盘上涨的年份,定投会让收益减少约 40%~80%;
  3. 近 10 年,要么是大盘表现最好,要么是 15 日定投策略表现最好;

接下来我们选择一些典型的场景来看下走势图。

扭亏为盈 - 2022,2016,2010,2005,1994

走势图

2022 -10.07 vs 1.02(15) image.png 2016 -12.24 vs 3.83(10) image.png 2010 -16.12 vs -0.70(25) image.png 2005 -7.91 vs 1.38(30) image.png 1994 -22.66 vs -0.28(20) image.png

小结

  • 数量:5,15.63%;
  • 近十年数量:2,占比 20%;

可以看出这几个行情有明显的 V 字形走势,仔细想一下也不难明白这个道理。先下行,定投就可以不断摊薄单位成本,再上行自然就能获得收益。不过关键点还是在于前期下行那段时间的成本摊薄。

损失锐减 - 2018,2008

走势图

2018 -24.75 vs -13.94(15) image.png 2008 -65.19 vs -36.83(25) image.png

小结

  • 数量:2,占比 6.25%;
  • 近十年数量:1,占比 10%; 毫无意外的,持续下跌的走势定投策略可以将损失减少 50% 左右。不过这种走势占比不算大,毕竟持续下跌就意味着经济下行,这种情况是谁都不愿意看到的,大家懂得都懂。

效果持平 - 2021,2020,2017,2014,2013,2012,2011,2004,2002,2001,1998,1995

走势图

2021 4.16 vs 2.83(25) image.png 2020 11.35 vs 10.14(25) image.png 2017 6.50 vs 1.92(5) image.png 2014 49.89 vs 44.36(30) image.png 2013 -8.39 vs -3.73(15) image.png 2012 0.96 vs 1.30(30) image.png 2011 -22.15 vs -16.71(15) image.png 2004 -14.67 vs -13.56(20) image.png 2002 -16.86 vs -12.28(25) image.png 2001 -21.07 vs -15.21(25) image.png 1998 -4.39 vs -9.09(25) image.png 1995 -12.93 vs -15.05(20) image.png

小结

  • 数量:12,占比 37.5%;
  • 近十年数量:5,占比 50%;

大体来看这些图形基本属于横盘活动的情况,也比较符合直觉。但是下面还有一些明显下行的行情,效果也差不多,这是怎么回事呢?

实际上再仔细观察一下,这几年定投摊薄成本的效果还是有的,只是效果没那么明显而已。所以如果严格来说,再细分一点的话,这几个明显下行的还可以分成一类。

另外,值得注意的是近十年有 4 年是这种震荡行情,占比不少,历史上的占比也不少,这也比较符合直觉。

收益锐减 - 2019,2009,2007,2006,2003,2000,1996,1992,1991

走势图

2019 21.70 vs 4.13(15) image.png 2009 76.45 vs 17.86(5) image.png 2007 92.86 vs 24.70(25) image.png 2006 129.88 vs 66.05(10) image.png 2003 12.06 vs 3.52(25) image.png 2000 51.49 vs 10.18(5) image.png 1996 69.78 vs 24.78(15) image.png 1992 161.29 vs 17.90(15) image.png 1991 128.59 vs 83.04(15) image.png

小结

  • 数量:9,占比 28.13%;
  • 近十年数量:1,占比 10%;

从图形来看,基本上都是大涨之后开始横盘,同样也符合认知。这种情况近十年只出现了 1 次,但历史上出现的次数不少,且主要集中在 2000~2010 这十年。所以如果这段时间你选择了定投……

扭赢为亏 - 2015,1999,1997,1993

注:取定投最差的测略

走势图

2015 9.64 vs -5.81(25) image.png 1999 19.36 vs -1.63(10) image.png 1997 29.22 vs -0.84(15) image.png 1993 5.37 vs -21.15(25) image.png

小结

  • 数量:4,占比 12.5%;
  • 近十年数量:1,占比 10%;

基本就是倒 V 字走势,先涨后跌,其中就有 2015 年的股灾。好在占比不算高,但是破坏性很大。结合人性之类的去考虑,那基本上就是一幕幕悲剧的上演。

多年定投

看了单年的数据,我们再来看下从 2015 年起,不同年份,一直定投到现在,收益率是什么样的。

  • 横轴:上证指数或定投周期
  • 纵轴:定投开始年份,从每年的 1.1 日算起
  • 数据:持有至现在的收益率,单位「%」

image.png

image.png

我们可以得到几个结论:

  • 如果时间拉得足够长,定投周期对收益率的影响实际上微乎其微;
  • 今年的数据来看,定投有较大优势,可以有效避免损失,但是今年才过了 8 个月,所以仅做参考;
  • 从近 2 年数据来看,定投策略有一定的正向作用
  • 从近 5 年的数据来看,尤其是 2019 年,定投不是个好的选择
  • 从近 10 年来看,定投也不是个好选择,收益率锐减

总结

有了数据之后,心里终于有谱了,数据证明,「定投」这种策略更倾向于是一种防守策略。在大环境持续下行时,有摊薄成本的作用,可以让损失降低。但是在大环境持续变好时,他也会让收益变少。如果我们相信宏观来说大环境会一直向上的话,那么定投就只能作为短期的「建仓策略」来用。

  • 也就是说当你判断市场要见底的时候,并且能够粗略判断底部周期,那么此时可以采用定投来进行建仓;
  • 但是当市场开始长线上扬时,一定要停止定投,等待市场见顶,此时应该进行出仓操作,当然也可以采用「定投」的方式出仓;
  • 当判断市场又要见底的时候,才开始定投建仓,重复操作。

所以不要妄想什么策略能够躺赢了,以上的操作也是要建立在对大势的判断基础之上的,它只是一种让你用概率优化行为的策略而已。

不要再相信那些忽悠你一直定投的宣传了,他们(通常是基金)只是想让你不断往里面投钱而已

附录

核心代码

声明:笔者为了方便,提取了一些方法,这些方法在该篇文档中仅会以引用方式出现,但不影响代码的整体理解。所以请将本文的代码当做伪代码来看即可。

from __future__ import (absolute_import, division, print_function,
                        unicode_literals)

import datetime  # For datetime objects
import csv

# Import the backtrader platform
import backtrader as bt
from utils import (BasicStrategy, bt_run)


class TestStrategy(BasicStrategy):
    params = (
        ('fixedperiod', 5),
        ('printlog', False),
    )

    def __init__(self):
        # Keep a reference to the "close" line in the data[0] dataseries
        self.dataclose = self.datas[0].close

        # To keep track of pending orders and buy price/commission
        self.firstbaropen = self.data0.open[1]
        self.order = None
        self.bar_executed = 0
        self.cost = 0

    def notify_order(self, order):
        if order.status in [order.Submitted, order.Accepted]:
            # Buy/Sell order submitted/accepted to/by broker - Nothing to do
            return

        self.order_log(order)

        if order.status in [order.Completed]:
            if order.isbuy():
                self.cost += order.executed.value
                self.bar_executed = len(self)

        self.order = None

    def next(self):
        # Simply log the closing price of the series from the reference
        self.log('Close, %.2f' % self.dataclose[0], doprint=False)

        # Check if an order is pending ... if yes, we cannot send a 2nd one
        if self.order:
            return

        if len(self) >= (self.bar_executed + self.p.fixedperiod):
            self.log('BUY CREATE, %.2f' % self.dataclose[0])

            # Keep track of the created order to avoid a 2nd order
            self.order = self.buy()

    def stop(self):
        position_value = self.broker.getvalue() - self.broker.getcash()
        self.profit = (position_value - self.cost)*100/self.cost
        self.sse = (self.data0.close[0] -
                    self.firstbaropen) * 100/self.firstbaropen

历年持股 1 年收益率

数据

  • 横轴:上证指数或定投周期
  • 纵轴:年度
  • 数据:收益率,单位「%」
SSE5 Days10 Days15 Days20 Days25 Days30 Days
2022-10.07-0.470.281.020.810.83-0.30
20214.162.482.722.102.502.832.03
202011.359.548.788.278.6610.147.66
201921.703.874.114.133.683.753.39
2018-24.75-15.22-14.45-13.94-15.06-15.20-14.06
20176.501.921.761.581.301.571.26
2016-12.243.573.833.393.633.462.83
20159.64-3.84-3.75-4.53-2.69-5.81-3.31
201449.8942.4241.1641.0941.9841.3444.36
2013-8.39-4.25-4.01-3.73-4.88-4.52-4.50
20120.960.540.460.480.770.631.30
2011-22.15-17.62-17.07-16.71-17.63-17.24-18.38
2010-16.12-2.16-1.90-1.63-1.82-0.70-1.16
200976.4517.8617.4915.9416.5315.7417.66
2008-65.19-39.47-38.61-37.37-38.53-36.83-37.83
200792.8624.4823.5222.0022.1324.7022.98
2006129.8863.8166.0560.0264.5061.9663.34
2005-7.910.641.071.000.901.291.38
2004-14.67-14.12-13.91-13.93-13.56-14.11-15.15
200312.062.822.812.692.603.522.50
2002-16.86-12.96-12.96-13.12-12.68-12.28-12.62
2001-21.07-16.48-16.46-16.87-15.55-15.21-16.65
200051.4910.189.9510.038.677.377.45
199919.36-1.02-1.63-0.64-0.78-0.41-1.30
1998-4.39-9.24-9.15-9.78-10.01-9.09-9.99
199729.220.16-0.19-0.840.470.110.78
199669.7821.2822.2124.7818.9018.2324.40
1995-12.93-15.57-15.70-16.08-15.05-16.02-17.31
1994-22.66-3.77-2.80-0.91-0.28-3.15-5.35
19935.37-19.16-18.54-20.48-17.49-21.15-17.79
1992161.2916.5611.4917.9010.4013.5417.26
1991128.5979.4876.8383.0474.4582.5273.39

代码

此处仅为生成 csv 的代码,核心逻辑见上文。

if __name__ == '__main__':
        with open('annual.csv', 'w', newline='') as csvfile:
            csv.writer(csvfile).writerow(
                ['', 'SSE', *map(lambda x: '%s Days' % x, periods)])

        for year in range(2022, 1991, -1):
            bts = bt_run(TestStrategy, 'datas/000001-20220811.csv', datetime.datetime(year, 1, 1), datetime.datetime(year, 12, 31),
                         strategy_params={"fixedperiod": periods}, strategy_mode=1, cerebro_opt={"optreturn": False})

            with open('annual.csv', 'a+', newline='') as csvfile:
                csv.writer(csvfile).writerow(
                    [year, bts[0][0].sse, *map(lambda x: x[0].profit, bts)])

多年定投收益

数据

  • 横轴:开始持股年度,从 1.1 日算起
  • 纵轴:持股结束电镀,截止到 12.31 日
  • 数据:收益率,单位「%」

SSE

SSE20152016201720182019202020212022
20159.64
2016-4.76-12.24
20171.49-6.496.50
2018-23.47-29.48-19.69-24.75
2019-6.71-14.04-2.10-8.2721.70
20204.78-3.459.963.0336.6911.35
202111.062.3416.559.2144.8918.034.16
20220.71-7.215.68-0.9831.387.02-5.55-10.07

5 Days

5 Days20152016201720182019202020212022
2015-3.84
2016-7.413.57
2017-0.275.901.92
2018-22.64-18.61-19.29-15.22
2019-3.830.460.183.843.87
20208.2612.1311.6514.1812.579.54
202112.8015.7514.7315.7313.068.782.48
20222.094.473.494.252.02-1.25-4.48-0.47

代码

此处仅为生成 csv 的代码,核心逻辑见上文。

if __name__ == '__main__':
    periods = range(5, 31, 5)
    years = range(2015, 2023)
    sse_done = False
    sse_row = []

    with open('sse.csv', 'w', newline='') as csvfile:
        csv.writer(csvfile).writerow(
            ['', *years])

    for period in periods:
        with open(str(period)+'.csv', 'w', newline='') as csvfile:
            csv.writer(csvfile).writerow([str(period)+" Days", *years])
        if not sse_done:
            with open('sse.csv', 'w', newline='') as csvfile:
                csv.writer(csvfile).writerow(
                    ['SSE', *years])

        for end_year in years:
            row = [end_year]
            if not sse_done:
                sse_row = [end_year]

            for start_year in years:
                if start_year > end_year:
                    break
                bts = bt_run(TestStrategy, 'datas/000001-20220811.csv', datetime.datetime(
                    start_year, 1, 1), datetime.datetime(end_year, 12, 31), strategy_params={"fixedperiod": period}, cerebro_opt={"optreturn": False})
                row.append(bts[0].profit)
                if not sse_done:
                    sse_row.append(bts[0].sse)

            with open(str(period)+'.csv', 'a+', newline='') as csvfile:
                csv.writer(csvfile).writerow(row)
            if not sse_done:
                with open('sse.csv', 'a+', newline='') as csvfile:
                    csv.writer(csvfile).writerow(sse_row)

        sse_done = True

详尽代码和数据

代码

见笔者的 Gitee:gitee.com/zhaolandelo…

数据

飞书文档:dda4ylqtxi.feishu.cn/sheets/shtc…