研报复现:【方正金工】成交量激增时刻蕴含的alpha信息——多因子选股系列研究之一
由hxgre创建,最终由hxgre 被浏览 58 用户
本文旨在复现方正证券的金融工程类研报,通过构建高频因子让大家学习股票分钟数据的使用。原始研报贴在文章的最后附录部分。
一、因子逻辑介绍
在股票市场中,成交量的边际变化隐含着非常重要的信息,特别是在技术分析领域,成交量被认为是股票市场的原动力。俗语“量在价先”深刻的反应了成交量的变化对于股票价格波动的预测具有指示性作用。
以利好信息为例,当一个利好信息公布后,可能会引起相应个股成交量的突然放大。如果在成交量激增的同时,价格却未发生变动,或者未能引起价格的波动,则表明这一利好消息没能得到市场广泛的认可。相反,如果成交量激增的同时,价格出现大幅上涨,则表明市场对于此利好信息反应过于趋同,有可能出现反应过度。因此,当市场获得新的利好信息后,一方面我们希望此信息可以被市场广泛的认可和接受,推动股票价格稳步上涨;而另一方面我们不希望成交量的激增引起的价格变动太过剧烈,这样可能代表了投资者反应过度,导致股票短时间内涨幅过大,或者风险大幅加剧。
通过观察日内成交量激增的时段,考察这些时段的收益率与波动率,我们将与市场平均水平作为“适度”程度的衡量标准,进而构建“耀眼波动率”因子和“耀眼收益率”因子,并最终合成为能综合反应投资者反应不足和反应过度程度的“适度冒险”因子。
1.1 成交量激增的定义
观察个股分钟频成交量的边际变化来定义成交量是否激增,具体如下:
- 剔除开盘和收盘数据,仅考虑日内分钟频数据,我们首先计算个股每分钟的成交量相对于上一分钟的成交量的差值,作为该分钟成交量的增加量。
- 计算每天每只个股分钟频成交量的增加量的均值 mean 和标准差std。
- 我们定义那些分钟频成交量增加量大于“均值+一倍标准差”的时刻为成交量激增的时刻,我们将对应的时刻统称为“激增时刻”。
- 举例来说,假设股票 A 在某天 t 的第 9 分钟、第 10 分钟、第 88分钟、第 200 分钟的成交量增加量大于当天的 mean+std,那么我们将第 9、10、88、200 这四个分钟统称为 A 股票在 t 日的“激增时刻”。
下图展示了某股票日内成交量的“激增时刻”:
2.2 激增时刻的价格波动
首先来考察成交量激增引起的价格波动,进而构造“月耀眼波动率”因子,具体过程如下:
- 定义“耀眼 5 分钟”为“激增时刻”的这一分钟及其随后的 4 分钟。因为这一段时间是因成交量激增而引起投资者关注的 5 分钟,投资者对成交量激增的反应,在这 5 分钟里表现得最充分最强烈。在上例中,如果第 9、10、88、200 分钟为“激增时刻”,那么对应的“耀眼 5 分钟”分别为第 9~13 分钟、第 10~14 分钟、第 88~92 分钟、第 200~204 分钟。
- 使用分钟收盘价,计算每分钟的收益率,进而可以得到每个“耀眼 5 分钟”里,收益率的标准差,作为成交量激增引起的价格波动率,我们将其称为“耀眼波动率”。
- 计算 A 股票在 t 日内所有“耀眼波动率”的均值,作为 t 日 A股票对成交量的激增在波动层面上反应的代理变量,记为“日耀眼波动率”。
- 根据前述分析,我们希望“日耀眼波动率”不要太大,也不要太小,适度最好,为了不引入其他参数,此处选取“日耀眼波动率”的截面均值作为最“适度”的水平。因此我们将每日的“日耀眼波动率”减去截面的均值再取绝对值,表示个股的“日耀眼波动率”与市场平均水平的距离,并将其记为日频因子“适度日耀眼波动率”。
- 再分别计算最近 20 个交易日的“适度日耀眼波动率”的平均值和标准差,记为“月均耀眼波动率”因子和“月稳耀眼波动率”因子。
- 将“月均耀眼波动率”与“月稳耀眼波动率”等权合成,得到“月耀眼波动率”因子。
下图展示了耀眼波动率因子的单因子分析结果,回测参数:全A样本、中性化处理、测试区间为2013年至2022年。从结果来看,因子均表现出强势的选股能力。
2.3 激增时刻的价格变动
接下来考察成交量激增引起的价格变动,进而构造“月均耀眼收益率”和“月稳耀眼收益率”因子,具体过程如下:
- 通过股票的每分钟收盘价,计算股票每分钟的收益率。
- 找到“激增时刻”对应的分钟收益率,定义为“耀眼收益率”。
- 对 A 股票在 t 日内所有的“耀眼收益率”求均值,作为股票对成交量激增在收益率层面反应的代理变量,记为“日耀眼收益率”。
- 同样地,“耀眼收益率”保持适度最好,因此将每日的“日耀眼收益率”减去截面的均值再取绝对值,表示个股的“日耀眼收益率”与市场平均水平的距离,并将其记为“适度日耀眼收益率”。
- 再分别计算最近 20 个交易日的“适度日耀眼收益率”的平均值和标准差,记为“月均耀眼收益率”和“月稳耀眼收益率”因子。
- 将“月均耀眼收益率”与“月稳耀眼收益率”因子等权合成,得到“月耀眼收益率”因子。
下图展示了耀眼收益率相关因子的单因子分析结果,回测参数:全A样本、中性化处理、测试区间为2013年至2022年。从结果来看,合成之后的“适度冒险”因子表现非常出色,Rank IC 为-8.89%、Rank ICIR 为-4.84,多空组合年化收益率达 37.46%,信息比 4.10,因子月度胜率 87.74%。
2.4 适度冒险因子合成
将上述“月耀眼波动率”因子与“月耀眼收益率”因子合并,等权合成为最终的“适度冒险”因子。
下图展示了耀眼波动率因子的单因子分析结果,回测参数:全A样本、中性化处理、测试区间为2013年至2022年。从结果来看,因子均表现出强势的选股能力。
二、数据源介绍
BigQuant 平台为各位量化投资者准备好了股票的分钟数据。使用过数据平台的朋友可能发现,平台上存储了有两张股票分钟数据表,分别是 cn_stock_bar1m(https://bigquant.com/data/datasources/cn_stock_bar1m) 和 cn_stock_bar1m_c(https://bigquant.com/data/datasources/cn_stock_bar1m_c),其实这两张表结构和存储数据都是相同,只是存储方式不同,适用场景不同,这里给大家比较下两张表的区别:
- cn_stock_bar1m 底层是按照股票存储数据的,适用于读取单只股票多年的数据。
- cn_stock_bar1m_c 底层是按照年份存储数据的,c表示截面(cross section),适用于读取多只股票单年的数据。
下图展示了 cn_stock_bar1m 和 cn_stock_bar1m_c 两张数据表 000002.SZ 在 2024-09-10 这一天的示例数据:
- 可以看出,cn_stock_bar1m 和 cn_stock_bar1m_c 两张表的数据是一致的,只是存储方式不同,使用场景不同。
- 统一把开盘集合竞价阶段(09:15:00 至 09:25:00)的数据归纳到 09:25:00 这一分钟上,因此,连续竞价阶段第一分钟是 09:31:00。
- 收盘集合竞价阶段(14:57:00至15:00:00)不做特殊处理,每一分钟会有条记录,14:58:00会有成交量可能是因为14:57:00这一刻左右有部分成交会被归为14:58:00这一分钟。
- 正常交易的情况下,每日会有241根分钟K线。
三、因子构建代码介绍
3.1 导入必要的包
import dai
import random
import numpy as np
import pandas as pd
from datetime import datetime
3.2 获取原数据
以下代码从 cn_stock_bar1m_c 表中读取分钟数据:
- 限制了时间范围和股票池,这里随机选取50只作为股票池是分钟数据量较大后续有较多的groupby计算操作,考虑到大多用户计算资源较小,所以限制了数据量,保证代码能在较小的资源规格下也能跑通。后续要计算全市场多年的因子数据,需要对代码进行工程化处理。
- 读取了分钟原始数据后,增加了trading_day和time两列数据,分别标识交易日期和交易时间,且均为 int 类型,用于后续的数据过滤,比如剔除开盘集合竞价和收盘集合竞价的数据直接用 time 进行过滤。
# 确定时间范围和股票池
sd = "2023-11-01"
ed = "2024-02-01"
stk_pool = dai.query("SELECT date, instrument FROM cn_stock_instruments", filters={"date": [sd, ed]}).df()
ins = tuple(random.sample(stk_pool["instrument"].unique().tolist(), 50))
# 读取分钟原始数据
raw_data = dai.query(f"""
SELECT date, instrument, close, volume, close / m_lag(close, 1) - 1 as ret,
FROM cn_stock_bar1m_c
WHERE instrument IN {ins}
""", filters={"date": [f"{sd} 00:00:00", f"{ed} 00:00:00"]}).df()
# 对原始数据进行简单处理
raw_data["trading_day"] = raw_data["date"].dt.year*10000 + raw_data["date"].dt.month*100 + raw_data["date"].dt.day
raw_data["time"] = raw_data["date"].dt.time
raw_data["time"] = raw_data['time'].apply(lambda x: int(x.strftime('%H%M%S')))
# 剔除开盘和收盘数据
raw_data = raw_data[(raw_data["time"]>=93000) & (raw_data["time"]<=145700)]
3.3 计算日内数据
接下来,正式进入因字构建计算的代码。以下代码计算分钟数据的下述指标:
- 激增时刻(surge_time):当前分钟成交量增加值大于全天均值+1倍标准差时,设置为1,不满足设置为0。
- 耀眼时刻(shine_time):将激增时刻的1分钟和后续的4分钟设置为1,其他设置为0。
- 耀眼波动率(shine_volatility):每个耀眼时刻的收益率标准差。
- 耀眼收益率(shine_return):激增时刻对应的分钟收益率
# 计算日内数据
def calc_intraday(group: pd.DataFrame) -> pd.DataFrame:
# 成交量增加值
group["vol_diff"] = group["volume"].diff()
# 成交量增加值的均值
group["vold_mean"] = group["vol_diff"].mean()
# 成交量增加值的标准差
group["vold_std"] = group["vol_diff"].std()
# 激增时刻:当前分钟成交量增加值 大于 均值+1倍标准差
group["surge_time"] = 0
group.loc[
group["vol_diff"]>(group["vold_mean"]+group["vold_std"]),
"surge_time"
] = 1
# 耀眼时刻:激增时刻的1分钟 + 后续的4分钟
group["shine_time"] = 0
for idx in group[group["surge_time"]==1].index.tolist():
group.loc[idx: idx+4, "shine_time"] = 1
# 耀眼波动率:每个耀眼5分钟里的收益率标准差
group.loc[idx, "shine_volatility"] = group.loc[idx: idx+4, "ret"].std()
# 耀眼收益率:激增时刻对应的分钟收益率
group.loc[group["surge_time"]==1, "shine_return"] = group.loc[group["surge_time"]==1, "ret"]
return group
df_1m = raw_data.groupby(["trading_day", "instrument"]).apply(lambda x: calc_intraday(x)).reset_index(drop=True)
3.4 统计日内数据到日频数据
统计每日的耀眼波动率和耀眼收益率
# 日耀眼波动率/收益率 = 日内所有耀眼波动率/收益率的均值
df_daily = df_1m.groupby(["trading_day", "instrument"])[["trading_day", "instrument", "shine_volatility", "shine_return"]].agg({
"trading_day": "first",
"instrument": "first",
"shine_volatility": "mean",
"shine_return": "mean"
}).reset_index(drop=True)
3.5 截面统计适度耀眼波动率和耀眼收益率
下述代码是计算“适度”数据,所谓“适度”就是评价个股的“日耀眼波动率”或“日耀收益率”与市场平均水平的差异
# 适度日耀眼波动率/收益率 = ABS(个股日耀眼波动率 - 截面所有个股日耀眼波动率平均值)
def calc_daily(group: pd.DataFrame):
group["moderate_shine_volatility"] = abs(group["shine_volatility"] - group["shine_volatility"].mean())
group["moderate_shine_return"] = abs(group["shine_return"] - group["shine_return"].mean())
return group
df_daily = df_daily.groupby("trading_day").apply(lambda x: calc_daily(x)).reset_index(drop=True)
3.6 计算时序数据
# 月均耀眼波动率/收益率 = 最近20个交易日的适度日耀眼波动率/收益率的平均值
# 月稳耀眼波动率/收益率 = 最近20个交易日的适度日耀眼波动率/收益率的标准差
def calc_month(group: pd.DataFrame, n: int) -> pd.DataFrame:
group[["shine_volatility_monthavg", "shine_return_monthavg"]] = group[["moderate_shine_volatility", "moderate_shine_return"]].rolling(window=n).mean()
group[["shine_volatility_monthstd", "shine_return_monthstd"]] = group[["moderate_shine_volatility", "moderate_shine_return"]].rolling(window=n).std()
return group
df_daily = df_daily.groupby("instrument").apply(lambda x: calc_month(x, 20)).reset_index(drop=True)
3.7 因子合成
在进行因子合成时,对波动率数据和收益率数据均进行了标准化操作,消除量纲不统一的问题。
# 月耀眼波动率/收益率 = 月均耀眼波动率/收益率 与 月稳耀眼波动率/收益率 的等权合成
# 适度冒险因子 = 月耀眼波动率 和 月耀眼收益率 合成
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
def combine(group: pd.DataFrame) -> pd.DataFrame:
cols = ["shine_volatility_monthavg", "shine_return_monthavg", "shine_volatility_monthstd", "shine_return_monthstd"]
group[["scaled_"+i for i in cols]] = scaler.fit_transform(group[cols]) # 标准化:统一量纲
group["factor_shine_volatility"] = group["scaled_shine_volatility_monthavg"] + group["scaled_shine_volatility_monthstd"]
group["factor_shine_return"] = group["scaled_shine_return_monthavg"] + group["scaled_shine_return_monthstd"]
return group
df_daily = df_daily.groupby("trading_day").apply(lambda x: combine(x)).reset_index(drop=True)
3.8 计算适度冒险因子
适度冒险因子 = 月耀眼波动率 和 月耀眼收益率 等权合成
# 适度冒险因子 = 月耀眼波动率 和 月耀眼收益率 合成
df_daily["factor"] = df_daily["factor_shine_volatility"] + df_daily["factor_shine_return"]
factor_data = df_daily[["trading_day", "instrument", "factor"]].dropna()
附录
1. 代码
https://bigquant.com/codesharev3/808e57a8-cbb5-49c9-8314-fde8bfc2c42f
2. 研报
/wiki/static/upload/16/16624975-f21f-4eeb-a3b8-ffa0bf5030d1.pdf
\