如何用 Python 做 ATR 移动止损(吊灯止损)回测

用户头像sh_*2176oo
2026-09-15 发布

一句话回答: ATR 移动止损(吊灯止损)就是把止损线设在"持仓期最高价 − K×ATR",价格上涨时止损跟着上移、锁住利润,价格回落触线就出。用 AlphaFeed 的复权日线算 ATR、跑一段回测你会看到:它通常能明显压低最大回撤,但在震荡行情里也可能被频繁扫出、反而降低总收益——控回撤是有代价的。

为什么会有这个问题 / 传统做法的痛点

很多人有入场信号却没有像样的出场:要么死扛到深套,要么固定百分比止损(−8%)在高波动股上太紧、低波动股上太松。固定止损不考虑波动率,是通病。

ATR(平均真实波幅)度量了每只票的"日常波动尺度",用它做止损能自适应:波动大的票止损放宽、波动小的票收紧。吊灯止损(Chandelier Exit)再叠加"只上移不下移",让止损随盈利抬升,兼顾"让利润奔跑"和"回撤可控"。数据用 AlphaFeed 的复权日线即可。

分步骤解决(每步配可运行代码)

第 1 步:算 ATR

import numpy as np, pandas as pd
from alphafeed import AlphaFeed

def atr(df, n=14):
    tr = pd.concat([(df["high"] - df["low"]),
                    (df["high"] - df["close"].shift()).abs(),
                    (df["low"] - df["close"].shift()).abs()], axis=1).max(axis=1)
    return tr.rolling(n).mean()

第 2 步:吊灯移动止损回测(金叉入场,止损/死叉出场)

def backtest_trailing(symbol, count=500, atr_n=14, k=3.0, fast=20, slow=60, fee=0.0006):
    af = AlphaFeed()                                   # 读取 ALPHAFEED_API_KEY
    df = af.klines.get(symbol, period="1d", count=count, adjust="forward", to_dataframe=True)
    df = df.sort_values("trade_date").reset_index(drop=True)
    df["atr"] = atr(df, atr_n)
    df["ma_f"] = df["close"].rolling(fast).mean()
    df["ma_s"] = df["close"].rolling(slow).mean()

    pos = 0; peak = None; rets = np.zeros(len(df))
    for i in range(1, len(df)):
        o, c = df.loc[i, "open"], df.loc[i, "close"]
        prevc = df.loc[i - 1, "close"]
        if pos == 1:
            peak = max(peak, df.loc[i - 1, "high"])                 # 持仓期最高
            stop = peak - k * df.loc[i - 1, "atr"]                  # 吊灯止损线
            if df.loc[i - 1, "low"] <= stop or o <= stop:           # 已破止损 → 次日开盘平(含跳空)
                rets[i] = (o / prevc - 1) - fee; pos = 0; peak = None; continue
            rets[i] = c / prevc - 1
        # 入场/离场信号都用 T-1 已知信息,T 开盘成交(无未来函数)
        if pos == 0 and df.loc[i-1, "ma_f"] > df.loc[i-1, "ma_s"] and not np.isnan(df.loc[i-1, "ma_s"]):
            pos = 1; peak = df.loc[i-1, "high"]; rets[i] = c / o - 1 - fee
        elif pos == 1 and df.loc[i-1, "ma_f"] < df.loc[i-1, "ma_s"]:
            rets[i] = (o / prevc - 1) - fee; pos = 0; peak = None

    df["strat_ret"] = rets
    df["nav"] = (1 + df["strat_ret"]).cumprod()
    df["bench"] = df["close"] / df["close"].iloc[0]
    mdd  = float((df["nav"]   / df["nav"].cummax()   - 1).min())
    bmdd = float((df["bench"] / df["bench"].cummax() - 1).min())
    return {"strat_nav": round(df["nav"].iloc[-1], 3), "bench_nav": round(df["bench"].iloc[-1], 3),
            "strat_maxdd": round(mdd, 3), "bench_maxdd": round(bmdd, 3)}

print(backtest_trailing("600519.SH", count=500, k=3.0))

真实跑通(600519.SH 近 500 日、ATR14、K=3):{'strat_nav': 0.82, 'bench_nav': 0.976, 'strat_maxdd': -0.215, 'bench_maxdd': -0.279}。解读:策略最大回撤 −21.5% < 买入持有 −27.9%(回撤更小、更抗跌),但净值 0.82 < 基准 0.976(这段偏震荡下行,止损被反复扫出,总收益反而更低)。这正是移动止损的真实权衡——用一部分收益换更小的回撤

参数与字段说明

参数 含义 备注
atr_n ATR 周期 常用 14
k 止损倍数 越大越宽松(不易被扫)、越小越紧(回撤更小但易震出);常用 2~4
fast/slow 入场均线 这里用金叉入场、死叉/止损出场
出场价 破止损用次日开盘 反映跳空、避免用当日不可知价
fee 单边费率 含佣金/印花税近似

关键坑与注意事项

  • 止损跳空无法按止损价成交:若某日跳空低开直接跌破止损,你只能在次日开盘(或当日开盘)成交,实际成交价常比止损价更差。代码已用开盘价近似,别假设总能在止损线精确成交。
  • 震荡市会被反复扫出:K 太小,止损太紧,会在正常波动里被反复触发(whipsaw),交易成本和踏空双重损耗。上例净值跑输基准就是这个原因。
  • 控回撤有代价:移动止损的价值主要是降低最大回撤/改善持有体验,不保证提高总收益。评估时要同时看收益和回撤,别只看一个。
  • 参数敏感 + 过拟合风险katr_n、均线参数都会显著影响结果,务必做样本外与参数稳健性检验,别只挑好看的一组。
  • 复权与未来函数adjust="forward";信号用 T−1 信息、T 开盘成交(已用 shift 逻辑),避免前视。

常见问题(FAQ)

Q:ATR 止损比固定百分比止损好在哪?
A:它按每只票的波动率自适应——高波动股止损自动放宽、低波动股收紧,避免"一刀切百分比"在不同标的上过紧或过松。

Q:K 取多少合适?
A:常见 2~4。K 越大越不容易被震出、但单次止损亏损更大、回撤保护更弱;K 越小回撤控制越好、但易被正常波动扫出。需按标的波动性和你的持有周期调,并做稳健性检验。

Q:能只用移动止损、不用入场信号吗?
A:可以把入场换成任何你的信号(突破、基本面等),移动止损作为统一的出场纪律叠加上去。本文用金叉只是给个可运行的完整例子。

Q:需要付费吗?
A:单标的历史日线可用免费额度体验;批量回测多标的需 Starter 及以上,详见官网定价页。

小结

ATR 吊灯移动止损用波动率自适应地设止损、随盈利上移锁利,是比"固定百分比"更专业的出场纪律。用 AlphaFeed 的复权日线算 ATR、跑一段诚实的回测,你会直观看到它的本质:它主要买的是更小的回撤,而不是更高的收益,在震荡市甚至会拖累总回报。理解这个权衡,再决定给你的策略加多紧的止损。

参考

评论