【中金固收·可转债】走势切割与Python实践
转债市场策略展望
上周周报(《离开低点两个月后》)有这样一张图受到不少投资者关注:这张图原本用来说明关于银行转债的走势,但中间的“直线”部分,是如何确定的?多数投资者可能已经猜想到这并非手动的、主观的划分,而是一种可以批量复制的程序实现。我们在日常分析策略及个券时也确实会用到这一技术,在此,我们进行简单的讨论。
光大转债正股日线
资料来源:万得资讯,中金公司研究部,
注:图来自2021年6月25日发布的《离开低点两个月后》
对转债投资者来说,将原始的走势进行此类划分的意义是什么?——主要在两方面,一是将富含“噪声”的行情波动,用更为简洁的趋势概括出来,从而进行行情分类。至少,我们在划分好这些后,我们能够知道,当前转债是走在上行趋势还是下行趋势中,以及结合MACD等,还可知道其运行阶段。对于标的普遍波动较大的转债而言,这一点尤其重要。另一方面,则在于转债投资者应当比股票投资者更关注弹性,而用每日波动计算出的弹性存在诸多弊端,比如无法反映趋势型。而如果将行情分好段,直观上平均线段的长度,就是对正股弹性一个很好的度量方式。
近期在十大推荐转债中的双环转债正股走势
资料来源:万得资讯,中金公司研究部
在此前报告《有关转债的技术分析——体系篇》中,我们曾详细介绍过利弗莫尔体系。简而言之,趋势投资就是要先对行情进行“提纯”,忽略不必要的波动,进而确定技术性趋势。而后来在此基础上,再利用很多其他理论、体系对一些细节处理,进行了改良。而我们同样在该报告中介绍的缠论,至少在行情的分类上,有了更好、更容易程序化的发展。在上面的图中,我们所展示的就是日线图上的“笔”(我们在其基础上也稍作一些修改),也即一顶和一底相连的部分。
缠论中分型、笔、线段、级别、中枢、趋势和盘整等概念
资料来源:万得资讯,中金公司研究部
注:图来自2020年8月28日发布的《有关转债的技术分析——体系篇》
简单来说,做出这样的图,我们需要:
1、处理K线之间的包含关系,即若出现前后2个K线,其中一个被另一个完全包含的情况,我们需要合并处理;
2、划分顶分型和底分型,定义见上图;
3、连接“合格”的顶和底,组成图上的笔。
下面我们来逐步分解:首先处理K线之间的包含关系。实践中我们发现此举主要是为了避免某一个交易日,即是顶也是底的状态。其中可能出现两种可能性,即前后2个K线,后者包含前者,以及前者包含后者。我们最终需要将存在包含关系的K线图合并,其中原本处于上行方向的,合并后的新K线的最高、最低价均为原本两个K线图的最高者,反之则相反。
K线的合并——以光大转债正股为例
资料来源:万得资讯,中金公司研究部
这里不需要更高级的算法处理,除了应用Python语言原生的Interval库(主要用来处理区间),只是要借助几个函数来分步处理:
1)处理确认两个K线是否有包含关系,以及这种关系的类型,这里要用到下面的isIncluding和intervalCompute两个辅助函数;
2)按照此前走势方向,合并这两个K线图,即includingProcess和_reviseInclude。程序实现逻辑如下:
处理K线之间包含关系的程序实现(一)
def intervalCompute(intvA, intvB):
# 计算A、B区间是否重叠,如有,则返回重叠部分,否则返回方向关系(b > a则为up反之down),和None
if intvA.overlaps(intvB):
intvRet = Interval(max((intvA.lower_bound, intvB.lower_bound)), min((intvA.upper_bound, intvB.upper_bound)))
return "overlap", intvRet
elif intvA.upper_bound < intvB.lower_bound:
return "up", None
else:
return "down", None
def isIncluding(intvA, intvB):
# 计算AB是否为包含关系,只要一方包含另一方,则返回True, 否则False
isOverlap, intvOvlp = intervalCompute(intvA, intvB)
if isOverlap == "overlap" and intvOvlp == intvA:
# 重复段为前者,即后包前
return True, 0
if isOverlap == "overlap" and intvOvlp == intvB:
# 重复段为后者,即前包后
return True, 1
else:
# 无包含关系
return False, None
def includingProcess(intvA, intvB, direction="up"):
# 计算相互有包含关系的A、B合并后的新区间,拟缠论规则
if direction == "up":
return Interval(max((intvA.lower_bound, intvB.lower_bound)), max((intvA.upper_bound, intvB.upper_bound)))
elif direction == "down":
return Interval(min((intvA.lower_bound, intvB.lower_bound)), min((intvA.upper_bound, intvB.upper_bound)))
def _reviseInclude(dfKlinesCopy, intvRet, close, iloc):
# 辅助函数,用于最后按包含关系修改dfKlinesCopy
dfKlinesCopy["HIGH"][iloc] = intvRet.upper_bound
dfKlinesCopy["LOW"][iloc] = intvRet.lower_bound
dfKlinesCopy["CLOSE"][iloc] = close
return dfKlinesCopy
资料来源:万得资讯,中金公司研究部
有了这些准备工作,处理包含关系并合并的工作就能相对简单完成。我们用_exclude函数作为这一步的汇总处理,程序逻辑如下:
处理K线之间包含关系的程序实现(二)
def _exInclude(dfKlines):
# 私有函数,处理包含关系,lastValid和lstValid用于在后面的遍历中标识有意义的K线的记录
# 同时为避免破坏数据源,先制作了一个备份变量dfKlinesCopy
lastValid = 0; lstValid = [dfKlines.index[0]]
dfKlinesCopy = dfKlines.copy()
for i, idx in enumerate(dfKlines.index):
# 把当日股价运行区间变换成一个区间对象,以便用上面的“intervalCompute”
intvKi = Interval(dfKlines["LOW"][i], dfKlines["HIGH"][i])
intvLast = Interval(dfKlinesCopy["LOW"][lastValid], dfKlinesCopy["HIGH"][lastValid])
if i > 1:
biIn, inType = isIncluding(intvLast, intvKi)
if not biIn:
# 如果没有包含关系,不改动数据
lastValid = i
lstValid.append(idx)
else:
direction = "up" if dfKlines["HIGH"][lastValid] >= dfKlines["HIGH"][lastValid-1] else "down"
intvRet = includingProcess(intvLast, intvKi, direction)
if inType == 1:
dfKlinesCopy = _reviseInclude(dfKlinesCopy, intvRet, dfKlines["CLOSE"][i], lastValid)
elif inType == 0:
dfKlinesCopy = _reviseInclude(dfKlinesCopy, intvRet, dfKlines["CLOSE"][i], i)
lastValid = i
lstValid.pop()
lstValid.append(idx)
# 最后,我们只输出在lstValid中的数据
dfRet = dfKlinesCopy.loc[lstValid]
return dfRet
资料来源:万得资讯,中金公司研究部
下一步较为关键,我们需要根据K线的高点、低点来生成“分型”。实际上,技术上分型的叫法常与几何中的“分形”混淆,而我们认为其更不容易出现误解的概念应该是“拐点”。技术上的经典定义为某K线图的高点高于其两侧的高点则定义为顶点(也有相应底点的定义) —— 但这样会形成较多互相临近的“假拐点”,于是我们选择用《混沌交易法》中的方式,需要某K线高于前后各两个K线图的高点,才定义为顶点。示意如下:
分型——以光大转债正股为例
资料来源:万得资讯,中金公司研究部
而这一步只需要巧用滚动窗口的计算,即可将符合条件的点选择出来。随后,我们可以用一个DataFrame结构保存这个结果,见getRet。程序逻辑很简易,如下:
处理K线分型的程序实现
def getInflection(dfK):
srsMax, srsMin = dfK["HIGH"].rolling(5).max().shift(-2), dfK["LOW"].rolling(5).min().shift(-2)
lstUp = list(dfK.loc[dfK["HIGH"] == srsMax].index)
lstDown = list(dfK.loc[dfK["LOW"] == srsMin].index)
return lstUp, lstDown
def getRet(dfK, lstUp, lstDown):
dfRet = pd.DataFrame(index=sorted(lstUp + lstDown), columns=["ALL", "pointType"])
dfRet.loc[lstUp, "ALL"] = dfK.loc[lstUp, "HIGH"]
dfRet.loc[lstUp, "pointType"] = 1
dfRet.loc[lstDown, "ALL"] = dfK.loc[lstDown, "LOW"]
dfRet.loc[lstDown, "pointType"] = -1
print dfRet
return dfRet
资料来源:万得资讯,中金公司研究部
接下来,理论上连接顶和底就可以得到我们需要的趋势图了,但这里我们还要针对两种情况进一步提炼:
1、不能出现两个顶点相邻或者两个底点相邻的情况。由于存在多顶点相邻的情景,同时最终我们要对多个顶部相邻的情景抽取最高者(底点则相反),这里我们不得已要对顶点、底点序列进行遍历。算法逻辑如下:
避免多顶/底点相邻的程序实现
def dropSameDirection(dfRet):
dictSeg = []; flag = 0
for i,d in enumerate(dfRet.index):
if i >= 1:
if dfRet.pointType[i] == dfRet.pointType[i-1]:
if flag == 0:
tempList = [dfRet.index[i-1], d]
flag = 1
else:
tempList.append(d)
elif flag == 1:
dictSeg.append(tempList)
flag = 0
continue
if d == dfRet.index[-1] and flag == 1:
dictSeg.append(tempList)
if len(dictSeg):
lst2drop = []
for lst in dictSeg:
if dfRet.pointType[lst[0]] == 1:
lst.remove(pd.to_numeric(dfRet.loc[lst, "ALL"]).argmax())
else:
lst.remove(pd.to_numeric(dfRet.loc[lst, "ALL"]).argmin())
lst2drop += lst
dfRet.drop(index = lst2drop, inplace=True)
资料来源:万得资讯,中金公司研究部
2、为屏蔽短期变动带来的干扰,顶与底之间的时间距离不能太短。不过,有些顶和底之间的距离虽然很近,但波动足够大,尤其近两年来市场可能还会受到隔夜海外市场、大宗商品的影响,这些短线波动是有意义的。因此,我们会剔除的是:
1)底点后很快迎来顶点,且顶点相比此前一个顶点更低的情况(即虽然有底部反转的迹象,但下一个顶点很快到来,且这个底点结构没能给市场带来足够高度的反弹)。
2)同样的情况,适用于顶点。
上述算法的程序逻辑如下。这里需要注意,由于不能破坏顶、底相连的规则,我们每次要剔除偶数个点,这里借助一个flag个变量:
控制顶/底点相连的程序实现
def dropNearPunc(dfRet, dfK):
print dfRet
dfRet["locInK"] = [dfK.index.get_loc(d) for d in dfRet.index]
lst2BeDropped = []; flag = 0
for i, d in enumerate(dfRet.index):
if flag :
flag = 0
continue
if i > 1 and d != dfRet.index[-1] and (dfRet.locInK[i+1] - dfRet.locInK[i] <= 3):
if dfRet.pointType[i] == 1:
if dfRet.ALL[i+1] >= dfRet.ALL[i-1]:
lst2BeDropped.append(d)
lst2BeDropped.append(dfRet.index[i+1])
flag = 1
else:
if dfRet.ALL[i+1] <= dfRet.ALL[i-1]:
lst2BeDropped.append(d)
lst2BeDropped.append(dfRet.index[i+1])
flag = 1
lstValid = sorted(set(dfRet.index).difference(lst2BeDropped))
return dfRet.loc[lstValid]
资料来源:万得资讯,中金公司研究部
有了上面的准备,最终投入使用的函数就显得相对容易,只需要将上述辅助工具组合到一起即可。如下:
合并函数的程序实现
def generatePunc(dfKlines):
# 处理包含关系
dfK = _exInclude(dfKlines)
# 通过滚动算法,找到符合顶分型、底分型的K线,存在lstUp和lstDown里面
lstUp, lstDown = getInflection(dfK)
print sorted(lstUp + lstDown)
# 将这些代表“拐点”的分型存入dfRet中,准备进一步过滤
dfRet = getRet(dfK, lstUp, lstDown)
# 去除同向相连的情况
dropSameDirection(dfRet)
# 去除二点过近且波动意义不大的情况
dfRet = dropNearPunc(dfRet, dfK)
# 将终结点与最后一个拐点连接
if not dfKlines.index[-1] in dfRet.index:
dr = "up" if dfRet["pointType"][-1] == -1 else "down"
dfRet.loc[dfKlines.index[-1], "ALL"] = dfKlines.loc[dfKlines.index[-1], "HIGH"] if dr == "up" else dfKlines.loc[dfKlines.index[-1], "LOW"]
return dfRet
资料来源:万得资讯,中金公司研究部
对当前转债市场,一些有用的结论:
1、哪些比较大的品种,是处于日线级别的上行中?大中盘转债中,至少苏银、光大、东财、大秦、国君、本钢、盛虹、青农、中化EB、本钢以及海亮符合这样的要求。当然,这样的择券倾向,和我们上期周报中提到的策略(乃至我们的十大个券),也比较接近。但相比于长期、中期动量,趋势划分的结果更具灵活性。
2、按照上述每一笔连线的长度,哪些正股是转债标的中,比较富有弹性的?当下转债的标的很多,我们只能列举一部分。不过,既然在考虑弹性,那么在择券时,一般也与价格、溢价率搭配考虑,如此方可取得不错的组合盈亏比。
正股弹性较好的转债列表(截至2021年6月25日)
资料来源:万得资讯,中金公司研究部
转债一级市场跟踪
本周新公告了2个转债预案,分别为阿拉丁(4.01亿元)与禾丰股份(15亿元)。10个转债预案通过股东大会,包括慧云钛业(4.9亿元)、东杰智能(6亿元)、会通股份(8.5亿元)、中富通(5.05亿元)、华友钴业(76亿元)、科伦药业(30亿元)、中环环保(8.64亿元)、中天精装(6.07亿元)、五洲特纸(6.7亿元)、精工钢构(20亿元);6个预案获受理,分别为立华股份(21亿元)、温州宏丰(3.21亿元)、珀莱雅(8.04亿元)、科蓝软件(5.38亿元)、华翔股份(8亿元)、百润股份(12.8亿元);闻泰科技(86亿元)与川恒股份(11.6亿元)过发审委;台华新材(6亿元)与中大力德(2.7亿元)获核准。目前已核准待发行个券合计25只,金额共计382.86亿元;已过会未核准个券10只,合计金额212.61亿元。
推荐阅读
文章来源
本文摘自:2021年7月2日已经发布的《适合转债的走势划分、弹性重塑与Python实践》
杨冰 SAC执业证书编号:S0080515120002;SFC CE Ref: BOM868
房铎 SAC执业证书编号:S0080519110001
罗凡 SAC执业证书编号:S0080120070107
陈健恒 SAC执业证书编号:S0080511030011;SFC CE Ref: BBM220
法律声明
向上滑动参见完整法律声明及二维码