📎 配套代码:
第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_code、trade_date、close
|
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 月末才实现。
两者相乘,必须让mom比ret早一期。
这和第 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_datetime、DatetimeIndex
|
| 第 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 股实际做不了。回测里做得出来、现实中做不到的事,要在结论里注明。
✍️ 作业
- 把完整回测跑一遍,记录每一步的形状。重点确认
(137, 31) × (137, 31) → (130,)这一步——组合收益是怎么从两个矩阵变成一个序列的。 - 复现开场那个对比:
shift(0)和shift(1)各跑一次,记录两组年化和夏普。用”信号在什么时候能被算出来”解释差异。 - 给权重矩阵加上三个断言,然后故意把
div(long.sum(axis=1), axis=0)去掉,确认断言能抓住”权重和不为 1″。 - 把持仓数从 5 个改成 3 个和 10 个,各跑一次,记录三组的年化、夏普和换手率。观察持仓数和换手率的关系。
- 把双边成本从 0.2% 改成 0.05%(机构费率)和 0.5%(含冲击成本),看策略在什么成本水平下开始亏钱。这个数叫”成本盈亏平衡点”。
- 思考题:本讲用
resample("ME").last()取月末价。如果改成resample("ME").mean()(月度均价),回测结果会偏好还是偏差?为什么?(提示:想想月均价里包含了哪些日子的信息,以及你在月末那天实际能以什么价格成交。)
🎓 全课收官
二十二讲走完,回头看这条线:
第 02–07 讲教的是取数——按位置、按标签、按条件,以及 Series 和 DataFrame 这两个容器。
第 08–14 讲教的是把数据揉成形状——读进来、补缺失、改值、分类、层次索引、合并、长宽互转。
第 15–19 讲教的是分组和时间——聚合、组内变换、透视、时间索引、重采样与滚动。
第 20–22 讲把它们串起来,做了三条完整的流水线。
如果只留一句,是这个:pandas 里最危险的错误都不报错。
课程里反复出现的那些——groupby 不排序、rolling 跨越股票边界、merge 悄悄膨胀、财报按报告期对齐、信号和收益同期相乘——它们的共同点是结果的形状正常、量级正常、画出来也正常。
所以真正的能力不是”会写 pandas”,而是:
- 知道每一步的输出应该是什么形状,并在形状变化处设检查点
- 知道每个数在什么时候能被算出来,不用还没发生的信息
- 知道数据本身有什么局限,并在结论里如实说明
前两条决定你的代码对不对,第三条决定别人该多大程度上相信你的结果。