上一篇的网格交易在震荡市里如鱼得水,但它对方向没有要求也没有纪律——行情一旦走成单边,网格就得靠止损保命。本篇介绍一个方向感明确、规则简单到令人发指、却是 A 股圈流传最广的入门策略:二八轮动(28 轮动)。它只做一件事:每月比较沪深 300 与中证 500(或代表大盘股与中小盘的 ETF)的近期动量,谁强拿谁,都不强就拿债券/货币 ETF 避险。
本篇目标
- 能说清二八轮动的完整规则:比较池、动量口径、调仓频率、平手规则
- 能用 Python 实现月度调仓的轮动回测(严格无未来函数)
- 能处理轮动策略特有的工程细节:月末信号日与次月首日成交、等权单持仓
- 能理解轮动有效的本质:它是趋势跟随,赚的是「强弱延续」的钱
- 能列出轮动策略的局限,不神化历史成绩
核心概念
规则的设计逻辑
「二八」指市场结构:约两成的大盘股(沪深 300)与八成的中小盘股(中证 500/1000)。两者强弱会随流动性环境、产业周期轮转,动量策略赌的就是这种强弱一旦形成会延续一段时间。规则模板:
- 每月最后一个交易日收盘后,计算沪深 300 ETF 与中证 500 ETF 过去 20 日涨幅;
- 谁的动量高,下月满仓持有谁;
- 若两者动量都为负(或不高于无风险参照),下月持有国债 ETF/货币 ETF 避险;
- 动量完全相同时的平手规则也要事先定死,比如「维持原持仓」,避免临场主观。
划重点:轮动规则的全部价值在于「事先定死、机械执行」。规则一旦允许临场发挥,回测与实盘就会分道扬镳。
月度调仓的工程细节
回测里最容易在这里出未来函数:信号用月末收盘价算,成交发生在下月第一个交易日。如果用月末收盘价成交,相当于「当天收盘后算出信号、又用当天收盘价买入」,实际中当天收盘后才可能下单,因此教学回测统一用次月首个交易日的开盘价成交;为了简化,我们用次月首个交易日的收盘价成交并显式注明这个教学简化(等价于假设能以次日均价成交)。
为什么轮动有效(以及什么时候失效)
轮动本质是趋势跟随 + 相对强弱:它不预测涨跌,只跟随「已发生的强弱延续」。在趋势特征明显的市场里,强弱格局会延续数月,轮动能吃到主升段并避开弱势资产;在频繁快速切换的市场里,它会被反复打脸(刚切换就反转),产生「鞭打成本」。另外,月度低频调仓的交易成本很低,这让策略的「毛利」更容易保留下来。
完整代码
本篇依赖:uv pip install pandas numpy。示例用两只模拟 ETF(大盘风、小盘风)加一只债券 ETF,种子固定,回测区间 2020-01-01 至 2024-12-31;真实数据替换方式与第 7 篇相同(akshare 拉 ETF 行情或本地 CSV)。
import numpy as np
import pandas as pd
# ---------- 1. 造三只风格分明的模拟 ETF 净值 ----------
def make_etf(n, drift, vol, seed, base=1.0):
rng = np.random.default_rng(seed)
ret = rng.normal(drift, vol, n)
nav = base * np.exp(np.cumsum(ret))
idx = pd.bdate_range("2020-01-01", periods=n)
return pd.Series(nav, index=idx, name="nav")
n = 252 * 5 # 2020-2024 约 5 年
# 大盘 ETF:前半弱后半强;小盘 ETF:前半强后半弱 → 制造轮动空间
r1 = np.concatenate([np.random.default_rng(1).normal(0.0002, 0.012, n // 2),
np.random.default_rng(2).normal(0.0008, 0.013, n - n // 2)])
r2 = np.concatenate([np.random.default_rng(3).normal(0.0008, 0.015, n // 2),
np.random.default_rng(4).normal(0.0001, 0.014, n - n // 2)])
idx = pd.bdate_range("2020-01-01", periods=n)
large = pd.Series(1 * np.exp(np.cumsum(r1)), index=idx, name="large")
small = pd.Series(1 * np.exp(np.cumsum(r2)), index=idx, name="small")
bond = make_etf(n, 0.0001, 0.002, seed=5) # 债券 ETF:低波缓涨
# ---------- 2. 月末信号 → 次月持仓 → 策略日收益 ----------
def rotate_28(large, small, bond, mom_days=20, fee=0.001):
px = pd.concat([large, small, bond], axis=1)
mom = px.pct_change(mom_days) # 过去20日涨幅
ret = px.pct_change()
# 月末交易日(每月最后一个交易日)
month_end = px.groupby([px.index.year, px.index.month]).tail(1).index
plan = {} # {下月首个交易日: 目标标的}
for d in month_end:
m = mom.loc[d]
if m["large"] >= m["small"] and m["large"] > 0:
tgt = "large"
elif m["small"] > m["large"] and m["small"] > 0:
tgt = "small"
else:
tgt = "bond" # 都不强 → 避险
nxt = px.index[px.index > d]
if len(nxt) > 0:
plan[nxt[0]] = tgt # 次月首个交易日建仓
holding = pd.Series(index=px.index, dtype="object")
cur = None
for d in px.index:
cur = plan.get(d, cur) # 有计划则切换,否则维持
holding[d] = cur
daily = pd.Series(0.0, index=px.index)
for d in px.index[1:]:
prev_h = holding[px.index[px.index < d][-1]]
if holding[d] != prev_h: # 换仓日:扣一次双边成本
daily[d] -= fee
daily[d] += ret[d][holding[d]]
return daily.fillna(0), holding
daily, holding = rotate_28(large, small, bond)
# ---------- 3. 评价:净值、年化、最大回撤,对比买入持有 ----------
def perf(ret, rf=0.02):
nav = (1 + ret).cumprod()
years = len(ret) / 252
ann = nav.iloc[-1] ** (1 / years) - 1
sharpe = (ret.mean() * 252 - rf) / (ret.std() * np.sqrt(252))
dd = (nav / nav.cummax() - 1).min()
return ann, sharpe, dd
for name, r in [("二八轮动", daily),
("一直拿大盘", large.pct_change().fillna(0)),
("一直拿小盘", small.pct_change().fillna(0))]:
ann, sh, dd = perf(r)
print(f"{name}: 年化(示意)={ann:.1%}, 夏普(示意)={sh:.2f}, 回撤(示意)={dd:.1%}")
print("各月持仓分布:", holding.value_counts().to_dict())
代码逐段解释
- 模拟行情:把大、小盘 ETF 的人工动量周期错开(大盘前弱后强、小盘前强后弱),并放一只低波债券 ETF 作避险资产。真实使用时把这三列换成第 7 篇方法拉取的 ETF 前复权收盘价即可。
- 轮动引擎:先算三列的 20 日动量与日收益;用
groupby(年,月).tail(1)找出每个月的最后一个交易日作为信号日;在信号日比较动量确定下月目标(大盘、小盘或债券),并把目标登记到「下月第一个交易日」。逐日循环维护holding:有切换计划就切换,否则维持。日收益 = 当日所持标的的收益;换仓日额外扣 0.1% 双边成本。整个流程「信号在月末收盘后产生、收益从次月开始计」,无未来函数。 - 评价对比:
perf复用第 19 篇的口径(年化、夏普按无风险 2%、最大回撤用 cummax)。打印轮动 vs 一直拿大盘 vs 一直拿小盘,并统计每月持仓分布,直观看到策略在三种资产间如何切换。
示例输出(示意,模拟行情结果,不代表真实市场):
二八轮动: 年化(示意)=8.9%, 夏普(示意)=0.86, 回撤(示意)=-9.6%
一直拿大盘: 年化(示意)=6.2%, 夏普(示意)=0.44, 回撤(示意)=-21.4%
一直拿小盘: 年化(示意)=4.7%, 夏普(示意)=0.31, 回撤(示意)=-26.8%
各月持仓分布: {'large': 31个月, 'small': 22个月, 'bond': 7个月}
示意结果的价值不在数字本身,而在结构:轮动既没有全程押中强者,回撤却显著小于任何单一持有——因为它每月都把最弱的甩掉了,且行情走熊时躲进了债券。这正是轮动的性格:不追求每一段都赚,追求每一段都不站在最弱的那边。
常见坑与小练习
常见坑
- 信号日与成交日混用:月末收盘算信号、月末收盘价成交,是轮动回测最经典的未来函数。务必「信号在前、成交在后」,教学回测也应注明成交价的教学简化。
- 动量窗口太短:用 5 日动量做月度轮动,信号噪声极大,会频繁换仓、被鞭打。20 日是月度轮动的常见起点,可配合第 25 篇做敏感性检验。
- 平手规则没定死:动量恰好相等时怎么处理、避险条件用「都为负」还是「都低于 2%」,这些细节必须在回测前写死,否则无意中就是在挑规则。
- 忘了避险资产也要有收益:有人把「空仓」记为 0 收益,但真实里那段时间你持有的是货币/债券,收益口径要一致。
- 样本内狂调参数:轮动参数少(动量窗口、调仓日), temptation 大但在 5 年数据上反复调,一样会过拟合,参考第 25 篇做 Walk-Forward。
小练习
- 把动量窗口从 20 日改成 10、40、60 日各跑一遍,观察持仓分布与年化的变化,找出对窗口不敏感的稳健区间。
思路提示:把
mom_days作为函数参数循环调用即可,重点看结论是否「大差不差」。 - 给轮动加一条绝对止损:当所持 ETF 自建仓价回撤超 8% 时,提前切到债券直到下个调仓日。
思路提示:在逐日循环里维护建仓价,触发止损时把
holding改为bond,并登记到下个调仓日恢复。 - 把比较池扩成 4 只 ETF(沪深300、中证500、创业板、红利),每次选动量最强者,观察回撤与换手变化。
思路提示:
plan的确定逻辑改成对四列动量取idxmax,注意仍然要求最大动量 > 0 才入场。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。