设想一个场景:你买了一只股票,给自己定了"跌 2% 就止损"的规矩。第二天它直接低开 3%,止损单还没来得及触发,亏损已经远超预期;又或者行情没坏,它只是正常晃了两下,就把你晃出了局,随后一路走高。问题出在哪?出在你的止损距离没有跟着"这只股票一天正常晃多远"走——风控的标尺,得用波动来定。
上一篇布林带用收盘价的滚动标准差度量波动,画出了会呼吸的通道。但标准差只反映收盘价相对均线的偏离,看不见"一天之内"的震荡,也看不见跳空缺口。本篇第一个主角 ATR(Average True Range,平均真实波幅)换一个角度,直接度量一根 K 线的真实波幅,它由提出 RSI 的同一位作者 Wilder 在 1978 年一并给出,是后文止损与仓位管理的地基。
不过只知波动、不知方向也不够用。第二个主角 KDJ 是国内行情软件里的常客,它把"今天收在近期价格区间的什么位置"量化成 0~100 的摆动读数,补上方向感的部分。本篇把两个指标都手写出来,并讲清它们一个偏波动、一个偏摆动的分工。
本篇目标
- 能写出真实波幅 TR 的三种情形定义,并用 Pandas 向量化计算 ATR。
- 能手写 KDJ:RSV 的计算、K 与 D 的递推、J 的放大作用,并画出三线。
- 能说清 ATR 与 KDJ 的定位差异:一个无方向的波动标尺,一个有方向的摆动读数。
- 能用 2×ATR 算出一笔交易的参考止损距离,为第 35 篇风控规则做铺垫。
- 能用代码识别并统计 KDJ 金叉、死叉在样本中出现的次数。
核心概念
ATR:一根 K 线的真实波幅
先定义单日的真实波幅(True Range,简称 TR),它取三个候选值里最大的那个:
TR = max(当日最高 − 当日最低, |当日最高 − 昨日收盘|, |当日最低 − 昨日收盘|)
白话解释:第一项是最普通的情形——今天从最高点到最低点晃了多远;后两项专门对付跳空:如果今天直接低开在昨天收盘价下方很远,只算当天高低差就会低估波动,得把"缺口"那一段也补进去,所以用与昨日收盘价的距离来比较。三者取最大,就是今天真实的波动幅度。
把 TR 做 Wilder 平滑平均(和上一篇 RSI 同款的 14 日递推,相当于 alpha=1/14 的 EMA),得到的就是 ATR(14)。注意 ATR 的单位是"元"——它是一个绝对量:10 元的股票 ATR 是 0.3 元,和 100 元的股票 ATR 是 0.3 元,含义天差地别。所以跨标的比较时,习惯除以收盘价换算成百分比:
ATR% = ATR / 收盘价 × 100
白话解释:ATR% 读作"这只股票每天正常晃动约百分之几",它消除了价格水平的影响。
ATR 的第一用途:止损距离的标尺
止损设多远是个高频难题:太近,会被日常波动随机扫掉;太远,单次亏损又太大。ATR 恰好给出了"市场日常呼吸量"的估计。常见做法是把止损放在入场价 ± 2×ATR 处:波动大的标的自动获得更宽的距离,波动小的标的自动更紧凑,同一条规则能自适应不同股票。本篇先做铺垫,第 35 篇会把它展开成完整的止盈止损与风控规则。
划重点:ATR 度量的是"正常的晃动范围",不是涨跌方向的预告。把止损距离放宽到 2×ATR,只是降低被随机波动扫出局的概率,它不会改善策略本身的胜率——两件事别混为一谈。
KDJ:从 RSV 到三条线
KDJ 的内核是随机指标(Stochastic Oscillator,由 George Lane 提出),它只问一个问题:今天的收盘价,收在最近 9 天价格区间的什么位置?先算未成熟随机值(Raw Stochastic Value,简称 RSV):
RSV = (收盘 − 9日最低) / (9日最高 − 9日最低) × 100
白话解释:收盘正好压在 9 天最高点上,RSV=100;压在最低点上,RSV=0;正中间就是 50。RSV 本身很抖,于是做两次平滑递推:
K = 2/3 × 昨日K + 1/3 × RSV
D = 2/3 × 昨日D + 1/3 × K
J = 3 × K − 2 × D
白话解释:K 是 RSV 的平滑版(递推系数 1/3,与 EMA 同族);D 是 K 的再平滑版,所以 K 快、D 慢;J 是 K 与 D 之差的放大器,跑得最快、最敏感,可以冲出 0~100 的范围。习惯上:K 上穿 D 称金叉,K 下穿 D 称死叉;K、D 高于 80 视为超买、低于 20 视为超卖。别忘了上一篇的教训——趋势市里 KDJ 同样会高位或低位钝化,超买卖出不是铁律。
一个偏波动,一个偏摆动
两个指标放在一起,定位差异一目了然:
| 对比维度 | ATR | KDJ |
|---|---|---|
| 回答的问题 | 一天正常晃多远 | 收在近期区间什么位置 |
| 有无方向 | 无方向,纯波动 | 有方向,摆动读数 |
| 数值形态 | 绝对值(元),常换算成百分比 | 0~100(J 可越界) |
| 典型用途 | 止损距离、仓位缩放、波动过滤 | 超买超卖、金叉死叉择时 |
划重点:记住分工——风险控制的事找 ATR(止损放多远、仓位给多大),择时信号的事找 KDJ、RSI 这类摆动指标。拿 ATR 猜涨跌方向是缘木求鱼,拿 KDJ 定止损距离是张冠李戴。
完整代码
依赖安装:uv pip install akshare pandas matplotlib。代码分五段,按顺序拼接即为完整脚本;数据源不可用时自动降级为本地造出的 OHLC 模拟行情。
import numpy as np # 数值计算
import pandas as pd # 表格与时间序列
import matplotlib.pyplot as plt # 画图
def get_data() -> pd.DataFrame:
"""优先拉真实行情;不可用时降级为本地模拟 OHLC,保证代码永远能跑。"""
try:
import akshare as ak
df = ak.stock_zh_a_hist(symbol="000333", period="daily",
start_date="20200101", end_date="20241231",
adjust="qfq") # 前复权日线
df = df.rename(columns={"日期": "date", "最高": "high",
"最低": "low", "收盘": "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(2024) # 固定种子可复现
n = 1215 # 约 5 年交易日
ret = rng.normal(0.0002, 0.018, n) # 日收益:微漂移+波动
close = 100 * np.exp(np.cumsum(ret)) # 收盘价路径
vol = 0.010 + 0.008 * np.abs(np.sin(np.arange(n) / 50)) # 波动率周期起伏
up = np.abs(rng.normal(0, 1, n)) * vol # 当日向上晃动幅度
dn = np.abs(rng.normal(0, 1, n)) * vol # 当日向下晃动幅度
df = pd.DataFrame({
"date": pd.bdate_range("2020-01-01", periods=n),
"high": (close * (1 + up)).round(2), # 当日最高
"low": (close * (1 - dn)).round(2), # 当日最低
"close": close.round(2)})
return df.reset_index(drop=True)
df = get_data()
数据就绪。下面先写 ATR:TR 三项取大,再用 ewm 一行完成 Wilder 平滑。
def atr(df: pd.DataFrame, n: int = 14) -> pd.DataFrame:
"""ATR:TR 三项取最大,再做 Wilder 平滑;顺带输出百分比版。"""
prev = df["close"].shift(1) # 昨日收盘
tr = pd.concat([df["high"] - df["low"], # 情形一:当日高低差
(df["high"] - prev).abs(), # 情形二:最高与昨收的距离
(df["low"] - prev).abs()], # 情形三:最低与昨收的距离
axis=1).max(axis=1) # 三项取最大 = 真实波幅
# alpha=1/n 的 ewm 正是 Wilder 递推(RSI 篇手写循环的等价一行版)
df["tr"] = tr
df["atr"] = tr.ewm(alpha=1 / n, adjust=False).mean()
df["atr_pct"] = df["atr"] / df["close"] * 100 # 换算成价格的百分比
return df
df = atr(df)
print(df[["high", "low", "close", "atr", "atr_pct"]].tail(3).round(2))
ATR 完成。接着写 KDJ:RSV 用 rolling 区间,K、D 的递推同样可以用 ewm 一行等价实现。
def kdj(df: pd.DataFrame, n: int = 9) -> pd.DataFrame:
"""KDJ:RSV 定位,K/D 两次平滑递推,J 为差值放大器。"""
low_n = df["low"].rolling(n).min() # 近 9 日最低
high_n = df["high"].rolling(n).max() # 近 9 日最高
rsv = (df["close"] - low_n) / (high_n - low_n) * 100
rsv = rsv.mask(high_n == low_n, 50.0) # 一字板/全横盘:区间为 0,按惯例记 50
# alpha=1/3 的 ewm 等价于 K = 2/3*昨K + 1/3*RSV 的递推
k = rsv.ewm(alpha=1 / 3, adjust=False).mean()
d = k.ewm(alpha=1 / 3, adjust=False).mean() # D 再对 K 平滑一次
df["rsv"], df["K"], df["D"] = rsv, k, d
df["J"] = 3 * df["K"] - 2 * df["D"] # J:K、D 差值的放大器
return df
df = kdj(df)
gold = (df["K"] > df["D"]) & (df["K"].shift(1) <= df["D"].shift(1)) # 金叉:K 上穿 D
dead = (df["K"] < df["D"]) & (df["K"].shift(1) >= df["D"].shift(1)) # 死叉:K 下穿 D
print(f"金叉 {int(gold.sum())} 次,死叉 {int(dead.sum())} 次")
print(df[["close", "K", "D", "J"]].tail(3).round(2))
两个指标都有了。然后做本篇的压轴演示:用 2×ATR 计算参考止损距离。
entry = float(df["close"].iloc[-1]) # 以最后一日收盘当"假设入场价"
atr_last = float(df["atr"].iloc[-1])
stop = entry - 2 * atr_last # 多头参考止损:入场价下方 2 倍 ATR
print(f"收盘价 {entry:.2f},ATR(14) = {atr_last:.2f}(占价 {atr_last / entry:.1%})")
print(f"多头止损参考:{entry:.2f} − 2×{atr_last:.2f} = {stop:.2f}"
f"(距入场价 {(stop / entry - 1):.1%})")
最后画三窗格图:价格、ATR%、KDJ 三线各一层。
fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(12, 9), sharex=True,
gridspec_kw={"height_ratios": [2, 1, 1.2]})
ax1.plot(df["date"], df["close"], lw=1, color="black") # 上:收盘价
ax1.set_title("Close")
ax2.plot(df["date"], df["atr_pct"], lw=1, color="orange") # 中:ATR 占价比
ax2.set_title("ATR(14) % of price")
ax3.plot(df["date"], df["K"], lw=1, label="K") # 下:K 线
ax3.plot(df["date"], df["D"], lw=1, label="D") # D 线
ax3.plot(df["date"], df["J"], lw=0.8, alpha=0.6, label="J") # J 线最敏感
ax3.axhline(80, color="red", ls="--", lw=0.8) # 超买参考线
ax3.axhline(20, color="green", ls="--", lw=0.8) # 超卖参考线
ax3.set_ylim(-20, 120) # 给 J 的越界留空间
ax3.legend(loc="upper left", ncol=3)
plt.tight_layout()
plt.savefig("atr_kdj_demo.png", dpi=120) # 存图便于查看
print("图已保存为 atr_kdj_demo.png")
代码逐段解释
- 数据获取与降级。优先用 akshare 拉美的集团 2020-01-01 至 2024-12-31 的前复权日线(系列统一示例区间),取出本篇要用的最高、最低、收盘三列;不可用时用固定种子造 1215 个交易日的模拟 OHLC:先造收盘价路径,再按一个周期起伏的波动率给每天加上下影线,于是模拟数据天然有"波动时大时小"的聚群现象,正好让 ATR 显出用处。示例输出(示意,实际行数随数据源变化):
已从 akshare 拉取真实行情 1215 行
- ATR。
shift(1)拿昨日收盘,三个候选波幅用pd.concat(...).max(axis=1)逐行取最大——首行没有昨收,TR 退化为当日高低差,属于教学简化。ewm(alpha=1/14, adjust=False)与 RSI 篇手写的"旧平均×13/14+今天÷14"递推完全等价,本篇顺带教你这种一行写法。atr_pct把绝对波幅换算成价格的百分比。示例输出(示意,数值随数据不同而变化):
high low close atr atr_pct
1212 74.15 72.80 73.42 1.58 2.15
1213 74.60 73.10 74.05 1.55 2.09
1214 74.95 73.55 74.38 1.52 2.04
- KDJ 与金叉死叉。
rolling(9)取区间最高最低算 RSV;K、D 两次ewm(alpha=1/3, adjust=False)等价于教科书递推(首次有效值从第 9 行开始)。金叉死叉用"今日 K、D 大小关系与昨日相反"来判定,是布尔序列的常规写法。示例输出(示意):
金叉 57 次,死叉 61 次
close K D J
1212 73.42 71.35 66.20 81.65
1213 74.05 73.58 68.66 83.42
1214 74.38 72.90 70.07 78.56
五年出现约五六十次金叉死叉,平均每周不到一次——频率不算低,所以信号质量必须靠后面的回测篇去验证,而不是肉眼感觉。
- ATR 止损演示。取最后一日收盘当假设入场价,止损放在其下方 2 倍 ATR。示例输出(示意):
收盘价 74.38,ATR(14) = 1.52(占价 2.0%)
多头止损参考:74.38 − 2×1.52 = 71.34(距入场价 -4.1%)
这只"日波动约 2%"的标的,2×ATR 的止损自然落在 4% 开外——距离是让波动自己告诉你的,不是拍脑袋定的。第 35 篇会把这里的 2 换成可回测的参数。
- 画图。三窗格从上到下是收盘价、ATR%、KDJ 三线。对照看:ATR% 的峰对应剧烈行情段,谷对应平静整理段;KDJ 在震荡段来回穿梭金叉死叉频繁,趋势段则会出现 K 压在 80 上方的高位钝化。示例输出(示意):
图已保存为 atr_kdj_demo.png
常见坑与小练习
常见坑
- TR 漏了跳空项。现象:只算"最高−最低",遇到大幅低开的日子 ATR 明显偏低。原因:跳空缺口那一段波动没被计入。避免:TR 必须三项取最大,把与昨日收盘的距离纳入比较。
- 跨标的直接比 ATR 绝对值。现象:得出"高价股波动更大"的错误结论。原因:ATR 单位是元,天然随价格水平放大。避免:比较波动一律用 ATR%。
- KDJ 的除零。现象:连续一字板或极端横盘时 RSV 出现 inf 或 NaN。原因:9 日最高等于 9 日最低,分母为 0。避免:用
mask把这种情况的 RSV 按惯例记 50。 - 忘了 KDJ 也会钝化。现象:趋势里 K 死死压在 80 上方,"超卖"信号接二连三失灵。原因:摆动指标在单边行情里的通病,与 RSI 篇同一个道理。避免:超买超卖只在震荡市当主证据,趋势市结合趋势指标或加过滤。
- 把金叉死叉当独立铁律。现象:按金叉买、死叉卖,结果在震荡市反复止损。原因:每周都可能出现信号,噪声占比很高。避免:教学阶段只要求会算会认;信号是否真有效,留给回测篇用数据说话。
小练习
- 把 ATR 周期改成 7 和 28 各跑一遍,对比 ATR% 曲线的灵敏程度。
思路提示:周期越短,ATR 越紧跟最近的波动、抖动越大;周期越长越平滑,但对波动变化的反应越迟。改函数参数
n即可。
- 把 KDJ 参数改成(19, 5, 5)再跑一遍,数一数金叉死叉次数的变化。
思路提示:RSV 的 rolling 窗口改成 19,两处
ewm的 alpha 改成 1/5。参数越钝,信号越少但越稳;统计前后次数对比就能看到取舍。
- 把第 12 篇的 RSI 也算出来,与
atr_pct做相关分析:高 RSI 的日子波动是不是也更大?
思路提示:用
df["rsi"].corr(df["atr_pct"])看相关系数,或者把 RSI 按十分位分组、算每组的平均 ATR% 再比较。
照例提醒一句:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。