DualThrust量化交易策略详解:Python实现与改进方向
简介围绕量化经典DualThrust交易系统这份解析PDF从策略原理、关键参数、交易逻辑到改进模型均有覆盖既适合初级量化爱好者快速入门也可供进阶者借鉴代码优化思路。包内为1个PDF文档压缩包仅34KB便于移动端阅读与反复查阅已有125人学习。正文先还原DualThrust原始代码用最高价、最低价、收盘价计算长短周期波动区间再基于5分钟周期给出改进版本引入offset应对滑点、增加PlotNumeric绘图以直观观察买卖触发点并强调Mday/Nday窗口和K1/K2灵敏度对信号的影响。系统通过比较两个时间窗口内的波动幅度来动态选择买入、卖出区间交易逻辑清晰改进模型还给出了信号过滤和仓位处理的可执行写法。读者可以据此掌握策略如何自动生成买卖触发价理解趋势行情下的开平仓流程同时认识到震荡市表现不佳、参数设置与滑点管理的重要性为后续自行优化或实盘测试提供一份简洁可查的方法论参考。1. DualThrust 交易系统一个没被时间淘汰的通道突破策略量化交易系统里DualThrust 是靠简单活了几十年的经典模型。它不预测方向只盯住过去 N 天的价格极端位置在今日开盘价上下各画一条触发线价格先碰到哪边就站到哪边多数版本还允许反手持仓。商品期货、股指期货、加密货币的 CTA 策略库里都有它的身影换了语言、调了参数骨架没变。网上流传的 DualThrust 源代码很多核心脚本往往就几十行真正拉开差距的是信号计算里的 shift(1)、Ks 与 Kx 不对称的含义以及逐 bar 撮合里怎么避免偷价。下文把算法链路、最小可回测实现、四个改进模型方向、参数寻优与验证依次拆开每个方向都带可以直接改的代码和边界条件。适合想系统研究通道突破、或者要在自己回测框架里加一个基准策略的开发者。先消除一个常见歧义标题里的量化指 Quantitative 交易不是深度学习里把 FP32 压成 int8 的模型量化全文不涉及神经网络部署相关话题。2. DualThrust 核心算法拆解N 日极值、Range 与上下轨的计算链路把所有能搜到的 DualThrust 源代码并排看信号公式基本一致差别只在极值口径和数据对齐方式。先把四个极值怎么取、两条线从哪里画搞清楚后续任何改进模型才有立足点否则改来改去都是在错误信号上加滤镜。2.1 HH、LC、HC、LL 与 RangeDualThrust 上下轨的四步计算先看最核心的计算链路代码里通常浓缩成三行Range max(HH - LC, HC - LL) buy_line open ks * Range sell_line open - kx * Range其中四个极值来自过去 N 天不含今天的日线数据HH 是 N 天内最高价的最高值LL 是 N 天内最低价的最低值HC 是 N 天内收盘价的最高值LC 是 N 天内收盘价的最低值。注意 HC、LC 取的是收盘价序列的极值而不是最高价、最低价的极值这是 DualThrust 和唐奇安通道最根本的分叉点。Range 的物理含义值得细说HH - LC衡量多头把价格推到 N 日高位、同时收盘价没被砸回低位的能力HC - LL衡量空头砸穿 N 日低位、同时收盘价没被拉回高位的能力。两者取 Max等于给更剧烈的那一方打了提前量这是 DualThrust 能抓跳空行情的核心也是它震荡市被连续打脸的原因。符号取值范围作用HHN 日最高价的最大值衡量上方空间LCN 日收盘价的最小值衡量多头防守位HCN 日收盘价的最大值衡量空头防守位LLN 日最低价的最小值衡量下方空间RangeMax(HH - LC, HC - LL)决定通道宽度buy_lineopen ks × Range上轨突破做多sell_lineopen - kx × Range下轨突破做空当日开盘后价格只要突破上轨就开多跌破下轨就开空持仓状态下反向触发则平仓反手也就是从多翻空或从空翻多。这样设计的结果是策略大部分时间都保持在场内通过始终持仓来避免趋势行情里下车太早。提示如果品种有夜盘连续交易这里的 open 应该用集合竞价后的第一笔成交价而不是日线里随意补的 0 或前收价。跨市场复权时尤其要核对这一列这是好源代码和烂源代码之间最常见的隐性差异。2.2 Ks、Kx、N 三个参数分别改变什么DualThrust 的参数少到只有三个回看窗口 N、多头通道系数 Ks、空头通道系数 Kx。没系统调过参的人建议把每个参数动的方向先背下来。N 决定极值窗口的长度。N 越大Range 越平滑触发线离开盘价越远信号数量减少但单笔行情含金量提高N 太小则通道贴近价格交易频率急剧上升手续费把利润吃光。国内商品期货最常见的默认值是 N15也有用 10 到 30 的取决于主力合约换月频率和品种波动周期。Ks 和 Kx 控制上下轨的宽度默认都是 0.5。很多人忽略一个点Ks 和 Kx 可以不相等且不相等时策略自带方向偏置。Ks 小于 Kx上轨离开盘价更近多头更容易触发系统间接偏向做多反过来则偏空。这种不对称用法经常被当作改进模型的入口比直接在信号上加过滤器要便宜得多。参数调大后的效果常见风险N通道更稳、信号更少反应迟钝错过 V 形反转Ks上轨抬高多头更难触发止损距离同样拉长单笔回撤变大Kx下轨压低空头更难触发Ks/kx 失衡时策略持续偏一边2.3 与唐奇安通道、ATR 通道的边界差异把 DualThrust 和另两个常被混为一谈的通道策略放在一起看差异更清楚。唐奇安通道是过去 N 日最高价和最低价直接作为上下轨没有任何以收盘价或开盘价为锚的计算DualThrust 的上下轨锚定在今日开盘价上等于把昨天的极端位置翻译成今天从开盘起跳多少才算突破对隔夜跳空的适应能力更强代价是震荡市里更敏感。ATR 通道则是均线加减若干倍平均真实波幅价格突破的是均线的合理波动范围本质是均值回归思维DualThrust 是趋势突破思维突破后还有反手机制适合单边流畅的品种。所以选型上有个粗糙但实用的经验螺纹、铁矿这类趋势性强的商品用 DualThrust指数和汇率这类震荡偏多的品种用 ATR 通道更不容易被反复收割。3. DualThrust 源代码实现用 Python 跑通最小可回测版本这一章给出一套可以直接贴进研究环境的最小实现按算信号、逐 bar 撮合、转绩效三步走。数据格式统一为包含 open、high、low、close、volume 字段的 pandas DataFrame索引是日期时间。先跑通这个版本再往上叠改进模型和优化器。3.1 信号计算代码rolling 极值加 shift(1)挡住当日数据import numpy as np import pandas as pd def build_dual_thrust_signal(df: pd.DataFrame, n: int 15, ks: float 0.5, kx: float 0.5) - pd.DataFrame: out df.copy() # 用截止昨日收盘的 N 根 K 线算极值shift(1) 把窗口整体后移一根 out[HH] out[high].rolling(n).max().shift(1) out[LC] out[close].rolling(n).min().shift(1) out[HC] out[close].rolling(n).max().shift(1) out[LL] out[low].rolling(n).min().shift(1) # Range 取两个方向极限差的较大者 out[Range] np.maximum(out[HH] - out[LC], out[HC] - out[LL]) out[buy_line] out[open] ks * out[Range] out[sell_line] out[open] - kx * out[Range] return out这里最容易被忽略的是.shift(1)。rolling(n).max()在本行会把当天的 high 也算进去用当天的极值去算当天的触发线等于让信号偷看了未来一根 K 线的数据。没有 shift 的 DualThrust 回测收益会明显虚高趋势越强虚高越明显这是网上源代码里出现频率最高的隐患。参数说明n 是回看天数默认 15ks、kx 是上下轨系数默认 0.5。前 n 行因为窗口不满四个极值都是 NaN对应的 buy_line、sell_line 也是 NaN信号从第 n1 根 K 线才开始产生这是正常现象不用特殊处理。3.2 逐 bar 撮合主循环反手与不反手两种写法def backtest_dual_thrust(df: pd.DataFrame, n: int 15, ks: float 0.5, kx: float 0.5, allow_reverse: bool True): sig build_dual_thrust_signal(df, n, ks, kx) pos 0 # 0 空仓, 1 持多, -1 持空 entry_px 0.0 trades [] for i in range(len(sig)): bar sig.iloc[i] if np.isnan(bar[buy_line]): continue if pos 0: if bar[high] bar[buy_line]: pos, entry_px 1, bar[buy_line] trades.append({bar: bar.name, side: 1, px: entry_px}) elif bar[low] bar[sell_line]: pos, entry_px -1, bar[sell_line] trades.append({bar: bar.name, side: -1, px: entry_px}) elif pos 1 and bar[low] bar[sell_line]: # 平多并反手做空 pos, entry_px -1, bar[sell_line] trades.append({bar: bar.name, side: -1, px: entry_px}) elif pos -1 and bar[high] bar[buy_line]: # 平空并反手做多 pos, entry_px 1, bar[buy_line] trades.append({bar: bar.name, side: 1, px: entry_px}) return sig, trades撮合逻辑一句话概括每根 bar 先看是否存在当前持仓空仓时谁先触发就进谁持仓时反向触发就平仓反手。这里默认成交价是触发线的价格因为日线数据无法还原日内价格先摸上轨还是先摸下轨取触发价是业内最常见的保守假设。有几个边界情况要补一是开盘价已经跳空越过两条线时真实成交价应该是 open 而不是触发线连续涨停或跌停的 bar 尤其要这样处理二是同一根 bar 同时满足多空触发条件时上面代码默认多头优先这种优先级设定在各家源代码里不统一严谨的做法是切到小时或十五分钟级别数据消除歧义。反手模式适合期货这类可以做空的品种做 A 股股票时建议把 allow_reverse 置为 False只保留多头开仓和平仓。3.3 把交易记录转成绩效指标胜率、盈亏比与资金曲线def evaluate_dual_thrust(trades, contract: float 10.0, init_cash: float 100000.0): pos 0 entry_px 0.0 cash init_cash closed [] for t in trades: if pos 0: pos, entry_px t[side], t[px] elif pos ! t[side]: # 每笔按 1 手计算平旧仓的同时建立反向仓 pnl (t[px] - entry_px) * contract * pos cash pnl closed.append({side: pos, entry: entry_px, exit: t[px], pnl: pnl}) pos, entry_px t[side], t[px] return cash, closed这段评估逻辑把反手当作旧仓结算 新仓开立两步闭仓记录里保存了每次反手的入场价、出场价和单笔盈亏。手数和加仓可以后续再扩展先看策略本身的形状对不对。胜率、盈亏比可以这样快速汇总统计闭仓记录中 pnl 大于零的比例再把所有盈利和亏损分别求和取比值。DualThrust 的胜率在 45% 到 55% 之间都算正常这个策略赚钱靠的是盈亏比而不是胜率少数大趋势赚的钱要能覆盖多数小亏损。如果回测结果胜率超过 65%先别高兴回头检查 3.1 里的 shift 是不是被删掉了。成交场景成交价假设何时更适合日内首次穿越上轨buy_line默认场景简单且保守开盘跳空越过上轨open连续涨跌停品种收盘前最后一根穿越close样本外验证时保守口径4. DualThrust 改进模型趋势过滤、波动加权与次日确认原始 DualThrust 的短板在实盘里会快速暴露震荡市连续假突破、单边行情起步抓不准、参数换品种就失效。所谓改进模型核心不是发明新指标而是给触发信号加约束条件。下面四个方向按工程量从小到大排列每个都是可以直接替换 3.2 主循环的独立模块。4.1 顺势过滤改进模型用均线方向门控只做单边第一个改进最直观把谁先触发就跟谁改成顺着均线方向才开仓逆势信号只用来平仓。def dual_thrust_trend_filter(df, n15, ks0.5, kx0.5, ma_fast20, ma_slow60): sig build_dual_thrust_signal(df, n, ks, kx) sig[ma_fast] sig[close].rolling(ma_fast).mean() sig[ma_slow] sig[close].rolling(ma_slow).mean() sig[trend_up] sig[ma_fast] sig[ma_slow] sig[long_allowed] sig[trend_up] sig[short_allowed] ~sig[trend_up] return sig接入主循环时长信号只有在 long_allowed 为 True 时才执行开多空信号同理。持有多头后即使下轨被触发如果 short_allowed 为 False也不反手做空而是平仓出场恢复空仓这就把双向反手降级成顺势单 逆势离场。提示过滤条件要在触发判断之前生效而不是回测跑完后删掉逆势交易记录。事后删记录会让回测成交序列和实盘不一致属于偷价的一种隐蔽形式。趋势过滤的代价是均线粘合期容易踏空快慢均线参数 20/60 只是一个起点换成布林带中轨或者 ADX 大于 25 的判定本质思路不变。4.2 波动率加权改进模型Range 与 ATR 混合让通道自适应原始公式里的 Range 是 N 日窗口的静态波动改进模型可以对它做二次平滑用短周期 ATR 给 Range 加权让通道宽度在市场活跃时自动收敛。def dual_thrust_vol_weighted(df, n15, ks0.5, kx0.5, atr_n14, w_vol0.4): sig build_dual_thrust_signal(df, n, ks, kx) tr np.maximum( df[high] - df[low], np.maximum( (df[high] - df[close].shift(1)).abs(), (df[low] - df[close].shift(1)).abs(), ), ) sig[ATR] tr.rolling(atr_n).mean() sig[width] (1 - w_vol) * sig[Range] w_vol * sig[ATR] sig[buy_line_w] sig[open] ks * sig[width] sig[sell_line_w] sig[open] - kx * sig[width] return sig新的通道宽度取 Range 和 ATR 的加权平均w_vol 默认 0.4表示四成权重给快速波动项。行情突然放大时ATR 比 15 日 Range 反应更快通道变宽减少了开仓频率行情萎缩时 ATR 变小通道收窄更容易捕捉到窄幅整理后的第一波突破。w_vol 取 0.6 以上时策略会退化成纯 ATR 通道失去 DualThrust 用收盘价极值刻画方向的本意。4.3 次日收盘确认改进模型用 shift 把开仓推迟到第二天这个改进专门对付假突破当日价格虽然碰到了触发线但收盘又收回通道内部说明突破没有成交量支撑最常见的原因是大户试盘。过滤办法是要求收盘价也站在触发线外侧次日开盘再进场。def dual_thrust_confirm(df, n15, ks0.5, kx0.5): sig build_dual_thrust_signal(df, n, ks, kx) sig[touch_up] sig[high] sig[buy_line] sig[touch_dn] sig[low] sig[sell_line] sig[close_above] sig[close] sig[buy_line] sig[close_below] sig[close] sig[sell_line] sig[enter_long] (sig[touch_up] sig[close_above]).shift(1) sig[enter_short] (sig[touch_dn] sig[close_below]).shift(1) return sigenter_long 为 True 的下一根 bar 开盘进多enter_short 同理。代价是放弃了一部分跳空利润收益上会显得比原始版本钝但交易次数显著下降回撤曲线更平滑。这个变体最适用在十五分钟或一小时级别放在日线上延迟一天往往错过整个波段。4.4 参考价替换改进模型用滚动 VWAP 替代当日开盘价最后一个改进方向动了最基础的参考价。开盘价容易受集合竞价噪音和隔夜情绪冲击影响改成滚动 VWAP 后上下轨更贴近多空双方的真实平均成本对资金推动型突破更敏感。def dual_thrust_vwap_ref(df, n15, ks0.5, kx0.5, vwap_n5): sig build_dual_thrust_signal(df, n, ks, kx) tp (df[high] df[low] df[close]) / 3.0 sig[vwap] (tp * df[volume]).rolling(vwap_n).sum() / \ df[volume].rolling(vwap_n).sum() sig[buy_line_v] sig[vwap] ks * sig[Range] sig[sell_line_v] sig[vwap] - kx * sig[Range] return sig这套变体在商品期货和指数上比较常见画到分时图上就是一条随成交量移动的动态主图参考线。如果要在通达信里做 A 股选股改造一般只保留多头侧信号收盘价上穿 buy_line_v 作为选股输出做空部分直接删掉因为 A 股没有便捷的做空通道。改进模型改动位置主要代价趋势过滤信号门控均线粘合期踏空波动加权通道宽度多一个 w_vol 参数次日确认开仓时点放弃跳空利润VWAP 参考价参考价依赖成交量字段质量5. 参数寻优与回测验证找 DualThrust 参数时的三个注意点5.1 网格搜索 N、Ks、Kx先固定手续费再看结果分布在 3.2 的主循环外加一层循环就是最朴素的参数寻优搜索空间 21 个 N、6 个 Ks、6 个 Kx日线五年的数据几分钟就能跑完from itertools import product def grid_search(df, n_listrange(10, 31), ks_listnp.arange(0.3, 0.81, 0.1), kx_listnp.arange(0.3, 0.81, 0.1)): rows [] for n, ks, kx in product(n_list, ks_list, kx_list): _, trades backtest_dual_thrust(df, nn, ksks, kxkx) _, closed evaluate_dual_thrust(trades) if len(closed) 30: rows.append({n: n, ks: ks, kx: kx, pnl: sum(t[pnl] for t in closed)}) return pd.DataFrame(rows).sort_values(pnl, ascendingFalse)交易笔数少于 30 的结果直接剔除样本太少时均值没有统计意义。最终结果不要只看第一名把 pnl 排前 10% 的参数画成散点图好的参数应该在某个区域形成高原而不是孤零零一个尖峰。尖峰多半是数据巧合换一段行情就消失。5.2 滚动前推验证样本外测试才可信全样本网格搜索出来的参数一定过拟合原因是它已经看过了全部行情。滚动前推的做法是把数据切成训练段和测试段用训练段选参数在测试段验证然后窗口整体向前滚动def walk_forward(df, train_len504, test_len63): rows, i [], train_len while i test_len len(df): best grid_search(df.iloc[i - train_len:i]).iloc[0] _, trades backtest_dual_thrust( df.iloc[i:i test_len], nbest[n], ksbest[ks], kxbest[kx]) _, closed evaluate_dual_thrust(trades) rows.append({pnl: sum(t[pnl] for t in closed), trades: len(closed)}) i test_len return pd.DataFrame(rows)每个测试段的样本外收益拼起来整体为正而且段间离散度不高参数才有外推价值。现在很多量化比赛和测评以样本外收益为排名标准全样本网格搜索的选手常常排名靠后原因就在这。5.3 三个现场验证技巧先怀疑代码再怀疑行情第一做成交价压力测试。所有触发价统一加一跳滑点再回测如果盈亏比和胜率剧烈恶化说明策略依赖理想成交。DualThrust 是触价成交型策略对滑点天然敏感这一步不能省。第二监控换手率。日线五年、反手模式下正常的交易量级是 100 到 300 笔如果跑出几千笔说明上下轨离开盘价太近策略已经退化成频繁止损器。先检查 Ks、Kx 是否设得太小再检查数据里有没有重复 bar。第三用极端行情窗口重跑。把 2015 年股指行情、连续涨跌停的期货合约单独切开验证跳空越过触发线时按 open 成交的假设不成立要把这类 bar 的滑点单独计提。拿一个期货主力合约的十五分钟数据n 取 60、Ks 取 0.6、Kx 取 0.4 先跑一遍看 5.1 的热力图是否成片再做 5.2 的前推最后用 5.3 的滑点测试收口过完这三关再谈上实盘。本文还有配套的精品资源点击获取