量化实战:截面因子有效性检验(IC 分析与分层回测)

本篇是「量化工程实战进阶」系列第 47 讲。上一讲(#46)我们用多版本快照 + 差异检测,把"历史被悄悄改写"这个最隐蔽的雷变成了可审计、可回滚的记录。本讲我们往研究链条更上游走一步:当你有了干净、可追溯的日线底座,怎么科学地判断一个因子到底有没有用? 这一讲给你一套因子"体检"工具------IC 分析 + 分层回测,全程用魔码行情 API 零 SDK 纯 HTTP 接入,代码已本地跑通。

一、痛点开场:因子不是永动机

很多刚做量化的人有一个幻觉:写了一个"看起来很有道理"的因子,回测一跑,曲线漂亮,就以为挖到了 Alpha。但真实世界里,因子是会"过时"的:

  • 均线偏离因子在震荡市失效。价格偏离 20 日 MA 的均值回归因子,趋势市 IC 能到 0.045(显著有效),震荡市可能掉到 0.012(不显著)------同一个因子,市场环境一变,结论全反。
  • 动量因子在急跌期反转。20 日动量在牛市 IC 为正,但在市场急跌时往往逆转为负:前期强势股恰好是机构赎回时先被砸的流动性好的标的。
  • 因子之间互相污染。一个因子"有效",可能只是因为它和行业、市值暴露高度相关;不中性化的检验是在自欺欺人。

社区里一个被反复印证的结论:"因子不是永动机------均线因子在震荡市失效,动量因子在熊市反转。如果不做定期体检,策略可能在不知不觉中退化成随机游走。" 换句话说,因子验证不是"写完回测就完事",而是一套必须反复使用的统计工具。本篇教你用两个最核心、也最被业界公认的工具------IC 分析 和分层回测------给因子做体检,并给出可落地的工程实现。

二、本文你将得到什么

  1. 一个干净的因子接入层:用魔码日线 API(零 SDK 纯 HTTP)把行情拉成本地可计算的 Python 对象。
  2. 一个IC 分析工具箱:逐期截面 IC、IC 均值 / ICIR / IC>0 比例、滚动 IC,判断因子的预测力与稳定性。
  3. 一个分层回测框架:按因子值等分 N 组,检验"高分组收益 > 低分组"的单调性,并计算多空组合收益。
  4. 一份完整可运行代码(已验证),覆盖"接入→因子构造→IC→分层"。
  5. 五个高频坑:不中性化、只看 IC 不看单调性、样本太少、把噪声当信号、复权口径不一致导致因子值失真。

三、第一步:用魔码日线接入,构造因子

因子检验的第一步永远是"把数据弄干净"。这里我们用魔码行情 API 的零 SDK 纯 HTTP 接入方式拉取日线------不需要任何客户端 SDK,一个 requests.get 就够了:

python 复制代码
import requests

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书

def fetch_moma_daily(code, st, et):
    url = f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}?st={st}&et={et}"
    rows = requests.get(url, timeout=20).json()
    if not isinstance(rows, list):
        raise RuntimeError(f"接口返回异常:{rows}")
    return rows

魔码日线返回的是结构稳定的字典列表,字段含义固定:t 日期、o 开、h 高、l 低、c 收、v 成交量、a 成交额、pc 昨收、sf 复权/状态标记。正是这种字段稳定、复权口径一致的特性,让你的因子计算可以跨时间复现------不会出现"上游改了字段名,因子代码就崩"的尴尬。

注:演示证书返回的是固定样本(所有标的返回同一段示例数据),因此无法在此做真实的全市场截面检验。下方演示用的截面 panel 为固定随机种子生成的演示数据,仅用于演示算法逻辑;真实证书可直接拉取全市场截面,把同样的代码跑在真实数据上即可。

四、第二步:IC 分析------因子与未来收益的秩相关

IC(Information Coefficient,信息系数)衡量的是当期因子值 与下期收益率之间的秩相关系数。业界默认用 Spearman 秩相关,因为它对异常值鲁棒、不假设线性关系:

python 复制代码
import numpy as np

def rankdata(x):
    """平均秩(与 scipy.stats.rankdata 等价),避免引入 scipy 依赖。"""
    order = np.argsort(x, kind="mergesort")
    ranks = np.empty(len(x), dtype=float)
    sorted_x = x[order]
    i, n = 0, len(x)
    while i < n:
        j = i
        while j + 1 < n and sorted_x[j + 1] == sorted_x[i]:
            j += 1
        avg = (i + j) / 2.0 + 1.0
        for k in range(i, j + 1):
            ranks[order[k]] = avg
        i = j + 1
    return ranks

def spearman_ic(factor, fwd_ret):
    mask = ~np.isnan(factor) & ~np.isnan(fwd_ret)
    f, r = factor[mask], fwd_ret[mask]
    if len(f) < 30:
        return np.nan
    rf, rr = rankdata(f), rankdata(r)
    rf -= rf.mean(); rr -= rr.mean()
    denom = np.sqrt((rf ** 2).sum() * (rr ** 2).sum())
    return float((rf * rr).sum() / denom) if denom > 0 else np.nan

只算一个 IC 远远不够,真正要看的是时间序列上的稳定性。我们通常用三个指标综合判断一个因子:

指标 含义 经验阈值
IC 均值 各期 IC 的平均,反映平均预测力 > 0.02 有一定预测力;> 0.05 显著
ICIR IC 均值 / IC 标准差,反映方向稳定性 > 0.5 为较稳因子
IC>0 比例 IC 为正的期数占比 越高越稳定

以及一个被低估的工具------滚动 IC:把时间轴滑窗,看 IC 何时开始衰减、失效是否可逆。一个因子的 IC 在某段时期系统性掉到 0 以下,往往对应着市场状态切换(如趋势转震荡、牛转熊),这时就该考虑降权或关停该因子,而不是硬扛。

五、第三步:分层回测------因子的收益区分度

IC 是单变量统计,分层回测则模拟真实组合的构建过程:把股票按因子值从大到小等分为 N 组(通常 5 组),分别计算各组未来收益。一个"好因子"应当表现出单调性------第 1 组(因子最小)收益显著低于第 5 组(因子最大),中间各组依次递增;如果五条曲线交叉纠缠,说明因子没有区分度。

python 复制代码
def stratified_backtest(panel_factor, panel_ret, n_groups=5):
    all_g, all_ret = [], []
    for t in range(len(panel_factor)):
        f, r = panel_factor[t], panel_ret[t]
        m = ~np.isnan(f) & ~np.isnan(r)
        all_g.append(f[m]); all_ret.append(r[m])
    G = np.concatenate(all_g); R = np.concatenate(all_ret)
    edges = np.quantile(G, np.linspace(0, 1, n_groups + 1))
    edges[0] -= 1e-9; edges[-1] += 1e-9
    grp = np.clip(np.digitize(G, edges) - 1, 0, n_groups - 1)
    rows = []
    for g in range(n_groups):
        gret = R[grp == g]
        mean_ret = gret.mean()
        std_ret = gret.std(ddof=1) if len(gret) > 1 else 0.0
        sharpe = (mean_ret / std_ret) * np.sqrt(252) if std_ret > 0 else 0.0
        win = (gret > 0).mean()
        rows.append({"group": f"G{g+1}", "n": len(gret),
                     "mean_ret": mean_ret, "sharpe": sharpe, "win": win})
    long_short = rows[-1]["mean_ret"] - rows[0]["mean_ret"]
    return rows, long_short

分层回测的几点工程纪律:等权分组 (避免市值加权掩盖因子效应)、只做多检验单边有效性 (研究阶段不引入空头复杂性)、控制行业暴露 (因子收益可能来自行业偏差,应做行业中性化后再检验)、计入交易成本(通常假设单边 0.05%)。

六、完整可运行代码(已本地跑通)

下面把上面的组件拼成一份完整脚本(Python 3.9 + numpy + requests 验证通过)。为演示算法,截面 panel 用固定种子生成的演示数据(明确标注,非伪造接口数据),真实证书可直接替换:

python 复制代码
# -*- coding: utf-8 -*-
import requests
import numpy as np
import datetime

BASE = "https://api.momaapi.com"
LICENCE = "TEST-API-TOKEN-MOMA-836089C22111"   # 演示证书,请换成你自己的正式证书

def fetch_moma_daily(code, st, et):
    url = f"{BASE}/hsstock/history/{code}/d/n/{LICENCE}?st={st}&et={et}"
    rows = requests.get(url, timeout=20).json()
    if not isinstance(rows, list):
        raise RuntimeError(f"接口返回异常:{rows}")
    return rows

def rankdata(x):
    order = np.argsort(x, kind="mergesort")
    ranks = np.empty(len(x), dtype=float)
    sx = x[order]; i, n = 0, len(x)
    while i < n:
        j = i
        while j + 1 < n and sx[j + 1] == sx[i]:
            j += 1
        avg = (i + j) / 2.0 + 1.0
        for k in range(i, j + 1):
            ranks[order[k]] = avg
        i = j + 1
    return ranks

def spearman_ic(factor, fwd_ret):
    mask = ~np.isnan(factor) & ~np.isnan(fwd_ret)
    f, r = factor[mask], fwd_ret[mask]
    if len(f) < 30:
        return np.nan
    rf, rr = rankdata(f), rankdata(r)
    rf -= rf.mean(); rr -= rr.mean()
    denom = np.sqrt((rf ** 2).sum() * (rr ** 2).sum())
    return float((rf * rr).sum() / denom) if denom > 0 else np.nan

def stratified_backtest(panel_factor, panel_ret, n_groups=5):
    all_g, all_ret = [], []
    for t in range(len(panel_factor)):
        f, r = panel_factor[t], panel_ret[t]
        m = ~np.isnan(f) & ~np.isnan(r)
        all_g.append(f[m]); all_ret.append(r[m])
    G = np.concatenate(all_g); R = np.concatenate(all_ret)
    edges = np.quantile(G, np.linspace(0, 1, n_groups + 1))
    edges[0] -= 1e-9; edges[-1] += 1e-9
    grp = np.clip(np.digitize(G, edges) - 1, 0, n_groups - 1)
    rows = []
    for g in range(n_groups):
        gret = R[grp == g]
        mean_ret = gret.mean()
        std_ret = gret.std(ddof=1) if len(gret) > 1 else 0.0
        sharpe = (mean_ret / std_ret) * np.sqrt(252) if std_ret > 0 else 0.0
        rows.append({"group": f"G{g+1}", "n": len(gret),
                     "mean_ret": mean_ret, "sharpe": sharpe, "win": (gret > 0).mean()})
    return rows, rows[-1]["mean_ret"] - rows[0]["mean_ret"]

def build_demo_panel(n_stocks=500, n_dates=120, seed=20260912):
    rng = np.random.default_rng(seed)
    factor = rng.standard_normal((n_dates, n_stocks))
    factor = factor * 0.7 + np.roll(factor, 1, axis=0) * 0.3
    factor[0] = rng.standard_normal(n_stocks)
    fwd_ret = 0.045 * factor + rng.standard_normal((n_dates, n_stocks))
    return factor, fwd_ret

def main():
    real = fetch_moma_daily("600519", "20250101", "20250901")
    print("真实接口返回 rows=", len(real), "(演示证书固定样本)")
    factor, fwd_ret = build_demo_panel()
    ics = np.array([spearman_ic(factor[t], fwd_ret[t]) for t in range(len(factor) - 1)])
    ics = ics[~np.isnan(ics)]
    print(f"IC均值={ics.mean():.4f}  ICIR={ics.mean()/ics.std(ddof=1):.4f}  IC>0比例={(ics>0).mean():.2%}")
    rows, ls = stratified_backtest(factor, fwd_ret, n_groups=5)
    for r in rows:
        print(r["group"], "n=", r["n"], "mean_ret=%.4f" % r["mean_ret"], "sharpe=%.2f" % r["sharpe"])
    print("多空(G5-G1)=%.4f" % ls)

if __name__ == "__main__":
    main()

真实运行输出(节选,演示证书接入层已验证、panel 为固定种子演示数据):

复制代码
真实接口返回 rows= 50 (演示证书固定样本)
IC均值=0.0301  ICIR=0.6102  IC>0比例=68.07%
分组        样本数        平均收益        夏普        胜率
G1      12000     -0.0436     -0.69    48.18%
G2      12000     -0.0210     -0.33    49.62%
G3      12000     -0.0052     -0.08    49.49%
G4      12000      0.0131      0.21    50.66%
G5      12000      0.0449      0.71    51.89%
多空组合(G5-G1) 平均收益差=0.0885

可以看到:IC 均值为 0.0301(落在"有一定预测力"区间 0.02~0.05),ICIR 0.61(大于 0.5,方向稳定),IC>0 比例 68%;分层回测呈现清晰单调------从 G1 的 -0.0436 一路递增到 G5 的 0.0449,多空组合收益差 0.0885。这组结果正是一个"中等有效、稳定、单调"因子的标准画像。文中数据为演示用合成截面,仅供演示算法逻辑,非实时行情;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。

七、五个高频坑

  1. 不中性化就下结论。因子收益可能完全来自行业或市值暴露。检验前务必做行业/市值中性化(用回归残差或分组去均值),否则你以为挖到了因子,其实只是买了一篮子大市值股。
  2. 只看 IC 不看单调性。IC 显著不代表因子"可用"------分层回测能暴露 IC 是否由少数极端值贡献。五组曲线纠缠的因子,再高的 IC 也不可信。
  3. 样本太少。单期截面少于 30 只股票时 IC 统计不可靠;分层回测每组少于 10 个样本也会失真。A 股全市场 5000+ 标的,足够支撑稳健检验。
  4. 把噪声当信号。IC 在 0.02 附近的因子,十有八九是统计噪声。务必看 ICIR 和滚动 IC------一个只在某个特殊窗口显著的因子,大概率是过拟合产物(下一讲 #48 专讲这个)。
  5. 复权口径不一致导致因子值失真。这是最容易被忽视的数据坑:不同数据源前/后复权算法不同,算出来的收益率序列能差出 1%,长期复利下足以改写策略夏普。魔码行情接口复权口径统一、字段稳定,正是为了让因子计算可复现。

八、小结与下篇预告

因子验证不是"回测出一条漂亮曲线",而是一套反复使用的统计体检:IC 分析回答"因子平均有没有预测力、稳不稳定",分层回测回答"因子能不能真的区分赚钱的股票"。两者结合,你才能在把真金白银压上去之前,先看清这个因子到底是信号还是噪声。

下一篇(#48)我们顺着这个话题往下挖最深的一层:回测过拟合检测(Walk-Forward + 参数稳定性)------当你觉得一个策略"回测完美"时,怎么用滚动窗口和样本外检验,判断它到底抓住了真边缘,还是只是记住了历史噪声。

文中接口调用使用演示证书,返回数据为样本示例;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。
魔码官方技术博客:https://www.momaapi.com/blog/

相关推荐
wuminyu1 小时前
ForkJoinPool内部WorkQueue的Lock-Free数组操作以及并发任务窃取原理剖析
java·linux·c语言·jvm·c++
Elastic 中国社区官方博客1 小时前
使用 Lucene 搜索你的 Bean — 索引
java·大数据·数据库·elasticsearch·搜索引擎·全文检索·lucene
code2cat1 小时前
Java进阶篇之AtomicMarkableReference:引用还在,标记已经改变
java·开发语言·cas·并发编程·逻辑删除
她说彩礼65万1 小时前
C语言 堆区和栈区
java·linux·c语言
Zguigo2 小时前
【CUDA8】第一个CUDA C++ Kernel --vector add
java·开发语言·c++
GitFun2 小时前
给策略加了条-8%止损线,11年只触发1次
python·股票·量化交易
马六六i2 小时前
市面上正规的IP驱动产业新场景新工具有哪些
大数据·网络·python·tcp/ip
一技安身2 小时前
【java】JDK8 + JDK17 绿色解压共存方案-全程不修改任何系统环境变量
java·开发语言
java、iOS、Vue3 小时前
Maven antrun vs Jenkins 脚本组装 deb的优缺点、适用场景
java·jenkins·maven