第 2 课:多标的回测 —— 一个策略跑 50 只股票
前置:已完成第 1 课 + 作业 1~4(见
lessons/01_pandas入门.md) 代码文件:02_多标的回测.py(本课新增)、config.py(新增STOCK_POOL) 学完本课 → 打开lessons/03_单因子测试.md开新会话学第 3 课
0. 新会话背景速览(给"接手的 AI"看的 30 秒)
- 项目位置:
D:/code/量化交易 - 学生已学完:pandas 基础、回测框架(engine 的 shift(1))、作业 1~4(含自己写 RSI)
- 本课已交付:
02_多标的回测.py可正常运行,config.STOCK_POOL(50 只股票)已配置 - 运行方式:项目根目录下
python 02_多标的回测.py(第一次会下载真实行情,之后走缓存) - 输出:
output/multi_stock_summary.csv、output/multi_stock_excess.png(条形图)、output/multi_stock_scatter.png(散点图) - 框架要点:信号 1=持有 0=空仓;
signal.shift(1)防前视;backtest/metrics.py的compute_metrics返回字符串(方便打印,不能做数值统计)
1. 本课目标
上一课只看一只股票(600519),结论容易被运气带偏。本课解决这个问题:
- for 循环批量跑:同一套策略跑遍 50 只股票
- 超额收益:策略总收益 − 买入持有总收益(策略到底有没有用的核心指标)
- 赢面:策略跑赢买入持有的股票占比(量化里最该盯的一个数字)
- 平均 vs 中位数:少数暴涨股会把平均拉高,中位数代表"普通股票"水平
- 出两张图:超额收益条形图 + 策略 vs 基准散点图(对角线上方 = 策略赢)
2. 概念先讲透
| 概念 | 一句话解释 |
|---|---|
| 单标的 vs 多标的 | 一只股票的结果 = 策略 + 运气;一批股票的平均/赢面才是策略的"体检报告" |
| 超额收益 | 策略收益 − 买入持有收益。为正 = 择时有效果;为负 = 还不如躺着不动 |
| 赢面 | 跑赢基准的股票占比。50% 以下说明策略没创造价值,只有择时运气 |
| 平均 vs 中位数 | 平均会被少数暴涨股拉高,中位数代表"随机挑一只股票的普通结果" |
3. 代码讲解(对着 02_多标的回测.py 看)
3.1 股票池(config.py)
STOCK_POOL = [ ... 50 只 ... ]
挑选原则写在注释里:覆盖消费/金融/医药/科技/制造/其他大盘 6 个行业, 别光挑"看起来会涨"的股票——否则等于考试前先偷看了答案。
3.2 逐股分析(analyze_one)
为什么不用 compute_metrics?因为它返回格式化字符串(如 "12.34%"),
字符串没法做加减平均,所以这里手算一遍数值指标——顺便复习年化/夏普/回撤公式:
strat_total = cum_strat.iloc[-1] - 1 # 策略总收益
annual = (1 + strat_total) ** (TRADING_DAYS / n) - 1 # 年化(复利)
vol = res["strategy_ret"].std() * np.sqrt(TRADING_DAYS) # 年化波动率
sharpe = (annual - rf) / vol
mdd = max_drawdown(cum_strat) # 复用 metrics 里的函数
3.3 批量收集
rows = []
for i, code in enumerate(config.STOCK_POOL, 1):
row = analyze_one(code, load_data(code))
rows.append(row) # 每只股票一行,最后拼成一张总表
summary = pd.DataFrame(rows)
3.4 汇总统计(本课的核心输出)
win = summary["超额收益"] > 0
win.mean() # 赢面 = 跑赢基准的股票占比
summary["超额收益"].mean() # 平均(会被暴涨股拉高)
summary["超额收益"].median() # 中位数(普通股票水平)
3.5 两张图
- 条形图:50 只股票超额收益排序,正绿负红,零轴一眼看出赢面
- 散点图:横轴买入持有收益、纵轴策略收益,画一条"跑平线"(对角线),线上方 = 策略赢
4. 真实运行结果(2021-01 ~ 2025-06,双均线 10/30,50 只真实行情)
这是本课跑出来的实际结果,供你复跑对照:
| 指标 | 结果 |
|---|---|
| 策略平均总收益 | −5.05% |
| 买入持有平均总收益 | +16.68% |
| 平均超额收益 | −21.73% |
| 中位数超额收益 | −0.96% |
| 超额最好 / 最差 | +112.05% / −519.10% |
| 赢面 | 50%(25/50) |
| 策略平均最大回撤 | −44.11% |
这个结果本身就是最好的教材: - 双均线平均跑输买入持有 21.7%,赢面只有 50% → 择时没有创造价值 - 跑输最惨的全是单边慢牛股:中国神华(基准 +695%、超额 −519%)、 中国石油(+290%、−194%)、紫金矿业(+128%、−166%)、农业银行(+207%、−133%) → 双均线在这些股票上反复被"洗下车",一卖就涨 - 超额收益最高的:宁德时代 +112%、东方财富 +100%、隆基绿能 +76% → 都是震荡/深跌后反弹的行情,均线来回吃波段
注:此结果会随数据缓存和参数变化而不同,重点看"数量级和方向"而不是精确数字。
超额收益条形图(正绿负红,零轴上方 = 跑赢买入持有):

策略 vs 买入持有散点图(对角线上方 = 策略赢):

5. 作业 5
把 02_多标的回测.py 里 analyze_one 的策略从双均线换成 RSI:
from strategies.rsi import rsi_signal
sig = rsi_signal(df, config.RSI_PERIOD)
重跑,回答三个问题: 1. RSI 的赢面是多少?比双均线高还是低? 2. RSI 在单边慢牛股(如中国神华)上还会大幅跑输吗?为什么? 3. 从结果反推:双均线和 RSI 各自更适合什么行情?(提示:均线吃趋势、RSI 吃震荡)
做完再看:如果 RSI 赢面还是不到一半,别急着失望—— 这正是第 4 课"参数敏感性分析"和第 3 课"单因子测试"要解决的事: 与其在单只股票上择时,不如全市场选股。
作业 5 参考答案(真实数据跑出来的,供对照)
| 指标 | 双均线(10,30) | RSI(14) |
|---|---|---|
| 策略平均总收益 | −5.05% | +1.44% |
| 平均超额收益 | −21.73% | −15.24% |
| 中位数超额收益 | −0.96% | +20.78% |
| 赢面 | 50%(25/50) | 62%(31/50) |
| 平均最大回撤 | −44.11% | −16.86% |
三个问题的答案要点: 1. 赢面 62%,高于双均线的 50%;回撤从 −44% 砍到 −17%(小一半以上),是赢面高的根源 2. 单边慢牛股上依然大幅跑输(神华超额 −714%):RSI 只在超卖(<30)时买入, 而慢牛股几乎不超卖(神华 1086 天里仅 25 天 RSI<30),策略 97.7% 时间空仓—— 踏空就是最大的跑输,它没亏大钱,但错过了整个 +695% 的行情 3. RSI(均值回归)适合震荡市/下跌市:跌深就买、反弹就跑、靠躲大跌赢; 双均线(趋势跟踪)适合单边趋势,但 10/30 参数太灵敏,震荡市反复被洗; 两者共同死穴是单边慢牛(趋势派被洗下车、反转派等不到回调,双双踏空)
作业 5 的深层教学点:平均 vs 中位数的矛盾
平均超额 −15.24% 但中位数 +20.78%:62% 的股票跑赢基准,平均却是负的—— 被神华(−714%)、石油(−281%)、农行(−207%)、紫金(−117%) 几只慢牛股拉穿。 中位数才是"随机挑一只股票的普通结果"。
作业 6(进阶,可选):状态机版 RSI
现有 rsi_signal 是点位信号(30~70 中间区域信号=0,等于 RSI 一回升到 30 就卖)。
标准玩法是状态机:跌破 30 买入 → 持有 → 冲上 70 才卖,中间维持上次状态:
signal = pd.Series(0, index=df.index)
for i in range(1, len(df)):
if rsi_val.iloc[i] < 30: signal.iloc[i] = 1 # 买入
elif rsi_val.iloc[i] > 70: signal.iloc[i] = 0 # 卖出
else: signal.iloc[i] = signal.iloc[i-1] # 中间:维持原状
预测:慢牛股上跑输会变小(能拿住更久),但平均回撤会变大(下跌初期持仓变长)。 跑完对照你的直觉。
作业 6 参考答案(真实数据跑出来的,供对照)
| 指标 | RSI 点位版(A) | RSI 状态机版(B) |
|---|---|---|
| 策略平均总收益 | +1.44% | +18.41% |
| 平均超额收益 | −15.24% | +1.73%(转正!) |
| 中位数超额收益 | +20.78% | +25.10% |
| 赢面 | 62%(31/50) | 66%(33/50) |
| 平均最大回撤 | −16.86% | −40.27% |
两条预测全部兑现: 1. 单边慢牛股跑输变小(拿得更久):神华 −714%→−635%、石油 −281%→−216%、 紫金 −117%→−53%、石化 −131%→−93%、农行 −207%→−199% 2. 平均回撤变大:−16.86% → −40.27%(下跌初期持仓变长)
白捡的惊喜:赢面从 62% 升到 66%,平均超额收益从 −15.24% 转正为 +1.73%—— 状态机版赚得更多、赢面更高,代价是回撤大了一倍多。
本质是风险-收益取舍: - A 版(抢反弹):只持仓 55 天/1086 天,回撤小(−17%),但交易 28 次磨损大、平均超额为负 - B 版(吃整段):持仓 376 天,回撤大(−40%),但把"超卖→超买"整段行情吃进肚,赢面 66% - 没有绝对更好的版本,只有不同性格——B 更接近教科书的 RSI,A 更像"抄底快枪手" - 下一步思路(预告第 5 课):B 的回撤能不能用仓位管理压下去? (第 5 课:波动大少买、波动小多买,而不是永远全仓)
6. 学完自检清单
- [ ] 能说清"为什么不能只看一只股票的绩效"
- [ ] 能说清超额收益、赢面的定义,以及为什么平均和中位数要一起看
- [ ] 复跑过
python 02_多标的回测.py,结果和上面的表数量级一致 - [ ] 能解释散点图里"对角线上方 = 策略赢"
- [ ] 做完作业 5(RSI 版多标的回测),能说出双均线 vs RSI 的适用行情差异
7. 下一课预告
第 3 课:单因子测试(lessons/03_单因子测试.md)
从"对一只股票选时"升级到"对全市场选股":
把股票池按某个因子(动量/反转/低波动……)分组,看高分组是否真的比低分组涨得多,
学会用 IC 和分组收益检验一个因子有没有用——这是量化研究员最日常的工作。