事件驱动“化学反应”与Python实现 需求不会凭空消失,我们认为明年可能要面对资本运作的“大年”。今年权益市场整体表现不佳似乎没有大面积影响到转债发行的热情与进度,毕竟这还是一个处在发展期的市场。但是发行之外,则显得没有那么顺利:以最常见的增持、减持为例,绝对金额较过去的2020~21年大幅下降。而其金额占A股总市值的比重,也降到了2018年年中的水平。但需求不会凭空消失,在市场回暖的2019年,股东增减持比例又迅速回升。未来一年,我们不能排除这样的可能性:从定量策略角度,我们也许会迎来一个“事件驱动”的大年。 图表1:A股增减持金额及其占比 资料来源:Wind,中金公司研究部 但转债本身也是一项资本运作,甚至是重要的。我们需要认识到的是,常规的资本运作事项,与转债放在一起,可能会存在不同的“化学反应”。下面我们介绍一些样本量不过小、也影响具备统计显著性的事件测试效果。 减持计划 股东减持的披露在近年来已经十分规范,如今上市公司的大股东及董监高减持需要提前发布减持计划,一般时间限制为公告后半年左右。下图“策略”是:1、正股近半年内有减持计划;2、减持计划的完成度低于50%;3、减持计划的拟减持额上限不低于总股本的1%;4、每42个交易日进行一次换仓(下同,不再重复说明)。 图表2:有减持计划的转债表现情况 资料来源:Wind,中金公司研究部 所以,“有减持计划”不是坏事?—— 对转债组合来说,至少不是。这个“组合”在过去近5年的时间里年化回报25.8%、夏普1.37x(对比转债等权的15%和1.06x)。 但似乎逻辑上不好理解,多数语境下,“股东计划减持”还是偏负面的时间描述。可是,这也意味着:1)发行人会更在意股价表现; 2)股价在过去一段时间表现应该尚可(否则不容易出现减持计划)——这代表这正面的动量水平,对转债本身就有加成; 3)此时,发行人少有谈及赎回的。 虽然我们并不愿称其为一个“策略”,但显然边际上这是一个值得注意的事件。数据处理是其中主要的工作,执行这一策略的数据处理Python程序如下,投资者可以作为参考。仅需要注意,由于存在多个股东在同一个公告内计划减持的,因此第一步(getPlans)我们需要利用pandas中的explode函数,将一条数据拆解为多条(但共享同一个事件ID),以便后续处理。getActs则是取减持行为数据,后面我们需要合并两个表,来得到减持的实际实施进度。 图表3:增减持数据处理代码 sql = f''' 资料来源:Wind,中金公司研究部 第二步数据合并,为每一个减持行为,找到其对应的减持计划。其中:1)由于日期非严格对应关系,此处用到两个for循环,为避免更大的时间开销,采用的itertuple循环;2)最终合并时需要较多的处理,因此用括号包含整个操作,方便换行,这也是目前比较推荐的一种筛选、合并方式。最后得到的dfSare包含了我们关注的数据维度。 图表4:增减持行为处理代码 if len(selected): 资料来源:Wind,中金公司研究部 此外,叠加EasyBall后,其效果将进一步提升至令人满意的水平: 图表5:增减持行为叠加EasyBall效果 资料来源:Wind,中金公司研究部 2 股份回购 股份回购也是样本足够大的运作事项。这里我们不考虑因为股权激励注销而被动产生的股份回购,这没有也不该有统计和逻辑意义。我们仍然先看测算结果,下图为我们限定以下条件的策略:1、上市公司在过去120日内发生过股份回购;2、回购目的应为市值管理或用于未来股权激励;3、回购金额不低于转债存量5%(以排除极为少量的回购)。 图表6:有股份回购的转债表现情况 资料来源:Wind,中金公司研究部 结果是,股份回购是一个对转债不利的事件。显然,这又与一般的直觉不符,但这与股票的结论并无太大矛盾,从一些具体案例我们看到,发生股份回购的情况下: 1)股价前期表现普遍不佳,具备负动量,从而引发上市公司的股价管理诉求; 2)虽然可能在回购的影响下股价企稳甚至反弹,但爆发力普遍有限,甚至当股价超过了回购上限后,更容易回落——而动量、爆发力才是转债所需要的,“托底”的功能,转债本就具备; 3)发生回购也意味着,上市公司端本身流动性尚可,暂时来说转债的转股并不是核心诉求。 但是否可以通过调整参数或者限制转债的估值水平,来优化这个“策略”呢——没必要,如果本身略有加强,我们当然可以考虑优化措施,但显然起手就是大额负Alpha的情况下,这个“策略”没有改进的空间。以上述统计效果看,我们更应该承认其负面效果,加以规避。下面为实现方式,将strategyRepo作为择券参数输入给cb.frameStrategy(见报告《转债量化策略框架2.0》)即可得到上图。程序逻辑较为简单,不赘述。 图表7:股份回购的数据处理 codes2 = srs[srs > 0].index.to_list() 资料来源:Wind,中金公司研究部 3 股权质押 这是大股东借助股票融资的常规手段,成本相对不低。我们首先看测算结果,选取方式非常简单,即过去100天内(质押可以发生在非交易日,因此这里设置自然日100天)股权质押比例超过总股本的3%的标的。这个答案可能与直观感受更加不符:提高股权质押的公司,长期有超额回报。而直观的印象则是,提高股权质押的公司,普遍资金压力较大——事实却不是这样: 1)还能提高质押率的公司,原本就不处在质押很满的阶段,不难看到这个“策略”的最大回撤也小于等权指数; 2)而股东在资金上有适度的紧迫性,对于转债而言,显然并非坏事。 图表8:有股权质押的转债表现 资料来源:Wind,中金公司研究部 而叠加EasyBall后,这个策略拥有着更小的回撤(不足14%,这是很多基础偏债策略也无法达到的水平线)。 图表9:股权质押叠加EasyBall的转债表现 资料来源:Wind,中金公司研究部 4 定增 这也是投资者普遍的疑问:有转债的情况下,再出现定增预案,会是什么效果呢?由于定增跨时较长,我们设计两个关键时点的策略——一个考虑近期有定增预案的,一个是预案审核通过的。下图为测算结果,均考虑过去60交易日是否发生相应事件。结果一目了然:出现定增过会的情况下效果显著强于全样本,而定增预案没有稳定效果——我们甚至不能像回购一样作为负面事件理解。 图表10:有定增的转债表现 资料来源:Wind,中金公司研究部 实现方式较为简单,仅列示考虑定增过会的策略,技术上没有更多需要注意的内容。 图表11:定增数据处理 if not hasattr(obj, "dfSEO"): 资料来源:Wind,中金公司研究部 而加入转债估值的考虑后,对这一事件的驱动效果亦有帮助: 图表12:定增策略叠加EasyBall的效果 资料来源:Wind,中金公司研究部 5 小结 虽有看起来不错的效果,我们也不认为,上述内容都足以成为独立的策略。样本风格偏差、时而出现的小样本,都决定了其增强效果的稳定性,达不到我们此前的水平。但在2022年资本运作需求被抑制的情况下,明年可能机会稍多。虽然转债市场在2022年的弱势环境下,仍未能解决掉估值问题,但目前囤积下来的资本运作需求,后续也能被理解为一种“资产”。 同时,这里我们至少希望投资者理解,这些常见的事项,在大样本下是怎样的影响。一个长周期的测算是值得信赖的,而不是直觉。下表为2017年末以来,上述提及的策略测算,供投资者参考。 图表13:各类型事件转债的表现情况 资料来源:Wind,中金公司研究部 我们更想投资者看到的是,至少这里介绍的很多结论都与“直观”、“印象”不符——这也是转债投资者在过去几年,尤其过去一年应该认识到的:在这个本就是“非线性”产品的领域,我们一向建议尽可能压低“印象”、“直观理解”的戏份。在转债只有二十几支的年代,我们可能苦于一些答案无法验证,而在数量突破400支的当下,在数据技术已经经历长足发展的当下,仍不去验证而靠直觉理解,才是我们见过的、投资者更应引以为戒的行为习惯。def getPlans(stocks, start, end):
'''stocks是非重复的股票代码列表'''
select s_info_windcode 股票代码, ann_dt 日期, holder_name 股东, HOLDER_STATUS 股东类型,
PLAN_TRANSACT_MIN_NUM 下限, PLAN_TRANSACT_MAX_NUM 上限,
PLAN_TRANSACT_MIN_RATIO 下限占比, PLAN_TRANSACT_MAX_RATIO 上限占比,
CHANGE_END_DATE 截止日, CHANGE_START_DATE 开始日, OBJECT_ID ID
from winddf.ASarePlanTrade
where
ann_dt >= {start} and
ann_dt <= {end} and
s_info_windcode in ({rs.rsJoin(stocks)}) and
TRANSACT_TYPE = '减持'
'''
con = rs.login()
df = pd.read_sql(sql, con)
df["股东2"] = df["股东"].str.split(",")
return df.explode("股东2")
def getActs(stocks, start, end):
sql = f'''
select S_INFO_WINDCODE 股票代码, TRANSACT_ENDDATE 截止日期, HOLDER_NAME 股东,
TRANSACT_QUANTITY 减持量, TRANSACT_QUANTITY_RATIO 减持率, ann_dt 日期
from winddf.AShareMjrHolderTrade
where
TRANSACT_ENDDATE >= {start} and
TRANSACT_ENDDATE <= {end} and
s_info_windcode in ({rs.rsJoin(stocks)}) and
TRANSACT_TYPE = '减持'
'''
con = rs.login()
df = pd.read_sql(sql, con)
return dfdef findAct(dfActs, dfPlans):
'''为减持计划添加行为'''
dfPlans = dfPlans.copy()
dfPlans["已实施"] = 0.0
for row in dfPlans.itertuples():
selected = (dfActs.loc[(dfActs["股东"] == row.股东2) *
(dfActs["股票代码"] == row.股票代码)])
for trade in selected.itertuples():
if pd.to_datetime(row.开始日) <= pd.to_datetime(trade.截止日期) <= pd.to_datetime(row.截止日):
dfPlans.loc[row.Index, "已实施"] += trade.减持量
temp = pd.DataFrame(index=dfPlans["ID"].unique(), columns=["已实施加总",
"计划量"])
temp["计划量"] = dfPlans.groupby("ID")["上限"].max()
temp["已实施加总"] = dfPlans.groupby("ID")["已实施"].sum()
dfSare = (dfPlans
.merge(temp, left_on="ID", right_index=True)
[['股票代码', '日期', '股东', 'ID','已实施加总', '计划量', '上限占比',
'开始日','截止日']]
.drop_duplicates()
)
return dfSaredef getRepo(stocks, dateUntil, dateFrom="20160101"):
sql = f'''
select a.s_info_windcode, a.ann_dt, a.amt
from winddf.AshareStockRepo a
where S_INFO_WINDCODE in ({rs.rsJoin(stocks)}) and
ANN_DT >= {pd.to_datetime(dateFrom).stftime('%Y%m%d')} and
ANN_DT <= {pd.to_datetime(dateUntil).stftime('%Y%m%d')} and
a.status in (324004001, 324004000) and
a.stock_repo_objective_code in (289001000, 289003000)
'''
con = rs.login()
dfRepo = pd.read_sql(sql, con)
con.close()
dfRepo["ANN_DT"] = pd.to_datetime(dfRepo["ANN_DT"])
return dfRepo
def strategyRepo(obj, c, date, codes, da):
if not hasattr(obj, "dfRepo"):
srs = obj.Amt.loc["2017":].sum()
dfUnderlying = rs.getUnderlyingCodeTable(codes2)
uniqueStock = dfUnderlying.UNDERLYINGCODE.unique()
obj.dfUnderlying = dfUnderlying
obj.dfRepo = getRepo(uniqueStock, obj.date, '20170101')
date = pd.to_datetime(date)
start = date - datetime.timedelta(days=120)
dfRepo = obj.dfRepo
srs = (
dfRepo.loc[dfRepo.ANN_DT.apply(lambda x: True if start <= x <= date else False)]
.groupby("S_INFO_WINDCODE")["AMT"].max()
)
dfRet = (obj.dfUnderlying.loc[codes]
.merge(srs, left_on="UNDERLYINGCODE", right_index=True))
dfRet["转债存量"] = obj.Outstanding.loc[date, codes]
srs = dfRet["AMT"] / dfRet["转债存量"]
ret = srs[srs > 0.05].index.to_list()
return ret if len(ret) else codesdef getSEO(stocks, dateUntil, dateFrom="20160101"):
sql = f'''
select s_info_windcode, S_FELLOW_COLLECTION, S_FELLOW_PREPLANDATE,
S_FELLOW_PASSDATE, S_FELLOW_APPROVEDDATE
from winddf.AShareSEO
where S_INFO_WINDCODE in ({rs.rsJoin(stocks)}) and
S_FELLOW_ISSUETYPE = 439006000 and
ANN_DT >= {toyyyy(dateFrom)}
'''
con = rs.login()
dfSEO = pd.read_sql(sql, con)
con.close()
for col in ['S_FELLOW_PREPLANDATE', 'S_FELLOW_PASSDATE',
'S_FELLOW_APPROVEDDATE']:
dfSEO[col] = pd.to_datetime(dfSEO[col])
return dfSEO
def strategySEO(obj, c, date, codes, da):
srs = obj.Amt.loc["2017":].sum()
codes2 = srs[srs > 0].index.to_list()
dfUnderlying = rs.getUnderlyingCodeTable(codes2)
uniqueStock = dfUnderlying.UNDERLYINGCODE.unique()
obj.dfUnderlying = dfUnderlying
obj.dfSEO = getSEO(uniqueStock, obj.date, "20170101")
dfSEO = obj.dfSEO
date = pd.to_datetime(date)
start = date - datetime.timedelta(days=60)
srs = ( dfSEO.loc[
(dfSEO.S_FELLOW_PASSDATE <= date) *
(dfSEO.S_FELLOW_PASSDATE >= start)]
.groupby("S_INFO_WINDCODE")["S_FELLOW_COLLECTION"].sum()
)
dfUd = obj.dfUnderlying.loc[codes]
srs = (dfUd
.merge(srs, left_on="UNDERLYINGCODE", right_index=True)
["S_FELLOW_COLLECTION"])
ret = srs[srs > 0].index
return ret if len(ret) else codes
风险
转债估值进一步上行,权益板块情绪低迷,个券基本面出现超预期变化。模型中样本数量有限,稳定性有限。
文章来源
本文摘自:2022年11月11日已经发布的《事件驱动“化学反应”与Python实现——转债年度展望系列(2)》
杨 冰 分析员 SAC执业证书编号:S0080515120002;SFC CE Ref: BOM868
罗 凡 分析员 SAC执业证书编号:S0080522070003
李奎霖 联系人,SAC执业证书编号:S0080122070189
陈健恒 分析员 SAC执业证书编号:S0080511030011;SFC CE Ref: BBM220
法律声明
还没有评论,来说两句吧...