上一篇我们解决了「单个资产该押多大仓位」的问题,用凯利公式和固定比例法给双均线策略配上了仓位纪律。但很多人实践下来会发现一个朴素的事实:只盯着一只股票或一个指数,再精细的仓位管理也躲不开「资产本身歇菜」的风险。老话说不要把鸡蛋放在一个篮子里——可篮子多了又有新问题:每个篮子放多少?放好之后要不要动?涨跌过后比例乱了要不要管?这就是组合层面的问题,本篇就来回答。
本篇我们搭一个四资产等权组合(沪深300、中证500、黄金、国债四只 ETF),先看懂相关性为什么是分散化的原料,再实现「月末分组取信号、次月生效」的定期再平衡回测,最后把「从不、季度、月度、每日」四档再平衡频率放在同一把尺子下比较:再平衡「收割波动」的收益,能不能盖过它带来的换手成本?整篇代码依然向量化、依然不偷看未来。
本篇目标
- 能计算多资产日收益的相关性矩阵(Correlation Matrix),并说清「相关性低于 1 才有分散效果」的原因;
- 能用
to_period("M")把交易日按月分组,实现「每月最后一个交易日收盘定目标权重、次日起生效」的月末再平衡,全程无未来函数; - 能对比「从不 / 季度 / 月度 / 每日」四档再平衡频率的年化收益、波动、最大回撤与累计换手成本;
- 能解释再平衡「收割波动」效应的来源,以及它在单边趋势市为什么会失效;
- 能指出权重「自然漂移」与「真实调仓」的区别,避免把漂移误算成交易成本。
核心概念
相关性:分散化的原料
两个资产日收益的相关系数(Correlation Coefficient)衡量它们「同涨同跌」的程度,取值 −1 到 1:+1 是完全同步,0 是各走各的,−1 是反向跷跷板。分散化(Diversification)的效果完全由这个数决定。等权组合的方差可以近似拆成两块:
σ_p² ≈ σ̄²/n + (1 − 1/n) × σ̄² × ρ
白话解释:第一块是各资产自身波动摊薄后的剩余,资产越多它越小;第二块由平均相关系数 ρ 主导——只有 ρ 小于 1,第二块才会被「互相抵消」削掉一块。极端情形最好记:ρ = 1 时两项加起来正好等于单资产波动,分散完全无效;ρ = 0 时资产足够多后组合波动趋近于零。这就是为什么「买了十只创业板股票」不叫分散——它们的相关性普遍高达七八成,是十个鸡蛋放在十个外观不同的同一个篮子里。
划重点:分散化的价值取决于相关性,而不是资产数量。跨大类(股票、黄金、债券)的相关性通常远低于同大类内部,这正是本篇选「沪深300 + 中证500 + 黄金 + 国债」的原因。
权重漂移:不管理的组合会自己走样
期初各买 25% 只是起点。此后哪类资产涨得多,它的占比就自动膨胀:假设一年后股票涨 30%、债券涨 3%,等权组合里股票的权重会从 50% 漂移到约 55% 以上——组合悄悄变成了「重仓股票」。权重漂移(Weight Drift)意味着你的风险敞口在没人操作的情况下不断向「过去涨得最好的资产」集中,恰恰是在它最贵的时候加的仓。定期再平衡(Rebalancing)就是对抗走样的纪律:每隔一段时间,把各资产权重强制调回目标比例。
再平衡「收割波动」与它的失手场景
再平衡的动作是机械的:卖掉涨多的、补进跌多的,等于一套系统化的高抛低吸。这套纪律在资产间来回震荡、相关性不高的市场里能赚到一块「再平衡溢价(Rebalancing Premium)」——你总在低配的资产反弹前补了仓,在高配的资产回落前减了仓,像定期把波动率「收割」成收益。但它不是免费的护身符:在单边趋势市里,再平衡会持续卖出强势资产、买入弱势资产,让你一次次下车又上错车,溢价变成折价。所以正确的预期是:再平衡买的是「组合不跑样、风险不失控」的确定性,附带一份行情配合时才兑现的溢价。
划重点:再平衡频率是一个「跟踪精度 ↔ 成本」的旋钮:调得越勤,组合越贴近目标权重、收割越及时,但换手与手续费越高;调得越懒,成本越低,但组合越容易漂移成重仓单一资产。没有永远最优的档位,只有适合资产波动特性的取舍。
月份分组取月末信号:无未来函数的实现思路
再平衡回测最容易埋未来函数的地方:把「月末收盘算出的目标权重」用在了「月末当天」的收益上——当天收盘才知道的事不能决定当天的持仓。本篇的实现约定是:用 index.to_period("M") 把交易日按月分组,取每组最后一个交易日作为调仓日,当日收盘确定目标权重(各 1/n),下一交易日起才按新权重计算收益;两个调仓日之间,权重随行情自然漂移。季度版把 "M" 换成 "Q" 即可,一行不差地复用。
完整代码
依赖安装:uv pip install akshare pandas。第一段:拉数据、算相关性矩阵。
import akshare as ak
import numpy as np
import pandas as pd
COST_SIDE = 0.0005 # 单边成本 0.05%,一买一卖合计 0.1%,全系列统一口径
def load_prices() -> pd.DataFrame:
"""拉取 4 只 ETF 的后复权收盘价;接口不可用时退回带共同因子的模拟数据"""
codes = {"510300": "沪深300", "510500": "中证500",
"518880": "黄金ETF", "511010": "国债ETF"}
try:
frames = {}
for code, name in codes.items():
df = ak.fund_etf_hist_em(symbol=code, period="daily",
start_date="20200101", end_date="20241231",
adjust="hfq")
s = df.rename(columns={"日期": "date", "收盘": "close"})
frames[name] = pd.Series(s["close"].values,
index=pd.to_datetime(s["date"]))
return pd.DataFrame(frames).dropna()
except Exception:
rng = np.random.default_rng(7) # 固定种子,模拟结果可复现
dates = pd.bdate_range("2020-01-01", "2024-12-31")
market = rng.normal(0.0002, 0.010, len(dates)) # 共同因子:制造资产间相关性
specs = { # (日均收益, 波动, 对市场因子的敏感度)
"沪深300": (0.0002, 0.012, 1.0), "中证500": (0.0002, 0.014, 1.2),
"黄金ETF": (0.0004, 0.008, 0.1), "国债ETF": (0.0001, 0.001, -0.1)}
out = {}
for name, (mu, sig, beta) in specs.items():
r = mu + beta * market + rng.normal(0, sig, len(dates)) * 0.5
out[name] = pd.Series(4000 * np.cumprod(1 + r), index=dates)
return pd.DataFrame(out) # 注意:模拟数据仅保证代码能跑,
# 相关性结构请以真实数据为准
prices = load_prices()
rets = prices.pct_change().dropna() # 日简单收益率
print("日收益相关性矩阵:")
print(rets.corr().round(2))
第二段是本篇的心脏:等权组合 + 定期再平衡的向量化回测。
def target_weights(rets: pd.DataFrame, freq: str | None):
"""返回 (t 日收盘的目标权重 W, 调仓日标记 is_last)。
约定:每组最后一个交易日收盘重设为等权,段内随行情自然漂移;
W 会在回测里 shift(1) 后使用,因此不偷看未来"""
n = rets.shape[1]
grow = (1 + rets).cumprod() # 各资产的累计净值
if freq is None: # 买入持有:期初等权后永不调仓
W = grow.div(grow.sum(axis=1), axis=0) # 权重随行情漂移
return W, pd.Series(False, index=rets.index)
if freq == "M": # 按月分组(季度版改成 "Q")
grp = pd.Series(rets.index.to_period("M"), index=rets.index)
else:
grp = pd.Series(rets.index.to_period("Q"), index=rets.index)
is_last = grp.ne(grp.shift(-1)) # 每组最后一个交易日 = 调仓日
base = grow.where(is_last).ffill().shift(1) # 段基准 = 上一次调仓日的累计净值
base.iloc[0] = 1.0 # 首段以 1 为基准(期初等权)
W = grow.shift(1).div(base) # 昨日累计 ÷ 段基准 = 段内漂移仓位
W = W.div(W.sum(axis=1), axis=0) # 逐行归一化,使权重和为 1
W.iloc[0] = 1 / n # 首行填期初等权(近似,见解释)
return W, is_last
def portfolio_backtest(prices: pd.DataFrame, freq: str | None):
"""等权组合回测:None=买入持有,'Q'=季度,'M'=月度,'D'=每日再平衡"""
rets = prices.pct_change().dropna()
n = rets.shape[1]
if freq == "D": # 每日收盘调回等权:权重恒为 1/n
W_used = pd.DataFrame(1 / n, index=rets.index, columns=rets.columns)
drift = (1 + rets).div((1 + rets).sum(axis=1), axis=0) # 当日自等权的漂移
turnover = drift.sub(1 / n).abs().sum(axis=1) # 每天调回等权的换手
else:
W, is_last = target_weights(rets, freq)
W_used = W.shift(1) # 关键:昨日目标权重 × 今日收益
W_used.iloc[0] = 1 / n # 首日收益用期初等权
raw = W.diff().abs().sum(axis=1).fillna(0.0) # 权重跳变 = 真实调仓
mask = is_last.shift(1, fill_value=False) # 跳变发生在调仓日的下一交易日
turnover = raw.where(mask, 0.0) # 段内自然漂移不算交易
port = (W_used * rets).sum(axis=1) - turnover * COST_SIDE
return port, turnover
第三段把四档频率拉到同一张表上比较,并演示「组合波动低于单资产」:
def perf(r: pd.Series) -> tuple[float, float, float]:
eq = (1 + r).cumprod()
ann = eq.iloc[-1] ** (252 / len(r)) - 1 # 年化收益
vol = r.std() * np.sqrt(252) # 年化波动
dd = (eq / eq.cummax() - 1).min() # 最大回撤
return ann, vol, dd
print(f"{'频率':<6}{'年化收益':>9}{'年化波动':>9}{'最大回撤':>9}{'累计成本':>9}")
for freq, label in [(None, "从不"), ("Q", "季度"), ("M", "月度"), ("D", "每日")]:
port, turn = portfolio_backtest(prices, freq)
ann, vol, dd = perf(port)
print(f"{label:<7}{ann:>9.1%}{vol:>9.1%}{dd:>9.1%}{(turn * COST_SIDE).sum():>9.2%}")
print("\n各资产单独持有的年化波动:")
for col in rets.columns:
print(f" {col}: {rets[col].std() * np.sqrt(252):.1%}")
port_d, _ = portfolio_backtest(prices, "D") # 每日等权 = 最纯粹的组合分散效果
print(f"等权组合(每日再平衡):年化波动 {port_d.std() * np.sqrt(252):.1%}")
代码逐段解释
- 数据与相关性矩阵(第一段代码):
load_prices优先拉四只 ETF 2020-01-01 至 2024-12-31 的后复权日线,接口不可用时退回「共同因子 + 特异波动」的模拟数据——共同因子让股票之间高相关、黄金基本独立、国债轻微反向,教学结构贴近真实但数值不代表行情。rets.corr()输出 4×4 相关性矩阵,示例输出(示意,真实数据结果会有出入):
日收益相关性矩阵:
沪深300 中证500 黄金ETF 国债ETF
沪深300 1.00 0.88 0.10 -0.18
中证500 0.88 1.00 0.08 -0.16
黄金ETF 0.10 0.08 1.00 0.09
国债ETF -0.18 -0.16 0.09 1.00
读法:股票内部 0.88——互相分散的空间很小;股票与黄金约 0.1、与国债为负——真正贡献分散的是这两位。
-
目标权重与漂移(
target_weights):grow是各资产累计净值;is_last用grp.ne(grp.shift(-1))找出每月(季)最后一个交易日;base记录上一次调仓日的累计净值作为「段基准」,W = grow.shift(1) ÷ base再逐行归一化,就得到「昨日收盘时各资产的自然漂移仓位」——段起点处分子分母相等、权重恰为 1/n,之后随行情逐日漂移,到下一次调仓前一直不复位。整个过程只用昨日及以前的数据。 -
回测与换手(
portfolio_backtest):收益一律用W.shift(1)与当日收益相乘——月末收盘定的目标,次月第一个交易日才开始生效,杜绝未来函数。换手只认权重跳变:W.diff()在调仓次日出现的大幅变化是真实交易;段内每天的微小漂移只是市值变化,用mask屏蔽掉、不计成本。每日再平衡("D")是频率上限的特例:权重每天回到 1/n,换手等于每天把漂移抹平的量。成本按全系列口径扣:换手 × 单边 0.05%。 -
四档频率对比(第三段代码):示例输出(示意,具体数值取决于真实行情):
频率 年化收益 年化波动 最大回撤 累计成本
从不 4.9% 8.6% -13.2% 0.00%
季度 5.2% 7.6% -12.0% 0.10%
月度 5.3% 7.4% -11.6% 0.18%
每日 4.5% 7.2% -11.3% 0.95%
三个规律值得盯住:波动随频率提高单调下降(组合越贴近等权,分散越充分);成本随频率提高单调上升(每日版一年磨掉近一个点);收益不保证单调——月度在这里占优是「收割波动」与成本妥协后的结果,换个区间它也可能输给季度。这正是频率旋钮的含义:没有免费午餐,只有权衡。
- 组合 vs 单资产的波动:示例输出(示意):
各资产单独持有的年化波动:
沪深300: 16.2% 中证500: 18.5% 黄金ETF: 11.7% 国债ETF: 2.1%
等权组合(每日再平衡):年化波动 7.3%
组合波动 7.3%,比四个资产波动的简单平均(约 12.1%)低了一大截——低相关的资产互相抵消,这就是第一段公式里 (1 − 1/n) × σ̄² × ρ 那一项被削掉的部分。若把黄金或国债从组合里去掉重跑,你会看到组合波动明显回升:分散化的功劳来自低相关成员,不是资产个数。
常见坑与小练习
常见坑
- 月末信号当月使用。 现象:
to_period("M")分组后直接把月末目标权重乘在当日收益上,回测漂亮得反常。原因:月末收盘才知道的权重偷看了当天的行情。避免:像本篇一样shift(1),目标权重次日起生效。 - 把漂移当交易、把交易当漂移。 现象:要么把段内每天的权重微变全算成换手(成本虚高十几倍),要么连调仓日的跳变也不计成本(结果虚美)。原因:没分清「市值自然变化」与「主动调仓」。避免:只对调仓日次日的权重跳变计换手,本篇的
mask就是干这个的。 - 只比收益不比波动。 现象:看到「月度再平衡年化只高了 0.1 个点」就断定没用。原因:忽略了同期回撤和波动下降带来的风险收益。避免:收益、波动、回撤三件套一起看,再用第 19 篇的夏普比率定胜负。
- 用历史相关性担保未来。 现象:按 2020–2024 的相关性配置组合,遇到危机时股票黄金同跌。原因:相关性不是常数,恐慌时「分散化失效、相关性趋近 1」是常态。避免:跨大类分散 + 接受极端日子里分散效果打折的心理准备。
- 降级模拟数据当真。 现象:断网时跑出模拟数据的结果,误当成真实行情结论。原因:fallback 是为了保证代码能跑通。避免:任何数值结论都确认数据来自真实接口后再下。
小练习
- 把资产清单换成三只行业 ETF(如科创 512480、证券 512880、光伏 515790),重跑相关性矩阵与四档频率对比,观察分散效果的变化。
思路提示:同类资产相关性普遍在 0.7 以上,组合波动会比四资产版本高得多——亲手验证「分散看相关性,不看数量」。
- 实现「阈值再平衡」:任一资产权重偏离 1/n 超过 ±5 个百分点时才调回,与月度定期再平衡比较成本和波动。
思路提示:需要逐日检查漂移权重与目标的偏离(可用循环),触发时只调偏离的资产;它的换手通常介于季度与月度之间。
- 把调仓日从「每组最后一个交易日」改成「每组第一个交易日」,重跑并比较两种时点的结果。
思路提示:分组后改用
grp.ne(grp.shift(1))取每组第一天;注意信号产生日与生效日依然要错开一天,别让未来函数从门缝溜回来。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。