本地量化系列教程 · 第五篇。配套笔记本:
RSI策略.ipynb;数据:stock_data/000063_中兴通讯.csv(本地日线,与原笔记本的 tushare 接口数据同源同列)。
前两篇的向量化回测快而优雅,但它把"仓位"当成一个抽象数字——没有账户、没有现金、没有"钱不够买不起一手"这种现实摩擦。这一篇换一种范式:事件驱动回测。写一个逐日循环,维护现金和持股两个状态变量,像实盘一样一根一根 K 线地推演。用 RSI 超买超卖策略作为载体。
1. RSI 指标
1.1 定义
RSI(Relative Strength Index,相对强弱指数)衡量一段时间内上涨力量占总波动的比例:
$$ RSI(N) = \frac{A}{A + B} \times 100 $$
其中 A 为 N 日内收盘涨幅之和,B 为 N 日内收盘跌幅之和的绝对值。RSI ∈ [0, 100]:数值大说明最近涨的日子贡献占比高,市场偏"超买";数值小说明跌的日子占主导,偏"超卖"。
1.2 Wilder 平滑
原论文(Wilder, 1978)用的是指数式平滑而非简单平均,等价于 alpha = 1/N 的 EWM:
def wilder_rsi(close, period=6):
delta = close.diff()
gain = delta.clip(lower=0) # 涨幅,跌日为 0
loss = -delta.clip(upper=0) # 跌幅,涨日为 0
avg_gain = gain.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1/period, min_periods=period, adjust=False).mean()
return 100 - 100 / (1 + avg_gain / avg_loss)
别用简单平均版本的 RSI 与行情软件对照——大多数软件(同花顺、通达信、TradingView)都用 Wilder 平滑,算法不同数值会差出好几个点,然后你会浪费一晚上怀疑人生。
本篇参数 period=6(原笔记本的选择),短周期 RSI 灵敏、信号多,适合演示;实盘更常用 14。
1.3 交易规则
原笔记本在迭代中把规则从"RSI>80 卖"调整为:
- RSI < 25 → 做多(超卖博反弹)
- RSI > 50 → 清仓(回归中性即离场)
- 其余时间维持现状
这是一个不对称规则:入场赌"超卖后修复",出场不赌顶部、只赌修复完成。超卖博反弹本质上是短线均值回归,在 A 股只能做多,所以出场条件天然弱于入场条件。
2. 事件驱动回测引擎
2.1 状态机
账户只有两个状态变量:capital(现金)和 shares(股数)。逐日循环:
initial_capital, cost = 10000, 0.001 # 1 万元本金,单边 0.1% 成本
r['position'] = np.nan
r.loc[r['rsi'] < 25, 'position'] = 1
r.loc[r['rsi'] > 50, 'position'] = -1
r['position'] = r['position'].ffill()
capital, shares = initial_capital, 0
curve, buys, sells = [], [], []
for idx, row in r.iterrows():
p = row['close']
if row['position'] == 1 and shares == 0 and capital >= p * (1 + cost):
n = int(capital / (p * (1 + cost))) # 全仓买入
capital -= n * p * (1 + cost)
shares += n
buys.append(idx)
elif row['position'] == -1 and shares > 0:
capital += shares * p * (1 - cost) # 全仓卖出
shares = 0
sells.append(idx)
curve.append(capital + shares * p) # 每日盯市净值
和向量化版的四个本质区别:
- 有账户:现金与持仓分开记账,
capital + shares * p才是总资产,每天盯市; - 有约束:
capital >= p * (1+cost)检查买不买得起;int()取整意味着零股不买——这些"土约束"恰是实盘的真实形状; - 有成本:买卖各扣 0.1%——6 日 RSI 信号频繁,成本在这个周期上必然重要;
- 有仓位判断:
shares == 0才买、shares > 0才卖,不会重复开仓。
2.2 交易记录
把每次买入/卖出的日期和当时净值记进 buys / sells,就能配对计算胜率——这是向量化框架里拿不到的 granular 信息:
wins = sum(1 for b, s in zip(buys, sells) if equity[b] < equity[s])
win_rate = wins / len(sells)
3. 回测结果
3.1 交易点位

上图为价格与买卖点(红▲买、绿▼卖),下图为 RSI(6) 与两条阈值线。可以看到信号的典型形态:RSI 探到 25 以下(价格急跌)后不久出现买点,RSI 修复穿过 50 后卖点跟进。2022-2023 年的阴跌段买点非常密集——超卖会连续触发,策略在下跌途中反复接飞刀、反复小亏,直到趋势反转才拿住一波大的。
3.2 资金曲线

实际数字(1 万元本金,2020-01 ~ 2025-07,中兴通讯):
| 指标 | 数值 |
|---|---|
| 完整交易笔数 | 32 笔 |
| 总收益 | +52.1%(期末 15212.5 元) |
| 年化收益 | 8.2% |
| 最大回撤 | -20.1% |
| 年化夏普 | 0.53 |
| 胜率 | 71.9% |
3.3 读懂数字:高胜率 ≠ 好策略
胜率 71.9% 看起来漂亮,但要配合另外两个数一起读:
- 单笔盈亏比:32 笔里 23 笔赚,但总收益才 +52%。算一下可知平均每笔盈利单赚 ~3-4%,而亏损单(接飞刀失败的那种)单笔亏损更深——典型的"高胜率、低盈亏比"形态。胜率与盈亏比存在天然权衡:赚小钱多次、亏大钱少次的策略,胜率好看但一次尾部亏损可能抹掉半年利润。
- 回撤 -20% 对 8% 年化来说不便宜:收益/回撤比(Calmar 类指标)只有 0.4 左右。
- 32 笔样本太小。统计上 32 笔的胜率置信区间宽得能开卡车(±16pp 以上),换一年数据重跑,胜率可能掉到 50%。小样本结论只能当线索,不能当证据。
4. 两种回测范式怎么选
| 向量化(02/03/04 篇) | 事件驱动(本篇) | |
|---|---|---|
| 速度 | 极快(列运算,C 速度) | 慢(Python 逐日循环) |
| 代码量 | 十行 | 百行 |
| 账户/现金 | 无 | 有 |
| 手续费/零股/资金约束 | 难以表达 | 自然表达 |
| 交易明细/胜率 | 拿不到 | 顺手记录 |
| 适用阶段 | 策略构思期、大规模参数扫描 | 仿真验证期、精细成本建模 |
工程实践的标准流程是两段式:先向量化扫参数(快),把候选参数放进事件驱动引擎复核(真)。两者结果对不上时,一般是三个地方出了鬼:信号对齐(shift)、成本口径、成交假设(能否按收盘价成交)。
5. 改进方向
原笔记本已经列了框架的改进清单,按优先级展开:
- 成交价假设:本引擎按当天收盘价成交,但 RSI 是收盘后才算出来的——严格说应按次日开盘价成交,这是比
shift(1)更诚实的时间对齐; - 止损:接飞刀策略必须配止损(如单笔 -5% 强制离场),能把"低盈亏比"的亏损单尾剪短;
- 参数稳健性:阈值 (25, 50) 与周期 6 同样需要网格 + 邻域平滑检验;
- 多标的验证:第六篇会把这套逻辑批量跑到 5000+ 只股票上。
6. 小结与练习
- 事件驱动回测 = 状态机(现金+持仓)+ 逐日循环 + 盯市净值,牺牲速度换真实;
- RSI 超卖买入/中性卖出的本质是短线均值回归,A 股语境下只能做多;
- 胜率必须和盈亏比、回撤一起读,单独的高胜率毫无意义;
- Wilder 平滑是 RSI 的标准算法,与软件对照时先统一口径;
- 成交价假设(收盘 vs 次日开盘)是最容易被忽视的系统性偏差。
练习:
- (基础)把买入阈值从 25 改成 20/30,统计三种设置下的交易笔数、胜率、总收益;
- (进阶)加入止损:持仓期间单日跌幅超 5% 或持仓亏损超 8% 强制清仓,对比最大回撤的变化;
- (进阶)把成交价改成次日开盘价(提示:循环里用
r['open'].shift(-1)或直接读下一行的开盘价),重算绩效; - (挑战)用同样的状态机实现 02 篇的双均线策略,验证向量化结果与事件驱动结果能否对齐(这同时是对两套代码的正确性交叉验证)。
上一篇:04 · 动量效应 · 下一篇:06 · 绩效评价与批量回测:从单票到全市场