第22讲 案例三 行业轮动策略完整回测

📎 配套代码第22讲_案例三_行业轮动完整回测.py
📊 配套数据data/industry/ —— 行业数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)

前两个案例都停在”因子值”这一步。这一讲走到底:从信号到持仓,从持仓到净值,从净值到绩效指标

数据换成申万一级行业指数——31 个行业、2015 年以来的日线。比个股面板小得多,正好用来把整条回测链看清楚。


🎬 开场:差一天,年化从 0.64% 变成 42.37%

同一个策略、同一份数据,只改一个数字:

sig = mom.shift(0)     # 用当月末的动量,赚当月的收益
sig = mom.shift(1)     # 用上月末的动量,赚当月的收益
当月末信号 + 当月收益     年化  42.37%    夏普 1.62
上月末信号 + 当月收益     年化   0.64%    夏普 0.14

年化差了 42 个百分点。

第一种写法的意思是:你在 1 月 31 日收盘时算出 1 月的动量,然后用它赚 1 月的钱。但 1 月的钱在 1 月 31 日已经赚完了——你是在用月底的信息交易整个月。

这就是第 19 讲那条规则在完整回测链上的样子:信号必须比收益早至少一期

它不报错。净值曲线画出来漂亮得不像话,夏普 1.62 看着像个能上线的策略。唯一能发现它的方式,是你知道去检查信号和收益的时间关系。


🎯 这一讲结束时,你能

  • 独立完成一条从原始价格到绩效指标的完整回测链
  • 说清链上每一处需要对齐时间的地方,以及对错了会怎样
  • 用矩阵运算表达”持仓 × 收益 = 组合收益”
  • 计算换手率并把交易成本扣进净值
  • 判断一个回测结果是不是”好得不真实”

一、🎯 要算什么

问题

给定一个行业轮动想法——每月选动量最高的 5 个行业等权持有——回答:它到底赚不赚钱?

这个问题看着简单,但”赚不赚钱”要用几个数一起回答,而且必须和一个基准比。

产出

一张绩效表:

                 年化      夏普    最大回撤
等权基准         1.14%     0.16    -38.63%
策略(税前)     0.64%     0.14    -54.87%
策略(扣成本)  -1.04%     0.07    -57.56%

三行缺一不可:

  • 等权基准——不做任何选择,31 个行业全买。没有它,”年化 0.64%”这个数没有意义
  • 税前——策略本身的能力
  • 扣成本——实际能拿到的。这一行往往和上一行差得让人意外

为什么要走到净值这一步

前两个案例停在”因子值”和”IC”。但 IC 高不等于赚钱——它没有回答”每次调仓要换掉多少持仓””交易成本吃掉多少”

只有走到净值和换手率,才能看出一个想法在现实中是不是可行。


二、📥 要用什么数据

字段 来源 为什么需要
ts_codetrade_dateclose sw_daily 申万一级行业指数日线,31 个行业
行业代码 → 名称 index_classify 结果可读
sw = rqdata.sw_daily()          # (85653, 4),31 个行业,2015 年以来
names = rqdata.industry_names() # {'801010.SI': '农林牧渔', ...}

为什么用行业指数而不是个股:31 个行业比 300 只股票小得多,整条回测链的每一步都看得清。而且行业指数本身就是等权/市值加权编制好的,不用再处理成分股变动。


三、🧭 设计思路

从”日线价格”到”绩效表”,中间是一条五段的链:

日线 → 月频 → 信号 → 权重 → 收益 → 净值 → 指标

每一段都要回答一个设计问题。

为什么先转宽表再重采样

后面要做”31 个行业横向排序”——这是列与列之间的比较。长表上要 groupby(日期).rank(),宽表上一个 rank(axis=1) 就够(第 14 讲)。

月末取哪个价

resample("ME").last() 而不是 .mean()。月末调仓用的是月末那天的价格,不是整月均价。用均价等于假设你能在一个不存在的价格上成交。

信号必须比收益早一期

这是整条链上最要命的一步。t 月的动量要等 t 月末收盘才算得出来,而 t 月的收益是从 t−1 月末持有到 t 月末才实现的。

用 t 月的信号赚 t 月的钱,等于收盘前就知道了当月涨跌。

sig = mom.shift(1)

权重怎么表达

选中的 5 个行业各占 20%,其余为 0。表达成一个和收益矩阵形状相同的权重矩阵,这样组合收益就是一次逐元素相乘再按行求和——不用循环,不用 merge。

成本从哪来

换手率 = 相邻两期权重差的绝对值之和 ÷ 2。乘上双边费率就是这一期的成本,从收益里直接扣。


四、📝 伪代码

输入:行业指数日线(行业代码、日期、收盘价)

1. 转宽表:行 = 日期,列 = 行业
2. 月频重采样:每月取最后一个交易日的收盘价

3. 算收益和信号:
   本月收益 = 相对上月末的涨幅
   动量     = 相对 6 个月前的涨幅
   信号     = 动量整体后移一期          # ← 关键
   
4. 由信号生成权重:
   对每个月:
       把 31 个行业按信号从高到低排名
       前 5 名各给 1/5 权重,其余给 0
   检查:每行权重和为 1、无负权重、每期恰好 5 个

5. 组合收益 = 权重矩阵 × 收益矩阵,按行求和
   净值     = (1 + 组合收益) 累乘

6. 绩效指标:
   年化   = 净值末值 ^ (1/年数) − 1
   夏普   = 月收益均值 / 月收益标准差 × √12
   回撤   = min(净值 / 历史最高净值 − 1)

7. 换手与成本:
   换手 = |本期权重 − 上期权重| 求和 ÷ 2
   成本 = 换手 × 双边费率
   扣成本后收益 = 组合收益 − 成本
   
8. 基准:31 个行业等权,同样算一遍 6、7 步

第 5 步那一行 权重矩阵 × 收益矩阵 是整条链的枢纽。两个矩阵形状必须完全一致——这正是前面坚持用宽表的原因。


五、🔧 逐段实现

日线 → 月频(第 14、19 讲)

sw["dt"] = pd.to_datetime(sw["trade_date"], format="%Y%m%d")
w = sw.pivot(index="dt", columns="ts_code", values="close").sort_index()
m = w.resample("ME").last()
(85653, 4)  →  (2763, 31)  →  (137, 31)
   长表          日频宽表        月频宽表

两步各有理由:

  • 转宽表(第 14 讲):后面要做”31 个行业横向排序”,这是列与列之间的比较,宽表是唯一自然的形态
  • 月频重采样(第 19 讲):resample("ME").last() 取每月最后一个交易日的收盘价

.last() 而不是 .mean()——月末调仓用的是月末价格,不是月度均价。


月频 → 信号

ret = m.pct_change(fill_method=None)          # 每月收益
mom = m.pct_change(6, fill_method=None)       # 6 个月动量
sig = mom.shift(1)                            # ← 关键的一行
(137, 31) → (137, 31)      形状不变,含义完全不同

shift(1) 把信号整体推后一期:第 t 月用的是第 t−1 月末算出的动量

fill_method=None 见第 14 讲——默认会先填充缺失再算,在数据空缺处造出假收益。

IMPORTANT: 🔑 判断方法:这个数在什么时候能被算出来
mom 在 t 月末才算得出来(要用到 t 月末的收盘价)。
ret 是 t 月的收益,从 t−1 月末持有到 t 月末才实现。
两者相乘,必须让 momret 早一期
这和第 19 讲 rolling 那个 .shift(1) 是同一条规则,只是从日频换成了月频。


信号 → 持仓权重

rank = sig.rank(axis=1, ascending=False)      # 横向排名,1 是动量最高的
long = (rank <= 5).astype(float)              # 选前 5 个行业
long = long.div(long.sum(axis=1), axis=0)     # 等权,每行加起来是 1
(137, 31) → (137, 31)      每行 31 个数,其中 5 个是 0.2,其余是 0

rank(axis=1)横向排名(第 09 讲),在每一行(每个月)内部对 31 个行业排序。宽表的价值在这里体现——长表上要 groupby(日期).rank(),宽表上一个 axis=1 就够了。

div(..., axis=0) 是按行归一化:每行除以该行的和,得到权重。

TIP: 🚀 权重矩阵的检查点

assert np.allclose(long.sum(axis=1).dropna(), 1.0)     # 每行和为 1
assert (long >= 0).all().all()                          # 纯多头没有负权重
assert (long > 0).sum(axis=1).max() == 5                # 每期恰好持 5 个

权重矩阵是回测链的中枢,后面所有结果都从它出发。这三个断言几乎零成本。


持仓 → 组合收益 → 净值

port = (long * ret).sum(axis=1)               # 组合收益
nav = (1 + port).cumprod()                    # 净值曲线
(137, 31) × (137, 31) → (130,) → (130,)

long * ret逐元素相乘——权重矩阵和收益矩阵形状相同,对应位置相乘就是”这个行业贡献了多少”。再按行求和,得到组合当月的总收益。

这一步是宽表形态的直接回报:组合收益就是一次矩阵乘法。长表上要先 merge 再 groupby,绕得多。

cumprod() 把收益序列累乘成净值。注意用 (1 + port) 而不是 port ——净值是复利累乘,不是收益累加。


净值 → 绩效指标

years = len(port) / 12
annual = nav.iloc[-1] ** (1 / years) - 1                  # 年化收益
sharpe = port.mean() / port.std() * np.sqrt(12)           # 年化夏普
mdd    = (nav / nav.cummax() - 1).min()                   # 最大回撤

三个指标各用了一个 pandas 特性:

  • 年化:净值末值开 1/年数 次方
  • 夏普:月度均值除以月度标准差,再乘 √12 年化。月频用 12,日频用 252
  • 最大回撤cummax() 是历史最高点(第 19 讲的 expanding 家族),当前净值除以它减 1 就是当前回撤,取最小值
                 年化      夏普    最大回撤
等权基准         1.14%     0.16    -38.63%
策略(税前)     0.64%     0.14    -54.87%

必须有基准。 单看”年化 0.64%”没有信息量——它是好是坏,取决于同期把 31 个行业等权持有能拿到多少。这里基准是 1.14%,策略不但收益更低,最大回撤还深了 16 个百分点。


换手与交易成本

turnover = (long.diff().abs().sum(axis=1) / 2)     # 单边换手率
cost = turnover * 2 * 0.002                        # 双边成本 0.2%
port_net = port - cost
月均单边换手  34.9%
年化成本拖累   1.68%

long.diff() 是权重矩阵的逐行差分——这个月的权重减上个月的。取绝对值求和再除以 2,就是单边换手率(除 2 是因为买入和卖出被算了两遍)。

扣掉成本之后:

                 年化      夏普    最大回撤
策略(税前)     0.64%     0.14    -54.87%
策略(扣成本)  -1.04%     0.07    -57.56%

年化从 +0.64% 变成 −1.04%。1.68 个百分点的成本,把一个勉强为正的策略变成了负的。

IMPORTANT: 🔑 换手率是回测里最容易被忽略的一列
一个”年化 5%”的策略,如果月均换手 80%,扣完成本可能只剩 1%。
计算成本很简单——难的是记得算。回测代码里 turnover 这一行经常被省略,因为不算它净值曲线一样能画出来。


六、🐛 完整回测链上的时间对齐点

这条链上有四个地方需要对齐时间,错任何一个结果都会失真:

位置 要求 错了会怎样
信号 vs 收益 信号早一期 年化从 0.64% 虚增到 42.37%
重采样取值 .last() 取月末价 用月均价等于用了整月信息
动量计算 fill_method=None 数据空缺处造出假收益
换手计算 用调仓后的权重差分 漏算成本

前三个都属于用了当时还不知道的信息。它们的共同特征在这门课里出现过很多次:

  • 不报错
  • 结果的形状、量级都正常
  • 让结果变好看

第一条尤其值得记住:未来函数总是让回测变好。所以当你看到一个夏普 1.5 的简单策略时,第一反应不该是兴奋,而是回去检查时间对齐。


七、📊 完整结果

回测区间     2015-08 ~ 2026-05     130 个月
持仓         每月选动量最高的 5 个行业,等权
调仓         月末信号,次月持有

                 年化      夏普    最大回撤
等权基准         1.14%     0.16    -38.63%
策略(税前)     0.64%     0.14    -54.87%
策略(扣成本)  -1.04%     0.07    -57.56%

月均单边换手   34.9%

NOTE: 💡 这个结果不需要修
简单的行业动量在这段样本上跑输等权基准,扣完成本是负的。这是诚实的输出。
这一讲的目标是走通整条链,不是找到一个能用的策略。 一个跑通的回测框架比一条漂亮的净值曲线有价值——因为框架可以用来测下一个想法,而漂亮的曲线往往只是某处时间没对齐。
如果你想让它变好看,最快的办法就是把 shift(1) 去掉——年化立刻变成 42%。这恰恰说明了为什么要检查。



八、📚 复盘:这个案例用到了哪些 pandas 功能

按讲次回顾

讲次 用在哪一步 具体写法
第 09 讲 缺失与排序 生成权重 rank(axis=1, ascending=False) 横向排名
第 14 讲 重塑与透视 第 1 步 pivot(index=, columns=, values=) 转宽表
第 14 讲 算收益 pct_change(fill_method=None)
第 18 讲 时间类型 第 1 步 to_datetimeDatetimeIndex
第 19 讲 重采样 第 2 步 resample("ME").last()
第 19 讲 移动窗口 净值、回撤 cumprod()cummax()
—— 信号滞后 shift(1)
—— 组合收益 矩阵逐元素相乘 + sum(axis=1)
—— 换手率 diff().abs().sum(axis=1) / 2

这一讲用到的 API 最少,但每一个都在做不可替代的事。 因为回测链的每一段都是不同性质的运算:重塑、重采样、排序、矩阵乘、累乘。

一个观察:整条链都在宽表上

从第 1 步 pivot 之后,一直到最后算完指标,数据始终是 (137, 31) 这个形状。这不是巧合:

操作 宽表怎么写 长表要怎么写
横向排名 rank(axis=1) groupby(日期).rank()
生成权重 div(sum(axis=1), axis=0) 再来一次 groupby + transform
组合收益 (w * ret).sum(axis=1) merge 权重和收益,再 groupby
换手率 diff().abs().sum(axis=1) 自连接前后两期,很绕

回测天然是矩阵运算——权重矩阵 × 收益矩阵。第 14 讲说”宽表擅长矩阵运算”,这一讲就是那句话最完整的兑现。

一个对比:三个案例的形态选择

案例 主要形态 为什么
第 20 讲 单因子测试 长表 大量 groupby 分组聚合,长表是 groupby 的语言
第 21 讲 时间对齐 长表 merge_asof 只能在长表上做
第 22 讲 完整回测 宽表 权重 × 收益是矩阵运算

同样是量化流程,形态选择完全不同。判据不是”哪个更好”,是”下一步要做什么运算”(第 14 讲)。

最值得带走的三件事

① 未来函数总是让回测变好。 差一天,年化从 0.64% 变成 42.37%。所以看到一个夏普 1.5 的简单策略,第一反应不该是兴奋,而是回去检查时间对齐。结果太好,通常意味着某处用了当时还不知道的信息。

② 权重矩阵是回测链的中枢。 后面所有结果——收益、净值、换手、成本——都从它出发。所以那三个断言(每行和为 1、无负权重、每期恰好 N 个)值得写:中枢错了,下游全错,而且全都不报错。

③ 算了收益还要算换手。 换手率这一行经常被省略,因为不算它净值曲线一样能画出来。但这个案例里,1.68 个百分点的成本把 +0.64% 变成了 −1.04%。一个不含成本的回测,回答的不是”能不能赚钱”,只是”信号有没有方向”。


🏋️ 训练营

QUESTION: 🟢 训练 1:权重矩阵 long 每行应该满足什么条件?写出三个断言。
TIP: 👉 参考

assert np.allclose(long.sum(axis=1).dropna(), 1.0)      # 每行和为 1
assert (long >= 0).all().all()                           # 纯多头无负权重
assert (long > 0).sum(axis=1).max() == 5                 # 每期恰好 5 个持仓

第一条最重要——权重和不为 1 意味着你的组合没满仓或者超配了,后面所有收益都会按比例失真。
dropna() 不能省:前 6 个月因为算不出动量,整行是 NaN,sum 会得到 0 而不是 1。

QUESTION: 🟡 训练 2:把 sig = mom.shift(1) 改成 mom.shift(2),结果会怎样?这么改有没有合理的场景?
TIP: 👉 参考
shift(2) 意味着用两个月前的信号交易当月,信息更陈旧,通常会让结果变差(动量信号的有效期有限)。
但它有合理场景:如果你的调仓不是月末立刻执行,而是需要几天时间(研究流程、审批、分批建仓),那么实际可用的信号确实更旧。用 shift(2) 做一次敏感性测试,能回答”这个策略对执行延迟有多敏感”。
这是回测里的一个常规检查:把关键参数上下动一动,看结果是不是稳定。如果 shift(1) 赚钱、shift(2) 就亏,说明策略依赖于极快的执行,实盘很难复制。

QUESTION: 🔴 训练 3:把这个策略从”选前 5 个行业做多”改成”多空组合”——做多动量最高的 5 个、做空最低的 5 个。写出权重矩阵的构造,并说明哪些检查点要跟着改。
TIP: 👉 参考

n = sig.notna().sum(axis=1)                              # 每期有效行业数
rank_asc = sig.rank(axis=1, ascending=True)              # 升序排名

long_  = (rank <= 5).astype(float)
short_ = (rank_asc <= 5).astype(float)
w = long_.div(long_.sum(axis=1), axis=0) - short_.div(short_.sum(axis=1), axis=0)

检查点要改三处
w.sum(axis=1) 现在应该是 0 而不是 1(多空对冲,净敞口为零)
(w >= 0).all() 不再成立——空头是负权重
③ 新增一个检查:w.abs().sum(axis=1) 应该是 2(多头总权重 1 + 空头总权重 1),这是杠杆率

换手率的算法也要改w.diff().abs().sum(axis=1) / 2 仍然成立,但含义变了——多空组合的换手包含了两边的调整,绝对值会更大,成本更高。
另外:A 股融券成本高、可融券的标的有限,行业指数更是无法直接做空。这个多空组合在数学上成立,在 A 股实际做不了。回测里做得出来、现实中做不到的事,要在结论里注明。


✍️ 作业

  1. 把完整回测跑一遍,记录每一步的形状。重点确认 (137, 31) × (137, 31) → (130,) 这一步——组合收益是怎么从两个矩阵变成一个序列的。
  2. 复现开场那个对比:shift(0)shift(1) 各跑一次,记录两组年化和夏普。用”信号在什么时候能被算出来”解释差异。
  3. 给权重矩阵加上三个断言,然后故意把 div(long.sum(axis=1), axis=0) 去掉,确认断言能抓住”权重和不为 1″。
  4. 把持仓数从 5 个改成 3 个和 10 个,各跑一次,记录三组的年化、夏普和换手率。观察持仓数和换手率的关系。
  5. 把双边成本从 0.2% 改成 0.05%(机构费率)和 0.5%(含冲击成本),看策略在什么成本水平下开始亏钱。这个数叫”成本盈亏平衡点”。
  6. 思考题:本讲用 resample("ME").last() 取月末价。如果改成 resample("ME").mean()(月度均价),回测结果会偏好还是偏差?为什么?(提示:想想月均价里包含了哪些日子的信息,以及你在月末那天实际能以什么价格成交。)

🎓 全课收官

二十二讲走完,回头看这条线:

第 02–07 讲教的是取数——按位置、按标签、按条件,以及 Series 和 DataFrame 这两个容器。
第 08–14 讲教的是把数据揉成形状——读进来、补缺失、改值、分类、层次索引、合并、长宽互转。
第 15–19 讲教的是分组和时间——聚合、组内变换、透视、时间索引、重采样与滚动。
第 20–22 讲把它们串起来,做了三条完整的流水线。

如果只留一句,是这个:pandas 里最危险的错误都不报错

课程里反复出现的那些——groupby 不排序、rolling 跨越股票边界、merge 悄悄膨胀、财报按报告期对齐、信号和收益同期相乘——它们的共同点是结果的形状正常、量级正常、画出来也正常

所以真正的能力不是”会写 pandas”,而是:

  • 知道每一步的输出应该是什么形状,并在形状变化处设检查点
  • 知道每个数在什么时候能被算出来,不用还没发生的信息
  • 知道数据本身有什么局限,并在结论里如实说明

前两条决定你的代码对不对,第三条决定别人该多大程度上相信你的结果。


← 上一讲  ·  返回课程