基金定投如何选择买卖点?——关于定投的择时研究
共 10688字,需浏览 22分钟
·
2021-03-25 14:16
↑↑↑关注后"星标"简说Python 人人都可以简单入门Python、爬虫、数据分析 简说Python推荐 来源|Python读财 作者|易执
大家好,我是老表~
基金定投是近几年各种理财平台比较推崇的一种理财方式,大多数关于定投的宣传语中,会有这”六字箴言“:不择时,长期投。
所谓择时,便是选择合适的买点和卖点,那基金定投真的用不着择时么?本文就用数据来探究一下是否需要择时以及该如何进行择时。
是否需要择时?
下面上的这张图是2004年到2020年16年间,上证指数和道琼斯指数的走势对比图,大致代表了两个不同资本市场的行情走势。
从图中可以看出两大指数的走势都颇具特点,道指基本是一种长牛的走势,对于这种走势,如果进行指数基金定投是可以不择时的。
而对上证指数而言,走势呈现一种比较明显的周期性,牛短熊长,一个牛熊周期大概为7.5年,整体的重心是向上移动的。如果进行长期投资但不进行择时,虽然长周期来看也可以获得正向的收益,但却享受不了牛市带来的情绪溢价,就像坐了趟过山车,图个刺激。
所以,对基金定投而言,还是有必要进行择时操作的,在指数低估时买入,等牛市来时卖出,那么该如何进行择时呢?
择时的基本策略
定投中可以根据股指的点位或估值高低等指标进行择时操作,本文主要研究基于估值的择时操作,在估值较低时开始定投,待估值达到历史较高水平时卖出,因此如何定义估值是高还是低便是择时操作的关键。
由于沪深300指数能够较好地反映沪深两市的走势,常被设为基金业绩的比较基准。所以本文其为例进行研究,并以市盈率(PE)为估值指标,探究不同估值区间的择时效果。
具体策略:择时的指标为该指数当前的PE估值在过去7年(兼顾一轮牛熊周期)的分位数水平,若分位数水平低于定义的区间下限,开启定投周期,直到估值的分位数水平高于区间上限,将持有的份额全部卖出。卖出后,要等到估值水平再次低于区间下限,开始下一轮定投,按照这个规则持续运行。
回测的具体参数如下:
投资标的:易方达沪深 300ETF联接A(110020.OF)
回测时间范围:2013年1月1日-2020年1月1日
估值指标:市盈率(PE)
申购费率:0.15%
赎回费率:0.5%
估值区间:20%-80,30%-80%,40%-80%,20%-70%,30%-70%,40%-70%
每次定投金额:5000
频率:每周一
回测结果
具体的回测框架已经被我封装成可以直接调用的函数run_strategy
,代码部分较长,已贴到文末,大家可以直接复制使用。下面直接以图形化的方式展示回测结果。
注: 结果图中,用基金净值走势近似指数走势,黄色阴影代表定投轮此区间,蓝色阴影代表当前估值水平在过去7年的百分位数(右轴表示)。
不择时,一直定投
如不进行择时,则在这七年间每周一均进行买入,一共定投了334期,总收益率为37.71%,年化的收益率大概为4.7%,总体结果算不上理想。
20%-80%区间
设定低估区间为20%以下,高估区间为80%以上则完美的吃到了2014-2015年的这轮牛市,在指数达到顶点前于左侧卖出。期间共进行了113次定投,该轮定投的收益率达到了92.8%,但由于后续估值百分位再也没有达到20%以下,之后没有产生任何买卖行为。
30%-80%区间
将低估区间的范围扩大到30%后,定投轮次加多了一轮,几乎在2018年末最低点抄底,赶上了2019年初的小阳春行情,第二阶段的定投了52期,整体收益率为10.2%。
40%-80%区间
将低估区间进一步放宽到40%后,2013-2020年这七年间共进行了3轮定投,三次定投的期数分别为113次,88次,以及68次,三个轮次定投所产生的收益也较为可观,虽然在16年和18年四季度定投产生了较大回撤,但也把握住了后期的行情。
下面将高估区间降低到70%,看进一步的回测情况。
20%、30%、40%-70%区间
下面一起显示定投区间分别为20%-70%,30%-70%以及40%-70%区间的回测结果图
若将估值70%分位数以上标定为高估区,则相较80%更早的逃顶卖出,在牛市行情中更早落袋为安,也损失了部分潜在收益。与此同时,以70%分位数为卖出点会使得卖出频率相应地也增加了。尤其是40%-70%区间,此时的高估和低估区距离较为接近,买入和卖出更加频繁。
汇总结果
将以上所有回测结果进行整理,得到下面的统计表
表中
绝对收益比 = 各区间回测总收益/不择时总收益
年化收益率 = 期间绝对收益/(首轮定投总期数*每期定投额)
由于定投是采用增量资金进行投资,如果期间定投了多轮,其实总收益率比较难去定义,所以这里选用绝对收益和我自己定义的年化收益综合去衡量(不一定科学)
如果从绝对收益的结果来看,不择时的绝对收益是最高的,但其总共定投了334期。40%-80%估值区间的回测结果用269期定投取得了和334期定投差不多的绝对收益值,且年化收益上更是远远跑赢。如果单从本次回测结果来看,综合考虑资金利用效率等因素,采用40%-80%区间进行择时的效果最好。
但实际进行选择时应该结合自己的风险偏好,低估区间设置得越高,一方面能收集更多筹码,但同时也意味着可能会承担更大的回撤风险。高估区间设置得越低,虽然会错过潜在的收益,但也能提早落地为安。
代码
具体的代码我已封装好,有兴趣的可以自己进行其他指数的研究。根据目前Tushare支持的数据,下面回测框架可以研究沪深300指数、创业板指数、中证500指数、上证50指数等主流的宽基指数的定投策略。
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import tushare as ts
import warnings
from IPython.display import display
from tqdm import tqdm_notebook
from datetime import *
warnings.filterwarnings("ignore")
%matplotlib inline
pro = ts.pro_api()
plt.rcParams["font.sans-serif"] = ["FangSong"]
def run_strategy(index_code="",fund_code="",money_per_time=5000,
start_date=None,end_date=None,v_low = None,
v_high = None,plot=True):
fund_df = pro.fund_nav(ts_code = fund_code)
fund_df = fund_df.loc[fund_df.update_flag=='0',['ts_code','end_date','unit_nav']].rename(columns = {'end_date':'trade_date'})
fund_df = fund_df[(fund_df.trade_date>=start_date) & (fund_df.trade_date<=end_date)]
fund_df["trade_date"] = pd.to_datetime(fund_df.trade_date)
fund_df.sort_values(by="trade_date",inplace=True)
fund_df.reset_index(drop=True,inplace=True)
# tushare提取数据有限制
# 分开提取然后合并数据
index_start_date = str(int(start_date[:4])-7) + start_date[4:]
index_end_date = end_date
index_valuation_1 = pro.index_dailybasic(ts_code = "000300.SH",start_date = index_start_date,end_date = start_date,fields="trade_date,pe_ttm")
index_valuation_2 = pro.index_dailybasic(ts_code = "000300.SH",start_date = start_date,end_date = index_end_date,fields="trade_date,pe_ttm")
index_valuation = pd.concat([index_valuation_1,index_valuation_2],axis=0)
index_valuation["trade_date"] = pd.to_datetime(index_valuation.trade_date)
index_valuation.sort_values(by="trade_date",inplace=True)
index_valuation.set_index("trade_date",inplace=True)
# 回测时需要计算的指标
fund_df["units"] = np.nan
fund_df["total_units"] = np.nan
fund_df["market_value"] = np.nan
fund_df["signal"] = np.nan
fund_df["avg_unit_cost"] = np.nan
fund_df["return_rate"] = np.nan
fund_df["round"] = np.nan
fund_df["percentile"] = np.nan
round_num = 0
round_on = False
# money_per_time = 5000
# 循环进行回测
# 申购费率设置为0.15%
# 赎回费率设置为0.5%
for i in tqdm_notebook(range(len(fund_df))):
valuation_start_date = (fund_df.loc[i,"trade_date"]-pd.Timedelta(7,unit="y")).date()
valuation_end_date = fund_df.loc[i,"trade_date"]
valuation_data = index_valuation.loc[valuation_start_date:valuation_end_date].iloc[:,0].values.tolist()
valuation_high = np.percentile(valuation_data,v_high)
valuation_low = np.percentile(valuation_data,v_low)
now = valuation_data[-1]
fund_df.loc[i,"percentile"] = np.round((sorted(valuation_data).index(now)+1)/len(valuation_data),4)
weekday = valuation_end_date.dayofweek
if now<=valuation_low and round_on == False:
round_on = True
round_num += 1
if round_on:
if now < valuation_high:
if weekday==0:
fund_df.loc[i,"round"] = round_num
fund_df.loc[i,"signal"] = 1
fund_df.loc[i,"units"] =np.round((money_per_time - money_per_time * 0.0015)/fund_df.loc[i,"unit_nav"],2)
fund_df.loc[i,"total_units"] = fund_df.loc[(fund_df.signal==1).values & (fund_df["round"]==round_num).values,"units"].sum()
fund_df.loc[i,"market_value"] = fund_df.loc[i,"total_units"] * fund_df.loc[i,"unit_nav"]
fund_df.loc[i,"avg_unit_cost"] = (len(fund_df[(fund_df.signal==1).values & (fund_df["round"]==round_num).values])*money_per_time)/fund_df.loc[i,"total_units"]
fund_df.loc[i,"return_rate"] = np.round(fund_df.loc[i,"unit_nav"]/fund_df.loc[i,"avg_unit_cost"] -1,4)
else:
fund_df.loc[i,"signal"] = 0
fund_df.loc[i,"units"] = -1 * fund_df.loc[fund_df.signal==1,"total_units"].iloc[-1]
fund_df.loc[i,"total_units"] = 0
fund_df.loc[i,"market_value"] = 0
fund_df.loc[i,"avg_unit_cost"] = 0
fund_df.loc[i,"return_rate"] = np.round((fund_df.loc[i,"unit_nav"]*0.995)/fund_df.loc[fund_df.signal==1,"avg_unit_cost"].iloc[-1] -1,4)
fund_df.loc[i,"round"] = round_num
round_on = False
# 结果指标的计算
action_rounds = fund_df['round'].value_counts().index
result_list = []
for action_round in action_rounds:
round_df = fund_df.loc[fund_df["round"]==action_round].reset_index(drop=True)
is_sell = True if round_df.loc[len(round_df)-1,'signal']== 0 else False
#判断该定投轮次是否已卖出
if is_sell:
start_date = round_df.loc[0,'trade_date']
n_period = len(round_df) - 1
end_date = round_df.loc[n_period,'trade_date']
avg_unit_cost = round_df.loc[n_period-1,"avg_unit_cost"]
final_unit_nav = round_df.loc[n_period,"unit_nav"]
return_rate = round_df.loc[n_period,"return_rate"]
else:
start_date = round_df.loc[0,'trade_date']
n_period = len(round_df)
end_date = round_df.loc[n_period-1,'trade_date']
avg_unit_cost = round_df.loc[n_period-1,"avg_unit_cost"]
final_unit_nav = round_df.loc[n_period-1,"unit_nav"]
return_rate = round_df.loc[n_period-1,"return_rate"]
result_list.append([start_date,end_date,n_period,avg_unit_cost,final_unit_nav,return_rate])
result_df = pd.DataFrame(result_list,columns=['起始日','截止日','投资期数','单位平均成本','期末单位净值','总收益率'])
display(result_df)
y_rate = ((result_df["投资期数"]*result_df["总收益率"]).sum()/result_df["投资期数"].sum()+1)**(1/7)-1
income = (np.sum(result_df["投资期数"]*result_df["总收益率"])*5000)
print("年化收益率:{:.2f}%".format(y_rate*100))
print("绝对收益:{}".format(income))
if plot:
fig,ax1 = plt.subplots(figsize=(12,8))
fig.text(x=0.1, y=0.92, s=' {low}%-{high}%区间定投结果 '.format(low=v_low,high=v_high), fontsize=32,
weight='bold', color='white', backgroundcolor='#3c7f99')
hq_plot = fund_df[["trade_date","unit_nav"]]
x1 = hq_plot["trade_date"].values
y1 = hq_plot["unit_nav"].values
buy_signal = fund_df.loc[fund_df.signal==1,["trade_date","unit_nav"]]
x2 = buy_signal["trade_date"].values
y2 = buy_signal["unit_nav"].values
sell_signal = fund_df.loc[fund_df.signal==0,["trade_date","unit_nav"]]
x3 = sell_signal["trade_date"].values
y3 = sell_signal["unit_nav"].values
# 绘制基金净值走势
# 绘制买点和卖点
ax1.plot(x1,y1,linewidth=1.5,label='指数走势')
ax1.scatter(x2,y2,marker="^",c = "r",label = "买点")
ax1.scatter(x3,y3,marker="v",c="g",label = "卖点")
ax1.spines['top'].set_visible(False)
ax1.spines['bottom'].set_visible(False)
ax1.set_ylabel("基金净值",fontdict={"size":16})
ax1.tick_params(axis='x',length=0,labelsize=16)
ax1.tick_params(axis='y',labelsize=16)
ax1.margins(0.01,0.02)
ax1.legend(fontsize=14)
# 绘制每日的估值百分位数,并用蓝色阴影部分表示
ax2 = ax1.twinx()
ax2.plot(x1,fund_df["percentile"].values,color="#87cefa",alpha=0.1,label="近七年PE")
ax2.fill_between(x1,0,fund_df["percentile"].values,color = "#87cefa",alpha=0.2)
ax2.spines['top'].set_visible(False)
ax2.spines['bottom'].set_visible(False)
ax2.set_ylabel("估值百分位数",fontdict={"size":16,'rotation':270},labelpad=20)
ax2.tick_params(axis='x',length=0,labelsize=16)
ax2.tick_params(axis='y',labelsize=16)
ax2.set_yticklabels(['' ,'0%','20%','40%','60%','80%'])
ax2.margins(0.01,0.02)
# 用黄色阴影表示定投轮次
for i in range(len(result_df)):
round_start = result_df.loc[i,'起始日']
round_end = result_df.loc[i,'截止日']
date_span = pd.date_range(round_start, round_end)
ax1.fill_between(date_span,np.min(y1),np.max(y1),facecolor="#ffff4d",alpha=0.2)
plt.show()
注:本文根据数据研究所得,不构成任何投资意见!
-END-
文末推荐一本《Python数据分析从入门到精通》,本书循序渐进地讲解了使用Python语言实现数据分析的核心知识,并通过具体实例的实现过程演示了数据分析的方法和流程,共12章,内容包括Python语言基础、处理网络数据、网络爬虫实战、处理特殊文本格式、使用数据库保存数据、操作处理CSV文件、操作处理JSON数据、使用库matplotlib实现数据可视化处理、使用库pygal实现数据可视化处理、使用库numPy实现数据可视化处理、使用库pandas实现数据可视化处理和大数据实战案例。Python数据分析从入门到精通简洁而不失技术深度,内容丰富全面。
【更多福利】
扫下方二维码添加我的私人微信,可以在我的朋友圈获取最新的Python学习资料,以及近期推文中的源码或者其他资源,另外不定期开放学习交流群,以及朋友圈福利(送书、红包、学习资源等)。 扫码查看我朋友圈
获取最新学习资源
学习更多: 整理了我开始分享学习笔记到现在超过250篇优质文章,涵盖数据分析、爬虫、机器学习等方面,别再说不知道该从哪开始,实战哪里找了
“点赞”传统美德不能丢