请 [注册] 或 [登录]  | 返回主站

量化交易吧 /  数理科学 帖子:3353187 新帖:0

事件驱动策略之“限售股解禁”

有事您说话发表于:7 月 9 日 16:00回复(1)

由于数据获取容量上限为3000,时间跨度不宜太长。我们的研究时间跨度为2018.6.1~2019.6.1。篇幅所限,以下所有数据结果均展示最后5行。

#导入模块
from jqdata import *
import datetime as dt
import matplotlib.pyplot as plt
%matplotlib inline
import pandas as pd
import numpy as np
import random
import warnings
warnings.filterwarnings('ignore')

限售解禁的原因很多,不同的解禁类型对应不同类型的股东,这些股东对公司信息的掌握情况不同,故解禁类型是我们关注的一个方面。不妨先看一下各种解禁类型的数量统计情况。

#定义起始和终止日期
date1 = dt.date(2018,6,1)
date2 = dt.date(2019,6,1)
#NewMarketableSharesSource:解禁原因的文字说明
#SourceType:解禁原因代码
q = query(
          jy.LC_SharesFloatingSchedule.SourceType,
          jy.LC_SharesFloatingSchedule.NewMarketableSharesSource
         ).filter(jy.LC_SharesFloatingSchedule.StartDateForFloating>=date1,
                  jy.LC_SharesFloatingSchedule.StartDateForFloating<date2)
release = jy.run_query(q)
#展示最后5行
release.tail()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
SourceType NewMarketableSharesSource
2136 24 增发A股法人配售上市
2137 80 股权激励限售流通
2138 24 增发A股法人配售上市
2139 82 发行前股份限售流通
2140 82 发行前股份限售流通
#统计各种解禁类型的占比
(release.groupby('NewMarketableSharesSource').count()/len(release)).applymap(lambda x: format(x, '.1%'))
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
SourceType
NewMarketableSharesSource
A股发行法人配售上市 0.0%
其他 0.1%
发行前股份限售流通 23.8%
增发A股原股东配售上市 11.3%
增发A股法人配售上市 35.9%
延长限售锁定期流通 2.0%
股权分置股东增持股份上市 0.0%
股权分置限售流通 1.4%
股权激励限售流通 25.0%
配股限售流通 0.3%

在2141行数据中,“发行前股份限售流通”、“增发A股法人配售上市”和“股权激励限售流通”是占比前三的解禁类型,总占比近85%。因此,这3种解禁类型是我们研究的重点。又因为股票的解禁数量与未来的抛售压力直接相关,自然我们关注的另一个方面是解禁规模的大小。我们用当期解禁数量占解禁前流通数量的比例(以下简称“解禁比例”)来描述这一指标。下面获取上述3种解禁类型的明细数据,字段包括公司代码、解禁日期、解禁比例和解禁类型。

#研究3种类型的限售解禁(24-增发A股法人配售上市,80-股权激励限售流通,82-发行前股份限售流通)
types = [82,24,80]
#CompanyCode:公司代码
#StartDateForFloating:解禁日期
#Proportion1:本次解禁数量占上期末已流通数量比例(%)
q = query(
          jy.LC_SharesFloatingSchedule.CompanyCode,
          jy.LC_SharesFloatingSchedule.StartDateForFloating,
          jy.LC_SharesFloatingSchedule.Proportion1,
          jy.LC_SharesFloatingSchedule.SourceType,
         ).filter(jy.LC_SharesFloatingSchedule.StartDateForFloating>=date1,
                  jy.LC_SharesFloatingSchedule.StartDateForFloating<date2,
                  jy.LC_SharesFloatingSchedule.SourceType.in_(types),
                 ).order_by(jy.LC_SharesFloatingSchedule.StartDateForFloating)
unbanned = jy.run_query(q)
#对解禁比例(%)保留两位小数
round(unbanned.tail(),2)
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
CompanyCode StartDateForFloating Proportion1 SourceType
1807 3955 2019-05-31 8.99 24
1808 75210 2019-05-31 0.24 80
1809 78120 2019-05-31 2.07 24
1810 170454 2019-05-31 377.54 82
1811 178293 2019-05-31 97.20 82

以上第1列‘CompanyCode’并非是我们常用的证券代码。为了便于研究,需要将它们转化为证券代码,方法是通过关联聚源数据的SecuMain表来实现。于是定义代码转换函数如下:

#定义函数:将公司代码转化为证券代码
def com2sec(code):
    #若在任一张表中查不到数据,则返回空值nan
    symbol = np.nan
    #获取含有公司代码code的SecuMain表
    q = query(
              jy.SecuMain.CompanyCode,
              jy.SecuMain.SecuAbbr).filter(jy.SecuMain.CompanyCode==code)
    df1 = jy.run_query(q)
    #先判断SecuMain表是否为空
    if not df1.empty:
        #获取公司代码对应的证券简称,SecuMain表中的名称中有空格,需去除
        name = df1.iloc[0,1].replace(' ','')
        df2 = get_all_securities()
        if (df2['display_name']==name).any():
            symbol = df2[df2['display_name']==name].index[0]
    return symbol

调用代码转换函数,将公司代码转换为证券代码:

unbanned['CompanyCode'] = unbanned['CompanyCode'].map(com2sec)
#‘CompanyCode’一列可能包含空值,故去除所有空值所在行
unbanned = unbanned.dropna()

因为不同板块的参照基准不同,我们还想通过个股的证券代码得到它对应的基准指数,于是定义基准获取函数如下:

def ref(stock):
    #深圳主板证券代码前3位是000,基准指数为深证成指399001
    if stock[0:3]=='000':
        reference = '399001.XSHE'
    #中小板证券代码前3位是002,基准指数为中小板指399005
    elif stock[0:3]=='002':
        reference = '399005.XSHE'
    #创业板证券代码前3位是300,基准指数为创业板指399006
    elif stock[0:3]=='300':
        reference = '399006.XSHE'
    #上海主板证券代码首位是6,基准指数为上证综指000001
    elif stock[0]=='6':
        reference = '000001.XSHG'
    #其它情形的比较基准设为沪深300指数000300
    else:
        reference = '000300.XSHG'
    return reference

通过调用基准获取函数,在unbanned中新增一列‘reference’来记录个股对应的基准指数:

unbanned['reference'] = unbanned['CompanyCode'].map(ref)

再来考虑“解禁比例”的数据处理。首先绘制“Proportion1”的累计分布直方图:

plt.xlabel('Proportion(%)',fontdict={'family':'Times New Roman','size':16})
plt.ylabel('Frequency',fontdict={'family':'Times New Roman','size':16})
plt.xlim(0,200)
plt.yticks(fontsize=13)
plt.xticks(range(0,210,25),fontsize=13)
plt.grid(linestyle='--',linewidth=1,axis='y')
plt.hist(unbanned['Proportion1'],bins=40,normed=True,cumulative=True,\
         align= 'mid',facecolor="yellow",edgecolor="black",alpha=0.7)
plt.show()

从上图中看出,解禁比例25%以下的数据最集中,故25%可近似认为是解禁比例的重要分水岭。因而我们以25%为分界线,将“解禁比例”划分为2个区间状态:“小于25%”和“大于25%”,分别命名为“25-”和“25+”,再将“解禁比例”的数值转换为对应的区间状态。为此,定义状态转换函数如下:

def val2sta(value):
    if value<25:
        status = '25-'
    else:
        status = '25+'
    return status

调用状态转换函数将‘Proportion1’一列转化为区间状态:

unbanned['Proportion1'] = unbanned['Proportion1'].map(val2sta)

对于‘StartDateForFloating’一列的数据,删除时间部分,保留日期部分:

unbanned['StartDateForFloating']=unbanned['StartDateForFloating'].map(lambda x: str(x).split(' ')[0])

最后,为方便引用,我们对各列重新命名:

unbanned.rename(columns={'CompanyCode':'security',\
                         'StartDateForFloating':'date',\
                         'Proportion1':'proportion',\
                         'SourceType':'type'},\
                inplace=True)

为规避次新股行情对股价走势的影响,我们过滤掉上市短于1年的股票:

#过滤次新股
for i in unbanned.index:
    #获取证券代码
    security = unbanned.loc[i,'security']
    #获取解禁日期(字符串类型)
    release_date = unbanned.loc[i,'date']
    #将解禁日期先转化为日期时间类型,再转为日期类型
    release_date = dt.datetime.strptime(release_date,'%Y-%m-%d').date()
    #获取证券的上市日期(日期类型)
    list_date = get_security_info(security).start_date
    #上市不超过1年,则置证券代码为空值
    if release_date-list_date<dt.timedelta(365):
        unbanned.loc[i,'security']=np.nan
unbanned = unbanned.dropna()

至此,得到的数据展示如下:

unbanned.tail()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
security date proportion type reference
1807 002077.XSHE 2019-05-31 25- 24 399005.XSHE
1808 002467.XSHE 2019-05-31 25- 80 399005.XSHE
1809 002591.XSHE 2019-05-31 25- 24 399005.XSHE
1810 002752.XSHE 2019-05-31 25+ 82 399005.XSHE
1811 002800.XSHE 2019-05-31 25+ 82 399005.XSHE

我们下一步是计算股票在解禁前后的绝对涨幅和相对涨幅(比较基准为板块指数)。我们的思路是:以解禁日后第10个交易日为期末日期,取前20个交易日个股和基准指数的收盘价进行处理。为获取某交易日后第10个交易日的日期,定义如下日期获取函数:

#获取以date为起始日的第n个交易日期,n默认为10
def later(date,n=10):
    #将字符串型转化为日期时间型
    date1= dt.datetime.strptime(date,'%Y-%m-%d')
    #确定起始日期
    start = date1
    #确定终止日期。自然日的范围要覆盖10个交易日,此处取自然日间隔为n+20
    end = date1 + dt.timedelta(n+20)
    #获取连续n+10个交易日的交易日期
    days = get_trade_days(start,end)
    #返回第n个交易日的日期
    date2 = dt.datetime.strftime(days[n-1],'%Y-%m-%d')
    return date2

例如,从‘2019-05-06’开始的第10个交易日为‘2019-05-17’:

later('2019-05-06')
'2019-05-17'

我们在unbanned中新增4列:‘absolute1’、‘absolute2’、‘relative1’和‘relative2’,分别记录解禁前10日的绝对涨幅、解禁后10日的绝对涨幅、解禁前10日的相对涨幅和解禁后10日的相对涨幅。

#定义解禁日期前后的时间窗口
n = 10
#遍历unbanned所有行索引
for i in unbanned.index:
    #获取证券代码
    security = unbanned.loc[i,'security']
    #获取基准指数
    reference = unbanned.loc[i,'reference']
    #获取解禁日期
    date = unbanned.loc[i,'date']
    #获取解禁后第10个交易日期
    end = later(date)
    #获取股票和指数在解禁前后10天的收盘价(共20天)
    panel = get_price([security,reference],count=2*n,end_date=end,fields=['close'])
    #记录股票解禁10天前的绝对涨幅
    unbanned.loc[i,'absolute1'] = panel['close'][security][n-1]/panel['close'][security][0]-1
    #记录股票解禁10天后的绝对涨幅
    unbanned.loc[i,'absolute2'] = panel['close'][security][-1]/panel['close'][security][-n]-1
    #记录指数解禁10天前的涨幅
    unbanned.loc[i,'ref_ret1'] = panel['close'][reference][n-1]/panel['close'][reference][0]-1
    #记录指数解禁10天后的涨幅
    unbanned.loc[i,'ref_ret2'] = panel['close'][reference][-1]/panel['close'][reference][-n]-1
#循环结束
#新增一列‘relative1’记录股票解禁10天前的相对涨幅
unbanned['relative1'] = unbanned['absolute1']-unbanned['ref_ret1']
#新增一列‘relative2’记录股票解禁10天前的相对涨幅
unbanned['relative2'] = unbanned['absolute2']-unbanned['ref_ret2']
#展示最后5行数据(格式化显示为百分数,下同)
unbanned[['absolute1','absolute2','relative1','relative2','ref_ret1','ref_ret2']]\
.applymap(lambda x: format(x, '.1%')).tail() 
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
absolute1 absolute2 relative1 relative2 ref_ret1 ref_ret2
1807 -26.0% 11.5% -24.9% 12.6% -1.2% -1.1%
1808 -14.5% 4.9% -13.3% 6.0% -1.2% -1.1%
1809 -5.4% -4.8% -4.2% -3.7% -1.2% -1.1%
1810 -1.0% -3.7% 0.2% -2.7% -1.2% -1.1%
1811 0.0% -5.5% 1.2% -4.4% -1.2% -1.1%

首先来看股价在解禁前后的平均涨幅:

unbanned.groupby(['proportion','type'])[['absolute1','absolute2','relative1','relative2']]\
.mean().applymap(lambda x: format(x, '.1%')).tail()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
absolute1 absolute2 relative1 relative2
proportion type
25+ 24 -3.5% -0.9% -2.6% -0.4%
82 -5.2% -1.1% -3.8% -0.4%
25- 24 -2.3% -0.1% -1.1% 0.5%
80 -2.4% -0.7% -0.5% 0.5%
82 -2.1% -0.3% -0.5% 0.3%

从上图中我们可以得到3个结论:1、无论是绝对涨幅还是相对涨幅,解禁后比解禁前都有显著提高。2、解禁比例25-的涨幅高于解禁比例25+的同期涨幅。3、不同解禁类型的涨幅无明显差异。

#每种情形下,解禁后绝对涨幅大于解禁前绝对涨幅的比例
condition = unbanned['absolute2']>unbanned['absolute1']
df1 = unbanned[condition].groupby(['proportion','type'])[['security']].count() 
df2 = unbanned.groupby(['proportion','type'])[['security']].count()
(df1/df2).applymap(lambda x: format(x, '.1%')).tail()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
security
proportion type
25+ 24 55.2%
82 61.0%
25- 24 55.7%
80 55.0%
82 55.0%
#每种情形下,解禁后相对涨幅大于解禁前相对涨幅的比例
condition = unbanned['relative2']>unbanned['relative1']
df1 = unbanned[condition].groupby(['proportion','type'])[['security']].count() 
df2 = unbanned.groupby(['proportion','type'])[['security']].count()
(df1/df2).applymap(lambda x: format(x, '.1%')).tail()
.dataframe tbody tr th:only-of-type { vertical-align: middle; } .dataframe tbody tr th { vertical-align: top; } .dataframe thead th { text-align: right; }
security
proportion type
25+ 24 59.5%
82 61.0%
25- 24 58.7%
80 55.2%
82 57.4%

通过两类解禁比例和三种解禁类型下的分类汇总,我们还能看出:解禁后股价表现强于解禁前的概率不低于55%,利空释放效应比较明显。根据结论1不难得到推论:如果股票解禁前的涨幅大于0,则预期股票解禁后的涨幅也大于0。而根据结论2,我们可以将投资范围局限于更强势的股票,即解禁比例小于25的股票。于是,我们可以在解禁日当天买入之前涨幅大于0且解禁比例小于25的股票,持有10天后抛售。

策略1:在解禁日等金额买入解禁前10日绝对涨幅大于0,且解禁比例小于25的股票,10个交易日后平仓。

#确定数据筛选条件:解禁比例小于25且解禁前绝对涨幅大于0
conditon = (unbanned['proportion']=='25-')&(unbanned['absolute1']>0)
#计算策略1单个事件的平均年化收益率(假设一年240个交易日)
print(format(unbanned[conditon]['absolute2'].mean()*24,'.1%'))
-12.1%

结果显示,单个事件的年化平均收益为-12.1%。收益为负的结果似乎与我们之前的结论矛盾。究其原因,应该跟同期的指数表现相关。我们通过计算同期指数收益来验证这个判断:

#计算指数的同期平均年化收益率
print(format(unbanned[conditon]['ref_ret2'].mean()*24,'.1%'))
-21.7%

可以看到,指数的同期平均年化收益率为-21.7%,显著弱势于同期个股,说明个股表现受到了指数的拖累,而事件释放效应不足以抵消市场环境的影响。但是换个角度,单个事件的平均收益率高于同期指数,给了我们获取相对收益的机会,继而派生出相对收益策略。

策略2:在解禁日等金额买入解禁前10日绝对涨幅大于0,且解禁比例小于25的股票,并按对冲比例1:1做空对应指数,10个交易日后平仓。

#计算策略2单个事件的平均年化收益率(假设一年240个交易日)
print(format(unbanned[conditon]['relative2'].mean()*24,'.1%'))
9.6%

策略2的平均年化收益为9.6%。策略2是一个有显著正回报的策略,但在实际操作中其仍存在局限性。因为我们的收益计算基于4个假设:1、无交易成本;2、指数可做空;3、可实现完全对冲;4、以收盘价开平仓。假设1会使实际收益略有降低。对于假设2和假设3,现实中难以找到相关指数的完美替代品,一种选择是做空股指期货,但期指合约标的有限(只有沪深300,上证50和中证500),且期价与现价走势有差异,难以做到完全对冲,期货保证金的占用也间接降低期望收益。对于假设4,由于当前采用集合竞价机制确定收盘价,也有无法成交或不能以理论价格成交的风险。

全部回复

0/140

量化课程

    移动端课程