好的,我们来深度总结一下这份海通证券的研报,并基于其核心思想尝试复现策略。
报告来源与标题
- 报告来源: 海通证券 (Haitong Securities)
- 报告标题: 选股因子系列研究(五十八)——知情交易与主买主卖
- 发布日期: 2020年02月14日
- 分析师: 冯佳睿, 袁林青
目录
1. 报告核心观点总结
该报告在前期《选股因子系列研究(五十七)——基于主动买入行为的选股因子》的基础上,进一步引入了知情交易概率模型(借鉴Chang, S.S. (2014)的动态日内方法思路,而非传统的PIN模型)来过滤投资者的主动买卖行为,旨在区分出由掌握未公开信息的“知情”交易者驱动的主动买卖,从而构建更有效的选股因子。
核心观点包括:
- 知情交易过滤: 利用日内分钟收益率回归模型剥离可预期收益,将残差(预期外收益)与主动买卖方向结合,识别潜在的知情交易(预期外收益为正时的主动卖出,预期外收益为负时的主动买入)。
- 知情主卖因子有效性:
- 知情主卖占比(特别是全天和开盘后时段计算的)与股票未来收益呈显著负相关(知情卖出越多,未来表现越差)。
- 正交处理(剔除市值、估值、换手率等常见因子影响)后,开盘后知情主卖占比(占同时段成交额) 表现尤为稳健,月均IC绝对值接近0.03,ICIR绝对值达3.5,多空月均收益0.94%,多头月均收益0.57%。
- 知情主买因子有效性:
- 正交处理后,收盘前知情主买占比 与股票未来收益呈现较为显著的负相关(这与直觉相悖,报告推测可能与收盘前过度反应有关)。
- 其中,收盘前知情主买占比(占全天成交额) 表现相对更强,因子月度多空收益达1.28%,月度胜率超80%,多头月均收益0.56%。
- 知情净主买因子有效性:
- 正交处理后,开盘后知情净主买占比 具有一定的正相关选股能力(知情净买入越多,未来表现越好)。
- 开盘后知情净主买占比(占全天成交额 或 占同时段成交额)月均IC约为0.02,ICIR在2.8-3.0之间,月度胜率超80%。
- 适用范围: 因子在中证800和中证500指数内表现较好,但在沪深300指数内选股能力有所减弱。
2. 因子构建方法详解
2.1 数据准备
- 分钟级数据: 股票的分钟 K 线数据(开高低收、成交量、成交额)。
- 逐笔成交数据: 用于区分主动买单(外盘)和主动卖单(内盘),并计算每分钟的主动买入金额和主动卖出金额。报告中称为“主买成交金额”和“主卖成交金额”。
- 时间窗口: 报告回测期为 2013.01.04 ~ 2020.01.23。计算知情交易识别模型时,使用了过去20个交易日的分钟数据。
- 计算时段: 报告分别计算了基于不同日内时段的因子:
- 全天: 9:30 - 14:56
- 开盘后: 9:30 - 9:59
- 盘中: 10:00 - 14:26
- 收盘前: 14:27 - 14:56
2.2 知情交易的识别
核心在于构建一个分钟收益率的回归模型,以分离预期外收益。模型形式如下 (Page 5):
R_i,T,j = ν_0 + Σ[k=1 to 4](γ_1,k * D_T,k^Weekday) + Σ[k=1 to 3](γ_2,k * D_T,k,j^Period) + γ_3 * R_i,T,j-1 + ε_i,T,j
其中:
R_i,T,j
: 股票 i 在 T 日第 j 分钟的收益率。ν_0
: 常数项。D_T,k^Weekday
: 星期虚拟变量 (k=1,2,3,4 代表周一至周四)。D_T,k,j^Period
: 时间段虚拟变量 (k=1 代表开盘后30分钟,k=2 代表盘中时段,k=3 代表收盘前30分钟)。R_i,T,j-1
: 上一分钟的收益率(滞后项)。ε_i,T,j
: 回归残差,即预期外收益 (Unexpected Return)。
回归设定: 使用某股票过去20个交易日的分钟数据进行上述回归,得到模型的系数(ν, γ)。然后用这些系数预测当期 (T日) 每分钟 j 的预期收益。
知情交易判定:
- 知情主卖 (Informed Sell): 如果 T 日第 j 分钟的实际收益率
R_i,T,j
大于模型预测的预期收益(即残差ε_i,T,j
> 0),则该分钟发生的主动卖出金额被认为是“知情主卖”金额。 - 知情主买 (Informed Buy): 如果 T 日第 j 分钟的实际收益率
R_i,T,j
小于模型预测的预期收益(即残差ε_i,T,j
< 0),则该分钟发生的主动买入金额被认为是“知情主买”金额。
2.3 因子计算
基于识别出的知情主买/主卖金额,计算日度因子值。以“开盘后”时段为例(9:30-9:59):
- 开盘后知情主卖占比 (占全天成交额):
Sum(开盘后每分钟的知情主卖金额) / Sum(全天每分钟的成交额)
- 开盘后知情主卖占比 (占同时段成交额):
Sum(开盘后每分钟的知情主卖金额) / Sum(开盘后每分钟的成交额)
- 开盘后知情主卖占比 (占同时段主卖额):
Sum(开盘后每分钟的知情主卖金额) / Sum(开盘后每分钟的主动卖出金额)
类似地,可以计算:
- 知情主买占比(分母可为全天成交额、同时段成交额、同时段主买额)
- 知情净主买占比(分子为
知情主买金额 - 知情主卖金额
,分母可为全天成交额、同时段成交额、同时段净主买额)
报告对全天、开盘后、盘中、收盘前四个时段都进行了上述计算,产生了多种因子变体。
3. 因子表现分析
报告对因子进行了详细的回测分析(IC、ICIR、分组收益、分年度表现等),并进行了正交化处理。
3.1 知情主卖占比因子
- 方向: 负相关。值越高,未来收益越差。
- 显著性: 全天和开盘后时段计算的因子普遍显著。
- 最优变体(正交后): 开盘后知情主卖占比 (占同时段成交额)。ICIR绝对值达3.5,多空组合年化收益约12.3%(月均0.94% * 12),多头组合年化超额约6.8%(月均0.57% * 12)。
3.2 知情主买占比因子
- 方向(正交后): 收盘前计算的因子呈现负相关。值越高,未来收益越差。
- 显著性: 正交后,仅收盘前时段的因子表现较好。
- 最优变体(正交后): 收盘前知情主买占比 (占全天成交额)。ICIR绝对值达3.05,多空组合年化收益约16.1%(月均1.28% * 12),多头组合年化超额约6.7%(月均0.56% * 12)。
3.3 知情净主买占比因子
- 方向: 正相关。值越高,未来收益越好。
- 显著性: 正交后,开盘后时段计算的因子表现较好。
- 最优变体(正交后): 开盘后知情净主买占比 (占全天成交额 或 占同时段成交额)。ICIR约2.8-3.0,多空组合年化收益约9.3% - 10.3%,多头组合年化超额约4% - 6%。
3.4 因子在不同指数范围的表现
- 知情主卖因子在中证800/500内仍有效,但在沪深300内效果减弱。
- 知情主买因子(收盘前)在中证800内仍有一定效果,但在沪深300内效果不佳。
- 知情净主买因子(开盘后)在中证800/500内仍有效,但随范围缩小(进入沪深300),效果也减弱。
这表明因子在中小盘股中区分度可能更高。
4. 策略复现与Python代码
4.1 策略思路
基于报告结论,我们可以选择表现较优且逻辑相对清晰的因子进行复现。例如:
- 策略一(负向选股): 做空 开盘后知情主卖占比 (占同时段成交额) 最高的股票组合,同时做多该因子最低的股票组合。
- 策略二(正向选股): 做多 开盘后知情净主买占比 (占同时段成交额) 最高的股票组合,同时做空该因子最低的股票组合。
以下代码将重点演示如何计算这两个因子,完整的策略回测需要更复杂的框架(如数据管理、组合构建、交易成本模拟、业绩归因等)。
4.2 所需数据
- 股票池: 例如中证800成分股。
- 历史分钟数据: 包含时间(datetime)、开盘价(open)、最高价(high)、最低价(low)、收盘价(close)、成交量(volume)、成交额(amount)。需要至少回溯20个交易日的分钟数据用于回归。
- 历史逐笔数据或已处理好的分钟级主动买卖额: 用于计算每分钟的主动买入金额 (
active_buy_amount
) 和主动卖出金额 (active_sell_amount
)。若无逐笔,可用通用算法近似(如比较成交价与买一卖一价)。 本代码假设此数据已存在。 - 交易日历: 用于识别交易日和获取日期信息。
- 因子数据(用于正交化,可选): 市值、BP、换手率等常见风格因子的日度数据。
4.3 Python代码实现
import pandas as pd
import numpy as np
import statsmodels.api as sm
from datetime import time, timedelta
# 假设我们有以下函数或数据获取方式:
# get_minute_data(stock_code, start_date, end_date): -> pd.DataFrame with columns ['datetime', 'open', 'close', 'volume', 'amount', 'active_buy_amount', 'active_sell_amount']
# get_trading_calendar(start_date, end_date): -> list of pd.Timestamp representing trading days
# get_stock_pool(date): -> list of stock codes
def calculate_minute_returns(minute_data):
"""计算分钟收益率"""
minute_data['return'] = minute_data['close'].pct_change().fillna(0)
return minute_data
def prepare_regression_data(stock_code, date, history_window=20):
"""准备用于回归的过去N天分钟数据"""
trading_calendar = get_trading_calendar('2010-01-01', date) # 假设有足够历史
end_date = date - timedelta(days=1) # 回归用过去的数据
start_date = trading_calendar[trading_calendar.index(end_date) - history_window]
hist_minute_data = get_minute_data(stock_code, start_date, end_date)
if hist_minute_data is None or hist_minute_data.empty:
return None
hist_minute_data = calculate_minute_returns(hist_minute_data)
# 创建虚拟变量
hist_minute_data['weekday'] = hist_minute_data['datetime'].dt.weekday
hist_minute_data['minute_of_day'] = hist_minute_data['datetime'].dt.time
# 周一到周四 (0-3)
for i in range(4):
hist_minute_data[f'weekday_{i}'] = (hist_minute_data['weekday'] == i).astype(int)
# 时间段虚拟变量 (报告中是开盘后30min, 盘中, 收盘前30min)
# 为简化,这里用具体时间段,需根据实际交易规则精确定义
hist_minute_data['period_open'] = ((hist_minute_data['minute_of_day'] >= time(9, 30)) & (hist_minute_data['minute_of_day'] <= time(9, 59))).astype(int)
hist_minute_data['period_mid'] = ((hist_minute_data['minute_of_day'] >= time(10, 0)) & (hist_minute_data['minute_of_day'] <= time(14, 26))).astype(int)
hist_minute_data['period_close'] = ((hist_minute_data['minute_of_day'] >= time(14, 27)) & (hist_minute_data['minute_of_day'] <= time(14, 56))).astype(int) # 假设14:57是收盘
# 滞后收益率
hist_minute_data['return_lag1'] = hist_minute_data['return'].shift(1).fillna(0)
# 清理数据,去除开盘第一分钟(若其滞后项无意义)或非交易时间数据
# ... (具体清理逻辑)
# 选择用于回归的列
# 注意:应移除一个weekday虚拟变量和一个period虚拟变量以避免完全共线性
predictors = ['const', 'weekday_0', 'weekday_1', 'weekday_2', 'weekday_3',
'period_open', 'period_mid', # 'period_close' 省略
'return_lag1']
hist_minute_data['const'] = 1.0
# 确保数据有效性
hist_minute_data = hist_minute_data.dropna(subset=['return'] + predictors)
if hist_minute_data.empty:
return None
X = hist_minute_data[predictors]
y = hist_minute_data['return']
return X, y
def fit_regression_model(X, y):
"""拟合OLS回归模型"""
if X is None or y is None or X.empty or y.empty:
return None
try:
model = sm.OLS(y, X, missing='drop') # drop missing rows if any
results = model.fit()
return results
except Exception as e:
print(f"Regression failed: {e}")
return None
def calculate_informed_trades(stock_code, date, regression_results):
"""计算当天的知情交易额"""
if regression_results is None:
return None, None, None
current_minute_data = get_minute_data(stock_code, date, date)
if current_minute_data is None or current_minute_data.empty:
return None, None, None
current_minute_data = calculate_minute_returns(current_minute_data)
# 创建与回归时相同的虚拟变量
current_minute_data['weekday'] = current_minute_data['datetime'].dt.weekday
current_minute_data['minute_of_day'] = current_minute_data['datetime'].dt.time
for i in range(4):
current_minute_data[f'weekday_{i}'] = (current_minute_data['weekday'] == i).astype(int)
current_minute_data['period_open'] = ((current_minute_data['minute_of_day'] >= time(9, 30)) & (current_minute_data['minute_of_day'] <= time(9, 59))).astype(int)
current_minute_data['period_mid'] = ((current_minute_data['minute_of_day'] >= time(10, 0)) & (current_minute_data['minute_of_day'] <= time(14, 26))).astype(int)
current_minute_data['period_close'] = ((current_minute_data['minute_of_day'] >= time(14, 27)) & (current_minute_data['minute_of_day'] <= time(14, 56))).astype(int)
current_minute_data['return_lag1'] = current_minute_data['return'].shift(1).fillna(0)
current_minute_data['const'] = 1.0
predictors = regression_results.model.exog_names # 获取模型使用的确切变量名
X_current = current_minute_data[predictors]
# 预测预期收益
try:
predicted_return = regression_results.predict(X_current)
except Exception as e:
print(f"Prediction failed for {stock_code} on {date}: {e}")
# Handle cases where prediction fails (e.g., missing data for a predictor)
# One way is to fill NA in X_current if possible, or skip prediction
return None, None, None # Or return the original data with NaNs for residuals
current_minute_data['predicted_return'] = predicted_return
current_minute_data['residual'] = current_minute_data['return'] - current_minute_data['predicted_return']
# 识别知情交易
# 确保 active_buy_amount 和 active_sell_amount 列存在
if 'active_buy_amount' not in current_minute_data.columns or \
'active_sell_amount' not in current_minute_data.columns:
print(f"Missing active trade amount columns for {stock_code} on {date}")
return None, None, None # Or handle differently based on data availability
current_minute_data['informed_sell_amount'] = np.where(
current_minute_data['residual'] > 0,
current_minute_data['active_sell_amount'],
0
)
current_minute_data['informed_buy_amount'] = np.where(
current_minute_data['residual'] < 0,
current_minute_data['active_buy_amount'],
0
)
# 筛选出开盘后时段的数据 (9:30 - 9:59)
opening_period_data = current_minute_data[
(current_minute_data['minute_of_day'] >= time(9, 30)) &
(current_minute_data['minute_of_day'] <= time(9, 59))
].copy() # Use .copy() to avoid SettingWithCopyWarning
# 计算所需聚合值
opening_informed_sell = opening_period_data['informed_sell_amount'].sum()
opening_informed_buy = opening_period_data['informed_buy_amount'].sum()
opening_period_amount = opening_period_data['amount'].sum()
# opening_period_active_sell = opening_period_data['active_sell_amount'].sum() # 用于计算其他变体
# opening_period_active_buy = opening_period_data['active_buy_amount'].sum() # 用于计算其他变体
# total_day_amount = current_minute_data['amount'].sum() # 用于计算其他变体
# 计算因子
# 因子1: 开盘后知情主卖占比 (占同时段成交额)
factor_informed_sell_ratio_open = opening_informed_sell / opening_period_amount if opening_period_amount > 0 else 0
# 因子2: 开盘后知情净主买占比 (占同时段成交额)
factor_informed_net_buy_ratio_open = (opening_informed_buy - opening_informed_sell) / opening_period_amount if opening_period_amount > 0 else 0
return factor_informed_sell_ratio_open, factor_informed_net_buy_ratio_open, current_minute_data # 返回计算好的因子和带标记的分钟数据
def calculate_daily_factors(date, stock_pool):
"""计算指定日期所有股票的因子值"""
daily_factors = {}
for stock in stock_pool:
print(f"Processing {stock} for {date.strftime('%Y-%m-%d')}...")
# 1. 准备历史数据用于回归
X_hist, y_hist = prepare_regression_data(stock, date, history_window=20)
if X_hist is None:
print(f"Skipping {stock}: Insufficient history data.")
continue
# 2. 拟合回归模型
regression_model = fit_regression_model(X_hist, y_hist)
if regression_model is None:
print(f"Skipping {stock}: Regression failed.")
continue
# 3. 计算当天的知情交易和因子
factor1, factor2, _ = calculate_informed_trades(stock, date, regression_model)
if factor1 is not None and factor2 is not None:
daily_factors[stock] = {'InformedSellRatioOpen': factor1,
'InformedNetBuyRatioOpen': factor2}
else:
print(f"Skipping {stock}: Factor calculation failed.")
return pd.DataFrame.from_dict(daily_factors, orient='index')
# --- 主程序入口示例 ---
if __name__ == '__main__':
# 设定日期和股票池
target_date = pd.Timestamp('2020-01-22') # 示例日期
# stock_list = get_stock_pool(target_date) # 获取当天的股票池
# !!! 注意:以下为伪代码/占位符,实际需要接入数据源 !!!
# --- 伪代码开始 ---
def get_minute_data(stock_code, start_date, end_date):
# 这里应连接数据库或API获取数据
# 返回 Pandas DataFrame, 包含 datetime, open, close, volume, amount, active_buy_amount, active_sell_amount
# 为演示,返回一个空 DataFrame 或包含随机数据的 DataFrame
print(f"Fetching minute data for {stock_code} from {start_date} to {end_date} (Placeholder)")
# Example structure (replace with real data loading)
dates = pd.date_range(start=start_date, end=end_date, freq='B')
all_data = []
for d in dates:
# Generate 240 minutes of data per day
times = pd.date_range(start=d.replace(hour=9, minute=31, second=0), periods=240, freq='T')
df = pd.DataFrame({
'datetime': times,
'open': np.random.rand(240) * 100,
'close': np.random.rand(240) * 100,
'volume': np.random.randint(100, 10000, 240),
'amount': np.random.randint(10000, 1000000, 240),
'active_buy_amount': np.random.randint(5000, 500000, 240),
'active_sell_amount': np.random.randint(5000, 500000, 240)
})
# Filter trading hours (e.g., remove lunch break if needed)
df = df[(df['datetime'].dt.time >= time(9, 30)) & (df['datetime'].dt.time <= time(11, 30)) |
(df['datetime'].dt.time >= time(13, 0)) & (df['datetime'].dt.time <= time(14, 57))] # Adjusted end time
all_data.append(df)
if not all_data:
return pd.DataFrame()
return pd.concat(all_data).reset_index(drop=True)
def get_trading_calendar(start_date, end_date):
# 返回交易日历列表
return pd.bdate_range(start_date, end_date).tolist()
stock_list = ['stock_A', 'stock_B', 'stock_C'] # 示例股票列表
# --- 伪代码结束 ---
# 计算因子
factors_df = calculate_daily_factors(target_date, stock_list)
print("\nCalculated Factors:")
print(factors_df)
# 后续步骤 (需要额外代码):
# 1. 将因子值保存到数据库或文件
# 2. 进行因子正交化 (可选, 使用 statsmodels 或 sklearn)
# 3. 构建投资组合 (例如,根据因子值排序分组)
# 4. 进行回测,计算组合收益、基准比较、风险指标等
4.4 注意事项
- 数据质量和来源: 复现效果极大依赖于分钟数据和准确的主动买卖额数据。如果无法获取逐笔数据来精确计算,使用通用算法近似会引入噪音。
- 计算效率: 对全市场股票计算该因子(尤其是历史回归部分)计算量巨大,需要高效的数据处理和并行计算能力。
- 回归模型的稳定性: 报告中使用的回归模型较为简单,可能存在遗漏变量问题。其系数的稳定性也值得关注。因子效果可能受市场状态影响。
- 正交化: 报告强调了正交化后的因子表现。实际应用中,需要实现因子正交化步骤,剔除常见风格因子的影响,得到更纯粹的alpha。
- 回测框架: 上述代码仅实现了因子计算的核心逻辑,完整的策略需要成熟的回测框架来处理头寸管理、交易成本、滑点、分红配股等现实问题。
- 参数敏感性: 回归窗口长度(20天)、时间段划分(开盘后30分钟等)等参数选择可能影响因子效果,可进行参数敏感性测试。
- 知情交易识别的假设: 该方法的核心假设是“预期外收益”反映了信息含量。正残差对应坏消息(知情者卖出),负残差对应好消息(知情者买入)。这个假设在特定市场或股票上可能不完全成立。
5. 风险提示
报告原文已提示:市场系统性风险、资产流动性风险以及政策变动风险会对策略表现产生较大影响。此外,模型风险(依赖特定模型识别知情交易)、数据风险(数据质量影响因子准确性)和过拟合风险(参数和模型选择可能基于历史数据优化)也需注意。