上一篇的 RSI 是把每天的涨跌拆成"涨的力量"和"跌的力量"来做对比,本质上还是在数涨跌。但你可能早就发现:同样是涨 2%,有的股票经常这么晃,有的股票一年才有一次——光看涨跌方向,丢掉了"波动有多剧烈"这个信息。
本篇要讲的布林带(Bollinger Bands,由 John Bollinger 在 1980 年代提出)换了一个思路:它不从涨跌出发,而从波动率出发,直接在均线两侧画一条随波动伸缩的通道。市场安静时通道收窄,市场暴躁时通道张开,价格就在这条会呼吸的通道里运动。它是技术分析里使用率最高的通道类指标,没有之一。
本篇我们把布林带的三条轨道画出来,理解带宽与 %b 这两个衍生指标,学会识别收口与开口,并且弄清一个新手最容易吃亏的问题:为什么价格碰到轨道,不等于要反转。
本篇目标
- 能用 Pandas 一步算出布林带的中轨、上轨、下轨,并解释滚动标准差在其中的作用。
- 能计算带宽(Bandwidth)与 %b(Percent B)两个衍生指标,并说清它们分别回答什么问题。
- 能用带宽识别收口(squeeze)与开口区间,知道两者分别意味着什么、不意味着什么。
- 能画出"价格 + 三轨 + 带宽"的双窗格图。
- 能用数据反驳"触轨即反转"的直觉,理解趋势中贴轨行走的现象。
核心概念
通道是怎么画出来的
布林带由三条线组成,默认参数是(20, 2):
- 中轨:20 日简单移动平均线 SMA20,代表"近一个月的平均成本中枢"。
- 上轨:中轨 + 2 × 过去 20 日收盘价的滚动标准差。
- 下轨:中轨 − 2 × 过去 20 日收盘价的滚动标准差。
滚动标准差(rolling standard deviation)衡量最近 20 天价格偏离自身均线的剧烈程度,是波动率最朴素的度量。白话解释:行情越动荡,标准差越大,上下轨被撑得越开;行情越平静,上下轨越向中轨收拢。所以布林带不是一条固定宽度的带子,而是一条随波动"呼吸"的弹性通道。
带宽与 %b:两个衍生指标
光看三条线的图,宽度变化不够直观,于是有两个常用的衍生量:
带宽 = (上轨 − 下轨) / 中轨
%b = (收盘价 − 下轨) / (上轨 − 下轨)
白话解释:带宽回答"通道现在有多宽"——除以中轨是为了消除价格水平的影响,否则高价股的带宽天然比低价股宽,没法跨标的比较;带宽越小,说明波动越收缩。%b 回答"价格站在通道的什么位置"——%b=1 表示收盘正好压在上轨上,%b=0 表示压在下轨上,%b=0.5 是正中间(中轨附近),大于 1 说明收盘已经冲出上轨。它相当于把价格位置标准化到 0~1 的刻度上。
收口与开口:波动有轮回
带宽持续收缩、通道越夹越紧的现象叫收口(squeeze);反过来带宽快速扩张叫开口。经验观察是:波动往往高低相间,长时间的窄幅整理之后常常跟着一波大波动,而剧烈的单边行情走到尾声,带宽也常常在高位逐步收敛。
划重点:收口只告诉你"弹簧正在被压缩,后面波动可能变大",它不指明方向。往上突破还是往下破位,收口本身给不出答案,必须结合突破方向、量能等其他证据。把收口当成"猜方向"的工具,是新手最常见的误用。
触轨不等于反转
布林带的数学目标是覆盖大约 95% 的近期价格(正态假设下均值 ±2 个标准差的经典结论,现实价格分布比正态更胖尾,实际覆盖率会低一些)。这意味着大约每二十天就有一两天收盘价落在轨道附近或之外——触轨本来就是"常态事件",不是稀罕信号。
更关键的是趋势行情里的贴轨行走(walking the band):强劲上攻时,价格可以连续多天贴着上轨甚至小幅越轨往上爬,下轨与中轨"托"着价格走。这时每一次触上轨都是强势的表现,而不是卖出信号;机械地"触上轨就卖",会在趋势里被一路甩下车。
划重点:触轨的正确读法是"波动与位置的描述",而不是"反转的判定"。趋势市里触轨常常意味着延续,震荡市里触轨才更接近回归的机会——这个分场景判断的思路,和上一篇 RSI 钝化是同一个道理。
完整代码
依赖安装:uv pip install akshare pandas matplotlib。代码分五段,按顺序拼接即为完整脚本;数据源不可用时自动降级为本地造数据,且特意造了"先震荡、后趋势"两段行情,方便你观察收口与贴轨。
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="600036", 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(7) # 固定种子可复现
n = 1215 # 约 5 年交易日
calm = 12 + 0.6 * np.sin(np.arange(n) / 18) \
+ rng.normal(0, 0.08, n) # 前 600 天:窄幅震荡
trend = 12 + np.arange(n) * 0.012 \
+ rng.normal(0, 0.18, n) # 全程缓慢上行的底稿
close = np.concatenate([calm[:600], trend[600:]]) # 后段切换为趋势
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 bollinger(close: pd.Series, n: int = 20, k: float = 2.0) -> pd.DataFrame:
"""布林带三轨 + 带宽 + %b,全部用 rolling,不含任何未来数据。"""
mid = close.rolling(n).mean() # 中轨:SMA20
std = close.rolling(n).std() # 滚动标准差:波动率
upper = mid + k * std # 上轨 = 中轨 + 2 倍标准差
lower = mid - k * std # 下轨 = 中轨 - 2 倍标准差
bandwidth = (upper - lower) / mid # 带宽:通道相对宽度
percent_b = (close - lower) / (upper - lower) # %b:通道内位置 0~1
return pd.DataFrame({"mid": mid, "upper": upper, "lower": lower,
"bandwidth": bandwidth, "percent_b": percent_b})
bb = bollinger(df["close"])
df = pd.concat([df, bb], axis=1) # 指标列并回主表
print(df[["close", "mid", "upper", "lower"]].tail(3).round(2))
指标算好了。接着统计触轨频率和收口区间,用数字验证前面"触轨是常态"的说法。
touch_up = int((df["close"] >= df["upper"]).sum()) # 收盘触及/突破上轨天数
touch_dn = int((df["close"] <= df["lower"]).sum())
print(f"收盘触及上轨 {touch_up} 天,触及下轨 {touch_dn} 天")
# 收口:带宽落入自身近一年(250 日)最低 10% 分位
low_bw = df["bandwidth"] <= df["bandwidth"].rolling(250, min_periods=60).quantile(0.1)
squeezes = int(low_bw.astype(int).diff().eq(1).sum()) # 收口区间起点个数
print(f"带宽进入近一年最低 10% 的收口区间数:{squeezes}")
再看 %b 的分布和"贴轨行走"有多少天;随后用一个小验证回应本篇的主题——触轨之后到底发生了什么。
pb = df["percent_b"].dropna()
print(f"%b 描述统计:均值 {pb.mean():.2f},最大 {pb.max():.2f},最小 {pb.min():.2f}")
walk = (df["percent_b"] > 0.95).rolling(5).sum().eq(5) # 连续 5 天贴上轨
print(f"连续 5 天收盘贴着上轨(%b>0.95)的天数:{int(walk.sum())}")
接着做本篇主题的小验证:把"触轨之后"的价格表现单独统计出来(shift(-5) 会用到未来数据,只允许用于事后观察现象,绝不许进策略)。
# 教学演示:触轨之后 5 天价格平均表现;shift(-5) 含未来数据,仅作事后观察
after5 = df["close"].pct_change(5).shift(-5) # 未来 5 日涨幅
mean_up = after5[df["close"] >= df["upper"]].mean() # 触上轨样本的平均
mean_dn = after5[df["close"] <= df["lower"]].mean() # 触下轨样本的平均
mean_all = after5.mean() # 全体交易日作对照
print(f"触上轨后5日平均涨幅 {mean_up:.2%},触下轨后 {mean_dn:.2%},全体 {mean_all:.2%}")
统计做完,最后画一张总览图。
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, color="black", label="close")
ax1.plot(df["date"], df["mid"], lw=1, label="SMA20") # 中轨
ax1.plot(df["date"], df["upper"], lw=0.8, ls="--",
color="red", label="upper") # 上轨
ax1.plot(df["date"], df["lower"], lw=0.8, ls="--",
color="green", label="lower") # 下轨
ax1.fill_between(df["date"], df["lower"], df["upper"],
alpha=0.08, color="gray") # 通道着色
ax1.legend(loc="upper left")
ax1.set_title("Bollinger Bands (20, 2)")
ax2.plot(df["date"], df["bandwidth"], lw=1, color="purple") # 下图:带宽
ax2.set_title("Bandwidth")
plt.tight_layout()
plt.savefig("bollinger_demo.png", dpi=120) # 存图便于查看
print("图已保存为 bollinger_demo.png")
代码逐段解释
- 数据获取与降级。优先用 akshare 招商银行 2020-01-01 至 2024-12-31 的前复权日线(系列统一示例区间);不可用时用固定种子造"前 600 天窄幅震荡、之后缓慢上行"的两段行情——前半段带宽窄、触轨后很快回归,后半段带宽张开、价格贴上轨走,正好对应本篇的两个主题。示例输出(示意,实际行数随数据源变化):
已从 akshare 拉取真实行情 1215 行
- 布林带主体。
rolling(20).mean()得中轨,rolling(20).std()得滚动标准差(Pandas 默认用样本标准差 ddof=1),上下轨各加减 2 倍标准差。带宽和 %b 都是逐行相除得来的派生列。因为都是 rolling 计算,前 19 行是预热期、值为 NaN,属于正常现象。示例输出(示意,数值随数据不同而变化):
close mid upper lower
1212 43.12 41.87 44.63 39.11
1213 43.55 42.03 44.91 39.15
1214 43.20 42.20 45.02 39.38
- 触轨统计。布尔序列求和就是天数。示例输出(示意):
收盘触及上轨 83 天,触及下轨 96 天
五年约 1215 个交易日里有一百七八十天的收盘价落在轨道上或轨道外,接近 15%——远高于"2 倍标准差只有 5% 概率"的正态直觉,这就是胖尾分布 + 波动聚群的现实。
- 收口识别与 %b 统计。带宽与其 250 日滚动 10% 分位比较,再用差分数出进入收口的区间数;
%b>0.95连续 5 天就视为贴轨行走。示例输出(示意):
带宽进入近一年最低 10% 的收口区间数:6
%b 描述统计:均值 0.51,最大 1.24,最小 -0.19
连续 5 天收盘贴着上轨(%b>0.95)的天数:14
%b 的均值接近 0.5,说明价格长期看是在通道里"居中"生活的;但最大值超过 1、最小值为负,说明越轨也确实发生过。
- 触轨后表现验证。用
shift(-5)取"之后 5 日"的涨幅,分别对触上轨、触下轨和全体交易日求平均。示例输出(示意,数值随数据不同而变化):
触上轨后5日平均涨幅 0.9%,触下轨后 1.1%,全体 0.4%
关键在于:触上轨之后的平均表现并不是负数——趋势段的贴轨上涨把这些样本的平均拉高了。这就是"触轨即反转"在数据面前站不住脚的直接证据,也与上一节贴轨行走的计数相互印证。
- 画图。上图价格与三轨、通道淡色填充;下图单独画带宽,收口看谷底、开口看山峰。对照看图:震荡段带宽趴在低位,趋势启动时带宽猛然张开——"收口在开口前"一眼可见。示例输出(示意):
图已保存为 bollinger_demo.png
常见坑与小练习
常见坑
- 用全样本标准差画历史通道。现象:回测里布林带特别"准",实盘完全不是一回事。原因:对整段数据算一个标准差,等于用未来信息画过去的通道。避免:必须逐日 rolling,每一天的通道只能用当天之前的数据。
- 触上轨就卖、触下轨就买。现象:趋势行情里反复被打脸。原因:把"触轨"当成"反转",忽略了贴轨行走。避免:先判断市场状态——震荡市里触轨才有"回归"的参考意义,趋势市里触轨更多是强势信号。
- 把收口当方向信号。现象:见到收口就押注突破方向,胜率不稳定。原因:收口只预告波动将放大,方向未知。避免:等突破方向确认后再行动,收口只用于"提高警惕"。
- 带宽没除以中轨。现象:高价股的带宽看起来总比低价股宽,跨标的比较失真。原因:用了通道绝对宽度。避免:统一用 (上轨−下轨)/中轨 的相对带宽。
- 标准差口径没对齐。现象:自己的轨道和某软件画的宽窄不一致。原因:Pandas 默认样本标准差(ddof=1),部分软件用总体标准差(ddof=0),通道会略有宽窄差。避免:知道口径差异即可,趋势结论通常不受这点差别影响,但精确对数时必须统一。
小练习
- 把参数改成(20, 1)和(20, 3)各跑一遍,观察通道宽度与触轨频率怎么变。
思路提示:k 越小通道越窄、触轨越频繁,信号噪声也越多;k=3 触轨变得罕见,但一旦出现,往往是极端行情。改函数参数
k即可。
- 统计收盘 %b 大于 1 之后 5 天的涨跌分布,看看"越轨后回落"在样本里到底多常发生。
思路提示:用
close.pct_change(5).shift(-5)做事后观察统计(含未来数据,只许观察现象,不许进策略),分组对比 %b>1 与其他交易日。
- 找出带宽最低的那 10 天,往后看 20 天的滚动标准差,验证"收口之后波动放大"在你的样本里是否成立。
思路提示:
df["bandwidth"].nsmallest(10)拿到日期,再对比这些日期之后 20 日标准差与全样本均值。
照例提醒一句:本文策略仅用于教学,不构成投资建议,实盘前请充分测试。