第 3 章 · 02 Dual Thrust:前日 OHLC 定上下沿 本节摘要:本节精读 Dual Thrust——和 London Breakout 同属"开盘区间突破"家族,但哲学相反。London Breakout 用当日东京盘当阈值,Dual Thrust 用前 N 日 OHLC当阈值;London Breakout 有 ±50bp 止盈止损,Dual Thrust 完全没有止盈止损,全靠日终清仓和"翻仓"机制(±2 信号)管理风险。我们重点讲清楚 min2day 怎么把 1 分钟数据聚合成日级 OHLC、range1/range2 怎么用滚动窗口算出来、signalgeneration 怎么用 检测"翻仓"时机发出 ±2 反向信号。
本节摘要:本节精读 Dual Thrust——和 London Breakout 同属"开盘区间突破"家族,但哲学相反。London Breakout 用当日东京盘当阈值,Dual Thrust 用前 N 日 OHLC当阈值;London Breakout 有 ±50bp 止盈止损,Dual Thrust 完全没有止盈止损,全靠日终清仓和"翻仓"机制(±2 信号)管理风险。我们重点讲清楚 min2day 怎么把 1 分钟数据聚合成日级 OHLC、range1/range2 怎么用滚动窗口算出来、signal_generation 怎么用
cumsum==0检测"翻仓"时机发出 ±2 反向信号。
涉及源码:原项目
Dual Thrust backtest.py(约 234 行),数据gbpusd.csv(同 London Breakout)。
⚠️ 注意:作者开篇调侃"Google 搜 dual thrust 出来全是火箭发动机——别慌,这策略远不是火箭科学,数学只有小学水平"。意思是这套策略的数学只有 max/min/加减乘除,真正的难点在信号约定和翻仓时序,而不是公式。
阅读完本节,你应当能够:
range1/range2 的差别。param=0.5 为什么是"多空对等"。cumsum==0 且价格穿过另一端阈值,发 +2 或 -2。"开盘区间突破"(Opening Range Breakout,ORB)是个大家族,所有成员都干同一件事:先用某段历史的最高/最低价定义上下沿,开盘后突破上沿做多、突破下沿做空。区别只在"那段历史"是什么:
| 策略 | 阈值来源 | 时间窗 |
|---|---|---|
| London Breakout | 当日东京最后一小时 | 当日 GMT 7:00-7:59 |
| Dual Thrust | 前 N 日的 OHLC 统计 | 滚动窗口 rg=5 |
Dual Thrust 由 Universal Technical Systems 公司创始人 Michael Chalek 推广,作者注释里直接给了 quantconnect 教程链接。它的核心思想是:用过去几天真实的波动幅度(range)做突破半径,这样阈值会自适应市场波动——市场近期波动大,阈值自动变宽;波动小,阈值自动变窄。
Dual Thrust 最"小学数学"的部分是 range 的定义。给定过去 rg=5 天的 OHLC,定义两个候选 range:
range1 = max(High_n) - min(Close_n) range2 = max(Close_n) - min(Low_n) range = max(range1, range2)
为什么要两个?这是对"日内真实波动幅度"的两种保守估计:
range1 衡量"最高价到收盘价的跨度"——上限取最高、下限取最弱收盘。range2 衡量"最强收盘到最低价的跨度"——上限取最强收盘、下限取最低。取两者较大者,意味着总是按更悲观(更宽)的估计设阈值,避免单边低估。这是非常工程化的"防御性设计",不追求最优,只追求不被打脸。
定义上下沿时引入参数 param(本策略 0.5):
sigup = param * range + 当前价 # 上沿(做多阈值) siglo = -(1-param) * range + 当前价 # 下沿(做空阈值)
param=0.5 意味着多空对称——上沿离当前价 0.5 个 range,下沿离当前价 -0.5 个 range,两边距离相等。如果 param=0.7,则上沿更宽(更难触发做多)、下沿更窄(更容易触发做空),表达"看空偏好"。作者注释:"normally ppl use 0.5 to give long and short 50/50 chance to trigger"。
这是本策略最反直觉的地方。London Breakout 用 ±50bp 紧止损保护单子,Dual Thrust 完全不用。作者在 L15-16 注释里直说:"However, there is no stop long/short position in this strategy. We clear all positions at the end of the day"。它靠两道防线:
这种哲学假设:只要不过夜,日内波动可控;一旦方向反转,立即翻仓跟随。代价是没有"小幅止盈"机制,要么日终、要么翻仓。
本策略的 signals 列取值更丰富:0 不动、+1 首次做多、-1 首次做空、+2 翻仓做多(从空翻多)、-2 翻仓做空(从多翻空)。cumsum 同样是当前持仓张数(允许 ±1,因为单次入场被 cumsum 卡住)。翻仓时发 ±2 是为了一步到位——cumsum 从 -1 跳到 +1 需要净 +2 的信号,所以单根 K 线就发 +2。
Dual Thrust backtest.py:43-87。这个函数是回测效率的关键——作者特意把它从 for 循环里抽出来,因为"实时交易时算不了未来,但回测时所有数据已知,可以预算好日级表,否则时间复杂度爆炸"。
def min2day(df,column,year,month,rg): memo={'date':[],'open':[],'close':[],'high':[],'low':[]} for i in range(1,32): try: # 取每日 EST 3:00-12:00 的分钟切片 temp=df['%s-%s-%s 3:00:00'%(year,month,i):'%s-%s-%s 12:00:00'%(year,month,i)][column] memo['open'].append(temp[0]) # 切片第一根 = 日开盘 memo['close'].append(temp[-1]) # 切片最后一根 = 日收盘 memo['high'].append(max(temp)) # 切片最高 memo['low'].append(min(temp)) # 切片最低 memo['date'].append('%s-%s-%s'%(year,month,i)) except Exception: pass
注意几个细节:
temp 为空,后续 temp[0] 抛 IndexError 被 except 接住。作者特意提醒:"date appendix 必须放在 try 内部最后",否则周末日期会先入表,导致所有列表长度不一致。随后算 range(L83-85):
intraday['range1']=intraday['high'].rolling(rg).max()-intraday['close'].rolling(rg).min() intraday['range2']=intraday['close'].rolling(rg).max()-intraday['low'].rolling(rg).min() intraday['range']=np.where(intraday['range1']>intraday['range2'],intraday['range1'],intraday['range2'])
rolling(rg).max() 是过去 5 天最高价的滚动最大值,rolling(rg).min() 是过去 5 天收盘价的滚动最小值。两者相减就是 range1。np.where 取较大者——和"小学数学"的描述完全吻合。
Dual Thrust backtest.py:94-166。for 循环遍历分钟数据,每天干三件事。
第一段 EST 3:00 定阈值(L127-129):
if (i.hour==3 and i.minute==0): sigup=float(param*intraday['range'][date]+pd.Series(signals[column])[i]) siglo=float(-(1-param)*intraday['range'][date]+pd.Series(signals[column])[i])
每天伦敦开盘第一根,从预算好的 intraday 表里取出当天的 range,叠加当前价格得到上下沿。date 变量是字符串(由 i 的 year/month/day 拼成),用来在日级表的字符串索引里查值。
第二段 破阈入场 + 翻仓(L133-152):
# 破上沿 if (sigup!=0 and pd.Series(signals[column])[i]>sigup): signals.at[i,'signals']=1 # 破下沿 if (siglo!=0 and pd.Series(signals[column])[i]<siglo): signals.at[i,'signals']=-1 # cumsum 限单次 if pd.Series(signals['signals'])[i]!=0: signals['cumsum']=signals['signals'].cumsum() if (pd.Series(signals['cumsum'])[i]>1 or pd.Series(signals['cumsum'])[i]<-1): signals.at[i,'signals']=0 # 翻仓:cumsum 回到 0 说明先前的反向仓位刚平掉,此时再破阈就翻仓 if (pd.Series(signals['cumsum'])[i]==0): if (pd.Series(signals[column])[i]>sigup): signals.at[i,'signals']=2 if (pd.Series(signals[column])[i]<siglo): signals.at[i,'signals']=-2
这里的精妙在于翻仓检测:cumsum[i]==0 意味着之前的入场信号已经被某个反向信号抵消(净持仓归零),此时如果价格又穿过阈值,就发 ±2 一步翻仓到位。例如先做多(cumsum=1),价格跌回下沿触发 -1(cumsum=0),紧接着价格又穿下沿——此时 cumsum=0,发 -2 直接做空到 -1。这条路径让单日多次反转的行情也能持续跟随。
第三段 EST 12:00 强制平仓(L157-160):
if i.hour==12 and i.minute==0: sigup,siglo=float(0),float(0) signals['cumsum']=signals['signals'].cumsum() signals.at[i,'signals']=-signals['cumsum'][i:i]
和 London Breakout 一模一样——伦敦收盘 EST 12:00,清零阈值,-cumsum 一键平仓。

图中蓝色阴影是 upper/lower 阈值带,可以看到每日开盘后阈值带是恒定的(由前 5 日 range 决定),绿色三角是多、红色倒三角是空。
🎯 反直觉发现:本策略无止盈止损,全靠日终清仓和反向翻仓。这和直觉相悖——大多数教程都会教你"必须设止损"。但 Dual Thrust 的逻辑是:止损的前提是"知道自己在错的方向上",而日内突破策略一旦突破,假设就是方向已确立。如果突破后立刻止损,等于承认突破信号本身就是噪音;如果不止损让行情跑,要么日终平掉损失可控,要么翻仓跟随新方向。这是一种反止损的趋势跟随哲学,适合趋势市、不适合震荡市——震荡市里没有止损会被反复打脸翻仓。
date 字符串索引用于跨频表查询:'%s-%s-%s'%(i.year,i.month,i.day) 把分钟索引转成字符串,再去日级表查 range——这是 pandas 跨频对齐的轻量做法,不用 merge/join。cumsum==0:这是 Dual Thrust 区别于 London Breakout 的核心——London 只首次入场,Dual 允许日内多次反转跟随。pd.Series(signals[column])[i] 反复读:作者大量用 pd.Series(...) 包装后用 [i] 索引,是因为 signals 同时有 Index 和列,直接 signals[column][i] 在某些 pandas 版本会按位置而非标签解析,pd.Series 强制按标签。这是兼容性写法,不是炫技。param 调多空偏好:0.5 对称,>0.5 偏空(下沿更近、更易触发做空),<0.5 偏多。改 param 是最直接的多空调钮。range1=max(High)-min(Close)、range2=max(Close)-min(Low) 取较大,小学数学但保守。param=0.5 多空对等,改 param 调多空偏好。cumsum==0 触发)管理风险。下一节,我们从"时段结构"转向"K 线形态"——Shooting Star 流星线,看作者怎么用 8 个 Investopedia 条件严格定义一根 K 线,以及它和 hammer 锤子线的"邪恶孪生"关系。