做趋势交易的人一定绕不开那两个经典问题:趋势到底走没走出来?现在进场是顺势还是接飞刀?我做过一段时间CTA实盘,也把市面上的趋势指标挨个试了个遍,最后长期留在策略里的组合,就是SuperTrend配ADX。这篇文章想把这对“黄金搭档”的原理、Python实现和实战调参一次讲透。
SuperTrend负责把行情压缩成一条直观的跟踪线,告诉你“当前该站在多头还是空头这一边”;ADX负责告诉你“这股势头到底有多猛,值不值得信任”。单独用SuperTrend,震荡市里会被反复打脸;单独用ADX,能识别强度却给不了具体的进出场位置。两个一组合,就是一个把“方向”和“强度”都管住的完整框架。
这篇文章适合正在写趋势策略、被震荡行情折磨过、或者想把手上的SuperTrend信号加一层过滤器的量化研究者。我会把ATR的计算细节、DMI的推导过程、信号叠加的逻辑、回测时容易踩的坑全部交代清楚,代码直接可跑,策略框架可以直接改成你自己的品种和周期。
1. SuperTrend指标原理解析
1.1 从ATR说起:SuperTrend的地基
SuperTrend这个名字听着高级,看穿之后非常简单,它就是一条围绕价格波动的自适应跟踪线。它跟的是平均真实波幅(ATR,Average True Range),ATR本身就是衡量波动率的工具,通俗地讲就是“最近一根K线平均能晃荡多少个点位”。
真正的趋势跟踪,难的不是“涨了买跌了卖”,而是震荡和回调时还能不能拿住仓位。ATR能告诉你当前市场的“噪音水平”,SuperTrend再用这个噪音水平去构建一条上下轨。行情向上突破上轨,就认为上升趋势成立,跟踪线翻到多头方向;行情向下跌破下轨,就认为下降趋势成立,跟踪线翻到空头方向。
1.2 真实波幅TR的计算
真实波幅TR(True Range)的定义要记住三个值:
- 当前K线最高价减去当前K线最低价,即|High - Low|
- 当前K线最高价减去前一根K线收盘价的绝对值,即|High - PrevClose|
- 当前K线最低价减去前一根K线收盘价的绝对值,即|Low - PrevClose|
取三者最大值就是TR。
为什么是这三项?因为有时候K线直接跳空高开或低开,用当根高低价差会漏掉跳空带来的波动。TR把前收盘加进来,等于把跳空缺口也算进了“真实波幅”。这也是为什么ATR比普通标准差指标更能反映行情真正的波动烈度。
ATR就是TR在N个周期内的均值,常用14。用Python写一段最朴素的计算逻辑:
import pandas as pd import numpy as np def calc_atr(df, period=14): high = df['high'] low = df['low'] close = df['close'] prev_close = close.shift(1) tr = pd.concat([ high - low, (high - prev_close).abs(), (low - prev_close).abs() ], axis=1).max(axis=1) # 首根K线没有前收盘,直接用高低价差 atr = tr.ewm(alpha=1/period, min_periods=period).mean() return atr这里用的ewm是变种的Wilder平滑,和直接用rolling(14).mean()的区别在于记忆连续性。Wilder平滑对近期的波动更敏感,同时不会让很久以前的极端波动一次性掉出窗口,这一点在趋势跟踪里很关键。
1.3 SuperTrend上下轨与方向判定
有了ATR,SuperTrend就顺理成章了。需要两个参数:
- 周期length
- 倍数multiplier
经典默认参数是10和3,也就是用10周期ATR,把ATR乘以3作为上下轨的缓冲宽度。
基本逻辑:
def calc_supertrend(df, period=10, multiplier=3): atr = calc_atr(df, period) hl2 = (df['high'] + df['low']) / 2 upper_band = hl2 + multiplier * atr lower_band = hl2 - multiplier * atr supertrend = pd.Series(np.nan, index=df.index) direction = pd.Series(1, index=df.index) for i in range(1, len(df)): # 上轨不能突降,下轨不能突升,保证轨道的连续性 upper_band.iloc[i] = min(upper_band.iloc[i], upper_band.iloc[i-1]) if direction.iloc[i-1] == 1 else upper_band.iloc[i] lower_band.iloc[i] = max(lower_band.iloc[i], lower_band.iloc[i-1]) if direction.iloc[i-1] == -1 else lower_band.iloc[i] if direction.iloc[i-1] == 1: direction.iloc[i] = 1 if df['close'].iloc[i] > upper_band.iloc[i] else -1 else: direction.iloc[i] = -1 if df['close'].iloc[i] < lower_band.iloc[i] else 1 supertrend.iloc[i] = lower_band.iloc[i] if direction.iloc[i] == 1 else upper_band.iloc[i] return supertrend, direction这里有一处非常容易写错的地方:轨道的“连续性”处理。上升趋势中,如果价格回调导致计算出来的上轨突然下移,不能让上轨直接跳下去,否则行情稍微一抖就会沾到上轨触发反转信号。所以要取当前计算值和前值较小者,而不是直接用最新值。同理,下降趋势中的下轨,要取当前计算值和前值较大者。
很多初学实现的人省略了这一步,直接用原始上下轨判方向,结果就是信号在震荡行情里频繁翻转。这个细节直接决定SuperTrend在实盘里好不好用。
1.4 SuperTrend信号怎么看
direction为1时,表示趋势偏多,SuperTrend线画在K线下方,扮演支撑位角色;direction为-1时,表示趋势偏空,SuperTrend线画在K线上方,扮演压力位角色。信号翻转时,就是方向切换的时刻。
我个人经验是,SuperTrend在日线和小时级别表现最稳定,在分钟级别上噪音偏大,如果一定要做分钟级别,multiplier要加大到4以上。具体参数后面专门讲调参时再展开。
2. ADX指标原理解析
2.1 ADX不是用来判断方向的指标
ADX全称是Average Directional Index,平均方向性指数。很多人第一次看到ADX会误会它和MACD一样判断涨跌,其实它只衡量趋势的强度,不给出多空方向。真正的多空方向,要看它的两个辅助线:+DI和-DI。
打个比方:ADX就像汽车仪表盘上的时速表,告诉你车开得有多快,但不告诉你车是往南开还是往北开。方向要看+DI和-DI的相对高低。
这个区分极其关键,也解释了为什么ADX最适合给SuperTrend做过滤器。SuperTrend管方向,ADX管力度,两者各司其职,不重叠不冲突。
2.2 +DI与-DI的计算
计算ADX之前,先要计算方向变动值DM(Directional Movement)。
+DM的定义:当前最高价减去前一根最高价,且这个差值要大于前一根最低价减去当前最低价的差值,才有效;如果差值小于等于0,+DM直接取0。
-DM同理,当前最低价减去前一根最低价,且这个差值要大于前一根最高价减去当前最高价的差值,才有效;如果差值小于等于0,-DM取0。
用Python实现:
def calc_di(df, period=14): high = df['high'] low = df['low'] prev_high = high.shift(1) prev_low = low.shift(1) up_move = high - prev_high down_move = prev_low - low plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0) minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0) # 用Wilder平滑 plus_dm_smooth = pd.Series(plus_dm).ewm(alpha=1/period, min_periods=period).mean() minus_dm_smooth = pd.Series(minus_dm).ewm(alpha=1/period, min_periods=period).mean() atr_smooth = calc_atr(df, period) plus_di = 100 * plus_dm_smooth / atr_smooth minus_di = 100 * minus_dm_smooth / atr_smooth return plus_di, minus_di+DI和-DI都是百分比形式。+DI大于-DI说明多头力量占优,反之空头力量占优。+DI和-DI的差,构成了方向性指数DX(Directional Index),DX再经过平滑就成了ADX。
2.3 DX与ADX
DX的计算:
dx = (plus_di - minus_di).abs() / (plus_di + minus_di) * 100ADX就是对DX做一次Wilder平滑,参数通常取14:
adx = dx.ewm(alpha=1/period, min_periods=period).mean()为什么ADX会比DX有用?因为单日DX波动极大,一根大阳线可能让DX从20飙到60,下一根小十字星又跌回15。ADX平滑之后,显示的是“平均”的方向强度,过滤掉了单日噪音。
把整套逻辑合到一起的完整实现:
def calc_adx(df, period=14): plus_di, minus_di = calc_di(df, period) dx = (plus_di - minus_di).abs() / (plus_di + minus_di) * 100 adx = dx.ewm(alpha=1/period, min_periods=period).mean() return adx, plus_di, minus_di2.4 ADX数值怎么读
ADX取值范围在0到100之间。常见经验值:
- ADX低于20:趋势很弱或没有趋势,典型的震荡市
- ADX在20到40之间:趋势正在形成或发展中,适合趋势跟踪
- ADX高于40:趋势非常强劲,但要小心过度延展
这个阈值不是死规矩。不同周期、不同品种的“强趋势”标准不一样。有人用25做阈值,有人用30。我感觉最佳阈值要在具体品种上回测统计,先看ADX分布,再定阈值。
3. 为什么SuperTrend与ADX是“黄金搭档”
3.1 一个早晚会单独失效的SuperTrend
单独用SuperTrend的问题,我最开始做回测的时候就撞上了:在单边行情里,它确实能吃到一整段利润;但行情一旦进入横盘整理,价格反复穿越上下轨,信号就会高频翻转。
用一个词形容就是“磨损”。每次翻转产生一次手续费和滑点,账户资金就在震荡中被慢慢磨掉。更麻烦的是,SuperTrend的信号频率自己控制不了,它不像布林带那样可以通过带宽判断“现在适不适合交易”。
3.2 ADX能精准堵住震荡期的亏损
ADX的强项恰好弥补了这个缺陷。ADX低的时候,说明当前没有像样的方向性推动力,价格只是在原地打转。这时SuperTrend给的信号,大概率是假突破。
把ADX作为过滤器叠加到SuperTrend上,逻辑就变成:只有当ADX高于某个阈值,确认趋势强度足够时,才允许SuperTrend信号进场。震荡期ADX低于阈值,即使SuperTrend翻了信号,策略也只是空仓等待,而不是反复止损。
我把这对组合称为“黄金搭档”,核心原因就是它们一个管“怎么做”,一个管“做不做”。SuperTrend解决不了的是“这个信号值不值得做”,ADX恰好能回答。
3.3 ADX本身不适合单独做信号
反过来说,ADX单独用也成不了策略。ADX很高的时候,只代表当前趋势很强,但价格已经走了很远,追进去大概率买在趋势尾段。ADX刚抬头的时候,又没法给出具体进场点位。
所以实战中最佳配合方式是:用SuperTrend确定方向和进出场位置,用ADX过滤掉低质量信号。ADX用来做闸门,SuperTrend用来做方向盘,闸门不开,方向盘打到哪都没有意义。
3.4 组合后的信号明确且可执行
组合之后的交易规则非常清晰:
- 当前方向为多头,且ADX大于阈值,做多
- 当前方向为空头,且ADX大于阈值,做空
- ADX低于阈值,空仓观望,不参与震荡
另外还有一个进阶用法:ADX的高点拐头经常警示趋势即将反转或加速,配合SuperTrend翻信号一起看,可以减少右侧进场的滞后感。
4. 完整策略实现:Python代码与回测
4.1 策略逻辑设计
策略整体分为三大部分:
- 计算指标:SuperTrend、ADX、+DI和-DI
- 生成信号:SuperTrend翻多且ADX满足阈值,买入;SuperTrend翻空且ADX满足阈值,卖出或做空;ADX跌破阈值,平仓
- 回测评估:计算收益曲线、最大回撤、胜率等基础绩效指标
这里不引入复杂的第三方回测框架,就用向量化思想写一套轻量回测,逻辑透明,出了问题一眼就能看到数据哪一步不对。对于想快速验证指标想法的人来说,这套代码比背框架好用得多。
4.2 完整可运行代码
先把指标计算和回测逻辑整合成一个完整的脚本,以下代码基于经典的OHLCV数据格式:
import pandas as pd import numpy as np def calc_atr(df, period=14): prev_close = df['close'].shift(1) tr = pd.concat([ df['high'] - df['low'], (df['high'] - prev_close).abs(), (df['low'] - prev_close).abs() ], axis=1).max(axis=1) return tr.ewm(alpha=1/period, min_periods=period).mean() def calc_supertrend(df, period=10, multiplier=3): atr = calc_atr(df, period) hl2 = (df['high'] + df['low']) / 2 upper_band = hl2 + multiplier * atr lower_band = hl2 - multiplier * atr supertrend = pd.Series(np.nan, index=df.index) direction = pd.Series(1, index=df.index) for i in range(1, len(df)): if direction.iloc[i-1] == 1: upper_band.iloc[i] = min(upper_band.iloc[i], upper_band.iloc[i-1]) else: lower_band.iloc[i] = max(lower_band.iloc[i], lower_band.iloc[i-1]) if direction.iloc[i-1] == 1: direction.iloc[i] = 1 if df['close'].iloc[i] > upper_band.iloc[i] else -1 else: direction.iloc[i] = -1 if df['close'].iloc[i] < lower_band.iloc[i] else 1 supertrend.iloc[i] = lower_band.iloc[i] if direction.iloc[i] == 1 else upper_band.iloc[i] df['supertrend'] = supertrend df['direction'] = direction return df def calc_adx(df, period=14): prev_high = df['high'].shift(1) prev_low = df['low'].shift(1) up_move = df['high'] - prev_high down_move = prev_low - df['low'] plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0) minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0) plus_dm_smooth = pd.Series(plus_dm).ewm(alpha=1/period, min_periods=period).mean() minus_dm_smooth = pd.Series(minus_dm).ewm(alpha=1/period, min_periods=period).mean() atr_smooth = calc_atr(df, period) plus_di = 100 * plus_dm_smooth / atr_smooth minus_di = 100 * minus_dm_smooth / atr_smooth dx = (plus_di - minus_di).abs() / (plus_di + minus_di) * 100 adx = dx.ewm(alpha=1/period, min_periods=period).mean() df['adx'] = adx df['plus_di'] = plus_di df['minus_di'] = minus_di return df def backtest(df, adx_threshold=25, initial_capital=100000): df['position'] = 0 # 进场条件:SuperTrend方向为多/空,且ADX过阈值 long_condition = (df['direction'] == 1) & (df['adx'] > adx_threshold) short_condition = (df['direction'] == -1) & (df['adx'] > adx_threshold) # 离场条件:ADX跌破阈值则退出持仓 exit_long = (df['direction'] == 1) & (df['adx'] <= adx_threshold) exit_short = (df['direction'] == -1) & (df['adx'] <= adx_threshold) df.loc[long_condition, 'position'] = 1 df.loc[short_condition, 'position'] = -1 df.loc[exit_long, 'position'] = 0 df.loc[exit_short, 'position'] = 0 # 持仓状态保持:只做多头+空头的简单切换 df['position'] = df['position'].replace(0, np.nan).ffill().fillna(0) # 收益率计算 df['returns'] = df['close'].pct_change() df['strategy_returns'] = df['position'].shift(1) * df['returns'] # 资金曲线 df['equity'] = initial_capital * (1 + df['strategy_returns']).cumprod() # 绩效指标 total_return = df['equity'].iloc[-1] / initial_capital - 1 daily_returns = df['strategy_returns'].dropna() sharpe = np.sqrt(len(daily_returns)) * daily_returns.mean() / daily_returns.std() max_drawdown = (df['equity'] / df['equity'].cummax() - 1).min() return df, { 'total_return': total_return, 'sharpe': sharpe, 'max_drawdown': max_drawdown, 'positive_rate': (daily_returns > 0).mean() }跑一个简单示例:
# 假设df已经加载好,包含open, high, low, close, volume df = calc_supertrend(df, period=10, multiplier=3) df = calc_adx(df, period=14) result_df, metrics = backtest(df, adx_threshold=25) print(f"总收益率: {metrics['total_return']:.2%}") print(f"夏普比率: {metrics['sharpe']:.2f}") print(f"最大回撤: {metrics['max_drawdown']:.2%}") print(f"胜率: {metrics['positive_rate']:.2%}")4.3 回测时最容易犯的错
上面这段代码看着简单,里面有几个埋点必须说明白,这些都是我实际写回测时踩过的坑。
第一,position的shift(1)。策略信号基于当根K线收盘价产生,但你最早只能在下一根K线开盘或收盘时成交。直接拿当根返回值做收益计算,会造成前视偏差(look-ahead bias),回测曲线会异常好看,实盘却完全做不到。所以在策略收益那一行,一定要对position做shift(1)。
第二,ADX过滤条件不能和SuperTrend翻信号逻辑写反。正确逻辑是“方向已经定了,ADX决定做不做”。如果你写的是“ADX刚突破阈值才进场”,那会在ADX高位回落时错过离场,在底部攀升时错过进场。过滤器和触发器的职责要分清。
第三,空仓期间的position用ffill填充,这里有一个隐性设定:一旦进入空仓状态,需要SuperTrend和ADX重新同时满足条件才能再次进场。如果你没有这段逻辑,程序会把空仓状态直接跳回上一次的多头或空头方向,这绝对是错误信号。
4.4 改用日线数据和真实品种前的验证步骤
在实盘或更精细的回测平台上跑之前,建议先做三轮验证:
第一轮:肉眼验证指标画线。把SuperTrend线、ADX、+DI、-DI全部画到图上,随机抽查10个信号点,手动确认信号逻辑没有错位。
第二轮:与买卖点一一对照。挑一段大涨和一段横盘,看策略有没有在ADX低于阈值时频繁交易,是否在趋势最强时持有仓位。
第三轮:参数敏感性分析。把period和multiplier、adx_threshold各取几个相邻值,观察绩效是否剧烈波动。如果参数微调导致结果天翻地覆,说明策略过拟合风险极大。
5. 参数优化与实战调参心得
5.1 三个核心参数的物理意义
SuperTrend有两个参数:ATR周期period和倍数multiplier。ADX有一个参数:周期period,外加一个过滤器阈值adx_threshold。
period决定了指标对行情的响应速度。数值越小,指标越敏感,信号出得越早,但假信号越多;数值越大,指标越迟钝,信号滞后严重,但穿过震荡区的韧性更强。
multiplier决定了轨道离价格的距离。multiplier越小,轨道越贴近价格,信号早出,但容易被小回调打掉;multiplier越大,轨道越宽,能滤掉更多噪音,但进场明显偏晚。
ATR倍数和ADX阈值本质上都在做同一件事:过滤噪音。但两者过滤维度不同,一个从波动幅度上过滤,一个从趋势强度上过滤。联合使用比单靠某一个参数更能适应不同市场状态。
5.2 一个相对稳妥的起始参数组
| 参数 | 快速追踪型 | 稳健型 | 大周期型 |
|---|---|---|---|
| SuperTrend period | 10 | 12 | 20 |
| SuperTrend multiplier | 2.5 | 3.0 | 4.0 |
| ADX period | 14 | 14 | 18 |
| ADX阈值 | 20 | 25 | 30 |
没有万能参数。波动率高的品种,比如主流数字货币和某些商品期货,multiplier要加大,否则一根大影线就打出止损;波动率低的品种,比如债券期货和部分股指,multiplier可以适当放小。
ADX阈值也是一样。我看过一个统计,某商品期货日线级别的ADX中位数在22左右,但到了周线级别,中位数能上到28。所以换周期之后,阈值一定要重新校准,不能拿着日线的25直接套到周线上。
5.3 调参时如何避免过拟合
如果你是拿历史数据一个个试参数,试出个漂亮曲线就急着上实盘,那我劝你先冷静。一个坑我反复踩过:回测曲线越是完美,参数对历史数据的“记忆”越强,实盘越容易翻车。
健康的调参顺序应该是:
- 把数据切成样本内和样本外两部分,样本内确定参数范围
- 在样本外验证参数表现,不追求最优,追求稳定
- 观察相邻参数组的收益分布是否平滑,如果只有孤零零一组参数暴利,其他全部亏损,剔除掉
还有一点:不要只盯总收益率和夏普比率。最大回撤和回撤持续周期,决定了你能不能拿住这个策略。一个年化30%但回撤50%的策略,和年化20%但回撤15%的策略放在一起,我会选后者,因为我见过太多人在50%回撤时心理崩溃。
5.4 过滤器阈值是不是越灵活越好
严格来说,ADX阈值本身也可以动态化。比如用ATR的百分位滚动来确定“当前波动下什么算高趋势强度”,这种做法在震荡转单边的时候反应更快。
但我个人建议,新手阶段先用固定阈值。动态阈值的优势需要足够的样本量才能体现,而且会引入更多自由度,过拟合风险成倍增加。固定阈值至少让人一眼能看懂信号怎么来的,出了问题也容易排查。策略先做减法,做简单,稳定了再谈复杂化。
6. 常见问题与排查技巧实录
6.1 高频踩坑点速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 回测收益极高但实盘连续亏损 | 前视偏差,position未shift | 检查策略收益计算时是否用了当根信号 |
| 震荡期交易频繁,磨损严重 | ADX阈值设太低或未加ADX过滤 | 提高阈值,观察ADX分布曲线 |
| 大涨行情中途被甩下车 | multiplier太小或ATR周期太短 | 适当加大multiplier,延长period |
| 信号翻转但ADX还没跟上 | 指标同步性错位 | 先确认SuperTrend和ADX用的是同一组OHLC数据 |
| 数据前几根指标为NaN | Wilder平滑需要预热期 | 手动丢弃初期数据,不要强制填0 |
| ADX很高但SuperTrend方向频繁变 | 可能用了超短线周期 | 切换更高周期或加大multiplier |
6.2 SuperTrend连续上轨被突破的真相
我最开始写SuperTrend实现的时候,跑出一段连续信号翻转的行情,一度怀疑是算法写错了。后来把行情图调出来才发现,那段行情是典型的“一字横盘”,多空双方反复拉锯,每根K线都同时触上轨又触下轨。
这种情况靠加ADX过滤还不够,因为ADX在横盘末期可能已经开始抬头。更稳妥的方式是叠加一个“K线实体过滤”:只有收盘价明显收在轨道外侧才算有效突破,例如要求突破幅度超过0.2个ATR。这个过滤条件能多挡掉一大部分毛刺信号。
6.3 ADX数据与SuperTrend信号不同步怎么处理
你会发现有时SuperTrend已经翻空,但ADX还在40以上高位运行。这是因为ADX衡量的是过去一段时间的平均趋势强度,天然滞后。此时不要强行要求ADX先降下去再进空单,正确做法是:SuperTrend失去多头方向,ADX强度虽高但方向已不匹配,先离场观望,等新的方向信号确认后再进场。
用ADX不是用来“预测趋势何时结束”,而是用来“避免在无趋势时进场”,记住这一点,就不会总纠结它为什么不提前拐头。
6.4 极端行情下的应对经验
单根巨大的影线会在一瞬间打穿SuperTrend的上下轨,又迅速拉回。这种情况下信号会闪断,策略可能先止损一次,又马上反向进场,然后再次止损。
我的应对方法是在策略层加一条“最短持仓K线数”规则,比如信号翻转后至少持仓2根K线,期间无论指标怎么说都不动。这个规则会增加单笔回撤,但能大幅减少极端行情下的反复止损次数,整体收益反而提升。
6.5 代码实现的性能注意点
这里有个实际经验:如果数据量巨大,比如几年逐笔或几分钟级别的K线,循环实现SuperTrend会比较慢。可以先用Numba的jit加速循环,或者用PyTorch把序列计算并行化。但如果你只是试验策略思路,没必要为性能优化耗费精力,先把逻辑正确放在第一位。
很多人的误区是先折腾性能优化,最后发现指标算错了,白瞎了半天。先跑顺最基本逻辑,验证策略有效,再回头做性能优化,顺序千万别搞反。
写在最后
我个人在实际操作中的体会是,SuperTrend和ADX这套组合最讨喜的地方,不是它胜率有多高,而是它让交易决策变得特别清晰。每次看到信号的时候,我都知道为什么进场、为什么空仓,不再凭感觉猜行情。信号质量不达标就不做,这种纪律性,比任何花哨的指标都值钱。
最后再分享一个小技巧:实盘部署之前,一定要让策略先跑一段模拟盘或最小仓位实盘,时间跨度至少覆盖一次完整的ADX从低到高再走低的过程。见过它在真实行情里的表现,你才能对参数和过滤器有真正的信任感。这种信任,不是回测曲线能给你的。