系列:Python 量化策略评估体系 #02 数据源:麦蕊行情 API(mairui SDK)日线 演示证书:LICENCE-66D8-9F96-0C7F0FBCD073(公开演示证书,正式项目请替换为正式证书)
一、策略跑完还在"盲盒"里?
策略回测赚钱了,是不是真的"赚到了"?很多开发者的策略其实是因子黑盒:
- 加了 5 个因子(动量 / 反转 / 低波 / 流动性 / 趋势)一起贡献收益,到底哪个真贡献 Alpha?
- 哪个其实是噪声?哪个其实是"伪因子"(看起来有贡献、其实只是恰好那段数据巧合)?
- 关闭某些因子,策略会塌方吗?
上篇 #01 用 Brinson 做了行业维度的收益分解 (大盘涨的还是选股选的),但单只股票维度 的因子分解需要另一套方法------单因子隔离 + MPA 多因子回归。本篇把这套方法用 Python 实战。
读完本文你能拿到:
- 5 个经典因子的 Python 实现(只用 stock_history 可构造)
- 单因子隔离回测:每个因子独立跑一次,看真实贡献
- MPA 多因子分解:策略收益 = α + Σ β_i × F_i,看边际贡献
- 一个完整的因子评估脚本,可直接套到你自己的策略上
二、本文你将得到什么
- 5 个经典因子的统一接口 ------
factor_funcs = {"MOM_20": ..., "REV_5": ...},每个因子的输入输出标准化 - 单因子隔离回测------给每个因子跑一次"多 Top1 + 空 Bottom1",得到该因子的累计收益
- MPA 多因子回归------用 OLS 把策略收益回归到 5 个因子收益上,得到每个因子的边际贡献
- IR 风险调整后收益------mean / std,单因子的"实战门槛"判据
- 策略类型自动判定------α-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
六、常见坑
- 未来函数 :单因子回测必须用"截至上一交易日"的因子值排序(
bars[:today_idx+1]),用当日全 bar 会引入未来函数,回测美化但不真实。 - Top-Bottom 多空 vs Top 全持:单因子隔离推荐多空对冲(消除大盘波动干扰),如果只做多头会被市场 β 主导,看不清因子真贡献。
- 5 因子 vs 50 样本:5 个 beta 拟合 50 个样本是 10:1,过拟合风险高;正式实战建议 ≥ 30 只股票 × ≥ 250 日,比例 ≥ 5:1。
- IR 才是硬指标:累计收益再高、IR 低 = 风险大、不稳定;优先看 IR 而不是累计收益。
- 演示证限制:演示证 stock_history 返回 50 条 mock OHLC,所有股票原始数据一致,正式项目必须用正式证书拿差异化数据。
- alpha ≠ 选股能力:MPA 里的 alpha 是"因子解释不了的部分",可能是真选股能力、也可能是遗漏因子、也可能是噪声------别迷信 alpha。
七、小结 + 下篇预告
本篇用 Python 把单因子隔离 + MPA 多因子分解全部跑通,真正的实战结论往往出人意料:看似 5 个因子都在贡献,其实只有 1 个是真贡献,其他 4 个是噪声。这种"识别假因子"的能力,是策略评估体系的核心。
下一篇 #03 我们来解决另一个常见痛点:训练集 +20% 测试集 -3%,怎么区分"真稳健"和"过拟合"?------滚动回测 + Walk-Forward + 鲁棒性指标三件套,让你的策略在样本外也能站得住。
免责声明
本文涉及的方法论与代码仅供学习量化策略评估,不构成投资建议。市场有风险,投资需谨慎。