在转债周报《这半年,谁的择时精准,谁的择券有效》中,我们讨论了转债基金的择时、择券能力。我们在该周报也进行了简短的介绍,当然其中一个假设是没有多少投资者对这个业绩拆分的过程感兴趣。不过虽然与我们讨论程序实现方法的投资者比意料中多,我们在本专题中进行专门介绍。
基础模型实际很简单。我们采用了认可度比较高,又有着诸多其他优点(后面会详细说)的CL模型。基础框架如下:
Rp - Rf = alpha + beta1 * min(Rm - Rf, 0) + beta2 *max(Rm - Rf, 0) + e
几点解释:
1、实际这个式子没有比CAPM复杂很多,只是把Rm - Rf再拆分成“下跌”和“上涨”两种状态,分别对应beta1和beta2两个敏感系数。所以在这里,alpha 显著大于0,则可以认为是有择券效果,而在beta2 > 0的情况下,beta2 - beta1比较大,说明基金能在上涨和下跌阶段,体现出不同的beta值来。最理想的情况莫过于beta2很大,beta1接近0 —— 这就是一般情况下说的精准逃顶了;
2、但alpha和beta
仍然不能完全被拆分开,比如有的品种就是拥有超大的Beta值,包括券商转债、某些情况下的周期转债(例如之前的三一、有色品种)以及溢价率很低的银行转债。再如,有的品种就是有很强的不对称性,例如诸多低估值品种,跌已无太多空间,而涨却也无溢价来压制弹性,最终以择券的形式,产出的择时的效果 —— 这也是模型无能为力的方面;
3、Rf怎么选一直是个问题,国开债收益率、企业债收益率都是个办法。不过实际还是基金倾向于拿哪一类当 “债”,哪一类就更合适(历史上或许是城投,最近来看基金更愿意拿利率债)。所以有一个还算稳定的方法是:用不拿转债\股票的长债基金收益率 —— 当然用哪个都不是重点;
4、这始终是个归因模型,不是测仓位手段,所以无论beta1还是beta2都与 “仓位”这个概念有差异。无论如何,这里讨论的是实际效果,或者 “有效仓位”的概念,不必纠结于实际仓位是多少 —— 满仓特发和满仓山高EB效果自然是不一样的。
下面进入实现的流程。在确定基金研究范围的情况下,我们需要的其实只是以下这些数据:基金调整后净值、转债指数、长债基金指数。我们还是用class将这些数据的初始化、获取、处理和最终的计算封装,初始化部分如下。里面涉及到_fetchAdjNav、_fetchCBIndex和_fetchBondIndex这三个私用函数(在私用函数前加一个"_",以区别于公用函数)。以及,self.dfNav = self.dfNav.reindex(columns=codes)这句是利用pandas中的reindex来对表格的列进行重排,比较好理解。最后会得到.dfNav和.dfIndex两个pandas下的DataFrame,分别是净值和转债、债基指数的表。
import pandas as pd
from sklearn.linear_model import LinearRegression
class fundAttr(object):
'''写文档是美德
初始化输入为:codes,start,end,字面意思
'''
def __init__(self,codes,start,end):
self.codes=codes
self.start=start
self.end=end
self.dfNav=self._fetchAdjNav()
self.dfNav=self.dfNav.reindex(columns=codes)
self.dfIndex=pd.DataFrame(index=self.dfNav.index,columns=['CB','BOND'])
self.dfIndex['CB']=self._fetchCBIndex()
self.dfIndex['BOND']=self._fetchBondIndex()
下面是_fetchAdjNav、_fetchCBIndex和_fetchBondIndex这三个私用函数,比较好理解,不用解释。有一些做法则是习惯的问题,比如df.sort_index(inplace=True)这句不一定要加,如果习惯于在sql的最后一句加上"order bytradedate"的话。以及,其实如果做对外接口的话,没有必要做三个函数,而是一个就够了,这里面存在有待商榷的地方。
def _fetchAdjNav(self, method='sql'):
if method =='sql':
#这是sql版本
sql
='''select a.f_info_windcode windcode,
a.price_datetradedate,
a.f_nav_adjustednav
from winddf.chinamutualfundnava
where
a.f_info_windcodein({_codes}) and
a.price_date>={_start} and
a.price_date<={_end}
'''.format(_codes= '"' + ','.join(self.codes) + '"',
_start = self.start,
_end = self.end)
con =mylogin() # con为cxOracle的登录变量,一般输入服务器地址等信息
df = pd.read_sql(sql, con)
con.close()
df = df.pivot(index='TRADEDATE', columns='WINDCODE', values='NAV')
df.sort_index(inplace=True)
elif method=='api':
#这是万得pythonapi的版本
if not w.isconnected(): w.start()
wobj = w.wsd(','.join(self.codes),'adjnav', start, end)
df = pd.DataFrame(wobj.Data.transpose(), index=wobj.Times.apply(lambda x:'/'.
join([x.year, x.month, x.day]), columns=self.codes)
w.close()
else:
raise ValueError u"这method不对"
return df
def _fetchCBIndex(self, method='sql'):
if method =='sql'
sql ='''select a.s_info_windcodewindcode,
a.trade_dttradedate,
a.s_dq_closeindexprice
from winddf.aindexeodpricesa
where a.s_info_windcode='000832.CSI' and
a.trade_dt >={_start} and
a.trade_dt <={_end}
'''.format(_start=self.start, _end=self.end)
con =mylogin()
df = pd.read_sql(sql, con)
con.close()
df = df.pivot(index='TRADEDATE', columns='WINDCODE', values='INDEXPRICE')
df.sort_index(inplace=True)
elif method=='api':
#这是万得pythonapi的版本
if not w.isconnected(): w.start()
wobj = w.wsd("000832.CSI",'close', start, end)
#这里面注意,api有时候在只有1个code被输入时,.Data吐出来一个一维变量,用之前先试一下最好
df= pd.DataFrame(wobj.Data.transpose(), index=wobj.Times.apply(lambda x:'/'.join([x.
year, x.month, x.day]), columns=self.codes)
w.close()
else:
raise ValueError u"这method不对"
return df
def _fetchBondIndex(self):
if method =='sql'
sql ='''select a.s_info_windcode windcode,
a.trade_dttradedate,
a.s_dq_closeindexprice
from winddf.AIndexWindIndustriesEODa
where a.s_info_windcode='885008.WI' and
a.trade_dt >={
_start} and
a.trade_dt <={_end}
'''.format(_start=self.start, _end=self.end)
con =mylogin()
df = pd.read_sql(sql, con)
con.close()
df = df.pivot(index='TRADEDATE', columns='WINDCODE', values='INDEXPRICE')
df.sort_index(inplace=True)
elif method == 'api':
#这是万得pythonapi的版本
if not w.isconnected(): w.start()
wobj = w.wsd("885008.WI",'close', start, end)
#这里面注意,api有时候在只有1个code被输入时,.Data吐出来一个一维变量,用之前先试一下最好
df = pd.DataFrame(wobj.Data.transpose(), index=wobj.Times.apply(lambda x:'/'.
join([x.year, x.month, x.day]), columns=self.codes)
w.close()
else:
raiseValueError u"这method不对"
return df
然后是数据的计算,这一块反而是比较简单的,借助sklearn的LinearRegression就行,而且效率很高。这里也充分体现了能不用for就不用的原则。最后需要的是sklearn拟合出来的coef_这个变量。
def anal_CL(self):
dfRet = pd.DataFrame(index=self.codes, columns=['alpha','beta1','beta2'])
pctNav = self.dfNav.pct_change()
pctIndex = self.dfIndex.pct_change()
for col in pctNav.columns:
pctNav[col]-= pctIndex['BOND']
pctNav.dropna(inplace=True)
x = pctIndex['CB']- pctIndex['BOND']
x1 =x.apply(lambda y:min([y,0]))
x2 =x.apply(lambda y:max([y,0]))
dfX = pd.DataFrame(index=x1.index)
dfX['Ones']=1.0
dfX['x1']= x1 *100.0
dfX['x2']=
x2 *100.0
dfX.dropna(inplace=True)
lr =LinearRegression(fit_intercept=False)
lr.fit(dfX.reindex(index=pctNav.index).values, pctNav.values *100.0)
dfRet.iloc[:,:]= lr.coef_
return dfRet
最后,还是要提醒投资者的是,模型给不同的人,用出来效果是不同的 —— 取决于理解程度。以下几点应当注意:
1、如前所述,alpha和beta无法完全分离,尤其转债还存在不同品种股性完全不同的、以及Gamma大小差异很大的问题;
2、单看某一个数值总是容易出错,比如beta2 - beta1从数据上代表了择时能力,但也要结合仓位和基金规模大小来看。alpha也要注意结合beta2来评判,25
分45%命中率,和5分60%命中率相比,还是前者更强一些;
3、最后,择时和择券,两者兼顾很难很难,往往一端强势,另一端不拖就已经是不错的管理,更多这个方面的评论请见转债周报。