本篇是「量化工程实战进阶」系列第 47 讲。上一讲(#46)我们用多版本快照 + 差异检测,把"历史被悄悄改写"这个最隐蔽的雷变成了可审计、可回滚的记录。本讲我们往研究链条更上游走一步:当你有了干净、可追溯的日线底座,怎么科学地判断一个因子到底有没有用? 这一讲给你一套因子"体检"工具------IC 分析 + 分层回测,全程用魔码行情 API 零 SDK 纯 HTTP 接入,代码已本地跑通。
一、痛点开场:因子不是永动机
很多刚做量化的人有一个幻觉:写了一个"看起来很有道理"的因子,回测一跑,曲线漂亮,就以为挖到了 Alpha。但真实世界里,因子是会"过时"的:
- 均线偏离因子在震荡市失效。价格偏离 20 日 MA 的均值回归因子,趋势市 IC 能到 0.045(显著有效),震荡市可能掉到 0.012(不显著)------同一个因子,市场环境一变,结论全反。
- 动量因子在急跌期反转。20 日动量在牛市 IC 为正,但在市场急跌时往往逆转为负:前期强势股恰好是机构赎回时先被砸的流动性好的标的。
- 因子之间互相污染。一个因子"有效",可能只是因为它和行业、市值暴露高度相关;不中性化的检验是在自欺欺人。
社区里一个被反复印证的结论:"因子不是永动机------均线因子在震荡市失效,动量因子在熊市反转。如果不做定期体检,策略可能在不知不觉中退化成随机游走。" 换句话说,因子验证不是"写完回测就完事",而是一套必须反复使用的统计工具。本篇教你用两个最核心、也最被业界公认的工具------IC 分析 和分层回测------给因子做体检,并给出可落地的工程实现。
二、本文你将得到什么
- 一个干净的因子接入层:用魔码日线 API(零 SDK 纯 HTTP)把行情拉成本地可计算的 Python 对象。
- 一个IC 分析工具箱:逐期截面 IC、IC 均值 / ICIR / IC>0 比例、滚动 IC,判断因子的预测力与稳定性。
- 一个分层回测框架:按因子值等分 N 组,检验"高分组收益 > 低分组"的单调性,并计算多空组合收益。
- 一份完整可运行代码(已验证),覆盖"接入→因子构造→IC→分层"。
- 五个高频坑:不中性化、只看 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。这组结果正是一个"中等有效、稳定、单调"因子的标准画像。文中数据为演示用合成截面,仅供演示算法逻辑,非实时行情;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。
七、五个高频坑
- 不中性化就下结论。因子收益可能完全来自行业或市值暴露。检验前务必做行业/市值中性化(用回归残差或分组去均值),否则你以为挖到了因子,其实只是买了一篮子大市值股。
- 只看 IC 不看单调性。IC 显著不代表因子"可用"------分层回测能暴露 IC 是否由少数极端值贡献。五组曲线纠缠的因子,再高的 IC 也不可信。
- 样本太少。单期截面少于 30 只股票时 IC 统计不可靠;分层回测每组少于 10 个样本也会失真。A 股全市场 5000+ 标的,足够支撑稳健检验。
- 把噪声当信号。IC 在 0.02 附近的因子,十有八九是统计噪声。务必看 ICIR 和滚动 IC------一个只在某个特殊窗口显著的因子,大概率是过拟合产物(下一讲 #48 专讲这个)。
- 复权口径不一致导致因子值失真。这是最容易被忽视的数据坑:不同数据源前/后复权算法不同,算出来的收益率序列能差出 1%,长期复利下足以改写策略夏普。魔码行情接口复权口径统一、字段稳定,正是为了让因子计算可复现。
八、小结与下篇预告
因子验证不是"回测出一条漂亮曲线",而是一套反复使用的统计体检:IC 分析回答"因子平均有没有预测力、稳不稳定",分层回测回答"因子能不能真的区分赚钱的股票"。两者结合,你才能在把真金白银压上去之前,先看清这个因子到底是信号还是噪声。
下一篇(#48)我们顺着这个话题往下挖最深的一层:回测过拟合检测(Walk-Forward + 参数稳定性)------当你觉得一个策略"回测完美"时,怎么用滚动窗口和样本外检验,判断它到底抓住了真边缘,还是只是记住了历史噪声。
文中接口调用使用演示证书,返回数据为样本示例;生产环境请使用你自己的魔码正式证书,并以官方文档与实时返回为准。
魔码官方技术博客:https://www.momaapi.com/blog/