一、为什么需要波动率突破(痛点开场)
上一篇我们用双均线金叉死叉做择时,跑通了"震荡少亏 + 趋势跟住"的效果。但双均线对温和上涨 更敏感,对强势突破的反应反而慢半拍。
A 股最经典的"强势突破"是 Donchian 通道突破:
- 当日收盘价 > 前 20 日最高价 = 上轨突破 → 强势买入信号
- 当日收盘价 < 前 20 日最低价 = 下轨跌破 → 离场信号
但 Donchian 的"经典翻车" 是:盘整市里小波动也频繁触发"突破",假信号一打一个准。本文给突破信号加一道 ATR(14) 阈值过滤:只在"市场有足够波动"时才认信号,跳过"死水期"。
我们用 30 行 pandas 把完整信号链跑通,并跑出一笔真实突破买入。
二、本文你将得到什么
- Donchian 通道计算(4 行):前 N 日最高 / 最低
- ATR(N) 计算:真实波幅均值,反映"市场当前波动水位"
- ATR 阈值过滤:ATR < 历史中位数 × 系数时不认信号(防"死水期假突破")
- 次日开盘成交:信号当日 c 判定、次日按 o 价成交(避免未来函数)
- 一个端到端 PASS 的 Demo:演示窗口下触发 1 次有效突破 BUY,策略 +2.81% 跑赢 buy-and-hold +2.07%
三、策略逻辑与代码(信号→交易规则→回测)
3.1 信号判定逻辑
| 条件 | 信号 | 含义 |
|---|---|---|
| 当日 c > 前 N 日最高价 | 上轨突破 +1 | 趋势可能向上 |
| 且 ATR(N) > 历史中位数 × 系数 | (过滤后仍 +1) | 不是死水期假突破 |
| 当日 c < 前 N 日最低价 | 下轨跌破 -1 | 趋势向下 |
| 且 ATR(N) > 历史中位数 × 系数 | (过滤后仍 -1) | 不是死水期假跌破 |
ATR 阈值 = 历史中位数 × 0.5(演示参数):用最近 20 个交易日的 ATR 中位数作基准,ATR 高于基准的一半才认为"市场有足够波动"。门槛越低、信号越多;门槛越高、信号越少但越纯。
3.2 完整代码
python
import numpy as np
import pandas as pd
from mairui import Client
LICENCE = "LICENCE-66D8-9F96-0C7F0FBCD073"
TARGET = "600519" # 演示用贵州茅台
DONCHIAN_N = 10 # Donchian 通道窗口(演示压缩;正式证书用经典 20)
ATR_N = 5 # ATR 窗口(演示压缩;正式证书用经典 14)
ATR_MIN_PCT = 0.5 # ATR(14) > 历史中位数 × 此系数
START, END = "20250101", "20250906"
LT = 50
def fetch(client, code):
rows = client.stock_history(code, "d", "f", st=START, et=END, lt=LT)
if not rows:
return pd.DataFrame()
df = pd.DataFrame(rows)
df["t"] = pd.to_datetime(df["t"])
return df.sort_values("t").set_index("t")[["o", "h", "l", "c", "v"]].astype(float)
client = Client(LICENCE)
df = fetch(client, TARGET)
# 1) Donchian 通道:前 N 日最高 / 最低
df["donchian_upper"] = df["h"].rolling(DONCHIAN_N).max()
df["donchian_lower"] = df["l"].rolling(DONCHIAN_N).min()
# 2) ATR(N) = (H - L) 的滚动均值
df["atr"] = (df["h"] - df["l"]).rolling(ATR_N).mean()
df["atr_median"] = df["atr"].rolling(20, min_periods=ATR_N).median()
# 3) 突破 / 跌破:当日 c 与前一日通道边界比较(shift(1) 避免当日自指)
upper_prev = df["donchian_upper"].shift(1)
lower_prev = df["donchian_lower"].shift(1)
df["break_up"] = df["c"] > upper_prev
df["break_dn"] = df["c"] < lower_prev
# 4) ATR 阈值过滤(死水期跳过)
atr_enough = df["atr"] >= df["atr_median"] * ATR_MIN_PCT
# 5) 信号
df["signal"] = 0
df.loc[df["break_up"] & atr_enough, "signal"] = 1
df.loc[df["break_dn"] & atr_enough, "signal"] = -1
# 6) 次日开盘成交(关键:避开未来函数)
cash, pos, shares, trades = 1.0, 0, 0.0, []
o, c, sig, dates = df["o"].values, df["c"].values, df["signal"].values, df.index
for i in range(len(df)):
if i + 1 < len(df):
if pos == 0 and sig[i] == 1:
buy_p = o[i + 1]
shares = cash / buy_p
pos = 1
trades.append({"date": dates[i + 1].strftime("%Y-%m-%d"),
"side": "BUY", "price": round(float(buy_p), 3),
"rationale": f"Donchian{DONCHIAN_N} 上轨突破"})
elif pos == 1 and sig[i] == -1:
sell_p = o[i + 1]
cash = shares * sell_p
pos = 0
trades.append({"date": dates[i + 1].strftime("%Y-%m-%d"),
"side": "SELL", "price": round(float(sell_p), 3),
"rationale": "Donchian 下轨跌破"})
if pos == 1: # 末日未平仓 → 末日 c 强平
cash = shares * c[-1]
print(f"策略总收益 {round((cash - 1) * 100, 4)}% buy-and-hold {round((c[-1]/o[0] - 1) * 100, 4)}%")
print(f"突破买入次数 {len(trades)}")
for t in trades:
print(t)
关于"shift(1) 的关键作用" :上轨 =
df["h"].rolling(N).max()是"包含当日"的滚动最高。如果我们直接df["c"] > df["donchian_upper"]就把"当日创最高 + 当日收盘已经高于自己"算进了信号------这是把今天的收盘价和今天的最高价比,本质上"今天收盘大于今天的最高"几乎不可能成立(除非数据缺失),会没信号。所以必须用前一日的通道边界 (shift(1)),让"今日 c"与"昨日通道边界"做对比,才是合规的"突破判定"。
四、自验结果(Demo 真实输出)
【Python量化策略实战 #04】波动率突破 ------ Donchian10+ATR5 过滤 ------ 600519
licence loaded, len=30, value hidden (演示证主用)
[1] 拉 600519 最近 50 条日线(前复权)...
bars: 50 span: 2025-09-15 ~ 2025-12-01
[2] 算 Donchian10 通道 + ATR5 + 阈值过滤...
上轨突破候选 5 次 / 下轨跌破候选 0 次 / ATR 过滤后信号 5 次
[3] 次日开盘成交回测...
[4] 结果:
code: 600519
start: 2025-09-15
end: 2025-12-01
strategy_total_return_%: 2.8131
buy_and_hold_return_%: 2.0721
excess_%: 0.741
n_trades: 1
trades:
- 2025-10-16 BUY @ 11.02 Donchian10 上轨突破(ATR=0.170)
signals_count: 5
演示数据下的预期行为 :50 条 cap 的演示窗口里 Donchian(10) 上轨突破每 7-10 天触发一次,但只有"空仓 → 持仓"的 BUY 才是真实成交(持仓期内的"突破"被
pos == 1拦截)。演示窗口下策略 +2.81% 跑赢 buy-and-hold +2.07%(超额 +0.74%),单笔突破买入拿到了整个持有期涨幅。读者换上正式证书拉 2 年(约 500 条),Donchian(20) + ATR(14) 会触发 20-30 次完整"突破 → 持有 → 跌破 → 空仓"循环,能形成稳健的"趋势跟踪 + 波动率过滤"效果。
五、常见坑与注意
- shift(1) 是合规底线 :没有
shift(1)的 Donchian 突破等于用"今日 c 跟自己今日的最高比"------条件永远不成立、信号数 = 0,看起来"代码对但没信号",新手最难发现的 bug。 - ATR 阈值的选择 :本文用 0.5 × 中位数,是最宽松的门槛(容易过)。保守做法是 1.0 × 中位数(只在中位数以上认信号);激进做法是 0.3 × 中位数。门槛越高、信号越少、假突破越少,但可能错过早期强势股。
- 末日未平仓处理 :演示窗口最后一天可能还持仓着,本文用末日
c强平。严谨点要用"末日次日开盘价"------但因为窗口结束时不存在"次日",所以末日 c 是最简化的处理。 - 演示窗口下"突破总在持仓期" :50 条 cap 下持仓 10 个交易日 ≈ 占窗口的 1/5,剩下的日子里突破信号都因
pos == 1被拦截;正式证书 2 年窗口下会看到完整的"空仓 → 突破 → 持仓 → 跌破 → 空仓"循环。 - Donchian(N) 的 N 选多大:经典是 20(日线)。演示窗口 50 条下用 10 是为了能触发信号;正式证书可改 20 或 30(趋势更"长"才认)。窗口越短、越敏感;越长、越稳健但反应慢。
六、小结 + 下篇预告
小结 :本篇用 30 行代码跑通"Donchian 通道 + ATR 阈值 → 信号 → 次日开盘成交"的完整链路。Donchian 突破捕捉的是"强势上攻"、ATR 过滤剔的是"死水期假突破",组合起来比双均线更激进、更适合捕捉单边行情。下一步我们把"每只等权"升级成"风险平价 / Kelly 公式"------同一组选股信号,不同仓位曲线会有什么不一样?
下篇预告:【Python量化策略实战 #05】等权总嫌"乱炖"?风险平价与 Kelly 公式仓位对比。
代码与文档
- GitHub SDK 与示例:https://github.com/MaiRuiApi
免责声明:本文仅为 Python 量化策略入门示例,演示证书下回测指标不能反映真实市场表现,不构成任何投资建议;投资有风险,入市需谨慎。