【量化课堂】“高送转”预测模型

引言

每逢年末,A 股市场都将迎来“高送转”预案公告的重头戏。无论市场行情怎样,总有“高送转”概念股能够吸纳大量资金、实现大幅上涨,正因如此,“高送转”概念一直都受到投资者的广泛关注。

概念简介

“高送转”股票(简称“高送转”)是指送红股或者转增股票的比例很大的个股。参照中信证券的研报,我们定义 10 送(转)5 及以上为“高送转”。“高送转”的实质是股东权益的内部调整,对净资产收益率没有影响,对公司的盈利能力也没有任何实质性的影响。“高送转”后,公司股本总数虽然扩大了,但公司的股东权益并不会因此增加。另一方面,在净利润不变的情况下,由于资本公积转增股本使得股本扩大,摊薄每股收益。在公司“高送转”方案的实施日,公司股价将做除权处理,尽管“高送转”方案使得投资者持有的股票数量增加了,但股价也做出了相应的调整,投资者持股比例不变,持有的股票的总价值也未发生改变。

送红股与资本公积转增股本方式对公司的股东权益和盈利能力并没有实质性的影响,也不能给投资者带来直接的现金回报,但为什么“高送转”概念股总是能够引发市场的广泛关注呢?

我们认为,原因主要有三个方面:一,投资者通常十分认可公司敢于“高送转”摊薄每股利润的勇气,往往将“高送转”视作公司对未来实现高速增长充满信心的表现,认为“高送转”向市场传递了公司未来业绩将保持高增长的积极信号,有助于保持良好的市场形象,引发股价上扬;另一方面,一些股价较高、股票流动性较差的公司,也可以通过“高送转”达到降低股价的目的,进而增强公司股票的流动性;最后,市场对“高送转”题材的追捧,也对股价的大幅变动起到了推波助澜的作用,投资者有望通过填权行情,从二级市场的股票增值中获利。因此,绝大多数投资者都将“高送转”看做重大利好消息,“高送转”也成为半年报和年报出台前的炒作题材。在董事会“高送转”预案前后,几乎每家公司的股价都出现了大幅上扬。

策略思路

高送转的硬性基础是高资本公积或高未分配利润,上市公司进行高送转的动机是扩张股本和降低股价,从而提高股票流动性,敢于高送转摊薄利润的勇气是对未来公司高速成长的信心。新上市的股票资本公积较高,是送转的热门候选。因此,筛选高送转标的可以从高资本公积、高未分配利润、高股价、高 EPS、高 EPS 增速、低股本和上市时间短等方面进行。

我们提出的“高(EPS、股价)”、“富(资本公积、BPS)”、“帅(上市短、小盘股)”的标准筛选“高送转”标的,本文暂不予考虑具备送转实力的定增股和马上面临限售股解禁的标的。


资料来源:中信证券研究部

策略细节

每股资本公积分组 VS 高送转概率

股价分组 VS 高送转概率

BPS 分组 VS 高送转概率

EPS 分组 VS 高送转概率

上市时间分组 VS 高送转概率

股本分组 VS 高送转概率


资料来源:中信证券数量化投资分析系统

根据以上 6 图所示关系并结合研报,我们建立如下策略:

1.每年 11 月 1 日,对全部 A 股股票按照每股资本公积、每股净资产、EPS 和股价由高到低排序,分成 10 组。对最高组的标的在对应指标赋 10 分,次高组赋 9 分,至最低组赋 1 分。
2.对上市时间、股本排序分为 10 组。对最低组的标的在对应指标赋 10 分,次低组赋 9 分,之最高组赋 1 分。
3.将上述 6 个指标的得分加总,形成预测得分。
4.按最终得分对标的排序,选取得分最高的前 60 只,形成“高送转”预测名单。
5.在预测名单上的 60 只股票间平均分配资金,持有至下一年 3 月 1 日卖出。

策略实现

1)高送转打分选股-持有至次年 3 月初
交易标的:A 股
调仓周期:无调仓
持仓量:60 支股票
回测时间:2012~ 2017 每年 11.1 至次年 3.1
回测时长:5 年

分段收益率

根据回测结果,我们可以发现,通过因子打分“埋伏”高送转股票的策略表现并不稳定,在 5 个 段内收益波动很大。我们尝试另一个策略,即每年 11.1 日调仓,其余时间持有股票。

2)高送转打分选股-每年 11.1 调仓
交易标的:A 股
调仓周期:每年
持仓量:60 支股票
回测时间:201211.1~2017.8.15
回测时长:4.75 年

收益曲线


收益归因


业绩分析

回测结果显示,在采取长期持有,每年调仓的策略后,我们取得了 20.6%的年化收益率,胜率高达 81.5%。不过策略回撤过高,波动率偏大。这可能是因为我们的持仓周期太长,导致个股对投资组合影响过大,策略不稳定。下面我们考虑对个股中报时的高送转情况作出预测,并在每年 5 月调仓,以期增加调仓频率。要注意的是,在中报发布高送转信息的公司一般偏少,这里我们的持仓数量设置为年报期的一半。

3)高送转打分选股-每年 11.1,5.1 调仓
交易标的:A 股
调仓周期:每半年
持仓量:11.1 持有 60 支股票,5.1 持有 30 支股票
回测时间:201211.1~2017.8.15
回测时长:4.75 年

收益曲线


收益归因


业绩分析

从回测结果我们可以发现,策略(3)与策略(2)表现相差不大,二者的收益率、回撤、波动率、夏普率等指标都很相近,并且两个策略的买入卖出数差别也不多。这可能是因为在公司基本面没有出现重大变化的情况下,年末和年中选出的有高送转潜质的股票出现很多重合。因此两个策略表现接近。

到这里,我们似乎可以认为我们的高送转预测策略是成功的。但是,值得注意的一点是,我们采用因子打分的方式选择出最有可能进行高送转的股票进行投资得到的收益,有没有可能只是来自于因子本身的有效性呢?如果我们使用这些因子进行简单的多因子选股,结论又会如何呢?下面我们将采用这些因子测试一个周期 20 天的多因子选股策略。

4)高送转打分选股-以 20 天为周期调仓
交易标的:A 股
调仓周期:20 天
持仓量:60
回测时间:201211.1~2017.8.15
回测时长:4.75 年

收益曲线


收益归因


业绩分析

可以发现,在采用 20 天调仓之后,我们的策略表现与之前不仅相差不大,反而略有上升。从买卖数量来看,策略(3)相对于策略(1)(2)大大增加了,这表明二者的选股存在差异。理论上来说,如果策略的收益仅仅来源于对高送转的预测成功,那么在高送转一般发生的时间段内,也就是 11 月至次年 3 月,策略的收益应当高过其他时间的收益。下面是策略(3)在不同时间段内运行的回测结果:

简单对比就可以发现,因子在 11.1次年 3.1 的时间段内表现并没有明显优于 3.111.1 的时间段,这表明该策略的收益更可能来自于因子本身的有效性,而非根据因子预测出的高送转事件。

小结

经测试,使用因子打分方式对高送转股票进行预测,进而“埋伏”高送转的选股策略总体表现良好。但是,通过与其他策略的对比,我们认为该策略的有效性更多的体现在因子本身与股票收益的相关性,而非来自于对高送转的准确预测。如果想要更精确的预测高送转,从而提前布局获取收益,我们应当更多的考虑具备送转实力的定增股和马上面临解禁的限售股,而非对全 A 股进行简单排序。此外,单纯的因子打分模型可能过于简单,预测效果未必准确,我们可以考虑采用更加复杂的预测模型,例如使用 Logistic 回归对高送转进行预测。

关于回测平台、历史数据,可以在这里下载:http://www.yunkuanke.com/#/introduce
这个平台的数据都是经过清洗的,而且策略不会外露,有保密系统,还是比较安全的。
想学习更多量化策略,也可以来这里看看:http://www.yunkuanke.com/#/lzClass
想学习更多关于量化投资策略相关内容的,可以关注微信:量化投资与金融科技(微信号:QuantumFintech)
微信二维码:

Code

# -*- coding:utf-8 -*-

_from _CloudQuant _import _SDKCoreEngine  # 导入量子金服SDK
_from _CloudQuant _import _AssetType
_from _CloudQuant _import _QuoteCycle
_from _CloudQuant _import _OrderType
_import _numpy _as _np  # 使用numpy
_import _pandas _as _pd

np.seterr(invalid='ignore')

config = {
'username': 'pengkun',
'password': '111111',
'rootpath': 'c:/cStrategy',  # 客户端所在路径
'assetType': AssetType.Stock,
'initCapitalStock': 100000000,  # 初始资金
'startDate': 20121101,  # 交易开始日期
'endDate': 20170301,  # 交易结束日期
'cycle': QuoteCycle.D,  # 回放粒度为1分钟线
'feeRate': 0.001,
'feeLimit': 5,
'strategyName': '高送转打分选股_持有到次年2月底',  # 策略名
"logfile": "ma.log",
'dealByVolume': False
}

NUM=60

_def _initial(_sdk_):
_pass__
__
__
__def _initPerDay(_sdk_):
_pass__
__
__def _strategy(_sdk_):
#每年11月第一个交易日交易交易
record=_sdk_.getGlobal('r')
stock_list = np.array(_sdk_.getStockList())
date=_sdk_.getNowDate()
lmonth=_sdk_.getGlobal("lm")
month=date%10000//100
_sdk_.setGlobal("lm",month)
_if _(month==11 _and _lmonth==10) _or _(month==11 _and _lmonth _is _None):
    #获取因子
    capitalReserve=_sdk_.getFactorData("LZ_CN_STKA_FIN_IND_SURPLUSCAPITALPS")[-1]
    bps=_sdk_.getFactorData('LZ_CN_STKA_FIN_IND_BPS')[-1]
 eps = _sdk_.getFactorData('LZ_CN_STKA_FIN_IND_EPBASIC')[-1]
price = _sdk_.getFactorData('LZ_CN_STKA_QUOTE_TCLOSE')[-1]
listdate = _sdk_.getFactorData('LZ_CN_STKA_LISTDATE').T
    listdate=np.array(listdate[1])
    listdate=listdate.astype(float)
tshr = _sdk_.getFactorData('LZ_CN_STKA_VAL_TSHR')[-1]

    #过滤停牌股票
    condition=preFilter(_sdk_)

    #因子打分n
    rank_captialReserve=score(_sdk_,capitalReserve,condition,stock_list)
    rank_bps=score(_sdk_,bps,condition,stock_list)
rank_price = score(_sdk_, price, condition, stock_list)
rank_eps = score(_sdk_, eps, condition, stock_list)
rank_listdate = score(_sdk_, listdate, condition, stock_list)
rank_tshr = score(_sdk_, -tshr, condition, stock_list)
    rank_t=rank_captialReserve+rank_bps+rank_eps+rank_price+rank_listdate+rank_tshr
    rank_t=rank_t.sort_values(ascending=False)
    stock_pool=rank_t.index[:NUM]

    #交易
    transferPosition(_sdk_,stock_pool)

    record=dict(zip(stock_pool,[0.0]*len(stock_pool)))
divident_bonus=_sdk_.getFactorData('LZ_CN_STKA_DIVIDEND_BONUS_RATE')[-1]
divident_bonus=np.nan_to_num(divident_bonus)
divident_bonus = pd.Series(divident_bonus, index=stock_list)
divident_converse=_sdk_.getFactorData('LZ_CN_STKA_DIVIDEND_CONVERSED_RATE')[-1]
divident_converse=np.nan_to_num(divident_converse)
divident_converse = pd.Series(divident_converse, index=stock_list)
_for _s _in _record:
    _print _s
    record[s]=record[s]+divident_converse[s]+divident_bonus[s]
_print _record
_sdk_.setGlobal('r',record)

# if month==3 and lmonth==2:
#     stock_pool=[]
#     transferPosition(sdk,stock_pool)

_def _transferPosition(_sdk_,_stock_pool_):
position = _sdk_.getPositions()
position_dict = dict([i.code, i.optPosition] _for _i _in _position)
stock_to_buy = set(_stock_pool_) - set(position_dict.keys())
stock_to_sell = set(position_dict.keys()) - set(_stock_pool_)
quotes = _sdk_.getQuotes(list(stock_to_buy | stock_to_sell))
_if _stock_to_sell:
    sell_orders = []
    _for _stock _in _stock_to_sell:
        _if _stock _in _quotes.keys():
            price = quotes[stock].current
volume = position_dict[stock]
order = [stock, price, volume, -1]
            sell_orders.append(order)
    _if _sell_orders:
        _sdk_.makeOrders(sell_orders)
        _sdk_.sdklog("------------------------------------------")
        _sdk_.sdklog(_sdk_.getNowDate(),"DATE")
        _sdk_.sdklog(sell_orders,"SELL")
_if _stock_to_buy:
    available_cash = _sdk_.getAccountInfo().availableCash
available_cash_one_stock = available_cash / len(stock_to_buy)
buy_orders = []
    _for _stock _in _stock_to_buy:
        _if _stock _in _quotes.keys():
            price = quotes[stock].open
volume = int(available_cash_one_stock / (price * 100)) * 100
            _if _volume > 0:
                order = [stock, price, volume, 1]
                buy_orders.append(order)
    _if _buy_orders:
        _sdk_.makeOrders(buy_orders)
        _sdk_.sdklog("------------------------------------------")
        _sdk_.sdklog(_sdk_.getNowDate(),"DATE")
        _sdk_.sdklog(buy_orders,'BUY')

_def _preFilter(_sdk_):
suspend=_sdk_.getFactorData("LZ_CN_STKA_SLCIND_STOP_FLAG")[-1]
condition=suspend==0
# condition_suspend=suspend==0
# st=sdk.getFactorData("LZ_CN_STKA_SLCIND_ST_FLAG")[-1]
# condition_st=st==0
# condition=condition_st+condition_suspend
_return _condition

_def _score(_sdk_,_f_,_condition_,_stock_list_):
f=_f_[_condition_]
stock_list=_stock_list_[_condition_]
f = pd.Series(_f_, index=_stock_list_)
f = _f_.sort_values(ascending=False)
l=len(_f_)
n=l//10
rank=[0]*l
_for _i _in _range(l):
    c=i//n
    rank[i]=10-c
rank = pd.Series(rank, index=_f_.index)
_return _rank

_def _run_all():
# 将策略函数加入
config['initial'] = initial
config['strategy'] = strategy
config['preparePerDay'] = initPerDay
# 启动SDK
SDKCoreEngine(**config).run()

_if ___name__ == "__main__":
run_all()