从新年开始,固收+投资者的情绪起伏恐怕不小。 先是作为主力轮转工具的转债在1月成了“固收-”——当然,这里还有很大的结构分化,当时的小票基本就是“禁触区”,好在高位品种还能独善其身。但问题又来了:春节后,高位品种(类似股票中的白马)开始瓦解,市场似乎又进入小票的节奏里。令投资者略感沮丧的点也在这里: 对于“固收+”基金们而言,其上游客户不一定有太高的风险容忍度,而2021年到现在,想保一月的净值必然在二月付出代价,而反过来的代价也许更高,比如在一月就要面对大量赎回。以我们的十大转债组合为例,二月同样表现不佳。随之而来的,是不免迷茫。我们的一个小经验:如果心态不能很快整理好,就看一看“其他人”,至少知道市场的一个平均组合是怎样做的,在状态调整好或者市场环境变好之前,先守住中庸,再伺机而动。 但前提又是:只要我们需要一个稳定的工具,去观察“其他人”。
在此我们先介绍一个结合稳定性、拟合优度和直观性来讲,比较可接受的一个模型。 我们在《转债基金的风格分解与Python》 中介绍过一个简单模型,用以将转债基金的收益分摊到不同的指数上,从而得到基金的择券alpha和风格alpha。不过此时此刻,我们想要的并不是观察真实仓位也不是做收益的归因与评价,而是需要一个稳定性更好、也更加直观反映基金风格分布和边际变化的工具,因而我们不能照搬,或者至少要做一些改进。这方面的问题,一个简单的思路是做一个多元回归,例如:
其中Ri为基金净值回报,Rm1为第1个指数(例如转债指数或者沪深300),beta1对其对应的beta系数,以此类推。但相比于统计领域的其他问题,这样做几乎必然要面对一个比较大的多重共线性问题 ,这也是基金仓位测算中普遍存在的问题。对于固收+基金可能尤其如此,多数固收+基金都有仓位不低的转债,而无论是转债指数还是细分的策略指数,都会与股票指数有较强相关性。
针对此,我们用分步过滤法解决。 学界存在着很多方法,例如岭回归、Lasso回归、PCA、逐步回归等,但诸如岭回归存在一个关键变量(λ),实践中多用交叉验证法确定——这会引入新的问题。而PCA会使回归参数失去直观意义,逐步回归则难以形成稳定的时间序列。我们的思路是,先用最简单的一元线性回归,找到“最适合”待研究基金的指数作为“主风格指数”,然后再对其他指数提纯,并用提纯后的数据再去拟合“主风格指数”无法解释的部分。
具体步骤和程序实现方法如下:
1、准备指数: 当然,首先是准备工作,我们需要基金净值数据,并确定一个指数篮,通常不需要多,3~5个有代表性的即可。后面分别以Rm1、Rm2等表示,假设共n个。考虑到差异化和基金实际可能用到的策略,我们选取:转债大盘指数(简称转债指数)、双低策略指数、基金重仓指数和国证2000指数(代表小票)。其中基金重仓指数、国证2000指数直接提取便可,转债大盘指数和双低策略指数需要我们临时编制,所幸比较简单。实现逻辑如下,最终我们得到代表指数篮子的变量dfRet。
为什么这里不放低溢价策略? 普遍来讲,溢价率很低的转债与正股区别比较小,为节省变量个数,我们希望这方面的暴露在股票指数上反映即可,因为实质如此,尤其是基金重仓指数 。
# 风格指数编制逻辑 import libCB as cb # 我们的常用函数和数据库,这里仅用到计算转债策略指数的cb.frameStrategy from WindPy import wimport readSql as rsimport pandas as pdfrom scipy.optimize import leastsqfrom sklearn.linear_model import LinearRegressiondef getRet (obj, start, end=None) : # 整合函数,需要用到下面的三个函数,返回dfRet。obj为保存转债基础数据的class,frameStrategy请见《转债策略测试》 if end is None : end = obj.DB["Amt" ].index[-1 ] dfBig = cb.frameStrategy(obj, start, roundMethod=21 , weightMethod='fakeEv' ) dfSD = cb.frameStrategy(obj, start,selMethod=LowPremLowPrice, roundMethod=21 ) dfRet = pd.DataFrame(index = dfBig.index) dfRet[u"转债指数" ] = dfBig.NAV dfRet[u"双低" ] = dfSD.NAV dfRet.index = pd.to_datetime(dfRet.index) # 这里是因为万得API返回的日期为dt模式,我们则是str,所以在这里先转化统一好 dfIndex = getIndex(start, end) dfRet[u"基金重仓股" ] = dfIndex['8841271.WI' ] * 100 / dfIndex['8841271.WI' ][0 ] dfRet[u"国证2000" ] = dfIndex['399303.SZ' ] * 100 / dfIndex['399303.SZ' ][0 ] return dfRetdef bigCB (obj, codes, date, tempCodes, dfAssetBook) :
t = obj.DB["Oustanding" ].loc[date, tempCodes] > 5000000000.0 # 大盘转债:溢价率 return t[t].indexdef LowPremLowPrice (obj, codes, date, tempCodes, dfAssetBook) : # 简易双低策略 intLoc = obj.DB["Amt" ].index.get_loc(date) date = obj.DB["Amt" ].index[intLoc - 1 ] # 平移一下日期,在t日用t-1日的数据 t = (obj.DB['Close' ].loc[date, tempCodes] < \ obj.DB['Close' ].loc[date, tempCodes].quantile(0.5 )) # 取价格前50% t *= (obj.DB['ConvPrem' ].loc[date, tempCodes] < \ obj.DB['ConvPrem' ].loc[date, tempCodes].quantile(0.5 )) # 取溢价率前50% return t[t].index
2、整合数据: 除了指数外,自然我们还需要基金的净值回报,直接提取即可。我们在此顺便将前述基础数据一起整合到一个class中(命名为fundStyle)。
# 基金净值回报与基础数据整合 class fundStyle (object ): def __init__ (self , codes, dfRet) : self .codes = codes self .dfRet = dfRet self .navReturn = self .getNavPctChg() # 基金净值回报 def getNavPctChg
(self ) : codes = self .codes start = dfRet.index[1 ] end = dfRet.index[-1 ] if not w.isconnected(): w.start() strCodes = ',' .join(codes) _ , dfNavPct = w.wsd(strCodes,'NAV_adj_return1' ,start,end , usedf=True) return dfNavPct
3、用普通的带截距的模型,先逐个用Rm1~Rmn回归Ri,得到n个一元线性模型的R^2(拟合优度)。选择其中R^2最大也就是拟合效果最好的那一个指数,作为“主风格指数”,该指数也被记作Rmk。这里的回归我们不加任何限制,于是sklearn的LinearRegression即可解决问题,如下:
# 初步回归 def reg5 (self, code, n_window, n_calc) : # n_window为单次计算所用的时间窗口, n_calc为计算间隔(当然可以为1), 最终结果以df形式存储于ret xPct = self.dfRet.pct_change().dropna() # 指数回报,备用 srsChg = self.navReturn[code] # 该基金的净值回报 idx = list(srsChg.index[n_window::n_calc]) # 日期轴 ret = pd.DataFrame(index=idx, columns=['alpha' ,u'转债指数' ,u'双低' ,u'国证2000' ,u'基金重仓股' ,'level1' ]) for date in ret.index: i = srsChg.index.get_loc(date) y, x = srsChg[i-n_window+1 :i+1 ], xPct.iloc[i-n_window+1 :i+1 ] * 100.0 # 初次回归 _dfScore = pd.DataFrame(index=x.columns, columns=["score" ,"beta" ]) # 保存每一个单因子回归的beta和拟合优度(score) Line = LinearRegression(fit_intercept=True ) srsPara = pd.Series(index=ret.columns) # 用来保存中转结果
for col in x.columns: Line.fit(x[col].values.reshape(-1 ,1 ), y.values.reshape(-1 ,1 )) _dfScore.loc[col, "score" ] = Line.score(x[col].values.reshape(-1 ,1 ), y.values.reshape(-1 ,1 )) _dfScore.loc[col, "beta" ] = Line.coef_[0 ][0 ] level1 = pd.to_numeric(_dfScore["score" ]).argmax() # 直接argmax会有数据类型报错,这里需要中转一下 srsPara['level1' ] = level1 beta = _dfScore.loc[level1, "beta" ] alpha = Line.intercept_[0 ]
4、计算用Rmk一元回归得到的残差值,即Ri’= Ri – betak * Rmk - alpha。下一步我们要去用其他指数拟合Ri’。但在此之前,其他指数之间的多重共线依然存在。所以,这里我们先用Rmk对其他指数“提纯”。我们先用Rmk去做单因子拟合其他指数,得到诸如Rm1= c1 * Rmk + e的线性关系,然后得到Rm1’= Rm1 – c1*Rmk,以此类推。下面的程序仍在“reg5”函数内部。
# 非主要指数的提纯 y -= (beta * x[level1] + alpha) # 首先要对y也做提纯 LineX = LinearRegression(fit_intercept=False ) dictCoef = {} # 保存c1,c2,c3,c4 for k in x.columns: if k != level1: LineX.fit(x[level1].values.reshape(-1 ,1 ), x[k].values.reshape(-1 ,1 )) dictCoef[k] = LineX.coef_[0 ][0 ] x[k] -= x[level1] * LineX.coef_[0 ][0 ] x.drop(columns=level1, inplace=True )
5、最后,我们用一个带约束的多元回归,去得到上述x与y之间的关系。 由于sklearn不支持简易的带约束回归,我们需要用scipy中的leastsq并手动做约束条件。这里我们不多加限定,只要参数回归出来不要小于0即可,如下文的constrainRegV3即实现该功能。
# 带约束的多元回归
def constrainErrV3 (p, x, y, beta) : err = list(p[0 ] + (p[1 :]* x).sum(axis=1 ) - y) pen2 = max([-min(p[1 :]), 0 ]) err.append(pen2*100000000 ) return errdef constrainRegV3 (x, y, beta) : return leastsq(constrainErrV3, [0 ,0.2 ,0.2 ,0.2 ], args=(x,y, beta), maxfev=1000 )[0 ]
6、使用方式: 由于封装完整,使用时只需要初始化后调用reg5即可,不赘述。
# 完整回归函数reg5 def reg5 (self, code, n_window, n_calc) : # n_window为单次计算所用的时间窗口, n_calc为计算间隔(当然可以为1), 最终结果以df形式存储于ret xPct = self.dfRet.pct_change().dropna() # 指数回报,备用 srsChg = self.navReturn[code] # 该基金的净值回报 idx = list(srsChg.index[n_window::n_calc]) # 日期轴 ret = pd.DataFrame(index=idx, columns=['alpha' ,u'转债指数' ,u'双低' ,u'国证2000' ,u'基金重仓股' ,'level1' ]) for date in ret.index: i = srsChg.index.get_loc(date) y, x = srsChg[i-n_window+1
:i+1 ], xPct.iloc[i-n_window+1 :i+1 ] * 100.0 # 初次回归 _dfScore = pd.DataFrame(index=x.columns, columns=["score" ,"beta" ]) # 保存每一个单因子回归的beta和拟合优度(score) Line = LinearRegression(fit_intercept=True ) srsPara = pd.Series(index=ret.columns) # 用来保存中转结果 for col in x.columns: Line.fit(x[col].values.reshape(-1 ,1 ), y.values.reshape(-1 ,1 )) _dfScore.loc[col, "score" ] = Line.score(x[col].values.reshape(-1 ,1 ), y.values.reshape(-1 ,1 )) _dfScore.loc[col, "beta" ] = Line.coef_[0 ][0 ] level1 = pd.to_numeric(_dfScore["score" ]).argmax() # 直接argmax会有数据类型报错,这里需要中转一下 srsPara['level1' ] = level1 beta = _dfScore.loc[level1, "beta" ] alpha = Line.intercept_[0 ] # 二次回归看残余 y -= (beta * x[level1] + alpha) # 首先要对y也做提纯 LineX = LinearRegression(fit_intercept=False ) dictCoef = {} # 保存c1,c2,c3,c4 for k in x.columns: if k != level1: LineX.fit(x[level1].values.reshape(-1 ,1 ), x[k].values.reshape(-1 ,1 )) dictCoef[k] = LineX.coef_[0
][0 ] x[k] -= x[level1] * LineX.coef_[0 ][0 ] x.drop(columns=level1, inplace=True ) coef = list(constrainRegV3(x, y, beta)) for i, v in enumerate(x.columns): srsPara[v] = coef[i+1 ] beta -= srsPara[v] * dictCoef[v] alpha += coef[0 ] srsPara[level1] = beta srsPara['alpha' ] = alpha ret.loc[date] = srsPara return ret
针对当下,固收+们有什么动作? 以下结论:
1、首先看转债基金,其整体仓位仍在中位水平。 但由于转债基金本身调仓余地不大,我们更关注构成。与直觉不同的是,我们未能看到这些基金加码双低小转债或者国证2000小股票。 这些产品在股票仓位上由此前高点有所收敛,国证2000小股票未有明显变化(因为本来的数字也已经很小),提高的则是 大盘转债的占比。
2、积极型固收+基金(非转债基金,但转债+股票仓位高于40%)在近期几乎没有结构上的调整。 仅仅出现大盘转债些许提升的迹象。
3、稳健性固收+基金(不属于前两者的二级债基与偏债混基)的仓位略有提高,且高在了“基金重仓股”上。
4、当然上述只是特定类型的平均情况,不同经理、产品自然有个性化差异。 但总体来看,头部大固收+产品倾向于保持原有的风格,只是仓位的加加减减。例如以下案例,产品名称暂以字母代替。
注 报告原文请 见2021 年03月05日 中金固定收益研究发表的研究报告 。