前言
不管是刷短视频,还是各种基金科普,「定投」这个策略经常会被推荐。但是本着科学的怀疑(总有奸臣想要谋害朕)精神,作为一个追求实事求是的码农,必须用数据说话。
我们就以「上证指数」为基础,用 Backtrader 来回测一下「定投策略」是否真的能赚钱。另外,估计大家更关心数据和结论,所以为了提升用户体验,代码和数据详情将会放到文末的【附录】中,请自行取用。
如果想更细致的研究代码,推荐先阅读《【Backtrader】统计上证指数历年收益率》一文,会有一些工具代码的详细说明。
勘误:本文的定投策略测试的是定量定投,即每次买都是 100 股。更多的是定价定投,即每次用 10000 元来买入,后面可能会再测一下这种策略。
数据与分析
单年定投
规则如下:
- 不考虑佣金,货币时间价值等因素;
- 以年为单位,分别跑出:上证指数涨幅和周期分别为 5、10、15、20、25、30 天的定投策略的收益率,单位
%
; - 为了突出对比,绿色越深表示在同组中表现越好,红色越深表示在同组中表现越差;
初步浏览全局,我们大概能够得到以下结论:
- 大盘下跌的年份,定投可以减少损失,减少幅度大概在 40%~50%;
- 大盘上涨的年份,定投会让收益减少约 40%~80%;
- 近 10 年,要么是大盘表现最好,要么是 15 日定投策略表现最好;
接下来我们选择一些典型的场景来看下走势图。
扭亏为盈 - 2022,2016,2010,2005,1994
走势图
2022
-10.07 vs 1.02(15)
2016
-12.24 vs 3.83(10)
2010
-16.12 vs -0.70(25)
2005
-7.91 vs 1.38(30)
1994
-22.66 vs -0.28(20)
小结
- 数量:5,15.63%;
- 近十年数量:2,占比 20%;
可以看出这几个行情有明显的 V 字形走势,仔细想一下也不难明白这个道理。先下行,定投就可以不断摊薄单位成本,再上行自然就能获得收益。不过关键点还是在于前期下行那段时间的成本摊薄。
损失锐减 - 2018,2008
走势图
2018
-24.75 vs -13.94(15)
2008
-65.19 vs -36.83(25)
小结
- 数量: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)
2020
11.35 vs 10.14(25)
2017
6.50 vs 1.92(5)
2014
49.89 vs 44.36(30)
2013
-8.39 vs -3.73(15)
2012
0.96 vs 1.30(30)
2011
-22.15 vs -16.71(15)
2004
-14.67 vs -13.56(20)
2002
-16.86 vs -12.28(25)
2001
-21.07 vs -15.21(25)
1998
-4.39 vs -9.09(25)
1995
-12.93 vs -15.05(20)
小结
- 数量:12,占比 37.5%;
- 近十年数量:5,占比 50%;
大体来看这些图形基本属于横盘活动的情况,也比较符合直觉。但是下面还有一些明显下行的行情,效果也差不多,这是怎么回事呢?
实际上再仔细观察一下,这几年定投摊薄成本的效果还是有的,只是效果没那么明显而已。所以如果严格来说,再细分一点的话,这几个明显下行的还可以分成一类。
另外,值得注意的是近十年有 4 年是这种震荡行情,占比不少,历史上的占比也不少,这也比较符合直觉。
收益锐减 - 2019,2009,2007,2006,2003,2000,1996,1992,1991
走势图
2019
21.70 vs 4.13(15)
2009
76.45 vs 17.86(5)
2007
92.86 vs 24.70(25)
2006
129.88 vs 66.05(10)
2003
12.06 vs 3.52(25)
2000
51.49 vs 10.18(5)
1996
69.78 vs 24.78(15)
1992
161.29 vs 17.90(15)
1991
128.59 vs 83.04(15)
小结
- 数量:9,占比 28.13%;
- 近十年数量:1,占比 10%;
从图形来看,基本上都是大涨之后开始横盘,同样也符合认知。这种情况近十年只出现了 1 次,但历史上出现的次数不少,且主要集中在 2000~2010 这十年。所以如果这段时间你选择了定投……
扭赢为亏 - 2015,1999,1997,1993
注:取定投最差的测略
走势图
2015
9.64 vs -5.81(25)
1999
19.36 vs -1.63(10)
1997
29.22 vs -0.84(15)
1993
5.37 vs -21.15(25)
小结
- 数量:4,占比 12.5%;
- 近十年数量:1,占比 10%;
基本就是倒 V 字走势,先涨后跌,其中就有 2015 年的股灾。好在占比不算高,但是破坏性很大。结合人性之类的去考虑,那基本上就是一幕幕悲剧的上演。
多年定投
看了单年的数据,我们再来看下从 2015 年起,不同年份,一直定投到现在,收益率是什么样的。
- 横轴:上证指数或定投周期
- 纵轴:定投开始年份,从每年的 1.1 日算起
- 数据:持有至现在的收益率,单位「%」
我们可以得到几个结论:
- 如果时间拉得足够长,定投周期对收益率的影响实际上微乎其微;
- 从今年的数据来看,定投有较大优势,可以有效避免损失,但是今年才过了 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 年收益率
数据
- 横轴:上证指数或定投周期
- 纵轴:年度
- 数据:收益率,单位「%」
SSE | 5 Days | 10 Days | 15 Days | 20 Days | 25 Days | 30 Days | |
---|---|---|---|---|---|---|---|
2022 | -10.07 | -0.47 | 0.28 | 1.02 | 0.81 | 0.83 | -0.30 |
2021 | 4.16 | 2.48 | 2.72 | 2.10 | 2.50 | 2.83 | 2.03 |
2020 | 11.35 | 9.54 | 8.78 | 8.27 | 8.66 | 10.14 | 7.66 |
2019 | 21.70 | 3.87 | 4.11 | 4.13 | 3.68 | 3.75 | 3.39 |
2018 | -24.75 | -15.22 | -14.45 | -13.94 | -15.06 | -15.20 | -14.06 |
2017 | 6.50 | 1.92 | 1.76 | 1.58 | 1.30 | 1.57 | 1.26 |
2016 | -12.24 | 3.57 | 3.83 | 3.39 | 3.63 | 3.46 | 2.83 |
2015 | 9.64 | -3.84 | -3.75 | -4.53 | -2.69 | -5.81 | -3.31 |
2014 | 49.89 | 42.42 | 41.16 | 41.09 | 41.98 | 41.34 | 44.36 |
2013 | -8.39 | -4.25 | -4.01 | -3.73 | -4.88 | -4.52 | -4.50 |
2012 | 0.96 | 0.54 | 0.46 | 0.48 | 0.77 | 0.63 | 1.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 |
2009 | 76.45 | 17.86 | 17.49 | 15.94 | 16.53 | 15.74 | 17.66 |
2008 | -65.19 | -39.47 | -38.61 | -37.37 | -38.53 | -36.83 | -37.83 |
2007 | 92.86 | 24.48 | 23.52 | 22.00 | 22.13 | 24.70 | 22.98 |
2006 | 129.88 | 63.81 | 66.05 | 60.02 | 64.50 | 61.96 | 63.34 |
2005 | -7.91 | 0.64 | 1.07 | 1.00 | 0.90 | 1.29 | 1.38 |
2004 | -14.67 | -14.12 | -13.91 | -13.93 | -13.56 | -14.11 | -15.15 |
2003 | 12.06 | 2.82 | 2.81 | 2.69 | 2.60 | 3.52 | 2.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 |
2000 | 51.49 | 10.18 | 9.95 | 10.03 | 8.67 | 7.37 | 7.45 |
1999 | 19.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 |
1997 | 29.22 | 0.16 | -0.19 | -0.84 | 0.47 | 0.11 | 0.78 |
1996 | 69.78 | 21.28 | 22.21 | 24.78 | 18.90 | 18.23 | 24.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 |
1993 | 5.37 | -19.16 | -18.54 | -20.48 | -17.49 | -21.15 | -17.79 |
1992 | 161.29 | 16.56 | 11.49 | 17.90 | 10.40 | 13.54 | 17.26 |
1991 | 128.59 | 79.48 | 76.83 | 83.04 | 74.45 | 82.52 | 73.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
SSE | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 |
---|---|---|---|---|---|---|---|---|
2015 | 9.64 | |||||||
2016 | -4.76 | -12.24 | ||||||
2017 | 1.49 | -6.49 | 6.50 | |||||
2018 | -23.47 | -29.48 | -19.69 | -24.75 | ||||
2019 | -6.71 | -14.04 | -2.10 | -8.27 | 21.70 | |||
2020 | 4.78 | -3.45 | 9.96 | 3.03 | 36.69 | 11.35 | ||
2021 | 11.06 | 2.34 | 16.55 | 9.21 | 44.89 | 18.03 | 4.16 | |
2022 | 0.71 | -7.21 | 5.68 | -0.98 | 31.38 | 7.02 | -5.55 | -10.07 |
5 Days
5 Days | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 |
---|---|---|---|---|---|---|---|---|
2015 | -3.84 | |||||||
2016 | -7.41 | 3.57 | ||||||
2017 | -0.27 | 5.90 | 1.92 | |||||
2018 | -22.64 | -18.61 | -19.29 | -15.22 | ||||
2019 | -3.83 | 0.46 | 0.18 | 3.84 | 3.87 | |||
2020 | 8.26 | 12.13 | 11.65 | 14.18 | 12.57 | 9.54 | ||
2021 | 12.80 | 15.75 | 14.73 | 15.73 | 13.06 | 8.78 | 2.48 | |
2022 | 2.09 | 4.47 | 3.49 | 4.25 | 2.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…