4. 流动性分组方法论
在上一节中我们已经明确:流动性是刻画股票交易难易程度的核心维度,常用换手率、成交额、Amihud非流动性等指标度量。但要把全市场股票按流动性"分组",仅仅把指标从大到小排个序是远远不够的。分组方法本身会直接影响后续策略测试的结论——同一份流动性数据,用不同的分组逻辑,可能得到截然不同的多空收益曲线。本节聚焦三种最常用的分组设计:分位数分组、行业中性化分组与市值中性化分组,讲清它们各自解决什么问题、如何实现、以及结果如何判读。
一、分位数分组:最直接的全样本切分
分位数分组(Quantile Grouping)是最基础的做法:在每个调仓截面上,把股票按流动性指标排序,然后按分位点切成若干组。假设我们做五分组,则第1组为流动性最低的20%,第5组为流动性最高的20%。实现上通常有两种写法:一是用排序后的序号除以样本数得到分位秩,再按区间切分;二是直接调用分位数函数取切点。
import pandas as pd
import numpy as np
def quantile_group(df, factor_col, n_groups=5):
# df: 单期截面,含 code 与流动性因子
df = df.dropna(subset=[factor_col]).copy()
# 用 rank(pct=True) 得到 0~1 的分位秩,避免重复值导致的切点偏移
df['pct_rank'] = df[factor_col].rank(pct=True, method='first')
# 切分为 n_groups 组,标签 1..n_groups,1 为流动性最低
df['group'] = np.ceil(df['pct_rank'] * n_groups).astype(int)
df['group'] = df['group'].clip(1, n_groups)
return df
这里有两个容易被忽略的细节。第一,方向约定必须全文统一:本节约定"第1组=流动性最低",若你的因子是换手率(越大越流动),则第1组对应换手率最小的20%;若用Amihud非流动性(越大越不流动),则第1组对应Amihud最大的20%,方向恰好相反。第二,重复值与缺失值要处理干净:用method='first'打破并列,用dropna剔除缺失,否则ceil切分会出现空组或组内数量严重不均。
分位数分组的优点是简单、可复现、组内样本量均衡;缺点是它不控制任何其他暴露。流动性低的股票往往同时是小市值、冷门行业的股票,因此第1组与第5组之间的收益差异,可能混杂了市值效应和行业效应,无法干净地归因于流动性本身。
二、行业中性化分组:剥离行业结构干扰
行业中性化分组要解决的问题是:不同行业的天然流动性水平差异很大。例如银行、公用事业类股票换手率普遍偏低,而计算机、传媒类股票换手率偏高。如果直接全样本排序,低流动性组可能被银行股"占满",高流动性组被科技股"占满",那么分组收益差实际上是在比较两个行业,而不是在比较流动性。
行业中性化的设计逻辑是:在每个行业内部独立排序分组,再把各行业的同一组别合并。这样每个组都按行业权重均匀地包含了各行业股票,行业结构被"对齐"了。实现步骤如下:
- 为每只股票打上行业标签(如申万一级,共31个行业);
- 按行业分组,在每个行业内对流动性因子做
rank(pct=True); - 在每个行业内按分位秩切分为N组;
- 合并所有行业的同组股票,得到最终的行业中性分组。
def industry_neutral_group(df, factor_col, industry_col, n_groups=5):
df = df.dropna(subset=[factor_col, industry_col]).copy()
df['pct_rank'] = df.groupby(industry_col)[factor_col]\
.rank(pct=True, method='first')
df['group'] = np.ceil(df['pct_rank'] * n_groups).astype(int)
df['group'] = df['group'].clip(1, n_groups)
return df
需要注意一个现实约束:行业内股票数量不足时无法均匀切分。若某行业只有8只股票却要分5组,每组不足2只,分组就失去统计意义。常见处理是:行业内股票数少于n_groups × 最小样本阈值(如每组至少3只,即少于15只)时,将该行业整体并入"其他"或按全样本分位秩处理,并在结果中标注。这一步不做,小行业会产生大量噪声组。
三、市值中性化分组:控制规模效应
市值中性化针对的是流动性研究中最大的混淆变量——市值。A股中流动性指标与市值高度相关:小市值股票成交额小、换手率波动大,大市值股票成交额大但换手率未必高。若不控制市值,流动性分组的收益差很可能只是"小市值溢价"的另一种表达。
市值中性化分组有两种主流实现路径,二者逻辑不同,需按研究目的选择:
路径A:先回归取残差,再分组。在每个截面上,将流动性因子对对数市值做横截面回归,取残差作为"剔除市值影响后的流动性",再对残差做分位数分组。残差为正表示该股票流动性高于同市值水平的预期,为负表示低于预期。
import statsmodels.api as sm
def size_neutral_factor(df, factor_col, mktcap_col):
df = df.dropna(subset=[factor_col, mktcap_col]).copy()
df['ln_cap'] = np.log(df[mktcap_col])
X = sm.add_constant(df['ln_cap'])
model = sm.OLS(df[factor_col], X).fit()
df['resid'] = model.resid # 市值中性化后的流动性
return df
路径B:市值分层内分组。先按市值把股票分为大、中、小三档(如按市值分位点切三组),再在每个市值档内对流动性做分位数分组,最后合并同组。这种做法更直观,且不假设流动性与市值呈线性关系,适合市值效应非线性明显的市场。
两条路径的差异值得强调:路径A输出的是连续残差,保留了流动性因子的全部信息,但依赖线性假设;路径B输出的是分层后的组别,对非线性更稳健,但会损失档内精细度。实际研究中,若流动性指标与市值关系近似单调,路径A更常用;若怀疑存在"大市值高流动性、小市值低流动性但中间段反转"的非线性结构,路径B更稳妥。
四、三种方法如何选择与组合
三种方法并非互斥,实际研究中常做双重中性化:先对流动性因子做市值回归取残差,再在行业内对残差做分位数分组。这样既剥离了规模效应,又对齐了行业结构,得到的分组收益差最接近"纯流动性效应"。代价是每一步都会损失一部分样本与信息,且残差的经济含义变得更抽象,解释时要格外谨慎。
选择方法时可按研究目的判断:若只想快速看流动性单调性,用分位数分组即可;若怀疑行业结构主导了结果,加行业中性化;若担心小市值溢价污染结论,加市值中性化。判断是否"分组有效",可观察两点:一是各组股票数量是否均衡(避免空组或极端不均),二是分组后各组在行业分布、市值分布上是否已明显对齐——若对齐后收益单调性消失,说明原结论很可能来自行业或市值暴露而非流动性本身。
最后强调一个实现层面的纪律:三种分组都必须在每个调仓截面上独立执行,不能跨期混排。跨期混排会让同一只股票在不同期落入不同组时缺乏可比性,也会引入前视偏差。把分组函数写成"输入单期截面、输出带组别标签的截面",再按日期循环调用,是保证可复现性的基本工程习惯。