本文你将得到什么:
- 一个统一的配权回测框架:权重每日用"截至昨日"的数据重估,杜绝前视
- 三种权重方案的完整实现与同台对比:等权 / 波动率倒数 / 动态评分
- 风险平价(ERC)的手写数值迭代------以及它在负相关下会"退化"的实测证据
- 一个反直觉结论:下跌市里追强势的"动态评分"反而亏得最少,但波动最高
痛点:等权是最省事的分法,也是最"一刀切"的分法
上一篇用等权组合把波动压了下来,但等权有个明显缺陷:波动 47% 的策略和波动 37% 的策略拿一样的钱------风险贡献严重失衡。机构里常听到的"风险平价""波动率倒数加权",说的就是按风险而非按钱数来分配。这些名字听着唬人,核心公式其实一行就能写下。这篇把三种方案放进同一个框架里实测,看它们到底差在哪。
环境准备
bash
pip install mairui pandas numpy matplotlib
第一步:复用上一篇的三个策略收益流
数据与信号口径与上一篇完全一致(昨日收盘信号、shift(1) 无前视),直接复用:
python
from mairui import Client
import pandas as pd
import numpy as np
UNIVERSE = [
("600519", "sma520", "MA5/20动量"),
("000001", "sma310", "MA3/10快速"),
("300750", "mom20", "20日动量"),
]
client = Client("LICENCE-66D8-9F96-0C7F0FBCD073") # 演示证书,请换成你自己的正式证书
def strategy_daily_returns(df, kind):
c = df["c"]
if kind == "sma520":
sig = (c.rolling(5).mean() > c.rolling(20).mean()).astype(float)
elif kind == "sma310":
sig = (c.rolling(3).mean() > c.rolling(10).mean()).astype(float)
elif kind == "mom20":
sig = (c / c.shift(20) - 1 > 0).astype(float)
pos = sig.shift(1).fillna(0.0)
ret = c.pct_change().fillna(0.0)
return pos * ret
rets = {}
for code, kind, label in UNIVERSE:
bars = client.stock_history(code=code, period="d", dividend="f")
df = pd.DataFrame(bars).rename(columns={"t": "date"}).sort_values("date").reset_index(drop=True)
for col in ("o", "h", "l", "c", "v"):
df[col] = df[col].astype(float)
rets[label] = strategy_daily_returns(df, kind)
R = pd.DataFrame(rets)
说明:这里用的是公开演示证书,为演示方便明文写入;实际项目请换成你自己的正式证书(申请配置见 SDK 文档 https://github.com/MaiRuiApi/mairui ),换证后代码无需修改。
第二步:三种权重函数,每种都是几行
python
LOOKBACK = 20
def w_equal(win):
"""等权:各 1/3。"""
return np.ones(win.shape[1]) / win.shape[1]
def w_inverse_vol(win):
"""波动率倒数:w_i ∝ 1/vol_i(过去20日)------风险平价的常用近似。"""
vol = win.std().values
w = 1.0 / np.maximum(vol, 1e-12)
return w / w.sum()
def w_dynamic_score(win):
"""动态评分:低波动得分与近期强势得分各占 50%,保底 5% 防单腿清零。"""
vol = win.std().values
mom = (1 + win).prod().values - 1 # 近 20 日累计收益
vol_s = 1 - (vol - vol.min()) / (np.ptp(vol) + 1e-12) # 波动越小分越高
mom_s = (mom - mom.min()) / (np.ptp(mom) + 1e-12) # 涨得越猛分越高
score = 0.5 * vol_s + 0.5 * mom_s
w = np.maximum(score, 0.05)
return w / w.sum()
第三步:统一回测框架(无前视是底线)
组合日收益 = w·r,但权重只能用截至昨日的窗口算------用今天的波动率算今天该配多少钱,就是未来函数:
python
def run_weighted(R, weight_fn):
"""每日再平衡:w_t 用截至昨日(不含当日)的窗口计算。"""
n = len(R)
port = np.ones(n)
w_last = None
for t in range(1, n):
win = R.iloc[max(0, t - LOOKBACK):t]
w = weight_fn(win)
if not np.all(np.isfinite(w)):
w = np.ones(R.shape[1]) / R.shape[1] # 冷启动窗口不足,退化等权
w_last = w
port[t] = port[t - 1] * (1 + float(np.dot(w, R.iloc[t].values)))
return pd.Series(port), w_last
def perf(r, name=""):
s = pd.Series(r, dtype=float)
eq = (1 + s).cumprod()
vol = s.std() * np.sqrt(252)
sharpe = s.mean() / s.std() * np.sqrt(252) if s.std() > 0 else 0.0
mdd = ((eq - eq.cummax()) / eq.cummax()).min()
return dict(name=name, 累计收益=eq.iloc[-1] - 1, 年化波动=vol, 夏普=sharpe, 最大回撤=mdd)
for name, fn in [("等权", w_equal), ("波动率倒数", w_inverse_vol), ("动态评分", w_dynamic_score)]:
port, w_last = run_weighted(R, fn)
p = perf(port.pct_change(fill_method=None).fillna(0.0), name)
print(f"{name}: 累计 {p['累计收益']:+.2%} 年化波动 {p['年化波动']:.2%} "
f"夏普 {p['夏普']:.2f} 最大回撤 {p['最大回撤']:.2%}")
输出示例(演示证样例数据,仅供演示):
等权: 累计 -22.09% 年化波动 26.32% 夏普 -4.64 最大回撤 -22.41%
波动率倒数: 累计 -21.85% 年化波动 28.82% 夏普 -4.16 最大回撤 -22.14%
动态评分: 累计 -19.45% 年化波动 32.63% 夏普 -3.17 最大回撤 -19.77%
第四步:风险平价(ERC)------把它讲透,包括它失灵的时候
风险平价要求每个策略对组合总风险的贡献相等。用不动点迭代可以手写求解,不依赖任何优化库:
python
def erc_weights(cov, iters=1000, tol=1e-12):
"""等风险贡献权重:迭代 w ∝ 1/MRC,返回权重与各腿风险贡献占比。"""
sigma = np.asarray(cov, dtype=float)
n = sigma.shape[0]
w = np.ones(n) / n
for _ in range(iters):
mrc = sigma @ w # 边际风险贡献
w_new = 1.0 / np.maximum(mrc, 1e-16)
w_new /= w_new.sum()
if np.max(np.abs(w_new - w)) < tol:
w = w_new
break
w = w_new
trc = w * (sigma @ w) # 总风险贡献
return w, trc / trc.sum()
w_erc, trc_share = erc_weights(R.cov().values)
print("权重:", dict(zip(R.columns, w_erc.round(3))))
print("风险贡献占比:", dict(zip(R.columns, trc_share.round(3))))
输出示例:
权重: {'MA5/20动量': 0.5, 'MA3/10快速': 0.0, '20日动量': 0.5}
风险贡献占比: {'MA5/20动量': 0.536, 'MA3/10快速': -0.0, '20日动量': 0.464}
注意 MA3/10 那一列:风险贡献是负的,权重被迭代逼成了 0 。原因是它与另外两个策略负相关(-0.176 / -0.157),长多约束下"每个策略风险贡献相等且为正"根本无解。这就是为什么工程上普遍用波动率倒数当风险平价的近似------它不需要解方程,且在策略相关性彼此接近时与 ERC 几乎等价。负相关腿反而应该在组合里放大(对冲属性),但那是带约束优化的范畴,属于进阶话题。
结果怎么读(诚实的部分)
- 没有万能赢家:动态评分亏得最少(-19.45%)但波动最高(32.63%);等权波动最低(26.32%)但收益垫底区间;波动率倒数夏普最好(-4.16)。选哪种取决于你要什么------压波动、博收益,还是风险调整后最优;
- 下跌市里"追强势"为什么没追在高点:动态评分 50% 权重给了近期收益,样例里有反弹段它跟上了,但它同时把波动放大了 6 个百分点------在震荡市里这套评分会反复追高杀低,别把单一行情段的表现当规律;
- ERC 的"退化"是真实世界的一部分:教科书假设相关性为正,实战中策略负相关很常见,看到 0 权重腿先别怀疑代码,先查相关性矩阵;
- 数字为演示证样例数据结果,仅演示方法;换正式证书即得真实行情下的对比。
常见坑
- 前视加权 :权重窗口含当日收益 = 未来函数。统一
R.iloc[max(0, t-LOOKBACK):t],右端开区间不含当日; - 冷启动 NaN :前几日窗口不足 2 行,
std()返回 NaN,权重全 NaN、净值成一条平线------务必np.isfinite兜底退化等权; - 单腿清零:评分加权不设保底时,某腿可能长期 0 权重,组合悄悄变成单策略。本文用 5% 保底;
- 把"波动率倒数"说成"风险平价":两者只在相关性彼此接近时近似相等,严谨的表述是"逆波动率加权是风险平价的常用近似"。
小结与下篇预告
- 三种配权同台:等权最稳、动态评分在下跌反弹段占优、波动率倒数是风险平价的实用近似;
- 风险平价的完整数学(等风险贡献)在负相关下可能无解,实测迭代收敛到 0 权重腿------先看相关性再谈风平;
- 权重定好之后还有个隐含问题:涨着涨着权重就"漂"了,要不要定期拉回来?拉回来要花钱吗?下一篇用实测回答:每月调一次仓到底值不值。
免责声明
本文所有内容仅用于技术学习与交流,不构成任何投资建议。股市有风险,投资需谨慎。