摘要及声明
1:本文基于Fama—French和Pastor Stambaugh模型讨论A股垃圾公司数据对回报率计算的影响;
2:本文主要为理念的讲解,模型也是笔者自建,文中假设与观点是基于笔者对模型及数据的一孔之见,若有不同见解欢迎随时留言交流;
3:笔者希望搭建出一套交易体系,原则是只做干货的分享。后续将更新更多内容,但工作学习之余的闲暇时间有限,更新速度慢还请谅解;
4:本文主要数据通过Tushare(ID:444829)金融大数据平台接口获取;
5:模型实现基于python3.8;
上期笔者介绍了两个计算回报率的多因子模型——Fama—French(下文简称“FFM”)和Pastor Stambaugh模型(下文简称“PSM”),在文章结尾笔者提到FFM三因子在中国市场的本土化(Liu et al., 2019a)观点,该观点认为认为中国市场的有效性低于美国等成熟市场,因此传统多因子模型很大程度上很容易失效,并在FFM基础上做了改动,例如在因子计算中舍弃后30%的垃圾公司。
那么在计算因子时消除壳资源污染对结果影响究竟如何?本期笔者将站在数据的角度对该观点进行验证,算是一期杂谈吧,主要内容如下:
目录
1. 壳资源污染
与“壳资源”息息相关的一个词是“借壳上市”,在A股没有进行注册制改革之前是审批制,公司IPO过程不仅麻烦,时间周期也很长。于是有资金实力的企业老板便通过收购上市公司股份的方式,披上上市公司外壳摇身一变成为上市公司。既是上市,那借壳只是手段,大家自然想以最小的成本披上上市公司的马甲,于是那些垫底的公司便成为炙手可热的标的。
但不是人人都是傻子,便宜,规模小,管理层好说话,原始大股东即使着急跑路,业务还能和收购方还能有协同效应,这种公司不得买爆哇。被收购方不傻,反正占着上市的坑位就是金字招牌。市场也不傻,这种收购对于小股东们来说是天大的好事,原来垫底的战五渣马上要被实力雄厚的公司收购,市场对这家原本濒临破产的公司预期肯定是180度转变,三板成妖,七板翻倍的故事比比皆是。
这其实是市场受消息面刺激而引发的异常波动,在笔者上期分享的论文中,Liu et al.(2019b)将之归为市场异象,即壳资源板块公司属于垫底的低质量公司,却能跑出与上证50不一样的走势(感兴趣的可以看看壳资源板块,近年来确实不比上证50差)。反过来在有效市场理论看来,这样的公司连板一路上涨就属于连弱有效市场都达不到的水平。
近年来针对市场的注册制改革简化了IPO流程,完善了退市制度,让原来稀缺的壳资源价值大幅降低。想想之前那篇文章作者也挺不巧的,发文没几年就赶上制度改革。
不过改革还需要时间推进,壳资源及壳资源板块依旧还存在市场中。
2. 需不需要剔除垃圾公司
直接上结论:笔者认为不需要,主要有三点看法和依据:
1):从收益与风险角度看
低质量公司收益率高是股东要求回报率高,因为承担的风险更大。
2):从数理统计角度上看
壳资源公司包含市场中很多小市值公司,它们反映的是市场现状,不应被剔除。
3):从模型结果上看
笔者实证检验发现即使剔除后30%公司,对模型提升效果也不明显。
实际上那篇论文对剔除尾部30%公司后的结果讨论得很少,后30%与后30%-51%(剔除后30%后的30%即是到51%了)的回报率差异究竟多大?笔者做了一些实证检验,下面笔者通过实际的数据分析论证该观点,不想看数据处理代码的读者可以直接跳到第四部分实证分析的内容。
3. 数据处理
首先使用tushare,本文主要行情数据通过Tushare金融大数据平台API获取(Tushare数据),花两分钟注册即可以使用自己的API请求很多经常使用的数据,非常方便。下面调用API,需要使用自己的密钥:
import tushare as ts
pro = ts.pro_api("token") # 输入自己的token
下面代码其实用的是上期的,只是这次跑十年数据。
import pandas as pd
import tushare as ts
import numpy as np
import datetime
pro = ts.pro_api("token")
class company:
def __init__(self, date, code, mv, pb, turn_over, r):
self.date = date
self.code = code
self.mv = mv
self.pb = pb
self.turn_over = turn_over
self.r = r
def data_request(codes, companies_data):
variables = "ts_code,trade_date,close,turnover_rate,volume_ratio,pb,circ_mv"
times, fail = 0, 0
try:
for i in codes:
#lock.acquire()
df = pro.query('daily_basic', ts_code=i, fields=variables)
if len(df) > 0:
df = df[::-1]
df.dropna(inplace=True)
df.drop_duplicates(inplace=True)
date = np.array(df["trade_date"].values[1:]) # 要算涨幅,最后一天舍弃
code = np.array(df["ts_code"].values[1:]) # 公司代码
mv = np.array(df["circ_mv"].values[1:]) # 流通市值
pb = np.array(df["pb"].values[1:])
turn_over = np.array(df["turnover_rate"].values[1:])
close_start = np.array(df["close"][:len(df)-1])
close_next = np.array(df["close"][1:])
r = (close_next - close_start) / close_start # 涨幅
companies_data.append(company(date, code, mv, pb, turn_over, r))
print("成功请求:{}个, 失败{}个\r".format(len(companies_data), fail), end="")
else:
pass
print("\n")
except:
fail+=1
pass
start_time = datetime.datetime.now()
import threading
stock_list = []
for i in ["D", "L"]:
data = pro.stock_basic(exchange='SSE', list_status=i, fields='ts_code')
stock_list.extend(data["ts_code"].values)
quin = len(stock_list)//3
companies_data, threads_pool = [], []
start_time = datetime.datetime.now()
for i in range(0, 3):
t1 = threading.Thread(target=data_request, args=(stock_list[quin*i:quin*(i+1)], companies_data),name="task{}".format(i))
threads_pool.append(t1)
t1.start()
for i in threads_pool:
i.join()
end_time = datetime.datetime.now()
print("耗时:", end_time - start_time)
耗时: 0:14:46.182942
这里开了三个线程,耗时15分钟。
下面跑4个因子,需要加入后30%垃圾公司的判断条件:
trash_companies = np.percentile(mv_lst, 30)
mv_lst, pb_lst, turnover_lst, trading_companies = [], [], [], []
for company in companies_data:
if i in company.date:
index = list(company.date).index(i) # 定位到当天的索引
if company.mv[index] >= trash_companies: # 拉取当天所有符合条件的股票数据
mv_lst.append(company.mv[index])
pb_lst.append(company.pb[index])
turnover_lst.append(company.turn_over[index])
trading_companies.append(company)
else:
pass
else:
pass
和上期一样,只是要跑十年,然后还需要上面的把市值筛选加入:
index_trade_date = pro.index_daily(ts_code='000001.SH', start_date='20110101', end_date='20220923')["trade_date"].values
date_times = []
big_ret = []
small_ret = []
high_ret = []
low_ret = []
liq_ret = []
illiq_ret = []
n = 0
for i in index_trade_date:
big, small, high, low, liq, illiq = [], [], [], [], [], []
mv_lst = []
for company in companies_data:
if i in company.date: # 拉取当天所有交易股票的市值
index = list(company.date).index(i)
mv_lst.append(company.mv[index])
else:
pass
trash_companies = np.percentile(mv_lst, 30)
mv_lst, pb_lst, turnover_lst, trading_companies = [], [], [], []
for company in companies_data:
if i in company.date:
index = list(company.date).index(i) # 定位到当天的索引
if company.mv[index] >= trash_companies: # 拉取当天所有符合条件的股票数据
mv_lst.append(company.mv[index])
pb_lst.append(company.pb[index])
turnover_lst.append(company.turn_over[index])
trading_companies.append(company)
else:
pass
else:
pass
if len(mv_lst) > 0:
date_times.append(i) # 拿这个给最后生成的表格一个时间索引
mv_big = np.percentile(mv_lst, 70) # 超过80分位阈值则认为是大市值公司
mv_small = np.percentile(mv_lst, 30) # 低于20分位阈值则认为是小市值公司
pb_high = np.percentile(pb_lst, 70)
pb_low = np.percentile(pb_lst, 30)
liq_good = np.percentile(turnover_lst, 70)
liq_bad = np.percentile(turnover_lst, 30)
# 得到当天的三因子阈值后判断每个公司是否符合阈值条件
total_mv_big, total_mv_small = 0, 0 # 几个投资组合总市值
total_mv_high, total_mv_low = 0, 0
total_mv_ilq, total_mv_illiq = 0, 0
for company in trading_companies:
index = list(company.date).index(i) # 定位当天索引
if company.mv[index] >= mv_big:
big.append(company.r[index] * company.mv[index]) # 乘市值方便后面进行市值加权
total_mv_big += company.mv[index]
else:
if company.mv[index] <= mv_small:
small.append(company.r[index] * company.mv[index])
total_mv_small += company.mv[index]
else:
pass
if company.pb[index] >= pb_high:
high.append(company.r[index] * company.mv[index])
total_mv_high += company.mv[index]
else:
if company.pb[index] <= pb_low:
low.append(company.r[index] * company.mv[index])
total_mv_low += company.mv[index]
else:
pass
if company.turn_over[index] >= liq_good:
liq.append(company.r[index] * company.mv[index])
total_mv_ilq += company.mv[index]
else:
if company.turn_over[index] <= liq_bad:
illiq.append(company.r[index] * company.mv[index])
total_mv_illiq += company.mv[index]
# 以市值加权求出每个因子当天的平均回报
big_ret.append(np.sum(big) / total_mv_big)
small_ret.append(np.sum(small) / total_mv_small)
high_ret.append(np.sum(high) / total_mv_high)
low_ret.append(np.sum(low) / total_mv_low)
liq_ret.append(np.sum(liq) / total_mv_ilq)
illiq_ret.append(np.sum(illiq) / total_mv_ilq)
n+=1
print("已完成{}天\r".format(n), end="")
# 导入字典存成表格吧
data_dic = {
"date": date_times,
"big_r": big_ret,
"small_r": small_ret,
"high_pb_r": high_ret,
"low_pb_r": low_ret,
"liq_r": liq_ret,
"illiq_r": illiq_ret
}
data = pd.DataFrame(data_dic)
print(data)
2011年到现在2852条数据:
Unnamed: date big_r small_r high_pb_r low_pb_r liq_r illiq_r
0 20220923 -0.000656 -0.023047 -0.013846 0.004408 -0.018500 0.011424
1 20220922 0.000519 -0.003635 -0.001805 -0.000265 0.008266 -0.001592
2 20220921 -0.000091 0.000670 -0.015603 0.005975 0.001688 0.011276
3 20220920 -0.002351 0.015031 0.011471 -0.007000 0.018688 -0.039951
4 20220919 0.000411 -0.014183 -0.004511 0.000346 -0.003315 0.014400
... ... ... ... ... ... ... ... ...
2847 20110110 -0.013850 -0.024892 -0.018734 -0.011900 -0.009467 -0.161237
2848 20110107 0.011999 -0.005321 -0.009939 0.016697 0.024939 0.107513
2849 20110106 -0.008128 0.000482 -0.012038 -0.006184 -0.011796 -0.056085
2850 20110105 -0.010006 0.008371 -0.000762 -0.008518 0.012182 -0.140667
2851 20110104 0.014703 0.019876 0.027113 0.011566 0.043729 0.154416
2852 rows × 8 columns
把数据存到本地,不然跑一次要好长时间:
data.to_csv("risk_factor.csv")
如法炮制跑没有剔除30%的十年数据,也存好csv:
# 此处省略n行代码
data.to_csv("risk_factor1.csv")
4. 实证分析
数据都拿到了,接下来笔者简单分析一下:
4.1 走势差异
df = pd.read_csv("C:/Users/Administrator/Desktop/risk_factor.csv")[::-1] # 剔除30%
df_1 = pd.read_csv("C:/Users/Administrator/Desktop/risk_factor1.csv")[::-1] # 未被剔除
plt.figure(figsize=(10,4))
for i in range(1,28):
plt.subplot(27,1,i)
plt.plot(range(len(df["date"][(i-1)*100:i*100])), df["small_r"][(i-1)*100:i*100])
plt.plot(range(len(df_1["date"][(i-1)*100:i*100])), df_1["small_r"][(i-1)*100:i*100])
plt.show()
笔者用子图展示,下面每个子图都包含100天的走势:
图二:剔除30%前后小市值公司走势
数据太多了,选择第一个子图展示吧:
图三:剔除30%前后小市值公司走势比较(数据集前100个交易日)
可以看到,剔除与不剔除投资组合的收益率在走势上其实差距很小。
我们不妨在看看同样不剔除的情况下大市值和小市值公司回报率差异有多大:
plt.plot(range(len(df_1["date"][(i-1)*100:i*100])), df_1["small_r"][(i-1)*100:i*100], color = "orange", label="small_r")
plt.plot(range(len(df_1["date"][(i-1)*100:i*100])), df_1["big_r"][(i-1)*100:i*100], label="big_r")
图四:小市值公司与大市值公司走势比较(数据集前100个交易日)
可以看到,大市值公司和小市值公司回报率差异是比较大的,并且大市值公司回报的波动率明显小于小市值公司。
大家可以验证其它时间段大小市值,剔除和不剔除垃圾公司的投资组合,结果其实都一样:后30%公司的回报率与30%-51%(剔除后30%后的30%)的公司回报率差异不大。换句话说,就算剔除了也没多大用。
4.2 中心趋势,离散程度分析
print("未被剔除30%,小市值公司回报率标准差", np.std(df_1["small_r"]))
print("剔除30%,小市值公司回报率标准差",np.std(df["small_r"]))
# 未被剔除30%,小市值公司回报率标准差 0.018201884783021587
# 剔除30%,小市值公司回报率标准差 0.018485703965005183
print("未被剔除30%,小市值公司回报率均值",np.mean(df_1["small_r"]))
print("剔除30%,小市值公司回报率均值",np.mean(df["small_r"]))
# 未被剔除30%,小市值公司回报率均值 0.0002571179133868966
# 剔除30%,小市值公司回报率均值 -0.00012664414485065036
可以看到,有一定差异,但非常小,基本只有2,3个基点。
不妨用大市值公司和小市值公司的离散度与均值比较一下:
print("大市值公司回报率标准差", np.std(df["big_r"]))
print("小市值公司回报率标准差",np.std(df["small_r"]))
# 大市值公司回报率标准差 0.013047366311401182
# 小市值公司回报率标准差 0.018485703965005183
print("大市值公司回报率均值",np.mean(df["big_r"]))
print("小市值公司回报率均值",np.mean(df["small_r"]))
# 未被剔除30%,小市值公司回报率均值 0.00048702596778191204
# 剔除30%,小市值公司回报率均值 -0.00012664414485065036
可以看到,标准差的差异显著提升,均值方面也产生了6个基点的差距。
在来看看时间序列上的分布情况,下面的代码将2011年以来的数据数据按100天为一个区间,求取100天中剔除和不剔除30%公司回报率均值的差异:
mean_lst = []
for i in range(29):
mean_lst.append(np.mean(df_1["small_r"][i*100:(i+1)*100])-np.mean(df["small_r"][i*100:(i+1)*100]))
plt.plot(df["date"][::100], mean_lst[:len(std_lst)])
plt.show()
图五:剔除30%公司前后回报率差异的时间维度变化(2011-2022)
通过时间维度走势可以看到,后30%和30%-51%的公司回报率差异在时间分布上很难说有什么显著的趋势。
调取最近两年的日回报差异看看:
图六:剔除30%公司前后回报率差异的时间维度变化(2020-2022)
笔者还是很难看出什么明显的关联度或者趋势,如果硬要说趋势就是今年7月以来这两组公司回报率差异在不断收窄。但似乎也很难说明问题。
4.3 解释力度
感觉中心趋势很难看出端倪,尽管两组数据是有一定差异的,但很难说它能提高多少模型的解释力度,于是笔者还是是跑回归,每个公司都跑一次,然后把得到的R方存列表,最后看看剔除与不剔除30%公司有没有提高模型解释力度。代码就省略了,和上期一样的跑回归,结果如图所示:
图七:剔除30%公司前后的R方分布
从R方分布上看,剔除与不踢除30%公司所回归出来的模型解释力度其实差异并还是不大。
5. 总结
通过目前的数据分析,笔者认为是否剔除30%的尾部公司对使用相关多因子模型计算回报率影响不大。