盯着一只连涨五六天的股票,想追进去又怕刚好站在山顶;反过来,看着它连跌好几天,想割肉又怕刚割完就反弹。"是不是涨过头了""是不是跌过头了",这种问题靠盘感是靠不住的,我们需要一把能算出来的尺子。
上一篇我们认识了 MACD,它是跟着趋势走的动能指标,趋势行情里好用,但震荡市里 DIF 和 DEA 来回穿插,假信号不少。本篇换一个视角:先不问趋势往哪儿走,而是问价格短期内有没有"走极端"。回答这个问题的经典工具是 RSI(Relative Strength Index,相对强弱指数),由 J. Welles Wilder 在 1978 年提出,至今仍是使用率最高的摆动类指标之一。
本篇我们把 RSI 的计算原理一步步推清楚,用 Python 写出"严格递推"和"rolling 简化"两个版本并对比差异,再聊聊最常用的 70/30 阈值,以及它在趋势市中最著名的坑——钝化。
本篇目标
- 能说清 RSI 的计算原理:平均涨幅、平均跌幅与相对强度 RS 的关系。
- 能用 Python 手写 Wilder 平滑版 RSI,并知道它为什么和看盘软件数值一致。
- 能写出 rolling 简化版 RSI,并用数据对比两种实现的差异。
- 能画出"价格 + RSI"双窗格图,并标出 70/30 参考线。
- 能用代码统计"超买赖着不走"的区间数,直观理解趋势市钝化。
核心概念
RSI 想度量什么
RSI 的名字里带着"相对强弱"四个字:它比较的是一段时间内上涨的力量和下跌的力量谁更大,再把结果压缩到 0 到 100 之间。默认周期是 14 天(14 个交易日)。数值高,说明近期涨的力量占上风;数值低,说明跌的力量占上风。它不看价格的绝对水平,只看涨跌的"力量对比",所以归为摆动指标——数值在 0 到 100 之间来回摆。
计算原理:平均涨幅与平均跌幅
先把每天的涨跌拆进两个口袋:
- 当日涨幅 gain = max(今日收盘 − 昨日收盘, 0):涨了记实际涨幅,跌了记 0。
- 当日跌幅 loss = max(昨日收盘 − 今日收盘, 0):跌了记实际跌幅(取绝对值),涨了记 0。
再分别求最近 14 天的平均值,得到平均涨幅 avg_gain 和平均跌幅 avg_loss,两者相除得到相对强度(Relative Strength,简称 RS):
RS = avg_gain / avg_loss
最后把 RS 换算成 0~100 的刻度:
RSI = 100 − 100 / (1 + RS)
白话解释:当 avg_gain 和 avg_loss 打平时 RS=1,代入公式 RSI=50,正好是中轴;一段时间里完全没跌过,avg_loss 趋近 0,RS 趋近无穷大,RSI 趋近 100;反过来完全没涨过,RSI 趋近 0。0~100 这把尺子就是这么来的。
Wilder 平滑:递推而不是截断
上面"取最近 14 天平均"的说法其实是简化。Wilder 原版的平均方式是递推(recursion):
avg = (上一次的 avg × 13 + 今天的值) / 14
白话解释:新的平均值里,13/14 来自旧的平均值,只有 1/14 来自今天。这相当于 alpha = 1/14 的 EMA(指数移动平均,原理第 10 篇讲过):历史信息慢慢衰减,而不是被窗口"咔嚓"截掉。所以看盘软件上的 RSI 曲线比简单平均版更平滑。
划重点:不少教程图省事,用 rolling 简单平均来算 RSI。曲线形状差不多,但数值和看盘软件对不上。不是谁算错了,是平均方式不同——下面的版本对比里能直接看到这个差异。
两个实现版本与 70/30 阈值
于是我们有两种写法:递推版严格按 Wilder 平滑,与主流看盘软件同源;rolling 版直接对最近 n 天的涨跌取简单平均,好写好懂,是很多入门教程的写法。两者在温和行情里差距不大,在单边趋势里可能差出好几个点。
阈值方面,传统经验把 RSI 高于 70 称为超买(Overbought),低于 30 称为超卖(Oversold)。要特别强调:超买不等于"马上要跌",它只说明近期上涨力量占比很高;超卖也不等于"马上要涨"。50 中轴可以粗略当作多空分界:RSI 长期站在 50 上方,说明涨的力量整体占优。
趋势市钝化:RSI 最著名的坑
在强劲的单边趋势里,RSI 可以在 70 以上"赖"很久,跌下来一点又弹回去,超买之后更超买;下跌趋势里同理,超卖之后还能更超卖。这个现象叫钝化。如果机械地把"超买"理解成卖出信号,趋势里你会一路踏空,或者一路接飞刀。
划重点:钝化不是 RSI 失灵,而是把它用错了场景。摆动指标天生适合震荡市——区间里来回摆,超买超卖的参考价值高;一旦市场进入单边趋势,就得交给均线、MACD 这类趋势工具,或者给 RSI 加上趋势过滤条件再用。
完整代码
依赖安装:uv pip install akshare pandas matplotlib。以下代码分五段,按顺序连起来就是一个完整脚本(遵守"单块不超过 60 行"才拆开),数据源不可用时自动降级为本地模拟行情。
import numpy as np # 数值计算
import pandas as pd # 表格与时间序列
import matplotlib.pyplot as plt # 画图
def get_data() -> pd.DataFrame:
"""拉真实行情;网络不通时降级为本地模拟数据,保证代码永远能跑。"""
try:
import akshare as ak
df = ak.stock_zh_a_hist(symbol="000001", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权日线
df = df.rename(columns={"日期": "date", "收盘": "close"})
df["date"] = pd.to_datetime(df["date"])
print(f"已从 akshare 拉取真实行情 {len(df)} 行")
except Exception as e: # 数据源不可用
print(f"akshare 不可用({e}),改用本地模拟数据演示")
rng = np.random.default_rng(42) # 固定种子可复现
n = 1215 # 约 5 年交易日数
trend = np.linspace(0, 4, n) # 长期缓慢上行
wave = 1.5 * np.sin(np.arange(n) / 30) # 周期项造波段
noise = rng.normal(0, 0.35, n) # 日间随机扰动
close = 10 + trend + wave + noise
df = pd.DataFrame({"date": pd.bdate_range("2020-01-01", periods=n),
"close": close.round(2)})
return df.reset_index(drop=True)
df = get_data()
先拿到一份收盘价序列(真实数据或模拟数据都行),后面的计算对两种数据都适用。
def rsi_wilder_loop(close: pd.Series, n: int = 14) -> pd.Series:
"""手写 Wilder 递推版 RSI:与看盘软件同一套平均方式。"""
diff = close.diff() # 今日收盘 - 昨日收盘
gain = diff.clip(lower=0) # 只记涨:跌的日子记 0
loss = (-diff).clip(lower=0) # 只记跌:取绝对值,涨的日子记 0
rsi = pd.Series(np.nan, index=close.index)
g = gain.iloc[1 : n + 1].mean() # 前 n 天简单平均,做递推"种子"
l = loss.iloc[1 : n + 1].mean()
rsi.iloc[n] = 100.0 if l == 0 else 100 - 100 / (1 + g / l)
for i in range(n + 1, len(close)): # 从第 n+1 行开始递推
g = (g * (n - 1) + gain.iloc[i]) / n # 旧平均占 13/14,今天占 1/14
l = (l * (n - 1) + loss.iloc[i]) / n
rsi.iloc[i] = 100.0 if l == 0 else 100 - 100 / (1 + g / l)
return rsi
df["rsi_loop"] = rsi_wilder_loop(df["close"])
递推版写好了。下面写 rolling 简化版,并把两个版本摆在一起对比。
def rsi_rolling(close: pd.Series, n: int = 14) -> pd.Series:
"""rolling 简化版:直接对最近 n 天的涨/跌取简单平均。"""
diff = close.diff()
gain = diff.clip(lower=0)
loss = (-diff).clip(lower=0)
avg_gain = gain.rolling(n).mean() # 最近 n 天平均涨幅
avg_loss = loss.rolling(n).mean() # 最近 n 天平均跌幅
rs = avg_gain / avg_loss
rsi = 100 - 100 / (1 + rs)
# 防除零:区间内只涨不跌时 avg_loss=0,按惯例 RSI 记 100
return rsi.mask((avg_loss == 0) & (avg_gain > 0), 100.0)
df["rsi_roll"] = rsi_rolling(df["close"])
gap = (df["rsi_loop"] - df["rsi_roll"]).abs() # 两种实现的逐行差异
print(f"两种实现最大差异 {gap.max():.1f} 点,平均差异 {gap.mean():.1f} 点")
print(df[["close", "rsi_loop", "rsi_roll"]].tail(3).round(1))
两个版本都算出来了。接着做一个"钝化"的小统计,最后看图。
overbought = df["rsi_roll"] > 70 # 超买布尔序列
share = overbought.mean() # 超买天数占比
stretch = overbought.rolling(10).sum().eq(10) # 连续 10 天都超买
starts = stretch.astype(int).diff().eq(1).sum() # "赖着不走"的区间数
print(f"RSI>70 天数占比 {share:.1%},连续 10 天超买的区间数 {starts}")
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 7), sharex=True,
gridspec_kw={"height_ratios": [2, 1]})
ax1.plot(df["date"], df["close"], lw=1, label="close") # 上图:价格
ax2.plot(df["date"], df["rsi_loop"], lw=1, color="purple",
label="RSI(14)") # 下图:递推版 RSI
ax2.axhline(70, color="red", ls="--", lw=0.8) # 超买参考线
ax2.axhline(30, color="green", ls="--", lw=0.8) # 超卖参考线
ax2.set_ylim(0, 100)
ax1.legend(loc="upper left")
ax1.set_title("Price vs RSI(14)")
plt.tight_layout()
plt.savefig("rsi_demo.png", dpi=120) # 存图,方便在无界面环境查看
print("图已保存为 rsi_demo.png")
代码逐段解释
- 数据获取与降级。
get_data()先尝试用 akshare 拉平安银行 2020-01-01 至 2024-12-31 的前复权日线(系列统一示例区间);一旦网络或接口出问题,就退回用固定随机种子造 1215 个交易日的模拟收盘价:一个缓慢上行趋势,加一个正弦波段,再加噪声。固定种子(default_rng(42))保证你跑出来的模拟数据和教程一致。示例输出(示意,实际行数随数据源变化):
已从 akshare 拉取真实行情 1215 行
-
递推版 RSI。先
diff()算每日涨跌,clip(lower=0)把序列分别剪成"只记涨"和"只记跌"两条;前 14 天用简单平均做种子,之后每一行都用"旧平均 × 13/14 + 今天 ÷ 14"递推。首行diff是 NaN,所以前 14 行 RSI 全是 NaN——这是正常的预热期,不是 bug。当平均跌幅为 0(区间内从没跌过)时公式会除零,代码里按惯例记 100。 -
rolling 版与对比。
rolling(14).mean()直接对最近 14 天的涨、跌取简单平均,同样是第 14 行才有第一个值,两种实现的首个有效行正好对齐。把两列相减取绝对值,就能量化"平均方式不同"带来的差异。示例输出(示意,数值随数据不同而变化):
两种实现最大差异 6.4 点,平均差异 1.9 点
close rsi_loop rsi_roll
1212 11.38 58.3 57.1
1213 11.52 62.7 61.2
1214 11.41 60.1 58.8
可以看到两条曲线方向一致、数值有差——趋势段差异会更明显,这就是"平均方式不同"的直观证据。
- 钝化统计。
rolling(10).sum().eq(10)找出"连续 10 天 RSI>70"的日子,再用差分数一数这种区间出现了几次。示例输出(示意):
RSI>70 天数占比 11.2%,连续 10 天超买的区间数 3
占比一成多、还能连着十天以上不下来——"超买之后继续涨"并不是罕见事件。
- 画图。上图收盘价、下图递推版 RSI,70/30 两条虚线把超买超卖区标出来。对照着看:RSI 触到 70 的位置,价格往往正在加速段,而不是马上掉头。示例输出(示意):
图已保存为 rsi_demo.png
常见坑与小练习
常见坑
- 把预热期的 NaN 当异常。现象:打印前几行全是 NaN,以为代码错了。原因:diff 首行为 NaN,且 RSI 要满 14 天数据才开始计算。避免:任何 rolling/递推指标都有预热期,画图和统计前先
dropna()。 - 平均跌幅为 0 的除零。现象:打印出 inf 或 NaN。原因:区间内一天都没跌,avg_loss=0,公式分母出问题。避免:按惯例把这种情况的 RSI 记为 100,代码里用
mask兜底。 - 和看盘软件对不上。现象:同一天的数值差好几个点。原因:软件用 Wilder 递推,你用 rolling 简单平均,或者复权方式不同。避免:对齐口径——用递推版、同样用前复权数据。
- 趋势市把超买卖出当铁律。现象:刚"超买"就卖出,价格继续一路上扬。原因:单边趋势里 RSI 高位钝化。避免:摆动信号只在震荡市当主证据,趋势市改用趋势指标或加过滤条件。
- 拿未来数据算信号。现象:回测漂亮得不真实。原因:比如用全样本均值,或者对齐信号时忘了
shift(1),让今天的决策用到了明天的数据。避免:任何一行的指标只能用"当时已经收盘"的数据,这个习惯要从写指标的第一天养成。
小练习
- 把 RSI 周期改成 6 和 24 各跑一遍,对比两条曲线的灵敏程度与触线频率。
思路提示:周期越短,RSI 越敏感、越容易冲到 70/30 之外;周期越长越平滑,但信号也越迟。改函数默认参数
n即可。
- 统计 RSI<30 之后 5 天收盘价上涨的比例,看看"超卖后反弹"这个说法在你的样本里成不成立。
思路提示:用
close.pct_change(5).shift(-5)做"事后观察"统计(它会用到未来数据,只允许用来观察现象,不是策略),再和"随机情况下约一半"作对比。
- 在 RSI 图上再加一条 50 中轴虚线,然后数一数 RSI 位于 50 上方的天数占比,和你对这段行情"偏多头"的直觉一致吗?
思路提示:加一条
ax2.axhline(50, ...),统计(df["rsi_roll"] > 50).mean()。
照例提醒一句:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。