【Python量化策略实战 #04】Donchian 通道假突破太多?用 ATR 阈值过滤跑通真实突破信号

一、为什么需要波动率突破(痛点开场)

上一篇我们用双均线金叉死叉做择时,跑通了"震荡少亏 + 趋势跟住"的效果。但双均线对温和上涨 更敏感,对强势突破的反应反而慢半拍。

A 股最经典的"强势突破"是 Donchian 通道突破:

  • 当日收盘价 > 前 20 日最高价 = 上轨突破 → 强势买入信号
  • 当日收盘价 < 前 20 日最低价 = 下轨跌破 → 离场信号

但 Donchian 的"经典翻车" 是:盘整市里小波动也频繁触发"突破",假信号一打一个准。本文给突破信号加一道 ATR(14) 阈值过滤:只在"市场有足够波动"时才认信号,跳过"死水期"。

我们用 30 行 pandas 把完整信号链跑通,并跑出一笔真实突破买入。

二、本文你将得到什么

  1. Donchian 通道计算(4 行):前 N 日最高 / 最低
  2. ATR(N) 计算:真实波幅均值,反映"市场当前波动水位"
  3. ATR 阈值过滤:ATR < 历史中位数 × 系数时不认信号(防"死水期假突破")
  4. 次日开盘成交:信号当日 c 判定、次日按 o 价成交(避免未来函数)
  5. 一个端到端 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 次完整"突破 → 持有 → 跌破 → 空仓"循环,能形成稳健的"趋势跟踪 + 波动率过滤"效果。

五、常见坑与注意

  1. shift(1) 是合规底线 :没有 shift(1) 的 Donchian 突破等于用"今日 c 跟自己今日的最高比"------条件永远不成立、信号数 = 0,看起来"代码对但没信号",新手最难发现的 bug。
  2. ATR 阈值的选择 :本文用 0.5 × 中位数,是最宽松的门槛(容易过)。保守做法是 1.0 × 中位数(只在中位数以上认信号);激进做法是 0.3 × 中位数。门槛越高、信号越少、假突破越少,但可能错过早期强势股。
  3. 末日未平仓处理 :演示窗口最后一天可能还持仓着,本文用末日 c 强平。严谨点要用"末日次日开盘价"------但因为窗口结束时不存在"次日",所以末日 c 是最简化的处理。
  4. 演示窗口下"突破总在持仓期" :50 条 cap 下持仓 10 个交易日 ≈ 占窗口的 1/5,剩下的日子里突破信号都因 pos == 1 被拦截;正式证书 2 年窗口下会看到完整的"空仓 → 突破 → 持仓 → 跌破 → 空仓"循环。
  5. Donchian(N) 的 N 选多大:经典是 20(日线)。演示窗口 50 条下用 10 是为了能触发信号;正式证书可改 20 或 30(趋势更"长"才认)。窗口越短、越敏感;越长、越稳健但反应慢。

六、小结 + 下篇预告

小结 :本篇用 30 行代码跑通"Donchian 通道 + ATR 阈值 → 信号 → 次日开盘成交"的完整链路。Donchian 突破捕捉的是"强势上攻"、ATR 过滤剔的是"死水期假突破",组合起来比双均线更激进、更适合捕捉单边行情。下一步我们把"每只等权"升级成"风险平价 / Kelly 公式"------同一组选股信号,不同仓位曲线会有什么不一样?

下篇预告:【Python量化策略实战 #05】等权总嫌"乱炖"?风险平价与 Kelly 公式仓位对比。


代码与文档


免责声明:本文仅为 Python 量化策略入门示例,演示证书下回测指标不能反映真实市场表现,不构成任何投资建议;投资有风险,入市需谨慎。

相关推荐
个 人 练 习 生1 小时前
C++:模板初阶详解
开发语言·c++·经验分享·学习
知识分享小能手1 小时前
C++ 学习教程,从入门到精通,C++类继承 — 完整知识点详解(13)
开发语言·c++·学习
弹简特1 小时前
【Java项目-企悦抽】15-活动管理模块02-活动创建测试与活动列表实现
java·开发语言·springboot
oooost2 小时前
Jupyter Notebook 常用快捷键速查
ide·python·jupyter
阿钱真强道2 小时前
30 嵌入式操作系统 | 上位机界面:Flask 网页显示 + 控制闭环
后端·python·flask
ysu_03142 小时前
Docker-Desktop-WSL2实战指南-Windows从安装配置到项目容器化
windows·python·docker·容器·composer·dockerfile·wsl2
keyipatience2 小时前
跳表详细解析
开发语言·数据结构·c++·算法·跳表
喆星时瑜2 小时前
R语言基础语法入门
开发语言·r语言
想吃火锅10052 小时前
【leetcode】189.轮转数组js
开发语言·javascript·leetcode