上一篇的市场状态识别解决「现在是什么天气」,本篇解决「哪块云彩下雨」——行业轮动(Sector Rotation)。A 股的行业分化是常态:同一年里,涨得最好的行业和最差的行业收益差常常超过 40 个百分点。行业轮动策略的目标就是:持续把仓位放在相对强势的行业上,躲开弱势行业。核心工具只有一个——相对强弱(Relative Strength)。
本篇目标
- 能计算个股/行业相对基准的相对强弱曲线(比值线)与 RPS 排名
- 能实现行业动量轮动:每月选相对强度排名前 N 的行业持有
- 能处理行业数据的现实问题:行业分类的来源与等权/流通权重的简化
- 能对比行业轮动与二八轮动的异同,理解「轮动池越细、分化越大、成本越高」
- 能给行业轮动加上切换冷却与止损等工程约束
核心概念
相对强弱:比值线与 RPS
相对强弱有两种常用表达:
比值线(RS 线):RS = 行业指数 / 基准指数。把两条线相除,市场整体的涨跌被约掉,剩下纯粹的「这个行业跑赢/跑输大盘的幅度」。RS 线持续上行 = 强势行业;持续下行 = 弱势行业;拐头往往是行业切换的先行信号。
RPS(Relative Price Strength,相对强度排名):把所有行业某段时间的涨幅做横截面排名,转换成 0~100 的分位数。RPS=90 意思是「这个行业的涨幅超过了 90% 的行业」。欧奈尔(William O'Neil)的 CANSLIM 体系里,RPS>80 是选股硬条件之一。
划重点:相对强弱衡量的是「比别人强」,不是「涨得好」。熊市里跌 10% 的行业,如果同期基准跌 25%,它的 RS 线是上行的——相对强弱策略在系统性下跌里照样会满仓「最不惨」的行业。所以行业轮动通常要叠加上一篇的市场状态过滤:趋势下空仓或降杠杆。
行业动量轮动的标准流程
每月末:
① 计算每个行业过去 N 日(如 20 日)涨幅 →
② 转成横截面 RPS 排名 →
③ 取排名前 K 的行业等权持有 →
④ (可选)动量都为负时退守现金/债券
次月首个交易日按新名单调仓
与二八轮动(第 30 篇)对比:二八轮动是「二选一」的特例,行业轮动是「N 选 K」的通例。轮动池从 2 个资产扩到 10+ 个行业后,横截面分化带来的选择空间变大,但调仓换手、行业数据维护成本也随之上升——这是收益与工程复杂度的交换。
行业数据的现实问题
真实研究里行业数据有三个坑:分类标准(申万一级 31 个?中信一级 30 个?口径要固定);等权还是流通市值加权(等权指数天然偏小盘,风格暴露要心里有数);历史成分变动(行业指数自身也会调整成分,严格研究要用「定点快照」数据)。本篇教学简化:用模拟行业指数演示方法,真实数据可用 akshare 的申万行业指数接口或 ETF 替代。
完整代码
本篇依赖:uv pip install pandas numpy。模拟 8 个行业 × 5 年日线,其中埋入「行业强弱轮转」结构。
import numpy as np
import pandas as pd
# ---------- 1. 模拟 8 个行业 + 基准指数 ----------
rng = np.random.default_rng(34)
n = 252 * 5
idx = pd.bdate_range("2020-01-01", periods=n)
common = rng.normal(0.0002, 0.011, n) # 市场共同因子
sectors = {}
for k in range(8):
# 每个行业有自己的风格系数:偶数行业前半段强、奇数后半段强(造轮转结构)
style = np.concatenate([np.full(n // 2, 0.001 if k % 2 == 0 else -0.001),
np.full(n - n // 2, -0.001 if k % 2 == 0 else 0.001)])
ret = common + style + rng.normal(0, 0.008, n) # 行业特质波动
sectors[f"行业{k+1}"] = pd.Series(np.exp(np.cumsum(ret)), index=idx)
bench = pd.Series(np.exp(np.cumsum(common + rng.normal(0, 0.004, n))),
index=idx, name="基准")
sector_px = pd.DataFrame(sectors)
# ---------- 2. 相对强弱:RS 比值线与 RPS ----------
rs = sector_px.div(bench, axis=0) # 每列 / 基准 → RS 比值线
rs_ma = rs.rolling(20).mean() # RS 线的20日平滑
def rps_rank(px, window=20):
"""每月末截面:过去window日涨幅 → 排名分位数(0~100)"""
mom = px.pct_change(window)
month_end = px.groupby([px.index.year, px.index.month]).tail(1).index
rps_table = {}
for d in month_end:
m = mom.loc[d]
rps_table[d] = m.rank(pct=True) * 100 # 横截面分位
return pd.DataFrame(rps_table).T
rps = rps_rank(sector_px)
print("最近一期各行业 RPS(示意):")
print(rps.iloc[-1].round(0).sort_values(ascending=False).to_string())
# ---------- 3. 行业轮动回测:每月持有 RPS 前 3 的行业 ----------
def sector_rotation(px, rps, top_k=3, mom_window=20, fee=0.001,
cooldown=0):
mom = px.pct_change(mom_window)
month_end = px.groupby([px.index.year, px.index.month]).tail(1).index
ret = px.pct_change()
holding, cur, last_switch = [], None, None
daily = pd.Series(0.0, index=px.index)
for d in month_end:
m = mom.loc[d]
if (m > 0).sum() == 0: # 全负 → 空仓避险
tgt = []
else:
tgt = m[m > 0].nlargest(top_k).index.tolist()
nxt_mask = px.index > d
nxt = px.index[nxt_mask]
end = month_end[month_end > nxt[0]].min() if len(
month_end[month_end > nxt[0]]) > 0 else px.index[-1]
span = px.index[(px.index >= nxt[0]) & (px.index <= end)]
# 冷却期:距上次换仓不足 cooldown 天则维持原持仓
if cooldown and last_switch is not None and \
(nxt[0] - last_switch).days < cooldown:
tgt = cur
for dd in span[1:]:
daily[dd] += ret.loc[dd, cur if cur else tgt].mean() \
if (cur or tgt) else 0.0
if tgt != cur and (cur or tgt):
daily[span[0]] -= fee # 换仓成本
last_switch = nxt[0]
cur = tgt
return daily
strat = sector_rotation(sector_px, rps)
bh = sector_px.pct_change().mean(axis=1) # 等权持有全部行业
for name, r in [("行业轮动", strat), ("等权全行业", bh.fillna(0))]:
nav = (1 + r).cumprod()
dd = (nav / nav.cummax() - 1).min()
print(f"{name}: 总收益(示意) {nav.iloc[-1] - 1:.1%}, "
f"最大回撤 {dd:.1%}")
# ---------- 4. RS 线可视化:谁在持续跑赢 ----------
import matplotlib.pyplot as plt
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(11, 5))
for col in ["行业1", "行业2", "行业3", "行业4"]:
ax.plot(rs.index, rs[col], lw=1, label=col)
ax.axhline(1.0, color="k", ls="--", lw=0.8)
ax.set_title("行业相对基准的 RS 比值线(>1 = 跑赢基准,示意)")
ax.legend(); ax.set_xlabel("日期")
plt.tight_layout(); plt.savefig("rs_lines.png", dpi=120); plt.show()
代码逐段解释
- 模拟行业:8 个行业共享市场因子(common),但风格系数前后半段反转——偶数行业先强后弱、奇数先弱后强,人为制造「行业轮转」。基准是纯市场因子。这样 RS 线会呈现漂亮的交叉结构。
- RS 与 RPS:
div(bench, axis=0)一步算出所有行业的比值线;rps_rank在每个月末截面把 20 日涨幅转成 0~100 的横截面分位数。注意 RPS 是排名不是收益——它回答「相对别人如何」。 - 轮动回测:每月末取动量为正的行业中前 3 名(全负则空仓),次月持有等权组合;
cooldown参数是切换冷却——距上次换仓太近就不动,抑制鞭打。换仓日扣一次 0.1%。逻辑与第 30 篇二八轮动同构,只是选择集从 2 变 8、单持仓变多持仓。 - RS 可视化:挑四个行业画比值线,交叉点即强弱易手的时刻。RS 线持续上行的行业即使绝对收益为负,也是「防御性强势」——这个概念对理解相对强弱策略的仓位行为至关重要。
示例输出(示意,模拟行情结果):
最近一期各行业 RPS(示意):
行业7 100.0
行业8 88.0
行业5 63.0
行业6 50.0
行业3 38.0
行业4 25.0
行业1 13.0
行业2 0.0
行业轮动: 总收益(示意) 47.2%, 最大回撤 -14.3%
等权全行业: 总收益(示意) 11.8%, 最大回撤 -23.7%
示意结果的结构很清楚:因为埋了「后半段奇数行业强」,最后一期 RPS 榜首是行业 7/8;轮动策略持续跟踪强势梯队,总收益显著跑赢等权、回撤更小。真实市场里分化没有这么「规整」,轮动的超额也小得多——模拟的价值是让机制显形,不是给收益预期。
常见坑与小练习
常见坑
- 行业分类口径漂移:今天用申万、明天用中信、后天用概念板块,回测口径不可比。选定一套分类并固定,换口径等于换策略。
- 用等权行业指数却不承认小盘暴露:等权行业指数长期偏小市值风格,轮动收益里可能有一部分其实是「小盘因子」的收益(回到第 31 篇的因子视角)。
- 轮动频率太高:行业动量在周频尺度噪声极大,月度是常见起点;调仓越频繁,0.1% 双边成本的累积越可怕。
- 不设全负避险:不加「动量都为负则空仓」的规则,熊市里轮动会满仓「最不惨」的行业继续亏。相对强弱是相对的,亏损是绝对的。
- 忽视行业容量与流动性:轮动到冷门行业时冲击成本上升,小资金无感、大资金很痛。
小练习
- 把
top_k从 3 改成 1、2、4 各跑一遍,观察收益与回撤对持仓集中度的敏感性。思路提示:集中度越高收益弹性越大、运气成分也越大——结合第 25 篇想想这算不算过拟合。
- 把轮动信号从「20 日动量」换成「RS 线的 20 日斜率」(RS 与其 20 日均线的差),对比两种口径的换仓次数。
思路提示:
rs_signal = rs - rs.rolling(20).mean(),再在月末截面取排名。 - 用 akshare 的申万行业指数(或 5~8 只行业 ETF)替换模拟数据跑一遍真实轮动,报告与模拟结果的最大差异。
思路提示:差异重点看三点——分化幅度、切换频率、避险规则的触发次数。
本文策略仅用于教学,不构成投资建议,实盘前请充分测试。