好的,我们来深度总结一下这份海通证券的《选股因子系列研究(七十一)——逐笔大单因子与大资金行为》报告,并基于其核心思想,用Python复现其提出的策略之一。
报告来源: 海通证券 金融工程研究
报告标题: 选股因子系列研究(七十一)——逐笔大单因子与大资金行为
报告日期: 2020年12月18日
分析师: 冯佳睿, 余浩淼
深度总结
目录
1. 核心观点
报告的核心目标是探索利用“逐笔大单因子”来捕捉“大资金行为”(主要指公募基金),并基于此构建选股策略。主要结论包括:
- 大单因子有效性: 报告构建的多种“大单因子”(如大买、剔除大买的大卖、正交大单的大买等)与交易所披露的“异动股票”前五大买入席位成交额占比(传统观测大资金方式)有显著正相关性,证明大单因子确实能一定程度上捕捉大资金的交易行为。
- 异动股票的负效应: 发生交易异动的股票,在异动次日后往往表现出显著的长期负收益(反转效应)。剔除近期发生过异动的股票后,大单因子的选股效果(IC、多空收益)得到明显提升。
- 基金行为的“提前效应”: 基金重仓股和增持股组合,在基金定期报告(季报/半年报/年报)披露前的1-2个月,表现显著优于报告披露后,无论是原始收益还是市值中性化后的收益,均有超额。这表明市场可能存在对基金持仓变动的“提前预期”或基金建仓/调仓行为本身推高了股价。
- 因子增强策略: 利用基金持仓的“延续性”(上期重仓股大概率仍是下期重仓/增持股),结合特定因子(非流动性、剔除大买的大卖、正交大单的大买)对“上期基金重仓股”进行二次筛选,可以在基金报告披露前的1-2个月获得显著增强的超额收益。这些因子与未来基金持仓量/增持量存在显著正相关。
2. 因子有效性验证:异动股票与大单因子
- 报告首先通过实证检验(表1),发现“大买”、“正交大单的大买”因子当日值与异动股票前五买入席位金额占比呈显著正相关(相关系数0.3+),而“剔除大买的大卖”呈显著负相关(-0.35),验证了大单因子捕捉大额买入行为的能力。
- 接着分析异动股票表现(表2、表3),发现虽然异动当日可能因大单买入有短期正收益(尤其高开),但随后(开盘计算或持有更长时间)则表现为显著的负收益。
- 剔除近期(过去10日)发生过异动的股票后,大单因子(尤其是大买、正交大单的大买)的IC值和多空组合收益均有明显提升(表4),说明异动股票的负向反转效应会干扰因子的原始表现。
3. 公募基金行为分析:重仓股与因子关联
3.1 基金持仓的“提前效应”
- 报告构建了“基金重仓股组合”(按全市场主动基金持有市值占股票总市值比例加权)和“基金增持组合”(按增持市值占比加权)。
- 回测发现(表5、表6),这两个组合在对应报告期(如Q1、Q2等)结束前的1个月、2个月,其收益分位数显著高于50%,且普遍优于报告期结束后的表现。这暗示在报告正式披露基金持仓前,市场已有所反应或基金行为已产生影响。
3.2 因子与未来持仓的相关性
- 报告计算了“基金持仓量”(某股票被基金持有的市值/总市值)和“基金持仓增量”两个指标。发现这两个指标与报告期前1-2个月的股票收益率显著正相关(表7)。
- 进一步分析发现(表8、表9、表10):
- 全市场范围: 市值、非流动性、ROE因子与基金持仓/增量指标正相关。
- 高频因子: “正交大单的大买”因子与基金持仓/增量指标正相关,“剔除大买的大卖”因子则与两者负相关。
- 在上期重仓股范围内: 市值因子与未来持仓的相关性变为负(说明在重仓股内部,基金更倾向于增持相对小市值的),而ROE因子相关性减弱。但非流动性、剔除大买的大卖、正交大单的大买这三个因子与未来持仓/增量的相关性依然非常显著。
4. 核心策略:基于上期重仓+因子增强
4.1 策略逻辑
利用基金持仓的惯性(上期重仓股是下期重仓/增持股的良好候选池)和特定因子能预测未来基金持仓/增持的特性,构建增强策略,旨在捕捉基金报告披露前的超额收益。
4.2 关键因子
报告重点测试了以下因子在“上期基金重仓股”宇宙内的增强效果:
- 非流动性 (Non-Liquidity): 通常用Amihud非流动性指标或类似指标衡量。
- 剔除大买的大卖 (Large Sell Excl. Large Buy): 衡量纯粹的大单卖出压力。
- 正交大单的大买 (Orthogonalized Large Buy): 对“大买”因子剔除其他因子(如波动率、换手率等)影响后的残差,可能更纯粹地反映主动大额买入意愿。
- 复合因子: 将非流动性因子与后两个大单因子之一进行复合筛选。
4.3 效果验证
- 持仓预测能力提升: 使用上述因子(尤其非流动性、正交大单的大买)对上期重仓股进行筛选(如非流动性选Top 100,再用大单因子选Top 50),得到的组合在下期重仓股/增持股中的占比,显著高于原始的上期重仓股组合(图3、图4)。
- 收益增强效果显著:
- 时点: 在基金报告期结束前两个月月末进行调仓,持有至报告期结束前一个月月末(即持有1个月)。或在报告期结束前一个月月末调仓,持有至报告期结束(即持有1个月)。报告展示了持有前两个月(表11)和持有前一个月(表12)、持有前两个月(分解月度)(表13)的回测结果。
- 方法: 在上期基金重仓股中,先用非流动性因子选前100名,再用“剔除大买的大卖”或“正交大单的大买”因子选前50名,构建等权组合。
- 结果: 无论是在报告期前1个月还是前2个月,基于“非流动性+大单因子”的增强策略组合,其绝对收益、相对沪深300/中证500/全指的超额收益、胜率,均显著优于原始的上期重仓股组合,也优于单独使用非流动性或大单因子筛选的组合。复合因子策略(如“非流+剔除”、“非流+大买”)表现最佳。
5. 策略复现说明与局限性
下面的Python代码框架旨在复现报告中提出的核心策略之一:基于上期基金重仓股,结合非流动性因子和正交大单的大买因子进行二次筛选,并在基金报告期结束前两个月持有该组合。
重要局限性与假设:
- 数据依赖: 策略的成功实施高度依赖于高质量的历史数据:
- 基金持仓数据: 需要准确、及时的公募基金(尤其是主动型基金)季度/半年度/年度前十大重仓股数据。
- 因子数据: 需要计算或获取历史的非流动性因子和正交大单的大买因子。特别是大单类因子,通常需要逐笔成交数据(Tick Data)才能精确计算,这在公开数据源中较难获得,往往是机构内部研究成果。本复现代码假设这些因子数据已存在。
- 股价数据: 需要日频或更高频率的后复权股价数据用于计算收益。
- 交易日历。
- 因子计算细节: 报告中未完全披露因子的具体计算方法(如非流动性的参数、大单阈值的定义、正交化的具体变量等)。复现时使用的因子若计算方法与报告不一致,结果会有偏差。
- 回测细节: 报告的回测区间为2013.03-2020.07。代码中的回测区间需要根据实际数据可用性调整。交易成本、滑点等未在代码中考虑,实际表现会受影响。报告中使用的筛选阈值(Top 100, Top 50)是固定值,最优参数可能随市场变化。
- 基金范围: 报告筛选了主动型公募基金,复现时需要同样或类似的基金池筛选逻辑。
因此,以下代码提供的是一个策略逻辑框架,需要用户自行接入数据和因子才能运行和验证。
6. 风险提示
报告中提及的风险包括:市场系统性风险、模型误设风险(因子定义、参数选择等)、有效因子变动风险(因子在不同市场环境下效果可能衰减)。
Python 策略复现框架
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
# 假设已经安装了常用的数据处理库 pandas, numpy
# --- 数据加载函数 (需要用户根据实际情况实现) ---
def load_price_data(tickers, start_date, end_date):
"""
加载指定股票列表在指定时间段内的后复权日收盘价数据
返回 DataFrame, index=date, columns=tickers
"""
# Placeholder: 用户需要实现从数据库、文件或API加载数据
print(f"[Data] Loading price data for {len(tickers)} tickers from {start_date} to {end_date}")
# 示例数据结构
dates = pd.date_range(start_date, end_date, freq='B') # 假设为交易日
df = pd.DataFrame(index=dates, columns=tickers)
# 填充随机价格数据作为示例 (实际应加载真实数据)
for ticker in tickers:
df[ticker] = np.random.rand(len(dates)) * 100 + 50
df[ticker] = df[ticker].cumprod() / df[ticker].iloc[0] * (np.random.rand() * 50 + 50) # 模拟股价变动
# 模拟部分缺失值
mask = np.random.choice([True, False], size=df.shape, p=[0.01, 0.99])
df = df.mask(mask)
print(f"[Data] Loaded price data shape: {df.shape}")
return df.resample('D').last().ffill() # 确保索引是日期,并向前填充周末数据
def load_factor_data(factor_name, date):
"""
加载指定因子在指定日期的截面数据
返回 Series, index=stock_code, value=factor_value
"""
# Placeholder: 用户需要实现
print(f"[Data] Loading factor data for {factor_name} on {date}")
# 示例数据:随机生成因子值
# 假设我们有一个股票池
stock_pool = [f'stock_{i:03d}' for i in range(500)]
factor_values = pd.Series(np.random.randn(len(stock_pool)), index=stock_pool)
print(f"[Data] Loaded factor data for {len(factor_values)} stocks.")
return factor_values
def get_last_period_heavy_holdings(report_date):
"""
获取指定报告日期对应的上一期基金前十大重仓股列表 (去重)
例如: report_date=2020-04-30 (Q1报告), 应返回截至 2019-12-31 的重仓股
返回 list of stock_codes
"""
# Placeholder: 用户需要实现从基金公告或数据库获取
print(f"[Data] Getting last period heavy holdings for report date {report_date}")
# 假设上一期报告日是 report_date 前一个季度的最后一天
# 示例数据:随机选择一些股票作为重仓股
stock_pool = [f'stock_{i:03d}' for i in range(500)]
heavy_holdings = np.random.choice(stock_pool, size=200, replace=False).tolist()
print(f"[Data] Found {len(heavy_holdings)} unique heavy holdings from last period.")
return heavy_holdings
def get_trading_calendar(start_date, end_date):
"""
获取指定时间段内的交易日历
返回 pd.DatetimeIndex
"""
# Placeholder: 用户需要实现,可以使用 tushare, joinquant, wind 等API
print(f"[Data] Getting trading calendar from {start_date} to {end_date}")
# 示例:使用 pandas 生成工作日近似
trading_days = pd.bdate_range(start_date, end_date)
print(f"[Data] Generated {len(trading_days)} trading days.")
return trading_days
# --- 策略核心逻辑 ---
def fund_holding_enhancement_strategy(start_date, end_date,
factor1_name='NonLiquidity', # 第一个筛选因子 (如: 非流动性)
factor2_name='OrthogonalLargeBuy', # 第二个筛选因子 (如: 正交大单的大买)
top_n1=100, # 第一个因子筛选数量
top_n2=50): # 第二个因子筛选数量
"""
复现基于上期重仓股+因子增强的策略
"""
start_date = pd.to_datetime(start_date)
end_date = pd.to_datetime(end_date)
trading_days = get_trading_calendar(start_date, end_date)
portfolio_log = {} # 记录每期持仓
portfolio_value = pd.Series(index=trading_days, dtype=float)
portfolio_value.iloc[0] = 1.0 # 初始净值为1
last_holdings = [] # 上一期持仓
rebalance_flag = False # 是否在持有期
# 确定报告期和调仓时点
# 报告期结束月: 3, 6, 9, 12
# 调仓月 (报告期结束前两个月月末): 1月底, 4月底, 7月底, 10月底
# 持有期: 2月-3月, 5月-6月, 8月-9月, 11月-12月
print("\n--- Starting Backtest ---")
for i, current_date in enumerate(trading_days):
# 确定是否为调仓日 (近似为1, 4, 7, 10月的最后一个交易日)
is_rebalance_month_end = (current_date.month in [1, 4, 7, 10]) and \
(i + 1 == len(trading_days) or trading_days[i+1].month != current_date.month)
if is_rebalance_month_end:
print(f"\n[Rebalance] Date: {current_date.strftime('%Y-%m-%d')}")
# 1. 获取上期重仓股 (对应本报告期,例如1月底用去年Q4数据)
# 报告期是调仓月+2个月,例如1月底调仓,对应Q1报告期(3月底结束),用Q4(12月底)的数据
report_period_end_month = (current_date.month + 2) % 12
if report_period_end_month == 0: report_period_end_month = 12 # 处理12月
# 这里简化处理,假设 get_last_period_heavy_holdings 能根据调仓日自动找到正确的上期报告日数据
# 严谨做法是精确计算上期报告日
universe_stocks = get_last_period_heavy_holdings(current_date)
if not universe_stocks:
print("[Rebalance] No heavy holdings found for last period. Skipping.")
last_holdings = []
rebalance_flag = True # 进入下个持有期
continue
# 2. 获取当前因子值
factor1_values = load_factor_data(factor1_name, current_date)
factor2_values = load_factor_data(factor2_name, current_date)
# 3. 因子筛选
# 过滤因子值,只保留在上期重仓股池中的股票
factor1_filtered = factor1_values.loc[factor1_values.index.isin(universe_stocks)].dropna()
factor2_filtered = factor2_values.loc[factor2_values.index.isin(universe_stocks)].dropna()
if factor1_filtered.empty or factor2_filtered.empty:
print("[Rebalance] Factor data missing for universe stocks. Skipping.")
last_holdings = []
rebalance_flag = True
continue
# 第一层筛选: factor1 (假设值越大越好)
factor1_rank = factor1_filtered.rank(ascending=False, method='first')
selected_by_factor1 = factor1_rank[factor1_rank <= top_n1].index.tolist()
print(f"[Rebalance] Selected {len(selected_by_factor1)} stocks after factor1 screening.")
if not selected_by_factor1:
print("[Rebalance] No stocks selected by factor1. Skipping.")
last_holdings = []
rebalance_flag = True
continue
# 第二层筛选: factor2 (在第一层选出的股票中,假设值越大越好)
factor2_screened = factor2_filtered.loc[factor2_filtered.index.isin(selected_by_factor1)]
if factor2_screened.empty:
print("[Rebalance] No stocks left after filtering for factor2. Skipping.")
last_holdings = []
rebalance_flag = True
continue
factor2_rank = factor2_screened.rank(ascending=False, method='first')
current_holdings = factor2_rank[factor2_rank <= top_n2].index.tolist()
print(f"[Rebalance] Selected {len(current_holdings)} final stocks after factor2 screening.")
if not current_holdings:
print("[Rebalance] No final stocks selected. Skipping.")
last_holdings = []
rebalance_flag = True
continue
# 记录持仓
portfolio_log[current_date] = current_holdings
last_holdings = current_holdings
rebalance_flag = True # 标记进入持有期
print(f"[Rebalance] New holdings: {last_holdings[:5]}...") # 显示部分持仓
# 计算每日收益率 (如果处于持有期且有持仓)
if rebalance_flag and last_holdings and i > 0:
prev_date = trading_days[i-1]
try:
# 加载昨日和今日价格
price_data = load_price_data(last_holdings, prev_date, current_date)
prev_prices = price_data.loc[prev_date, last_holdings]
current_prices = price_data.loc[current_date, last_holdings]
# 处理价格缺失或停牌 (简单处理:当天收益为0)
valid_mask = prev_prices.notna() & current_prices.notna() & (prev_prices > 0)
daily_returns = pd.Series(0.0, index=last_holdings)
if valid_mask.any():
daily_returns[valid_mask] = (current_prices[valid_mask] / prev_prices[valid_mask]) - 1
# 等权组合收益
portfolio_daily_return = daily_returns.mean()
# 更新净值
portfolio_value.iloc[i] = portfolio_value.iloc[i-1] * (1 + portfolio_daily_return)
# print(f"[Daily] {current_date.strftime('%Y-%m-%d')} Return: {portfolio_daily_return:.4f}, Value: {portfolio_value.iloc[i]:.4f}")
except Exception as e:
print(f"[Error] Failed to calculate return for {current_date.strftime('%Y-%m-%d')}: {e}")
portfolio_value.iloc[i] = portfolio_value.iloc[i-1] # 保持净值不变
elif i > 0:
portfolio_value.iloc[i] = portfolio_value.iloc[i-1] # 非持有期或无持仓,净值不变
else:
# 第一天净值为1
pass
print("\n--- Backtest Finished ---")
# 清理未计算的净值 (如果回测开始不是持有期)
portfolio_value = portfolio_value.fillna(method='ffill')
return portfolio_value, portfolio_log
# --- 结果分析函数 ---
def analyze_performance(portfolio_value):
"""
计算并打印策略表现指标
"""
if portfolio_value.empty:
print("Portfolio value series is empty. No analysis possible.")
return
# 确保时间序列连续性以便计算
portfolio_value = portfolio_value.dropna()
if portfolio_value.empty:
print("Portfolio value series has no valid data after dropna.")
return
daily_returns = portfolio_value.pct_change().fillna(0)
# 总收益率
total_return = (portfolio_value.iloc[-1] / portfolio_value.iloc[0]) - 1
# 年化收益率
days = (portfolio_value.index[-1] - portfolio_value.index[0]).days
annual_return = (1 + total_return) ** (365.0 / days) - 1
# 年化波动率
annual_volatility = daily_returns.std() * np.sqrt(252) # 假设一年252个交易日
# 夏普比率 (假设无风险利率为0)
sharpe_ratio = annual_return / annual_volatility if annual_volatility > 0 else 0
# 最大回撤
cumulative_value = (1 + daily_returns).cumprod()
peak = cumulative_value.cummax()
drawdown = (cumulative_value - peak) / peak
max_drawdown = drawdown.min()
print("\n--- Performance Analysis ---")
print(f"Backtest Period: {portfolio_value.index[0].strftime('%Y-%m-%d')} to {portfolio_value.index[-1].strftime('%Y-%m-%d')}")
print(f"Total Return: {total_return:.2%}")
print(f"Annualized Return: {annual_return:.2%}")
print(f"Annualized Volatility: {annual_volatility:.2%}")
print(f"Sharpe Ratio: {sharpe_ratio:.2f}")
print(f"Maximum Drawdown: {max_drawdown:.2%}")
# 可以增加绘图功能
# import matplotlib.pyplot as plt
# portfolio_value.plot(title='Portfolio Value Over Time', figsize=(12, 6))
# plt.show()
# --- 主程序入口 ---
if __name__ == "__main__":
# 设置回测参数 (需要根据实际数据调整)
backtest_start_date = "2014-01-01"
backtest_end_date = "2020-06-30" # 示例结束日期
# 运行回测
# 注意:这里使用的是示例数据加载函数,运行结果没有实际意义
# 需要替换为真实的 load_price_data, load_factor_data, get_last_period_heavy_holdings
portfolio_value, portfolio_log = fund_holding_enhancement_strategy(
start_date=backtest_start_date,
end_date=backtest_end_date,
factor1_name='NonLiquidity', # 使用占位符因子名
factor2_name='OrthogonalLargeBuy', # 使用占位符因子名
top_n1=100,
top_n2=50
)
# 分析并打印结果
if not portfolio_value.empty:
analyze_performance(portfolio_value)
# print("\nLast few portfolio logs:")
# for date, holdings in list(portfolio_log.items())[-3:]:
# print(f"{date.strftime('%Y-%m-%d')}: {holdings[:5]}...")
else:
print("Backtest did not produce results.")
如何使用和改进这个框架:
- 实现数据加载: 最关键的步骤是替换
load_price_data
,load_factor_data
,get_last_period_heavy_holdings
,get_trading_calendar
函数,使其能够从你的数据源(数据库、CSV文件、API如Tushare/JQData/Wind等)加载真实数据。 - 因子实现: 如果没有现成的因子数据,你需要根据因子定义自行计算。非流动性因子相对容易,大单类因子则需要高频数据支持。
- 细节调整: 根据报告或自己的研究调整筛选的因子、筛选数量(
top_n1
,top_n2
)、调仓频率、持有期等。 - 风险控制: 加入止损、行业中性化、风格因子中性化等风险控制措施。
- 成本模拟: 加入交易成本和滑点模拟,使回测结果更接近现实。
- 基准比较: 加入市场基准(如沪深300、中证500)的比较,计算Alpha、Beta、信息比率等更全面的评价指标。
这个框架提供了一个起点,用于理解和尝试复现海通证券报告中提出的这种结合基金行为和因子选股的有趣策略。请务必注意数据和因子计算的准确性是策略成功的关键。