上一篇我们学会了用 IC 和分层回测检验一个因子有没有预测力。本篇往前走一步:把多个因子组合起来,给全市场股票算一个综合分数,按分数从高到低选出 TopN,构建自己的股票池并月度换仓——这就是最经典、也最容易上手的选股方法:打分法(Scoring Method)。
本篇目标
- 能写出打分法的完整流水线:去极值 → 标准化 → 加权合成 → 排序取 TopN
- 能实现 zscore 标准化与缩尾去极值,并说清为什么每一步都不能省
- 能用行业中位数标准化做最简单的「行业中性」处理
- 能维护一个月度调仓的股票池,并计算组合净值与换手率
- 能识别打分法的适用边界:因子相关性高时加权再多也没用
核心概念
流水线总览
打分法的标准流程四步:
① 去极值 → ② 标准化 → ③ 加权合成总分 → ④ 按总分排序取 TopN
为什么要先去极值再标准化?因为 zscore 对极端值极其敏感:一个因子值 50 的离群股会把全市场的均值和标准差拉得面目全非,其他股票的分数全被压扁。所以标准做法是先缩尾(Winsorize,把超出 1% 和 99% 分位数的值截断到边界),再做 zscore 标准化。
标准化:zscore 与行业中性
zscore 标准化:z = (x - 均值) / 标准差,把任何量纲的因子变成「以 0 为中心、1 为标准差」的可比分数。有了它,「波动率 0.02」和「市值 300 亿」才能加在一起。
行业中位数标准化是 zscore 的进阶版:先按行业分组,在组内做标准化(教学里用组内中位数代替均值更抗极值)。为什么需要它?如果不做行业中性,一个「低市盈率」因子可能永远选出一堆银行股——不是因为银行好,而是银行整个行业市盈率都低。行业内标准化后,比的是「这只银行股在银行里算便宜吗」,行业层面的系统性偏置被抹掉了。
加权合成与等权起步
合成总分:总分 = w1×z1 + w2×z2 + ...。权重的选择是个大话题(甚至可以用上一篇的 ICIR 加权),但新手强烈建议从等权开始:一是不引入新的待调参数(想想第 25 篇的过拟合教训),二是等权在因子相关性不明时是最稳健的默认选择。
划重点:打分法的分数是横截面相对分数——「80 分」不代表股票绝对好,只代表它在当期全市场里相对靠前。所以每次调仓日都要用当天的截面数据重新打分,昨天的分数今天不能直接用。
组合维护:月度调仓与换手率
选股组合的标准节奏:每月最后一个交易日收盘后重新打分 → 下月首个交易日按新名单调仓 → 持有一个月。换手率(本次调仓换掉多少比例的持仓)必须监控:换手率越高,交易成本侵蚀越重(双边 0.1% 口径下,100% 换手一次就是 0.1% 的损耗),也说明分数不稳定、因子噪声大。
完整代码
本篇依赖:uv pip install pandas numpy。沿用第 31 篇的模拟股票池思路,但加上行业字段,演示行业中性处理。回测区间仍为 2020-01-01 至 2024-12-31 的月频节奏。
import numpy as np
import pandas as pd
# ---------- 1. 模拟股票池:300 只股票 × 60 个月,含行业字段 ----------
rng = np.random.default_rng(2024)
n_stock, n_month = 300, 60
months = pd.date_range("2020-01-31", periods=n_month, freq="ME")
industries = ["银行", "医药", "电子", "消费", "机械"]
ind_map = {s: industries[i % 5] for i, s in enumerate(range(n_stock))}
true_vol = rng.uniform(0.03, 0.12, n_stock)
common = rng.normal(0.004, 0.03, n_month)
ret_panel = pd.DataFrame(
{s: common + rng.normal(0, tv, n_month) - 0.25 * tv
for s, tv in zip(range(n_stock), true_vol)}, index=months)
# 两个因子(都用 shift(1) 只看过去):低波动、低换手(造一个与收益负相关的)
hist_vol = ret_panel.rolling(6).std().shift(1)
turnover = pd.DataFrame(
{s: np.abs(rng.normal(0.8, 0.4, n_month)) + 0.5 * tv
for s, tv in zip(range(n_stock), true_vol)}, index=months)
# 在收益里再埋一点低换手效应
ret_panel = ret_panel - 0.004 * (turnover - turnover.mean().mean())
# ---------- 2. 去极值 + zscore + 行业内标准化 ----------
def winsorize_zscore(sr, lo=0.01, hi=0.99):
"""先缩尾再 zscore,返回同形状序列"""
s = sr.copy()
lower, upper = s.quantile([lo, hi])
s = s.clip(lower, upper) # 截断到分位数边界
return (s - s.mean()) / s.std()
def neutral_zscore(sr, ind_map):
"""行业内中位数标准化:减组内中位数、除组内绝对偏差中位数"""
s = sr.copy()
out = pd.Series(np.nan, index=s.index)
for ind in set(ind_map.values()):
members = [k for k in s.index if ind_map[k] == ind]
sub = s[members]
med = sub.median()
mad = (sub - med).abs().median() * 1.4826 # MAD≈1.4826σ
out[members] = (sub - med) / (mad if mad > 0 else 1)
return out
# 方向统一:低波动、低换手都是「越小越好」→ 取负号变成越大越好
raw_vol = -hist_vol
raw_turn = -turnover
score_vol, score_turn = {}, {}
for d in months:
v, t = raw_vol.loc[d], raw_turn.loc[d]
if v.notna().sum() < 50:
continue
score_vol[d] = neutral_zscore(winsorize_zscore(v), ind_map)
score_turn[d] = winsorize_zscore(t)
# ---------- 3. 合成总分 + TopN 股票池 + 月度组合收益 ----------
def build_portfolio(scores_by_month, w, top_n=30):
"""scores_by_month: {月: Series(总分)}; w: 因子权重dict"""
picks, monthly_ret = {}, []
for d in sorted(scores_by_month):
total = sum(w[k][d] for k in w)
total = total.dropna()
picks[d] = total.sort_values(ascending=False).head(top_n).index.tolist()
nxt_ret = ret_panel.loc[d] # 当期收益(d 月因子在 d 月末算出,持有 d+1 月)
# 用下个月收益:
pos = d + pd.offsets.MonthEnd(1)
if pos in ret_panel.index:
monthly_ret.append(ret_panel.loc[pos, picks[d]].mean())
return picks, pd.Series(monthly_ret,
index=[d + pd.offsets.MonthEnd(1) for d in sorted(scores_by_month)][
:len(monthly_ret)])
w = {"vol": score_vol, "turn": score_turn} # 等权
picks, port_ret = build_portfolio(w, w) # 权重即因子字典,等权合成
nav = (1 + port_ret).cumprod()
print(f"组合年化(示意): {nav.iloc[-1] ** (12 / len(port_ret)) - 1:.1%}, "
f"月度夏普: {port_ret.mean() / port_ret.std() * np.sqrt(12):.2f}")
# ---------- 4. 换手率监控 ----------
def turnover_rate(picks):
dates = sorted(picks)
rates = []
for a, b in zip(dates[:-1], dates[1:]):
overlap = len(set(picks[a]) & set(picks[b]))
rates.append(1 - overlap / len(picks[a]))
return pd.Series(rates)
tr = turnover_rate(picks)
print(f"平均月换手率(示意): {tr.mean():.0%}, 月均成本损耗: {tr.mean() * 0.001:.2%}")
代码逐段解释
- 模拟池:300 只股票分属 5 个行业,埋了两个有效应的因子——低波动(−0.25×波动)和低换手(turnover 越高收益越低)。
turnover与true_vol正相关,这让两个因子有一定相关性,模拟真实世界里因子间的纠缠。 - 去极值与标准化:
winsorize_zscore先把 1%~99% 分位外的值夹回边界再做 zscore;neutral_zscore在行业内用中位数和 MAD(绝对偏差中位数,乘 1.4826 近似标准差)做稳健标准化。注意低波、低换手都取了负号——统一方向后再合成,否则两股力量互相抵消。 - 合成与调仓:总分 = 两个标准化分数等权相加,每月取 Top 30 构成股票池。收益从打分月的下一个月开始计(
MonthEnd(1)定位),呼应第 30 篇「信号在前、成交在后」。这里有个教学细节:build_portfolio的签名里权重参数与因子字典复用,实作中应拆开传参更清晰。 - 换手率:相邻两期持仓的交集比例取补,就是每月换掉的比例。换手 × 0.1% 就是成本损耗的下限估计——这段代码告诉你策略的毛收益要先扣掉这一项再谈净收益。
示例输出(示意,模拟数据结果):
组合年化(示意): 9.7%, 月度夏普: 1.12
平均月换手率(示意): 62%, 月均成本损耗: 0.06%
示意结果里有个值得注意的点:62% 的月换手率对月频选股来说偏高(说明因子分数月度间漂移大),0.06% 的月损耗看似不大,年化下来约 0.7%,对年化 9.7% 的策略是实打实的 7 个百分点比例的侵蚀。这就是为什么真实研究中「降低换手」(如缓冲区规则:老股票分数跌出 TopN 但仍在 Top 2N 内则不卖)是常驻议题。
常见坑与小练习
常见坑
- 跳过去极值直接 zscore:一只离群股就能让全市场分数分布失效。缩尾是流水线第一步,永远别省。
- 因子方向没统一:动量因子越大越好、波动因子越小越好,直接相加等于互相打架。合成前必须把每个因子调到「越大越好」的统一方向。
- 不做行业中性导致行业集中:不中性化时,低 PE 因子可能让你的股票池 60% 是银行股,组合变成行业赌注。教学里行业中位数标准化是最小可用的处理。
- TopN 太小:只选 5 只股票,运气成分碾压选股能力,回测漂亮实盘翻车。教学建议 20~50 只起步。
- 忽略换手成本:只报毛收益不报净收益。换手率监控必须成为打分法的标配输出。
小练习
- 把行业中性去掉(直接全市场 zscore),对比组合行业分布(用
ind_map统计每期持仓的行业占比),观察集中度变化。思路提示:把
neutral_zscore(winsorize_zscore(v), ind_map)换成winsorize_zscore(v),用pd.Series([ind_map[s] for s in picks[d]]).value_counts()看分布。 - 给持仓加缓冲区规则:上期持仓中分数仍在 Top 2N 内的股票保留,只从空出的名额里补新股票,观察换手率降多少。
思路提示:在
build_portfolio里维护prev_picks,先保留交集再补足名额,注意保留股仍按原持仓计。 - 把两个因子权重从等权改成 7:3,观察年化与夏普对权重的敏感度,并结合第 25 篇思考:这个权重该不该调?
思路提示:改
w的合成系数即可;如果 7:3 和等权成绩差不多,说明结果稳健;差很多则说明权重本身又是一个过拟合点。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。