1. 这节究竟解决什么实际问题?¶
3.3 波动率类指标 的具体任务是:用 ATR、布林带和波动率状态判断价格变化的尺度。 本节将明确输入数据、计算过程、输出及其在后续研究中的用途。
2. 前置知识¶
理解收益率、滚动窗口和基础 DataFrame 操作。 对公式的理解应包括变量定义、单位、时间索引和适用条件。
3. 数据说明¶
使用固定日频价格样本;高低开收和成交量如为教学代理,会在代码中明确说明。 运行前先检查 shape、日期范围、列名与缺失值。数据看起来能画图,不代表它适合回答研究问题。
4. 学习目标与完成标准¶
完成本节后,你应能用自己的话解释 3.3 波动率类指标 的输入、计算步骤、输出和局限;尤其能说明:用 ATR、布林带和波动率状态判断价格变化的尺度。 你还应能独立改动一个参数并解释结果为什么变化,指出至少一个会让结论失真的假设。
5. 核心概念与公式¶
技术指标将历史价格或成交量压缩为统计量。指标本身不包含交易方向、执行时点和风险约束。
滚动指标只使用窗口结束日及以前的数据;交易仓位必须至少滞后一个可交易周期。 看到公式时依次确认:每个符号的单位是什么?它属于哪个时点?分母会不会为零?它是描述性统计量、预测量,还是交易规则?
6. 从零实现¶
原始代码按照“准备输入 → 计算中间量 → 得到结果 → 解释输出”的顺序组织。每个单元应检查变量类型、索引、维度和代表性数值。中间变量用于核对公式实现和定位计算错误。
7. 成熟库实现¶
公共指标函数减少重复代码;首次学习仍应读懂每个滚动、平滑和标准化步骤。 使用库函数前先阅读参数含义、默认值和返回对象;库输出一个数字,并不说明这个数字适合你的交易假设。
8. 两种实现的差异¶
从零实现便于核对公式、调试和识别隐含假设;成熟库适合在计算逻辑已验证后复用。两种实现依赖相同的数据口径与时点假设,使用库函数不会改变输入数据的质量。
9. 预期结果与检查范围¶
你应看到维度正确的 Series/DataFrame、日期单调递增的索引,以及能支持“用 ATR、布林带和波动率状态判断价格变化的尺度。”这一任务的表格或图形。金融日收益率均值通常接近 0;若出现极大的连续收益、空表、全 NaN 或索引错位,应先排查数据和对齐逻辑,而不是立即相信结果。
10. 常见错误与诊断¶
窗口不足时误读 NaN、同日用收盘信号按收盘成交、忽略除零、把指标阈值当普适规律。
诊断顺序:先打印类型与 shape,再看 head()/tail(),接着检查日期和 NaN,最后才比较数值。这样能避免在错误的对象上反复改参数。
11. 研究偏差检查¶
测试大量窗口后只保留样本内表现最高的参数会产生数据窥探偏差;任何信号都应和简单基线比较。 结果至少应与一个简单基线比较,并明确哪些部分只是教学演示。
12. 三层练习与答案¶
- 基础练习:验证一个中间变量、补全一段小计算,并用一句话解释输出。
- 研究练习:改变窗口、样本区间、约束或标的,记录哪些结论仍成立。
- 开放项目:把本节方法嵌入模块项目,报告基线、失败结果和限制。
题目在 ../exercises/03_indicators/03_volatility_indicators_exercises.ipynb;完整推导、可运行参考实现与结果检查在 ../solutions/03_indicators/03_volatility_indicators_solutions.ipynb。请先独立作答,再打开答案。
13. 延伸阅读¶
延伸材料:Technical Analysis of the Financial Markets(Murphy)。阅读记录应包括研究假设、数据频率、样本范围和结论适用条件。
14. 下一节¶
下一节是 04_universe_selection。本节的概念会成为理解它的输入,而不是孤立的名词。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf
data = yf.download('AAPL', start='2022-01-01', end='2024-01-01', progress=False)
close = data['Close'].squeeze()
high = data['High'].squeeze()
low = data['Low'].squeeze()
# 默认使用固定样本,保证课程与 CI 不依赖实时接口。
USE_OFFLINE_DATA = True
if USE_OFFLINE_DATA:
from pathlib import Path
import sys
docs_root = next(parent for parent in [Path.cwd(), *Path.cwd().parents]
if (parent / 'utils' / 'offline_data.py').exists())
sys.path.insert(0, str(docs_root))
from utils.offline_data import configure_yfinance_offline
configure_yfinance_offline(yf)
else:
print('在线下载为扩展步骤;请核对数据字段、时区和下载日期。')
import matplotlib.pyplot as plt
# 1. 设置系统自带的中文字体(这里使用黑体 SimHei)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 如果你想用微软雅黑,可以改成 ['Microsoft YaHei']
# 2. 解决更换字体后,负号(-)显示为方块的问题
plt.rcParams['axes.unicode_minus'] = False
1. 布林带 (Bollinger Bands)¶
为什么需要它?¶
我们知道均线代表「正常价格水平」。但价格总是在均线上下波动,有时偏离多一点,有时偏离少一点。
布林带的思路是:给均线加上一个「正常波动范围」的边界。
用的是统计学里的「标准差」($\sigma$)——标准差越大,说明价格波动越剧烈,边界越宽;标准差越小,说明价格越稳定,边界越窄。
布林带由移动平均线及其上下若干倍滚动标准差构成:
- 价格碰到上护栏(上轨):偏贵了,超出正常范围,可能会回落
- 价格碰到下护栏(下轨):偏便宜了,可能会回弹
- 护栏越来越窄(带宽收缩):市场进入低波动期,「平静」之后往往是「突破」
公式¶
$$Middle = SMA_{20} \quad \text{(中轨 = 20日均价)}$$ $$Upper = SMA_{20} + 2\sigma_{20} \quad \text{(上轨 = 均价 + 2倍标准差)}$$ $$Lower = SMA_{20} - 2\sigma_{20} \quad \text{(下轨 = 均价 - 2倍标准差)}$$
正态分布下,价格有约 95% 的概率落在上下轨之间(因为 $\pm 2\sigma$ 覆盖 95% 的分布)。所以「突破上下轨」是相对罕见的事件。
window = 20
n_std = 2
mid = close.rolling(window).mean()
std = close.rolling(window).std()
upper = mid + n_std * std
lower = mid - n_std * std
bandwidth = (upper - lower) / mid # 带宽(相对值)= 波动程度的量化
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 8), sharex=True,
gridspec_kw={'height_ratios': [3, 1]})
ax1.plot(close.index, close.values, label='收盘价', linewidth=1.2, color='steelblue')
ax1.plot(mid.index, mid.values, label='中轨 SMA20', linewidth=1.5, color='orange')
ax1.plot(upper.index, upper.values, label='上轨(可能超买)', linewidth=1, color='gray', linestyle='--')
ax1.plot(lower.index, lower.values, label='下轨(可能超卖)', linewidth=1, color='gray', linestyle='--')
ax1.fill_between(close.index, upper.values, lower.values, alpha=0.07, color='gray')
ax1.set_title('AAPL 布林带 (20, 2) — 95% 的时间价格在上下轨之间', fontsize=13)
ax1.legend()
ax1.grid(alpha=0.3)
ax2.plot(bandwidth.index, bandwidth.values, color='purple', linewidth=1.2)
ax2.fill_between(bandwidth.index, bandwidth.values, alpha=0.3, color='purple')
ax2.set_title('布林带宽:滚动波动率的相对变化', fontsize=11)
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()
2. ATR — 真实波动幅度均值 (Average True Range)¶
为什么需要 ATR?¶
ATR 使用真实波幅的滚动均值衡量价格波动范围。
- 平坦的路 → 步伐很稳,每步走 1 米
- 崎岖的山路 → 步伐不稳,每步可能走 3~5 米
ATR 就是量化「路有多崎岖」——价格每天平均波动多少钱。
ATR 的常见用途之一是按近期波动水平设置止损距离。
如果苹果每天的正常波动是 2 美元,你把止损设在入场价下 0.5 美元,那一天之内就可能被正常波动触发止损——这不是你真的判断错了,而是止损设得太近。
经验法则:止损放在 1.5 × ATR 处——超出日常正常波动范围,只有真正走坏了才会触发。
为什么不直接用「最高 - 最低」?¶
有时候股价会跳空——今天最低价比昨天收盘价还高(跳空高开),最高 - 最低就漏掉了这个「跳空」的距离。ATR 用「真实波幅(True Range)」来修正这个问题:
$$TR = \max\underbrace{(H-L)}_{\text{今日振幅}},\ \underbrace{|H-C_{prev}|}_{\text{上影到昨收距离}},\ \underbrace{|L-C_{prev}|}_{\text{下影到昨收距离}}$$ $$ATR = EMA_{14}(TR) \quad \text{(14日平均真实波幅)}$$
def compute_atr(high, low, close, period=14):
prev_close = close.shift(1)
tr = pd.concat([
high - low, # 今日最高 - 最低
(high - prev_close).abs(), # 今日最高 - 昨日收盘(处理跳空高开)
(low - prev_close).abs() # 今日最低 - 昨日收盘(处理跳空低开)
], axis=1).max(axis=1) # 取三者最大值 = 真实波幅
atr = tr.ewm(com=period - 1, min_periods=period).mean()
return atr
atr = compute_atr(high, low, close)
atr_pct = atr / close # ATR 相对于价格的百分比(方便跨股票比较)
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 7), sharex=True)
ax1.plot(close.index, close.values, linewidth=1.2, color='steelblue', label='收盘价')
stop_loss = close - 1.5 * atr # 止损线 = 收盘价 - 1.5倍ATR
ax1.plot(stop_loss.index, stop_loss.values, linewidth=1, color='red',
linestyle='--', label='1.5×ATR 止损线(每日更新)')
ax1.set_title('ATR 止损线 — 根据市场波动动态调整止损距离', fontsize=13)
ax1.legend()
ax1.grid(alpha=0.3)
ax2.plot(atr_pct.index, atr_pct.values * 100, color='darkorange', linewidth=1.2)
ax2.set_title('ATR%(日均波动占价格的百分比)— 越高说明市场越动荡', fontsize=11)
ax2.set_ylabel('%')
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()
print(f'当前 ATR: {atr.iloc[-1]:.2f} USD(每天平均波动这么多)')
print(f'建议止损距离(1.5×ATR): {1.5 * atr.iloc[-1]:.2f} USD')
当前 ATR: 2.55 USD(每天平均波动这么多) 建议止损距离(1.5×ATR): 3.82 USD
3. VIX — 市场恐慌指数¶
为什么需要 VIX?¶
ATR 和布林带衡量的是某只股票的波动,而 VIX 衡量的是整个市场的情绪。
VIX 是什么? 它是芝加哥期权交易所(CBOE)基于标普 500 指数期权计算出的「隐含波动率」。简单说,就是市场参与者愿意花多少钱来买保险——
- 期权隐含波动率上升 → 期权价格中的波动率溢价上升 → VIX 通常上升
- 期权隐含波动率下降 → VIX 通常下降
关键规律:
- VIX 与市场通常呈负相关:市场大跌时 VIX 暴涨,市场平稳时 VIX 低迷
- VIX < 15 表示隐含波动率处于较低水平;具体分位取决于样本区间,不能单独判断市场顶部
- VIX > 30 表示隐含波动率处于较高水平;具体分位取决于样本区间,不能单独作为做多信号
- VIX = 20 是心理分界线:超过 20 就要开始警惕
「贪婪时恐惧,恐惧时贪婪」—— VIX 就是量化「恐惧」的温度计。
vix = yf.download('^VIX', start='2020-01-01', end='2024-01-01', progress=False)['Close'].squeeze()
spy = yf.download('SPY', start='2020-01-01', end='2024-01-01', progress=False)['Close'].squeeze()
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(13, 7), sharex=True)
ax1.plot(spy.index, spy.values, color='steelblue', linewidth=1.2)
ax1.set_title('SPY (S&P500 ETF) — 注意市场大跌时 VIX 如何变化', fontsize=12)
ax1.set_ylabel('价格')
ax1.grid(alpha=0.3)
ax2.plot(vix.index, vix.values, color='red', linewidth=1.2)
ax2.axhline(15, color='green', linestyle='--', alpha=0.6, label='VIX=15(示例低阈值)')
ax2.axhline(20, color='orange', linestyle='--', alpha=0.6, label='VIX=20(警戒线)')
ax2.axhline(30, color='red', linestyle='--', alpha=0.6, label='VIX=30(示例高阈值)')
ax2.fill_between(vix.index, vix.values, 20, where=(vix.values > 20),
alpha=0.2, color='red', label='恐慌区域')
ax2.set_title('VIX 恐慌指数 — 市场越害怕,VIX 越高', fontsize=12)
ax2.set_ylabel('VIX')
ax2.legend()
ax2.grid(alpha=0.3)
plt.tight_layout()
plt.show()
print(' 观察:2020年3月(新冠)VIX 曾突破80,历史最高;2022年也有多次超过30')
观察:2020年3月(新冠)VIX 曾突破80,历史最高;2022年也有多次超过30
练习¶
- 布林带带宽收窄到历史低点后,价格通常会如何运动?找 2 个历史例子验证(提示:观察带宽的局部最小值之后价格的走势)。
- ATR 仓位管理:假设你的账户是 100,000 元,每笔最大亏损是总资金的 1%(即 1000 元)。如果 ATR = 2 美元,止损设 1.5×ATR,你能买多少股?(答:1000 / (1.5×2) = 333 股)
- 实现 ATR 追踪止损(Trailing Stop):止损线随价格上涨而上移,但不随价格下跌而下移。
下一模块 → ../04_backtesting/01_simple_backtest.ipynb