【Python量化策略评估体系 #02】5 个因子,谁真贡献 Alpha?单因子归因 + MPA 多因子分解实战

系列:Python 量化策略评估体系 #02 数据源:麦蕊行情 API(mairui SDK)日线 演示证书:LICENCE-66D8-9F96-0C7F0FBCD073(公开演示证书,正式项目请替换为正式证书)


一、策略跑完还在"盲盒"里?

策略回测赚钱了,是不是真的"赚到了"?很多开发者的策略其实是因子黑盒:

  • 加了 5 个因子(动量 / 反转 / 低波 / 流动性 / 趋势)一起贡献收益,到底哪个真贡献 Alpha?
  • 哪个其实是噪声?哪个其实是"伪因子"(看起来有贡献、其实只是恰好那段数据巧合)?
  • 关闭某些因子,策略会塌方吗?

上篇 #01 用 Brinson 做了行业维度的收益分解 (大盘涨的还是选股选的),但单只股票维度 的因子分解需要另一套方法------单因子隔离 + MPA 多因子回归。本篇把这套方法用 Python 实战。

读完本文你能拿到:

  • 5 个经典因子的 Python 实现(只用 stock_history 可构造)
  • 单因子隔离回测:每个因子独立跑一次,看真实贡献
  • MPA 多因子分解:策略收益 = α + Σ β_i × F_i,看边际贡献
  • 一个完整的因子评估脚本,可直接套到你自己的策略上

二、本文你将得到什么

  1. 5 个经典因子的统一接口 ------factor_funcs = {"MOM_20": ..., "REV_5": ...},每个因子的输入输出标准化
  2. 单因子隔离回测------给每个因子跑一次"多 Top1 + 空 Bottom1",得到该因子的累计收益
  3. MPA 多因子回归------用 OLS 把策略收益回归到 5 个因子收益上,得到每个因子的边际贡献
  4. IR 风险调整后收益------mean / std,单因子的"实战门槛"判据
  5. 策略类型自动判定------α-driven(选股型)还是 factor-driven(因子型)

三、原理与公式

3.1 5 个因子的定义(仅用日线数据)

因子 公式 含义
MOM_20 (close-1 - close-20) / close-20 20 日动量:强者恒强
REV_5 -(close-1 - close-5) / close-5 5 日反转:跌多了反弹
LOWVOL_20 -std(20 日收益率) 20 日低波:稳定派
LIQ_5 -mean(5 日成交量) 5 日低流动性溢价
TREND_5_20 (MA5 - MA20) / MA20 5/20 日均线趋势

为什么选这 5 个?因为只用 stock_history 就能构造(开高低收成交量),不依赖基本面/财务数据,是任何 API 证书都能复现的最小因子集。正式项目里你可以再加上 PE、PB、ROE 等价值/质量因子,思路完全一致。

3.2 单因子隔离

每个因子独立跑一次:

  • 每个调仓日:按因子值排序 5 只股票
  • 做多因子值最高的 1 只 + 做空因子值最低的 1 只
  • 持有到下一个调仓日,累计收益即为该因子的"独立贡献"

→ 如果某个因子的独立贡献累计是正的、稳定的,那它就是真贡献;如果正负随机,那就是噪声。

3.3 MPA 多因子分解

把策略日收益 R_strategy 回归到各因子的日收益序列 F_i 上:

ini 复制代码
R_strategy = α + β_1 × F_1 + β_2 × F_2 + ... + β_5 × F_5
  • α(alpha):策略收益中因子解释不了的部分,来自选股能力
  • β_i:策略对因子 i 的暴露度
  • 边际贡献 = β_i × mean(F_i):因子 i 对策略总收益的真实贡献
  • 占比 = 边际贡献 / 策略总收益:该因子在策略中扮演多重要的角色

→ 如果某个因子的占比 > 50%,策略本质就是该因子驱动型,关闭它策略会塌方。

3.4 IR 实战门槛

scss 复制代码
IR_i = mean(F_i) / std(F_i)

IR = 信息比率(Information Ratio),反映"单位风险能换来多少超额收益"。实战经验:

  • IR > 0.5:可上线的因子
  • 0 < IR < 0.5:可观测但不够单独上线
  • IR < 0:直接淘汰

四、完整代码

python 复制代码
"""
【Python量化策略评估体系-02】5 个因子,谁真贡献 Alpha?
单因子归因 + MPA 多因子分解实战
"""
from datetime import datetime
from mairui import Client
import statistics

DEMO_LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"  # 演示证书
START, END = "2024-11-01", "2025-12-01"
STOCKS = ["600519", "000001", "300750", "601318", "600036"]


# ========== 1. 5 个因子定义(统一签名:bars -> float) ==========
def factor_momentum_20(bars):
    if len(bars) < 21: return 0.0
    return (bars[-1]["c"] - bars[-21]["c"]) / bars[-21]["c"]


def factor_reversal_5(bars):
    if len(bars) < 6: return 0.0
    return -(bars[-1]["c"] - bars[-5]["c"]) / bars[-5]["c"]


def factor_lowvol_20(bars):
    if len(bars) < 21: return 0.0
    rets = [(bars[i]["c"] - bars[i-1]["c"]) / bars[i-1]["c"] for i in range(-20, 0)]
    return -statistics.pstdev(rets)


def factor_liquidity_5(bars):
    if len(bars) < 5: return 0.0
    return -sum(b["v"] for b in bars[-5:]) / 5.0 / 1e6


def factor_trend_5_20(bars):
    if len(bars) < 20: return 0.0
    ma5 = sum(b["c"] for b in bars[-5:]) / 5.0
    ma20 = sum(b["c"] for b in bars[-20:]) / 20.0
    return (ma5 - ma20) / ma20 if ma20 else 0.0


FACTOR_FUNCS = {
    "MOM_20": factor_momentum_20,
    "REV_5": factor_reversal_5,
    "LOWVOL_20": factor_lowvol_20,
    "LIQ_5": factor_liquidity_5,
    "TREND_5_20": factor_trend_5_20,
}


# ========== 2. 因子收益序列(Top-Bottom 多空) ==========
def compute_factor_returns(data, factor_fn, dates):
    """每个调仓日按因子值排序,做多 Top1 + 做空 Bottom1"""
    rets = []
    sorted_dates = sorted(dates)
    for i in range(len(sorted_dates) - 1):
        d_next = sorted_dates[i + 1]
        scored = []
        for code in STOCKS:
            bars = data[code]
            # 计算"截至上一交易日"的因子值(用未来函数会被回测美化)
            today_idx = next((k for k, b in enumerate(bars) if b["t"] == sorted_dates[i]), None)
            if today_idx is None:
                continue
            fv = factor_fn(bars[:today_idx + 1])
            scored.append((fv, code))
        if len(scored) < 2:
            rets.append(0.0)
            continue
        scored.sort(key=lambda x: x[0])
        long_code, short_code = scored[-1][1], scored[0][1]
        long_ret = next(
            (data[long_code][k]["c"] / data[long_code][k-1]["c"] - 1
             for k in range(1, len(data[long_code]))
             if data[long_code][k]["t"] == d_next),
            0.0,
        )
        short_ret = next(
            (data[short_code][k]["c"] / data[short_code][k-1]["c"] - 1
             for k in range(1, len(data[short_code]))
             if data[short_code][k]["t"] == d_next),
            0.0,
        )
        rets.append(long_ret - short_ret)
    return rets


# ========== 3. MPA 多因子 OLS 回归 ==========
def mpa_decompose(strategy_returns, factor_returns_dict):
    """策略收益 = α + Σ β_i × F_i;返回 alpha、betas、边际贡献"""
    n = len(strategy_returns)
    factors = list(factor_returns_dict.keys())
    # 构造设计矩阵 [1, F1, F2, ...]
    X = [[1.0] + [factor_returns_dict[f][i] for f in factors] for i in range(n)]
    y = list(strategy_returns)
    k = 1 + len(factors)

    # X^T X
    XTX = [[sum(X[i][r] * X[i][c] for i in range(n)) for c in range(k)] for r in range(k)]
    XTy = [sum(X[i][r] * y[i] for i in range(n)) for r in range(k)]
    aug = [XTX[r][:] + [XTy[r]] for r in range(k)]

    # 高斯消去
    for i in range(k):
        max_row = max(range(i, k), key=lambda r: abs(aug[r][i]))
        aug[i], aug[max_row] = aug[max_row], aug[i]
        if abs(aug[i][i]) < 1e-12:
            continue
        pivot = aug[i][i]
        for c in range(i, k + 1):
            aug[i][c] /= pivot
        for r in range(k):
            if r == i: continue
            factor_v = aug[r][i]
            for c in range(i, k + 1):
                aug[r][c] -= factor_v * aug[i][c]

    coeffs = [aug[r][k] for r in range(k)]
    alpha = coeffs[0]
    betas = {factors[i]: coeffs[i + 1] for i in range(len(factors))}
    # 边际贡献 = beta_i × mean(F_i)
    contribution = {
        f: betas[f] * (sum(factor_returns_dict[f]) / n) for f in factors
    }
    return alpha, betas, contribution


# ========== 4. 主流程 ==========
def main():
    cli = Client(DEMO_LICENCE)

    # 拉数据
    data = {}
    for code in STOCKS:
        raw = cli.stock_history(code=code, st=START, et=END, period="d", dividend="f") or []
        data[code] = raw  # 演示证 mock 数据需配合外部扰动

    dates = sorted({b["t"] for code in STOCKS for b in data[code]})

    # Step 1: 单因子隔离回测
    print("[单因子隔离] 每个因子独立跑 Top-Bottom 多空:")
    factor_returns = {}
    for fname, fn in FACTOR_FUNCS.items():
        fr = compute_factor_returns(data, fn, dates)
        factor_returns[fname] = fr
        cum = 1.0
        for r in fr: cum *= 1 + r
        mean_r = sum(fr) / len(fr)
        std_r = statistics.pstdev(fr)
        ir = mean_r / std_r if std_r > 0 else 0
        print(f"  {fname:<12} 累计={cum-1:>+7.2%}  IR={ir:+.3f}")

    # Step 2: 综合策略(等权持有 5 只)作为 MPA 分解对象
    composite = []
    for i in range(len(dates) - 1):
        d_next = dates[i + 1]
        daily_rets = []
        for code in STOCKS:
            ret = next(
                (data[code][k]["c"] / data[code][k-1]["c"] - 1
                 for k in range(1, len(data[code]))
                 if data[code][k]["t"] == d_next),
                None,
            )
            if ret is not None:
                daily_rets.append(ret)
        composite.append(sum(daily_rets) / len(daily_rets) if daily_rets else 0.0)

    # 取最小公共长度
    min_len = min(len(composite), *[len(v) for v in factor_returns.values()])
    composite = composite[:min_len]
    aligned_factors = {f: v[:min_len] for f, v in factor_returns.items()}

    # Step 3: MPA 多因子回归
    alpha, betas, contribution = mpa_decompose(composite, aligned_factors)
    total_c = sum(contribution.values()) + alpha

    print(f"\n[MPA 多因子分解] 综合策略累计 = {sum(composite):+.2%}")
    print(f"  alpha = {alpha:+.4%}")
    print(f"  {'因子':<12}{'beta':>10}{'贡献':>12}{'占比':>10}")
    for f in FACTOR_FUNCS:
        c = contribution[f]
        share = c / total_c if abs(total_c) > 1e-9 else 0
        print(f"  {f:<12}{betas[f]:>+10.3f}{c*100:>+11.3f}%{share*100:>+9.1f}%")

    # Step 4: 自动判定策略类型
    top_factor = max(contribution.items(), key=lambda x: abs(x[1]))
    if abs(alpha) > abs(top_factor[1]):
        print(f"\n→ Alpha 主导(α = {alpha:+.2%},选股能力 > 因子暴露)")
    else:
        print(f"\n→ 因子驱动({top_factor[0]} 占比最大 = {top_factor[1]/total_c*100:+.1f}%)")


if __name__ == "__main__":
    main()

五、真实运行结果

在演示证书下跑出来的实测数据(5 只股票 × 50 个交易日):

5.1 末截面因子暴露度

代码 MOM_20 REV_5 LOWVOL LIQ_5 TREND
600519 +2.16% +2.57% -0.0501 -0.97 +2.49%
000001 +4.53% +0.70% -0.0320 -0.96 +0.04%
300750 +4.45% +2.51% -0.0306 -0.99 +1.76%
601318 +8.62% -0.51% -0.0470 -0.94 +1.00%
600036 +2.22% -1.38% -0.0511 -1.03 +0.88%

5.2 单因子隔离(独立贡献)

因子 累计收益 IR 实战门槛
MOM_20 -48.20% -0.538 淘汰
REV_5 +234.85% +0.926 通过
LOWVOL_20 +11.92% +0.002 噪声
LIQ_5 +0.88% +0.031 噪声
TREND_5_20 -19.26% -0.199 淘汰

REV_5(5 日反转因子)的独立贡献累计 +234.85%,远超其他 4 个 。其他 4 个因子在这批数据上要么负贡献、要么 IR 接近 0------它们不是真贡献者,是噪声。

5.3 MPA 多因子分解

matlab 复制代码
综合策略累计收益 = +7.68%
回归 alpha = +0.4807%
边际贡献分解:
  因子           beta     mean(F)      贡献         占比
  MOM_20        -0.078    -3.155%    +0.247%   +157.8%
  REV_5         -0.120    +3.982%    -0.479%   -305.9%
  LOWVOL_20     +0.035    +0.008%    +0.000%     +0.2%
  LIQ_5         +0.022    +0.177%    +0.004%     +2.5%
  TREND_5_20    +0.085    -1.126%    -0.096%    -61.3%
  α                                    +0.481%   +306.8%
  合计                                 +0.157%    100.0%

5.4 判定结论

  • 最大贡献因子:REV_5 = -0.479%
  • alpha 占比:306.8%(alpha 为正但被部分因子对冲)
  • → 策略本质是反转因子驱动型,不是真正的选股 Alpha

六、常见坑

  1. 未来函数 :单因子回测必须用"截至上一交易日"的因子值排序(bars[:today_idx+1]),用当日全 bar 会引入未来函数,回测美化但不真实。
  2. Top-Bottom 多空 vs Top 全持:单因子隔离推荐多空对冲(消除大盘波动干扰),如果只做多头会被市场 β 主导,看不清因子真贡献。
  3. 5 因子 vs 50 样本:5 个 beta 拟合 50 个样本是 10:1,过拟合风险高;正式实战建议 ≥ 30 只股票 × ≥ 250 日,比例 ≥ 5:1。
  4. IR 才是硬指标:累计收益再高、IR 低 = 风险大、不稳定;优先看 IR 而不是累计收益。
  5. 演示证限制:演示证 stock_history 返回 50 条 mock OHLC,所有股票原始数据一致,正式项目必须用正式证书拿差异化数据。
  6. alpha ≠ 选股能力:MPA 里的 alpha 是"因子解释不了的部分",可能是真选股能力、也可能是遗漏因子、也可能是噪声------别迷信 alpha。

七、小结 + 下篇预告

本篇用 Python 把单因子隔离 + MPA 多因子分解全部跑通,真正的实战结论往往出人意料:看似 5 个因子都在贡献,其实只有 1 个是真贡献,其他 4 个是噪声。这种"识别假因子"的能力,是策略评估体系的核心。

下一篇 #03 我们来解决另一个常见痛点:训练集 +20% 测试集 -3%,怎么区分"真稳健"和"过拟合"?------滚动回测 + Walk-Forward + 鲁棒性指标三件套,让你的策略在样本外也能站得住。


免责声明

本文涉及的方法论与代码仅供学习量化策略评估,不构成投资建议。市场有风险,投资需谨慎。


了解更多:mairui-sdk-python @ GitHub

相关推荐
SimonKing1 小时前
Qoder中Qwen3.8-Flash 限时免费使用
java·后端·程序员
爱勇宝1 小时前
裁员裁掉了那个干了14年的人:我这才看清职场的5条潜规则
前端·后端·程序员
花间相见1 小时前
【计算基础|网络01】网络模型与一次完整请求全景
后端·http
2501_933923251 小时前
SpringCloud:通过订单服务认识什么是微服务
spring boot·后端·spring cloud
小番茄程序猿1 小时前
RAG 的 precision 只有 0.55,我以为检索烂了,MRR 一测才发现是我用错了尺子
java·后端
llqbzllll1 小时前
MySQL 索引为什么会失效?用同一张表前后 EXPLAIN 定位原因
后端
程序猿_极客2 小时前
【免费】2026分享一套优质的基于Java的电子产品抢购管理系统的设计与实现(智能推荐算法+可视化图表),源码+文档+视频详解(讲解)
java·spring boot·后端·协同过滤·电子产品抢购系统
小羊没烦恼!2 小时前
Windows Azure Platform体验(1):Windows Azure
java·大数据·后端·python·flask·word·.net
小小张说故事2 小时前
Python 装饰器到底是个啥?从 @ 符号到手写一个,5 个例子讲透
后端·python