好的,我们来深入分析这篇方正证券的金融工程研究报告,并提供策略的Python复现代码。
报告来源与标题
- 机构: 方正证券 (Founder Securities)
- 系列: 多因子选股系列研究之十八
- 标题: 成交量激增与骤降时刻的对称性与“一视同仁”因子构建
- 日期: 2024年05月23日
- 作者: 曹春晓
目录
- 报告核心观点
- 引言:成交量变化的双重信息
- “一视同仁”因子构建逻辑与步骤
- 3.1. 正态激增/骤降时刻的定义 (Box-Cox变换)
- 3.2. “波动公平”因子
- 3.3. “收益公平”因子
- 3.4. “一视同仁”因子合成
- 因子测试与表现
- 4.1. “一视同仁”因子独立表现
- 4.2. 风格剥离后表现
- 4.3. 不同样本空间表现 (沪深300/中证500/中证1000)
- 4.4. 行业内表现
- 与其他量价因子结合:综合量价因子
- 5.1. 与其他12个因子的关系
- 5.2. 综合量价因子构建与表现
- 5.3. 指数增强策略应用
- 风险提示
- Python策略复现代码
- 7.1. 依赖库导入
- 7.2. 核心计算函数
- 7.3. 因子生成流程示例
- 7.4. 注意事项
1. 报告核心观点
报告认为,市场投资者对于成交量的突然放大(激增)和突然缩小(骤降)的反应可能存在不对称性。如果投资者反应过度(例如,对放量反应剧烈而对缩量反应平淡,或反之),则可能带来定价偏差和投资机会。报告旨在量化这种不对称性,构建“一视同仁”因子,期望投资者对成交量的激增和骤降给予“公平”的反应。测试结果显示该因子具有较好的选股能力(Rank IC -7.39%,多空年化31.36%)。进一步将其与前期报告中的12个量价因子结合,构建的“综合量价因子”表现更优(Rank IC -12.42%,多空年化48.07%)。
2. 引言:成交量变化的双重信息
先前研究(系列之一)关注了成交量激增时刻的信息。但现实中,成交量缩小(缩量)同样可能蕴含价格信息。若仅关注放量,则会丢失缩量的信息。报告提出,理性投资者对放量和缩量的反应强度应相对接近。若存在显著差异,则表明存在非理性或反应过度。“激增时刻”波动大而“骤降时刻”波动小,意味着投资者过度被放量吸引;反之,则过度被缩量吸引。这两种情况都可能导致当日收益率被放大。
3. “一视同仁”因子构建逻辑与步骤
因子构建的核心思想是度量和修正由成交量突变引起的波动和收益的不对称性。
-
3.1. 正态激增/骤降时刻的定义 (Box-Cox变换)
- 数据: 使用分钟 K 线数据。
- 预处理: 剔除开盘和收盘时段的数据;剔除成交量为 0 的分钟;对剩余的正成交量序列进行 Box-Cox 变换,使其接近正态分布。
- 变化量计算: 计算变换后成交量每分钟相对于 前一个非零成交量时刻 的变化量。
- 时刻定义:
- 计算当日成交量变化量的均值 (mean) 和标准差 (std)。
- 正态激增时刻: 变化量 > mean + std 的时刻。
- 正态骤降时刻: 变化量 < mean - std 的时刻。
-
3.2. “波动公平”因子
- 波动衡量窗口:
- 正态耀眼五分钟: “正态激增时刻”及其后的4分钟。
- 正态黯淡五分钟: “正态骤降时刻”及其后的4分钟。
- 日内波动率计算: 计算当日所有“正态耀眼五分钟”窗口内的平均波动率(报告未明确波动率计算方法,可用分钟收益率标准差或 GARCH 类方法),记为“正态耀眼波动率”;同理计算“正态黯淡波动率”。
- 波动公平度: 当日
波动公平度 = abs(正态耀眼波动率 - 正态黯淡波动率)
。该值越大,表示投资者对放量和缩量引起的波动反应差异越大,非理性程度可能越高。 - 波动公平收益率: 当日
波动公平收益率 = 波动公平度 * 当日日内收益率
。(以此修正原始日内收益) - “波动公平”因子: 计算过去20个交易日“波动公平收益率”的均值,作为月度因子值。
- 波动衡量窗口:
-
3.3. “收益公平”因子
- 日内收益率计算: 计算当日所有“正态耀眼五分钟”窗口内的平均收益率,记为“正态耀眼收益率”;同理计算“正态黯淡收益率”。
- 收益公平度: 当日
收益公平度 = abs(正态耀眼收益率 - 正态黯淡收益率)
。 - 收益公平收益率: 当日
收益公平收益率 = 收益公平度 * 当日日内收益率
。(以此修正原始日内收益) - “收益公平”因子: 计算过去20个交易日“收益公平收益率”的均值,作为月度因子值。
-
3.4. “一视同仁”因子合成
"一视同仁"因子 = 0.5 * "波动公平"因子 + 0.5 * "收益公平"因子
4. 因子测试与表现
- 4.1. “一视同仁”因子独立表现: 在全市场非ST、非次新股中测试(月频调仓,2013.01-2024.03),Rank IC 均值 -7.39%,ICIR -4.09,多空年化收益率 31.36%,信息比率 3.49,月度胜率 85.07%。负相关性表明因子值越小,预期未来收益越高。
- 4.2. 风格剥离后表现: 对常见风格因子(市值、估值、动量、波动率、流动性等)和行业因子进行正交化处理后,因子仍然有效(Rank IC -2.47%,ICIR -1.36,多空年化收益率 16.29%),证明其具有增量信息。
- 4.3. 不同样本空间表现: 在中证1000成分股内表现最好(Rank IC -7.04%,多空年化 30.99%),在沪深300和中证500内也有效。
- 4.4. 行业内表现: 在绝大多数一级行业内表现普遍较好,Rank IC 多为负值(-8%以上居多)。
5. 与其他量价因子结合:综合量价因子
- 5.1. 与其他12个因子的关系: “一视同仁”因子与“草木皆兵”、“飞蛾扑火”等因子相关性相对较高(约 0.5-0.6),与其他因子相关性适中。
- 5.2. 综合量价因子构建与表现: 将“一视同仁”因子与前期报告构建的12个量价因子(适度冒险、完整潮汐、勇攀高峰等)进行正交化处理后,等权合成“综合量价因子”。该综合因子表现显著优于单个因子(Rank IC -12.42%,ICIR -5.13,多空年化收益率 48.07%,信息比率 4.40,月度胜率 88.06%)。剔除风格影响后的“纯净综合量价因子”表现依然强劲。
- 5.3. 指数增强策略应用: 使用“综合量价因子”构建对沪深300、中证500、中证1000的指数增强策略(控制行业、市值中性,个股偏离度等),均取得不错的年化超额收益(分别为 8.81%, 11.54%, 17.02%)。
6. 风险提示
- 模型基于历史数据,未来可能失效。
- 市场可能发生未预期变化。
- 因子效果可能受特定市场环境影响而出现阶段性失效。
7. Python策略复现代码
以下代码提供了计算“一视同仁”因子核心逻辑的框架。请注意,实际应用中需要接入可靠的分钟 K 线数据源,并进行完整的因子生成、测试流程。
import pandas as pd
import numpy as np
from scipy import stats
from scipy.special import boxcox1p # 使用 boxcox1p 避免 0 值问题,或者预处理中过滤0
from scipy.stats import boxcox
from tqdm import tqdm
import warnings
warnings.filterwarnings('ignore')
# --- 7.1. 依赖库导入 ---
# (已在上方导入)
# --- 7.2. 核心计算函数 ---
def calculate_minute_volatility(series):
"""计算分钟收益率序列的年化波动率 (示例)"""
# 报告未明确方法,这里用标准差 * sqrt(交易分钟数 * 年交易日数)
# 假设一天 240 分钟有效交易时间
log_returns = np.log(series / series.shift(1)).dropna()
if len(log_returns) < 2:
return np.nan
daily_std = log_returns.std()
# 年化波动率简单示例,实际可能更复杂
# annualized_vol = daily_std * np.sqrt(240 * 242) # 假设一年242交易日
return daily_std # 返回日内标准差作为波动代理
def calculate_minute_return(series):
"""计算窗口期总收益率"""
if len(series) < 2 or series.iloc[0] == 0:
return np.nan
return (series.iloc[-1] / series.iloc[0]) - 1
def get_surge_drop_moments(minute_data: pd.DataFrame, volume_col='volume', price_col='close'):
"""
识别正态激增和骤降时刻
Args:
minute_data (pd.DataFrame): 单只股票单日的分钟数据, index为时间
volume_col (str): 成交量列名
price_col (str): 收盘价列名 (用于后续计算)
Returns:
tuple: (surge_moments_indices, drop_moments_indices, processed_minute_data)
返回激增时刻索引列表, 骤降时刻索引列表, 处理后的分钟数据
"""
df = minute_data.copy()
# 1. 预处理:剔除非交易时间 (假设输入已处理),剔除成交量为0
df = df[df[volume_col] > 0]
if len(df) < 10: # 数据太少无法计算
return [], [], df
# 2. Box-Cox 变换 (注意:boxcox要求正数)
# scipy.stats.boxcox 会自动找 lambda, 如果想固定 lambda 或处理 0 可以用 boxcox1p(x, lambda)
try:
transformed_volume, _ = boxcox(df[volume_col])
# 如果 boxcox 失败 (例如数据只有一个值), 则无法进行下一步
except ValueError:
return [], [], df
df['transformed_volume'] = transformed_volume
# 3. 计算变化量 (相对于前一个有交易的时刻)
df['volume_change'] = df['transformed_volume'].diff()
df = df.dropna() # 去掉第一个 NaN
if len(df) < 5:
return [], [], df
# 4. 计算均值和标准差
mean_change = df['volume_change'].mean()
std_change = df['volume_change'].std()
if std_change == 0 or pd.isna(std_change): # 防止标准差为0或NaN
return [], [], df
# 5. 定义时刻
surge_threshold = mean_change + std_change
drop_threshold = mean_change - std_change
surge_moments_indices = df[df['volume_change'] > surge_threshold].index
drop_moments_indices = df[df['volume_change'] < drop_threshold].index
return surge_moments_indices, drop_moments_indices, df
def calculate_daily_fairness_components(stock_code: str, trade_date: str, minute_data: pd.DataFrame,
volume_col='volume', price_col='close'):
"""
计算单只股票单日的波动公平度和收益公平度相关指标
Args:
stock_code (str): 股票代码
trade_date (str): 交易日期
minute_data (pd.DataFrame): 该股票该日的分钟数据 (需包含 volume, close, high, low, open)
volume_col (str): 成交量列名
price_col (str): 用于计算收益/波动的价格列名 (通常是 'close')
Returns:
dict: 包含当日计算结果的字典, 如 {'bodong_gongpingdu': val1, 'shouyi_gongpingdu': val2, 'intraday_return': val3}
如果无法计算则返回包含 NaN 的字典
"""
result = {
'stock_code': stock_code,
'trade_date': trade_date,
'bodong_gongpingdu': np.nan,
'shouyi_gongpingdu': np.nan,
'bodong_fairness_return': np.nan, # 波动公平收益率
'shouyi_fairness_return': np.nan, # 收益公平收益率
'intraday_return': np.nan
}
# 剔除开盘收盘 (示例: 假设9:31 - 11:30, 13:01 - 14:57)
# 注意:具体时间需要根据实际数据格式调整
try:
df_trading = minute_data.between_time('09:31', '11:30').append(minute_data.between_time('13:01', '14:57'))
if df_trading.empty:
return result
except Exception as e:
# print(f"Error filtering time for {stock_code} on {trade_date}: {e}")
return result
surge_indices, drop_indices, df_processed = get_surge_drop_moments(df_trading, volume_col, price_col)
surge_volatilities = []
surge_returns = []
drop_volatilities = []
drop_returns = []
window_size = 5 # 包含当前分钟及后续4分钟
# 计算激增时刻的窗口指标
for idx in surge_indices:
try:
# 获取 idx 所在的位置,并向后取 window_size 个点
loc = df_processed.index.get_loc(idx)
window_data = df_processed.iloc[loc : loc + window_size]
if len(window_data) == window_size:
# 波动率计算 (示例,使用收盘价)
vol = calculate_minute_volatility(window_data[price_col])
# 收益率计算
ret = calculate_minute_return(window_data[price_col])
if not pd.isna(vol): surge_volatilities.append(vol)
if not pd.isna(ret): surge_returns.append(ret)
except IndexError:
continue # 索引超出范围
except Exception as e:
# print(f"Error processing surge window for {stock_code} at {idx}: {e}")
continue
# 计算骤降时刻的窗口指标
for idx in drop_indices:
try:
loc = df_processed.index.get_loc(idx)
window_data = df_processed.iloc[loc : loc + window_size]
if len(window_data) == window_size:
vol = calculate_minute_volatility(window_data[price_col])
ret = calculate_minute_return(window_data[price_col])
if not pd.isna(vol): drop_volatilities.append(vol)
if not pd.isna(ret): drop_returns.append(ret)
except IndexError:
continue
except Exception as e:
# print(f"Error processing drop window for {stock_code} at {idx}: {e}")
continue
# 计算平均值
avg_surge_vol = np.mean(surge_volatilities) if surge_volatilities else np.nan
avg_drop_vol = np.mean(drop_volatilities) if drop_volatilities else np.nan
avg_surge_ret = np.mean(surge_returns) if surge_returns else np.nan
avg_drop_ret = np.mean(drop_returns) if drop_returns else np.nan
# 计算公平度
if not pd.isna(avg_surge_vol) and not pd.isna(avg_drop_vol):
result['bodong_gongpingdu'] = abs(avg_surge_vol - avg_drop_vol)
if not pd.isna(avg_surge_ret) and not pd.isna(avg_drop_ret):
result['shouyi_gongpingdu'] = abs(avg_surge_ret - avg_drop_ret)
# 计算当日日内收益率 (示例: 当日收盘价 / 当日开盘价 - 1)
# 需要从原始 minute_data (包含全天) 获取开盘收盘价
try:
day_open = minute_data.iloc[0]['open'] # 取全天数据的第一个 open
day_close = minute_data.iloc[-1]['close'] # 取全天数据的最后一个 close
if day_open > 0:
result['intraday_return'] = (day_close / day_open) - 1
except IndexError:
pass # 数据不足一天
except KeyError:
pass # 缺少 open/close 列
# 计算公平收益率
if not pd.isna(result['bodong_gongpingdu']) and not pd.isna(result['intraday_return']):
result['bodong_fairness_return'] = result['bodong_gongpingdu'] * result['intraday_return']
if not pd.isna(result['shouyi_gongpingdu']) and not pd.isna(result['intraday_return']):
result['shouyi_fairness_return'] = result['shouyi_gongpingdu'] * result['intraday_return']
return result
# --- 7.3. 因子生成流程示例 ---
def generate_yi_shi_tong_ren_factor(stock_list, date_list, minute_data_loader):
"""
生成“一视同仁”因子
Args:
stock_list (list): 股票代码列表
date_list (list): 交易日期列表 (通常是月底)
minute_data_loader (function): 一个函数,输入(stock_code, date),返回该股票该日的分钟DataFrame
Returns:
pd.DataFrame: 包含因子值的DataFrame, index为日期, columns为股票代码
"""
all_daily_components = []
# 1. 计算每日的公平度指标和公平收益率
# 需要过去 20+ 天的数据来计算移动平均,所以需要加载日期列表之前的日期
# 这里简化处理,假设 date_list 是我们需要计算因子值的日期,并且我们已经有了之前20天的日指标
print("Calculating daily fairness components...")
# !!! 注意:实际需要加载 date_list 以及之前的 N 天数据 (N>=20)
all_dates_needed = date_list # 简化:假设我们只需要计算这些日期的指标
for trade_date in tqdm(all_dates_needed):
for stock_code in stock_list:
try:
# 加载数据 - 这里需要替换成你实际的数据加载逻辑
minute_data = minute_data_loader(stock_code, trade_date)
if minute_data is None or minute_data.empty:
continue
daily_result = calculate_daily_fairness_components(stock_code, trade_date, minute_data)
all_daily_components.append(daily_result)
except Exception as e:
print(f"Error processing {stock_code} on {trade_date}: {e}")
continue
if not all_daily_components:
print("No daily components calculated.")
return pd.DataFrame()
daily_df = pd.DataFrame(all_daily_components)
daily_df['trade_date'] = pd.to_datetime(daily_df['trade_date'])
# 透视表,方便计算移动平均
bodong_pivot = daily_df.pivot(index='trade_date', columns='stock_code', values='bodong_fairness_return')
shouyi_pivot = daily_df.pivot(index='trade_date', columns='stock_code', values='shouyi_fairness_return')
# 2. 计算 20 日移动平均得到月度因子
# 按月计算,取每月最后一个交易日的值
# rolling(20) 至少需要20个数据点
bodong_factor_monthly = bodong_pivot.rolling(window=20, min_periods=10).mean().resample('M').last() # 月末值
shouyi_factor_monthly = shouyi_pivot.rolling(window=20, min_periods=10).mean().resample('M').last() # 月末值
# 3. 合成“一视同仁”因子
# 注意处理 NaN 值,例如某个月某个因子无法计算
yi_shi_tong_ren_factor = 0.5 * bodong_factor_monthly.fillna(0) + 0.5 * shouyi_factor_monthly.fillna(0)
# 或者更稳妥地处理 NaN:只在两者都非 NaN 时计算
# yi_shi_tong_ren_factor = (bodong_factor_monthly + shouyi_factor_monthly) / 2
# 筛选出目标日期列表中的因子值
target_dates_dt = pd.to_datetime(date_list)
# 找到与 target_dates_dt 月份匹配的索引
factor_final = yi_shi_tong_ren_factor[yi_shi_tong_ren_factor.index.strftime('%Y-%m').isin(target_dates_dt.strftime('%Y-%m'))]
# 可能需要调整索引为精确的目标日期,如果 date_list 不全是月底的话
# factor_final.index = target_dates_dt # 强行对齐,如果长度匹配的话
return factor_final
# --- 示例:数据加载函数 (需要替换为真实逻辑) ---
def dummy_minute_data_loader(stock_code, trade_date):
"""(示例) 加载分钟数据的函数"""
# 在这里实现从你的数据源加载数据的逻辑
# 例如: 从数据库、文件(csv, feather, hdf5)读取
# 返回一个 pandas DataFrame, index 是 datetime 对象,包含 'open', 'high', 'low', 'close', 'volume' 列
# print(f"Loading minute data for {stock_code} on {trade_date}...")
# 这里返回一个模拟数据
try:
date_obj = pd.to_datetime(trade_date)
start_time = date_obj.replace(hour=9, minute=30)
end_time1 = date_obj.replace(hour=11, minute=30)
start_time2 = date_obj.replace(hour=13, minute=0)
end_time2 = date_obj.replace(hour=15, minute=0)
index1 = pd.date_range(start=start_time, end=end_time1, freq='T')
index2 = pd.date_range(start=start_time2, end=end_time2, freq='T')
index = index1.append(index2)
count = len(index)
if count == 0: return None
data = {
'open': np.random.rand(count) * 10 + 100,
'high': lambda df: df['open'] + np.random.rand(count),
'low': lambda df: df['open'] - np.random.rand(count),
'close': lambda df: df['open'] + (np.random.rand(count) - 0.5) * 0.5,
'volume': np.random.randint(100, 10000, size=count) * np.random.choice([1, 1, 1, 5, 10], size=count) # 模拟成交量变化
}
df = pd.DataFrame(index=index)
for col, val in data.items():
if callable(val):
df[col] = val(df)
else:
df[col] = val
# 确保 high >= max(open, close), low <= min(open, close)
df['high'] = df[['high', 'open', 'close']].max(axis=1)
df['low'] = df[['low', 'open', 'close']].min(axis=1)
df.iloc[0]['open'] = 100 # 确保开盘价固定
df.iloc[-1]['close'] = df.iloc[0]['open'] + np.random.randn() * 2 # 模拟收盘价
# 模拟一些 0 成交量
zero_indices = np.random.choice(df.index, size=int(count * 0.05), replace=False)
df.loc[zero_indices, 'volume'] = 0
return df
except Exception as e:
print(f"Error generating dummy data for {stock_code} on {trade_date}: {e}")
return None
# --- 主程序入口 (示例) ---
if __name__ == '__main__':
# 假设的股票列表和目标因子日期列表 (月底)
stock_universe = ['stock_001', 'stock_002', 'stock_003']
# 需要计算这些日期的因子值, 注意需要加载这些日期及之前的约20个交易日的数据
target_factor_dates = pd.date_range(start='2023-01-01', end='2023-05-31', freq='M').strftime('%Y-%m-%d').tolist()
# !!! 重要: 为了计算移动平均,你需要获取 target_factor_dates 及其之前的约20个交易日的完整分钟数据
# 实际应用中, daily_components 的计算需要包含 target_factor_dates 之前的日期
# 这里为了演示,直接调用,但结果可能因数据不足而不准确
print(f"Target factor dates: {target_factor_dates}")
factor_result = generate_yi_shi_tong_ren_factor(stock_universe, target_factor_dates, dummy_minute_data_loader)
print("\n--- Factor Result ---")
print(factor_result)
7.4. 注意事项
- 数据质量: 分钟 K 线数据的质量至关重要,需要处理缺失、错误数据。尤其是成交量为 0 的情况。
- Box-Cox 变换:
scipy.stats.boxcox
需要输入为正数。如果成交量数据包含 0,需要先处理(如过滤或+1)。它会自动寻找最优的 lambda 值。如果想固定 lambda 或进行更精细控制,可以考虑scipy.special.boxcox1p
。 - 波动率计算: 报告未明确波动率的具体计算方式。代码示例使用了分钟对数收益率的标准差作为代理,实际中可替换为 GARCH、Parkinson、Garman-Klass 等方法。
- 窗口期: 报告明确使用“当前分钟 + 后续4分钟”共5分钟的窗口。
- 日内收益率: 报告未明确“当日日内收益率”的定义。代码示例使用了当日收盘价/开盘价 - 1,也可以使用 VWAP 等。
- 移动平均: 因子计算使用了过去20个交易日的“公平收益率”均值。这意味着计算某月底因子值时,需要用到该月及上月的部分交易日的每日指标。
- 计算效率: 处理全市场股票的分钟数据计算量巨大,需要优化数据读取、存储和计算过程,可以考虑并行计算(如
joblib
或multiprocessing
)。 - 数据加载:
minute_data_loader
函数需要根据你的实际数据存储方式(数据库、CSV、HDF5、Feather 等)进行实现。 - 边界条件: 需要仔细处理数据不足(如新股上市不足20天)、当日交易时间不足、某些时刻无法计算指标(如窗口内价格不变导致波动率为0或NaN)等情况。
这份总结和代码框架希望能帮助你理解并初步实现报告中的“一视同仁”因子策略。请务必结合实际数据和需求进行调整和完善。