【Python量化多因子实战 03】多因子怎么合成?等权、IC 加权与回归加权实测

0 阅读6分钟

上篇我们用 IC、IR 和分层回测验证了市值、动量因子的有效性。但真实策略不会只用一个因子,而是把多个因子合成一个综合信号。合成方式不同,结果可能天差地别。

今天我们就来实测三种最常见的合成方法:等权、IC 加权、回归加权,用真实数据看哪种更稳。


本文你将得到什么

  1. 三种多因子合成方法的 Python 实现。
  2. 用 IC、IR、五分组 Top-Bottom 差对比合成效果。
  3. 一个真实结论:在这个样本期内,IC 加权综合最优。
  4. 下篇预告:因子暴露不是 alpha,市值与行业中性化实战。

核心思路

等权合成

每个因子 z-score 后直接取平均。简单、稳定、不容易过拟合。

score = (z_size + z_mom + z_bp) / 3

IC 加权

用因子近期 IC 的绝对值作为权重。IC 高的因子权重更大,动态调整。

w_i = |mean(IC_i over last 5 days)|
score = sum(w_i * z_i) / sum(w_i)

回归加权

用过去几个截面的面板数据做截面回归,回归系数作为权重。理论上最「优化」,但样本小容易过拟合。

fwd_return = beta_size * z_size + beta_mom * z_mom + beta_bp * z_bp + alpha
score = beta_size * z_size + beta_mom * z_mom + beta_bp * z_bp

可运行代码

pip install mairui pandas numpy
from mairui import Client
import pandas as pd
import numpy as np

LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"  # 演示证书;换成你自己的正式证书
client = Client(LICENCE)

POOL = [
    "600519", "000001", "000002", "000333", "000568", "000651", "000725", "000858",
    "002001", "002007", "002142", "002230", "002304", "002415", "002594", "002714",
    "300003", "300014", "300015", "300033", "300059", "300122", "300124", "300274",
    "300750", "601318", "601398", "601888", "603288", "603501",
]


def fetch_history(code):
    try:
        bars = client.stock_history(code, "d", "n", st="20260801", et="20261010")
        if bars:
            df = pd.DataFrame(bars).rename(columns={"t": "date", "c": "close"})
            df["date"] = pd.to_datetime(df["date"])
            return df.sort_values("date").reset_index(drop=True)
    except Exception as e:
        print(f"{code} 受限: {e}")
    return None


def fetch_capital(code):
    try:
        cap = client.stock_capital(code=code)
        if cap:
            return sorted(cap, key=lambda x: x.get("bdrq", ""), reverse=True)[0]
    except Exception:
        pass
    return None


def fetch_pershare(code):
    try:
        ps = client.stock_financial(code=code, table="pershareindex")
        if ps:
            return sorted(ps, key=lambda x: x.get("jzrq", ""), reverse=True)[0]
    except Exception:
        pass
    return None


records = []
for code in POOL:
    hist = fetch_history(code)
    cap = fetch_capital(code)
    ps = fetch_pershare(code)
    if hist is None or len(hist) < 30:
        continue
    total_shares = cap.get("zgb") if cap else None
    if not total_shares:
        continue
    bvps = ps.get("mgjzc") if ps else None

    for i in range(20, len(hist) - 5):
        row = hist.iloc[i]
        past = hist.iloc[i - 20]
        future = hist.iloc[i + 5]
        records.append({
            "code": code,
            "date": row["date"].strftime("%Y-%m-%d"),
            "market_cap": row["close"] * total_shares,
            "momentum": (row["close"] / past["close"] - 1) * 100,
            "bp": (bvps / row["close"]) if bvps and row["close"] > 0 else np.nan,
            "fwd_return": (future["close"] / row["close"] - 1) * 100,
        })

df = pd.DataFrame(records)


def zscore(s):
    s = pd.to_numeric(s, errors="coerce")
    return (s - s.mean()) / s.std()


# z-score;市值取负向(小市值更好)
df["z_size"] = df.groupby("date")["market_cap"].transform(lambda s: -zscore(np.log(s.replace(0, np.nan))))
df["z_mom"] = df.groupby("date")["momentum"].transform(lambda s: zscore(s))
df["z_bp"] = df.groupby("date")["bp"].transform(lambda s: zscore(s))

# 等权
df["score_ew"] = df[["z_size", "z_mom", "z_bp"]].mean(axis=1, skipna=True)

# IC 加权:用最近 5 日滚动 IC 绝对值做权重
ic_records = []
for d, g in df.groupby("date"):
    ic_records.append({
        "date": d,
        "ic_size": g["market_cap"].corr(g["fwd_return"], method="spearman"),
        "ic_mom": g["momentum"].corr(g["fwd_return"], method="spearman"),
        "ic_bp": g["bp"].corr(g["fwd_return"], method="spearman"),
    })
ic_df = pd.DataFrame(ic_records).sort_values("date")
for col in ["ic_size", "ic_mom", "ic_bp"]:
    ic_df[f"w_{col[3:]}"] = ic_df[col].abs().rolling(5, min_periods=1).mean()
tot = ic_df[["w_size", "w_mom", "w_bp"]].sum(axis=1)
ic_df[["w_size", "w_mom", "w_bp"]] = ic_df[["w_size", "w_mom", "w_bp"]].div(tot, axis=0)
df = df.merge(ic_df[["date", "w_size", "w_mom", "w_bp"]], on="date", how="left")
df["score_icw"] = df["z_size"] * df["w_size"] + df["z_mom"] * df["w_mom"] + df["z_bp"] * df["w_bp"]

# 回归加权:用过去 5 个截面滚动回归
unique_dates = sorted(df["date"].unique())
reg_weights = []
for i, d in enumerate(unique_dates):
    if i < 5:
        reg_weights.append({"date": d, "rw_size": 1/3, "rw_mom": 1/3, "rw_bp": 1/3})
        continue
    train = df[df["date"].isin(unique_dates[:i][-5:])].dropna(subset=["z_size", "z_mom", "z_bp", "fwd_return"])
    if len(train) < 20:
        reg_weights.append({"date": d, "rw_size": 1/3, "rw_mom": 1/3, "rw_bp": 1/3})
        continue
    X = train[["z_size", "z_mom", "z_bp"]].values
    y = train["fwd_return"].values
    beta = np.linalg.lstsq(np.column_stack([np.ones(len(X)), X]), y, rcond=None)[0][1:]
    beta = np.clip(beta, -1, 1)
    s = np.sum(np.abs(beta))
    if s == 0:
        reg_weights.append({"date": d, "rw_size": 1/3, "rw_mom": 1/3, "rw_bp": 1/3})
    else:
        reg_weights.append({"date": d, "rw_size": beta[0]/s, "rw_mom": beta[1]/s, "rw_bp": beta[2]/s})
reg_df = pd.DataFrame(reg_weights)
df = df.merge(reg_df, on="date", how="left")
df["score_reg"] = df["z_size"] * df["rw_size"] + df["z_mom"] * df["rw_mom"] + df["z_bp"] * df["rw_bp"]


# 评估
def evaluate(score_col):
    ics, spreads = [], []
    for d, g in df.groupby("date"):
        ic = g[score_col].corr(g["fwd_return"], method="spearman")
        if not np.isnan(ic):
            ics.append(ic)
        g = g.copy()
        g["q"] = pd.qcut(g[score_col].rank(method="first"), 5, labels=[1, 2, 3, 4, 5])
        qm = g.groupby("q", observed=True)["fwd_return"].mean()
        if len(qm) == 5:
            spreads.append(qm.iloc[-1] - qm.iloc[0])
    ics = pd.Series(ics)
    return {
        "mean_ic": round(ics.mean(), 4),
        "ir": round(ics.mean() / ics.std(), 4),
        "ic_positive_ratio": round((ics > 0).mean(), 4),
        "avg_q_spread": round(np.mean(spreads), 4),
        "n_days": len(ics),
    }


results = pd.DataFrame([
    evaluate("score_ew"),
    evaluate("score_icw"),
    evaluate("score_reg"),
], index=["等权合成", "IC 加权", "回归加权"])

print(results.to_string())

运行后你会看到类似结果:

       mean_ic      ir  ic_positive_ratio  avg_q_spread  n_days
等权合成    0.1976  1.3214             0.8947        3.1239      19
IC 加权   0.2317  1.6301             1.0000        3.1642      19
回归加权    0.1607  0.7467             0.7368        2.2055      19

(示例基于 2026 年真实行情测算;演示证书返回就近样本,申请正式证书可拉取全量历史。)


怎么读这张表

  • IC 加权综合最优:mean_ic 0.23、IR 1.63、IC 正比例 100%、五分组差 3.16%。它通过动态放大近期有效的因子,实现了比等权更好的预测能力。
  • 等权是稳健基线:IC 0.20、IR 1.32,已经相当不错。如果你不想折腾,等权是很好的起点。
  • 回归加权在这个样本里表现最弱:IR 只有 0.75。原因很直接:滚动窗口只有 5 个截面、每截面 30 只股票,样本量太少,回归系数不稳定。回归加权需要更长的历史样本才能发挥优势。

三个容易踩的坑

  1. 权重不能天天大变。IC 加权用 5 日滚动已经比较激进,实盘通常用 20 日或更长的半衰期,否则权重抖动会带来高换手。
  2. 回归加权对样本量敏感。截面股票数少于 50、历史截面少于 20 时,回归系数容易过拟合,不如等权稳健。
  3. 别忘了因子方向。如果某个因子方向搞反(比如市值取正向),权重越大错得越狠,合成前务必确认每个因子的经济含义。

小结

多因子合成不是越复杂越好。本文三种方法里,IC 加权在这个短样本期内表现最好,等权是性价比最高的基线,回归加权需要更大数据量才能稳定。下一篇我们解决另一个关键问题:因子里的市值、行业暴露,会把 alpha 吃掉多少。

下篇预告:【Python量化多因子实战 04】因子暴露不是 alpha:市值与行业中性化实战。


免责声明

本文仅为技术方法演示,不构成任何投资建议。文中示例数据仅用于教学,历史表现不代表未来收益。投资有风险,决策需谨慎。

代码与文档可参考 GitHub:github.com/MaiRuiApi