简介:锁仓顺势加码EA源码面向外汇自动化交易学习者与MetaTrader策略开发者,围绕锁仓、顺势加码、对冲单管理和自动止盈等核心逻辑,提供可研读的MQL4专家顾问源码文档,帮助理解手动订单与对冲订单的协同处理。压缩包仅含1个doc文件,约45KB,内容以源码、注释和参数说明为主,涵盖ATR相关开关、对冲止盈水平、自动止盈等参数,以及订单筛选、平仓和锁仓加码等关键函数。已有1381人学习,适合有一定外汇交易基础、希望拆解EA订单管理与风险控制实现的读者。阅读后可掌握锁仓加码触发条件、对冲单止盈设置、手动订单主动止盈判定,以及平仓与订单修改的代码组织方式,为参数调整、策略复现和运行排错提供参考。
1. 一张手动单挂上去,它在下面接一张对锁
第一次拆这份锁仓顺势加码 EA 源码,我盯着那个不到两百行的Hedge_Assistante()看了很久。它干的事其实很朴素:你在 MT4 上手敲一张外汇多单,它就在下方按 ATR 距离挂一张等量空单,把仓位锁成一个盒子;盒子里的手动单只要浮盈到 10 到 17 个点,它替你平掉,回头再把盒子的另一条腿清走。整套逻辑没有神经网络,没有花哨的指标组合,就是订单池全表扫描加挂单管理,2012 年的写法,直来直去。
这份 EA 源码适合两类人:一类是想搞清楚「锁仓对冲」在代码层面到底怎么落地的新手,另一类是手里有半成品 EA、想抄一套订单配对模型的老手。它不适合拿来直接挂实盘,原因后面几章会讲透,但作为一份拆解范本,它的信息密度相当高,几乎每一个if背后都对应着一次真实的踩坑。
2. 工程骨架与运行环境:一个 .mq4 文件怎么在现代 MT4 里跑起来
2.1 全部依赖就是一个 .mq4 加一个标准库头
2012 年前后的 MQL4 工程基本没有工程化的概念,一个 EA 就是一个.mq4文件,编译出一个.ex4,扔进MQL4/Experts/目录,重启客户端或刷新导航器就能在图表上挂载。这份源码也遵循同样的形态,文件头部只有两行空的属性声明:
#property copyright "" #property link "" #include <stdlib.mqh> extern bool USE_ATR = true; extern int PercentATR = 40; // 40% ATR extern double HedgingLevel = 30; extern bool AutoTakeProfit = false;stdlib.mqh是 MQL4 自带的标准库头文件,这里唯一被用到的能力是ErrorDescription(GetLastError()),把错误码翻译成人话。少了这个头文件,OrderSend、OrderClose、OrderModify失败时日志里只有一串数字,排查全靠背错误码表,所以这行 include 属于刚需,不是装饰。
#property两行都是空字符串,说明作者当年也没打算发布,只是自己跑。这类源码在二手流转里很常见,拿到手第一件事不是读策略,而是先确认文件完整性:文件末尾的Hedge_Assistante()大括号闭合是否完整、有没有被论坛的回帖截断。我见过好几次拷贝漏了最后两个},编译报错报在莫名其妙的位置。
目录结构大致是这样,如果你手上是打包的一整份资源,对照着放就行:
MQL4/ ├── Experts/ │ └── HedgeAssistant.mq4 # 本份源码,主文件 ├── Include/ │ └── stdlib.mqh # 客户端自带,不需要手动拷 ├── Indicators/ # 本 EA 不依赖自定义指标 └── Libraries/ # 本 EA 不依赖 DLL整个项目没有 DLL、没有自定义指标、没有外部数据文件,纯逻辑。这意味着它的可移植性极好,拷到任何一台装了 MT4 的机器上都能编译;也意味着它没有任何防呆设计,所有风险都压在运行时的订单判断上。
2.2 四个 extern 参数决定了这套逻辑的全部行为
参数少是好事,但这四个参数之间存在耦合,不能孤立地看。先把它们的关系摆清楚:
| 参数名 | 类型 | 默认值 | 实际含义 | 什么时候改 |
|---|---|---|---|---|
USE_ATR | bool | true | 是否用日线 ATR(14) 动态算对锁距离 | 想做固定点数对锁时改false |
PercentATR | int | 40 | ATR 的百分比系数,40表示取 ATR 的 40% | 只在USE_ATR=true时生效 |
HedgingLevel | double | 30 | 双身份:USE_ATR=true时被覆盖成价格距离;false时表示 30 个 Point | 固定点数模式下调这个 |
AutoTakeProfit | bool | false | 是否对手动单做自动止盈 | 默认关闭,只做对锁不止盈 |
最容易看错的是HedgingLevel。它声明成extern double,看起来是个用户可以配置的常量,但在函数体里被这样覆盖:
if (USE_ATR) HedgingLevel = iATR(NULL, PERIOD_D1, 14, 0) * PercentATR / 100; else HedgingLevel = HedgingLevel * Point;USE_ATR=true时,HedgingLevel变成一个小数价格距离,比如 EURUSD 日线 ATR(14) 大约 0.0080,乘 40% 得到 0.0032,也就是 32 个标准点;USE_ATR=false时,它被乘上Point,变成以报价单位计的距离,30 个 Point 在四位报价的 EURUSD 上是 0.0030。
注意:
Point在五位报价的经纪商那里是0.00001,同样填30,实际距离只有 3 个标准点,几乎是贴脸开仓。这段代码诞生在四位报价年代,直接搬到现在的账户上会变形,第五章会讲怎么修。
AutoTakeProfit默认false这一点值得单独强调。很多人拿到源码挂上去,发现只有对锁、没有止盈,以为是坏了,其实是默认没开。这个设计是合理的——止盈逻辑里写死了 10 点和 17 点,作者自己也知道不能默认开。
2.3 从编译到首跑:新版 MT4 的入口兼容问题
这份源码的入口是start(),这是 build 509 之前的老写法。从 build 600 开始,MQL4 向 MQL5 的语法靠拢,start()不再被终端自动调用,取而代之的是OnTick()、OnInit()、OnDeinit()这一套事件函数。
直接编译,代码能过,但挂到图表上什么都不会发生,日志里干干净净,这就是最典型的「玄学」现场——代码没错,就是不动。解决办法不用重写整个文件,加一层转发就行:
// 新版 MT4(build 600+) 用事件函数驱动,老逻辑一行不改 void OnTick() { start(); // 把 2012 年的原始入口保留下来,减少改动面 } int OnInit() { Print("HedgeAssistant loaded. USE_ATR=", USE_ATR, " PercentATR=", PercentATR, " HedgingLevel=", HedgingLevel, " AutoTakeProfit=", AutoTakeProfit); return(INIT_SUCCEEDED); } int start() { Hedge_Assistante(); return(0); }OnTick()里只做一件事,把控制权交给老入口;OnInit()里打一行参数快照,这行日志在后续排查里价值极高,因为它把「当时挂载时参数是什么」固定了下来,事后回头看日志就能判断是不是参数被改过。
首跑不要上实盘,也不要用真实账户的模拟版,用经纪商给的 demo 账户。冒烟测试的步骤我一般这么走:
第一步,把AutoTakeProfit设为false、USE_ATR设为false、HedgingLevel设为300(放大距离,避免行情一波动就触发),编译挂载。
第二步,手动开一张 0.01 手 EURUSD 多单,看两个 tick 之内是否出现一张 0.01 手的 SELLSTOP,挂单价位在当前价下方 300 个 Point 处,订单注释里应该带Hedging <你的手动单Ticket>。
第三步,手动平掉那张多单,再看那张 SELLSTOP 是否在下一次 tick 被删除。
三步都过,说明订单扫描、挂单生成、挂单清理三条链路都活着,再往下才是调策略参数。跳过冒烟测试直接上参数,出了问题根本分不清是参数错还是链路断。
3. 核心链路拆解:一次 Hedge_Assistante 巡检到底做了什么
3.1 单入口无状态,每个 tick 全量重算
start()里只有一行Hedge_Assistante();,这个设计决定了整个 EA 的运行模型:没有全局状态机,没有持仓缓存,每个 tick 来了就把订单池从头到尾扫一遍,该建的建、该删的删、该改的改。好处是自愈能力强,中途重启终端、手动干预、断线重连,下一轮扫描自动对齐;坏处是复杂度直接顶到 O(n²),订单多了会拖慢响应。
Hedge_Assistante()的内部结构其实是四段串联,顺序不能乱:先做止盈,再算对锁距离,再补对冲单,再清孤儿单,最后抹手动单止盈。这个顺序有依赖关系——止盈可能把手动单平掉,平掉之后对冲单就该清,所以止盈必须排在最前面;v2.2 的止盈清零必须排在最后,因为只有确认对冲单已经存在,才敢动止盈。
3.2 手动单识别:三个条件缺一不可
整套逻辑的地基是「怎么认定一张单是用户手敲的」。代码里的判据是:
if(Symbol() == OrderSymbol() && OrderMagicNumber() == 0 && OrderProfit() != 0) // Actived! { // 认定这是一张手动市价单 }三个条件各管一件事。Symbol() == OrderSymbol()保证只看当前图表品种,避免在多品种账户上误伤;OrderMagicNumber() == 0是核心,EA 自己下的单都会带 Magic,手动单默认 Magic 为 0,用这个把两类单分开;OrderProfit() != 0是用来排除挂单的,因为未成交的挂单没有浮盈,恒为 0。
这个判据的漏洞正好在第三个条件上。市价单刚成交的瞬间,如果成交价和当前 Bid/Ask 完全一致,浮盈确实可能是 0,这一轮扫描就会把它当成挂单跳过。等到下一个 tick 点差一动,浮盈变成负数,它才被认出来。绝大多数情况下这只造成一两秒的延迟,但在点差极低、流动性极好的账户上,偶尔会看到「开了单但没对锁」的空窗。
参数说明:OrderProfit()返回的是以账户货币计的浮动盈亏(不含 swap 和 commission),所以严格来说这个判据测的是「有没有浮盈」,不是「有没有成交」。更稳的写法是用OrderType() < 2判断是否为市价单,下一章的改造会用到。
3.3 自动止盈分支:17 点和 10 点的硬编码
AutoTakeProfit打开后,买方向的止盈逻辑长这样:
if(Symbol() == "GBPUSD") TP = 17; else TP = 10; if(OrderType() == OP_BUY) { // 已经赚够点数,直接市价平 if(Bid - OrderOpenPrice() >= TP * Point) { if(OrderClose(OrderTicket(), OrderLots(), Bid, 2, 0) == true) { Alert("Good Job! 自动止盈@", Bid, " ", OrderTicket(), " ", OrderTakeProfit(), " - ", OrderOpenPrice(), " = ", OrderTakeProfit() - OrderOpenPrice(), " X ", OrderLots(), " =", OrderProfit()); return(0); } else Alert(OrderTicket(), " Close Order err---->", ErrorDescription(GetLastError())); } // 没赚够,但止盈价设得不对,改成 OpenPrice + TP*Point if(OrderTakeProfit() - OrderOpenPrice() < (TP - 1) * Point || OrderTakeProfit() - OrderOpenPrice() > (TP + 1) * Point) { if(OrderModify(OrderTicket(), OrderOpenPrice(), OrderStopLoss(), OrderOpenPrice() + TP * Point, 0, 0) == true) { Alert(OrderTicket(), " ", Symbol(), " Buy=", OrderOpenPrice(), " 设置止盈@", OrderOpenPrice() + TP * Point); Sleep(10000); } } }这段代码里有三层设计,值得一层层剥。
第一层是「到了就直接平」。注意它不只是设止盈价等市场来打,而是主动检查Bid - OrderOpenPrice() >= TP * Point,一旦浮盈够数就OrderClose市价平。这个做法的实战意义是:止盈价可能因为滑点、跳空没成交,主动平仓能保证落袋。代价是多消耗一次点差。
第二层是「容差修正」。(TP - 1) * Point到(TP + 1) * Point这个区间是容差带,止盈价落在带内就不动,落在带外才OrderModify。没有这个容差,每个 tick 都会尝试改一次止盈,日志会被刷爆,而且频繁改单在部分经纪商那里会被限流。
注意:
OrderModify的第三个参数传的是OrderStopLoss(),也就是保留原止损。这一点非常关键,因为最后一个代码块(v2.2 那段)传的是0,两处的行为完全不同,混起来会让止损莫名消失。
第三层是Sleep(10000)。改完止盈后硬睡 10 秒,作者的意图是避免同一张单被反复处理。但Sleep在 EA 里是阻塞式的,这 10 秒里整个start()停摆,所有 tick 堆积在队列里,行情剧烈时这 10 秒足够发生很多事。这是个典型的 2012 年写法,后面章节会讲怎么用状态标记替代它。
TP的两个硬编码值——GBPUSD 17 点、其他品种 10 点——是这份源码里最需要改的地方。它假设的是四位报价,且假设 GBPUSD 波动更大所以要给更宽的止盈。不同品种的点值、点差、日内波动差异很大,固定值必然失效。
3.4 ATR 换算对锁距离,以及 SELLSTOP/BUYSTOP 怎么挂
算完距离之后,代码第二次遍历订单池,找那些「还没有配对对冲单」的手动单,补挂一张反向 STOP 单:
for (int i = 0; i < OrdersTotal(); i++) { if(OrderSelect(i, SELECT_BY_POS, MODE_TRADES) == false) break; if(Symbol() == OrderSymbol() && OrderMagicNumber() == 0 && OrderProfit() != 0) { Hedge = 0; Ticket = OrderTicket(); Type = OrderType(); double Lots = OrderLots(), OpenPrice = OrderOpenPrice(); // 内层循环:看有没有哪张对冲单的 Magic 等于这张手动单的 Ticket for (int j = i; j < OrdersTotal(); j++) { if(OrderSelect(j, SELECT_BY_POS, MODE_TRADES) == false) break; if(Ticket == OrderMagicNumber()) Hedge++; } if (Hedge == 0) // 没有配对单,补一张 { if (Type == OP_BUY) { Tic = OrderSend(Symbol(), OP_SELLSTOP, Lots, NormalizeDouble(OpenPrice - HedgingLevel, Digits), 0, 0, 0, "Hedging " + DoubleToStr(Ticket, 0), Ticket, 0, Green); if(Tic > 0) Alert(Ticket, "的对冲单:", Tic, " 生成 SELL STOP:", NormalizeDouble(OpenPrice - HedgingLevel, Digits), " ", Lots, "Lots"); } if (Type == OP_SELL) { Tic = OrderSend(Symbol(), OP_BUYSTOP, Lots, NormalizeDouble(OpenPrice + HedgingLevel, Digits), 0, 0, 0, "Hedging " + DoubleToStr(Ticket, 0), Ticket, 0, Red); } } } }这套配对模型的精髓在于MagicNumber字段的挪用。MQL4 的OrderSend第九个参数是 Magic,本来是用来标识 EA 身份的整数,这里被塞进了「被保护的那张手动单的 Ticket 号」。于是对冲单和手动单之间建立起一条单向引用:拿对冲单的 Magic 去比对手动单的 Ticket,相等就是一对。
这个技巧很实用,因为它不需要额外的数据结构,订单池自己就是映射表。但它有两个隐含约束:第一,Ticket 和 Magic 都是int,Ticket 号在绝大多数经纪商那里不会溢出,但严格来说不是标准用法;第二,拿DoubleToStr(Ticket, 0)写进订单注释,是给人看的冗余信息,程序判断只认 Magic。
再看挂单方向和距离。买单手在开仓价下方HedgingLevel处挂 SELLSTOP,卖单手在上方挂 BUYSTOP,手数完全相同。这是标准的锁仓结构:价格跌到下方,空单成交,多空对冲,浮亏锁死;价格涨上去,多单盈利,SELLSTOP 一直挂着不动,相当于一个止盈保护之外的备用出口。
NormalizeDouble(..., Digits)不能省。不同品种的小数位不同,EURUSD 是 5 位、USDJPY 是 3 位,不规范化就会因为价格精度不对被经纪商拒单,错误码 130。
OrderSend里 Slippage 参数给的是0,对挂单影响不大,但对后面的OrderClose就给了2,这个差异要留意——挂单不需要滑点容忍,市价平仓需要。
3.5 孤儿对冲单清理:用 Magic 反查 Ticket
第三段逻辑反向扫描:如果一张对冲单的对手方(手动单)已经不存在了,这张对冲单就该删掉。
for (i = 0; i < OrdersTotal(); i++) { if(OrderSelect(i, SELECT_BY_POS, MODE_TRADES) == false) break; if(Symbol() == OrderSymbol() && OrderMagicNumber() != 0) // 这是一张对冲单 { Hedge = 0; Ticket = OrderTicket(); int Magic = OrderMagicNumber(); for(j = 0; j < OrdersTotal(); j++) // 找它的对手方还在不在 { if(OrderSelect(j, SELECT_BY_POS, MODE_TRADES) == false) break; if(Magic == OrderTicket()) Hedge++; // 对冲单的 Magic 比对手动单的 Ticket } // 只有挂单类型(OP_BUYSTOP/OP_SELLSTOP)才允许自动删除 if (Hedge == 0 && OrderType() > 1) OrderDelete(Ticket); } }OrderType() > 1这个条件是有来历的。MQL4 的订单类型枚举里,OP_BUY=0、OP_SELL=1、OP_BUYLIMIT=2、OP_SELLLIMIT=3、OP_BUYSTOP=4、OP_SELLSTOP=5。> 1意味着只处理挂单,已经成交的市价对冲单不碰。
文件头的版本注释把这个决定写得很清楚:
//2007.01.03 V2.1 对于已经成交的对冲单,系统不能自动平仓或删除,需要手动平仓.这是作者的血泪经验。已成交的对冲单代表仓位已经被锁住了,如果程序自动平掉一条腿,另一条腿就裸奔,风险敞口瞬间放大。所以设计上直接把已成交对冲单的处置权交给人,程序只负责清理还没来得及成交的挂单。
这条边界一划,整个 EA 的定位就清楚了:它是「建锁工具」,不是「自动交易系统」。解锁需要人来判断,这是个负责任的取舍。
3.6 v2.2 的止盈清零:锁上之后就别想着两头赚
最后一段是版本注释里说的 v2.2 改动:
//v2.2 revise manul order TP=0 if Hedge order have created. for (i = 0; i < OrdersTotal(); i++) { if(OrderSelect(i, SELECT_BY_POS) == false) continue; if(Symbol() == OrderSymbol() && OrderMagicNumber() != 0 && OrderType() < 2) { Ticket = OrderMagicNumber(); // 反查对手动单 if(OrderSelect(Ticket, SELECT_BY_TICKET) == false) break; if(OrderTakeProfit() != 0) if(OrderModify(Ticket, OrderOpenPrice(), 0, 0, 0, 0)) Alert(Ticket, "对冲单已生成, 系统将止盈改为零,需要手动开锁!"); } }这段的触发条件是「已经存在一张成交状态的对冲单」(OrderMagicNumber() != 0 && OrderType() < 2),一旦成立,就顺着 Magic 找到那张手动单,把它原本设好的止盈抹成 0。
为什么必须抹?因为如果手动单还挂着止盈,价格朝有利方向走到止盈位,手动单被平掉,而对冲单(此时已是市价单)还留在场上变成裸单。抹掉止盈,两条腿就绑在一起,要解锁只能靠人。
日志那句话说得直白:「需要手动开锁」。
注意:这一句
OrderModify(Ticket, OrderOpenPrice(), 0, 0, 0, 0)的第三个参数是 0,意味着止损也被一起清零了。前面止盈分支里传的是OrderStopLoss()保留止损,这里传 0,两处不一致。如果你原本手动单上带了止损,跑到这段就没了。这是我在实盘复盘时最想骂人的一处。
4. 常见问题排查:五个能把账户跑乱的现场
4.1 现象:挂上去几分钟内冒出十几张对冲单
现象很好认,日志区连续刷Alert,订单列表里一堆同价位同手数的 SELLSTOP,全指向同一张手动单。
原因有两个叠加。第一是 3.2 节说的OrderProfit() != 0判据不稳,市价单刚成交那一两个 tick 浮盈可能为 0,被判成挂单跳过;等浮盈出现,它才被当成手动单处理,此时如果外层循环还在跑同一个 tick 内的后续逻辑,就可能重复进入建单分支。第二是OrderSend成功后有网络延迟,下一张单还没出现在OrdersTotal()里,下一个 tick 扫描时Hedge==0依然成立,于是又补一张。
解决思路是把「已处理」这件事显式记下来,而不是靠订单池自己去重。我一般用一个固定的 Magic 段位做锁:建单前先检查有没有同 Ticket 的对冲单,建完立刻在全局数组里记一条Ticket -> 建单时间,一分钟内不再重复处理同一张单。更简单的做法是把OrderSend之后加一个Sleep(500),虽然丑但立竿见影。
4.2 现象:日志刷OrderSend error 130
错误码 130 是ERR_INVALID_STOPS,意思是止损/止盈/挂单价距离市价太近,违反了经纪商的最小停价距离。
原因在 2.2 节已经埋下:HedgingLevel在USE_ATR=false时是HedgingLevel * Point,五位报价账户上Point=0.00001,填 30 实际只有 3 个标准点;而很多经纪商的STOPLEVEL要求至少 10 到 20 个标准点。距离不够,挂单直接被拒。
解决要分两步。第一步是在OnInit()里读MarketInfo(Symbol(), MODE_STOPLEVEL),把最小距离打出来;第二步是在建单前做一次兜底:
double minDist = MarketInfo(Symbol(), MODE_STOPLEVEL) * Point; double realDist = HedgingLevel; if(realDist < minDist) { Print("HedgingLevel ", realDist, " 小于最小停价距离 ", minDist, ",已自动上调"); realDist = minDist * 1.5; } double hedgePrice = NormalizeDouble(OpenPrice - realDist, Digits);注意MODE_STOPLEVEL返回的单位是 Point 数,要乘Point换成价格。* 1.5是留缓冲,因为从计算到发单之间价格会动。
4.3 现象:手动单上的止损莫名其妙没了
这个坑最隐蔽,因为它不报错,只是某天复盘发现本该有止损的单子光着。
原因就是 3.6 节那段OrderModify(Ticket, OrderOpenPrice(), 0, 0, 0, 0)。作者在这里只想清止盈,但 MQL4 的OrderModify是「全量覆盖」语义:你传什么就是什么,传 0 就是清空。止损、止盈、价格三个字段一起被改。
解决很简单,改一行:
OrderModify(Ticket, OrderOpenPrice(), OrderStopLoss(), 0, 0, 0)第二个参数保留原止损,第三个参数传 0 只清止盈。改完记得在模拟盘上验证:手动开一张带止损的单,等对冲单生成,然后检查止损是否还在。
4.4 现象:对冲单删不掉,日志一直刷 OrderDelete 失败
OrderDelete返回 false,GetLastError()拿到 4104 或 4051 之类。
原因通常是循环里删单导致索引错位。for (i = 0; i < OrdersTotal(); i++)这种正序遍历,删掉第 i 张之后,原来的第 i+1 张会挪到第 i 位,下一轮i++就跳过了它;如果被跳过的那张也是该删的,就永远删不掉。更糟的情况是OrderSelect(j, ...) == false时用了break,直接跳出整个循环,后面的订单全都不处理。
解决是倒序遍历,从OrdersTotal() - 1开始往下走,删除不会影响尚未访问的索引:
for (int i = OrdersTotal() - 1; i >= 0; i--) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; // 用 continue,不用 break if(OrderSymbol() != Symbol()) continue; if(OrderMagicNumber() == 0) continue; if(OrderType() < 2) continue; // 已成交的不动 if(!OrderDelete(OrderTicket())) Print("Delete ", OrderTicket(), " failed: ", ErrorDescription(GetLastError())); }continue和break的区别在这里不是风格问题,是功能问题。OrderSelect失败往往是因为那张单在两次调用之间被别人平掉了,属于正常竞争,跳过就行,没必要终止整轮扫描。
4.5 现象:回测曲线漂亮,实盘一跑就变形
回测里胜率七成,实盘跑一周发现完全对不上,甚至方向相反。
原因有三层。第一层是Sleep(10000),回测环境下Sleep被忽略或大幅压缩,实盘里它是实打实阻塞 10 秒,这 10 秒内所有 tick 都排队,处理顺序和回测完全不同。第二层是点差模型,OrderClose用的是Bid/Ask,回测里点差固定,实盘里点差随行情波动,10 个点的止盈在点差 2 个点的账户上净赚 8 点,在点差 15 个点的账户上直接是亏的。第三层是 ATR,iATR(NULL, PERIOD_D1, 14, 0)取的是日线数据,回测时历史数据完整,实盘第一天挂上去日线数据可能还没下载完,ATR 返回 0,HedgingLevel变成 0,挂单价等于开仓价,直接被拒。
排查顺序我一般是:先在实盘日志里搜ATR(14)=那行Comment输出,确认 ATR 不是 0;再确认账户点差,用MarketInfo(Symbol(), MODE_SPREAD)打日志;最后把Sleep注释掉再跑一遍,对比差异。三步走完,基本能定位到是哪一层。
5. 二次开发的改造清单:把 2012 年的过程式脚本拆成可维护模块
5.1 第一步:把硬编码的止盈点数提成参数
3.3 节那两个17和10必须挪出来。改法不是简单加两个 extern,而是按品种特性做分层:
extern int TP_GBPUSD = 170; // 单位:Point,五位报价下 170 Point = 17 pip extern int TP_Default = 100; extern int TP_Min = 30; // 低于这个值不设止盈,交给人工 int GetTakeProfitPoints(string sym) { int tp = TP_Default; if(StringFind(sym, "GBP") >= 0) tp = TP_GBPUSD; // 用 Point 归一化,兼容四位/五位报价 double pip = (Digits == 3 || Digits == 5) ? 10.0 : 1.0; tp = (int)(tp * pip); if(tp < TP_Min) tp = TP_Min; return tp; }关键在最后那段归一化。Digits是 3 或 5 时说明是日元三位报价或非日元五位报价,一个标准 pip 等于 10 个 Point;否则是四位/两位报价,1 pip = 1 Point。这样参数写的是 pip,代码自动换成 Point,换经纪商不用改参数。
TP_Min的存在是为了挡住「止盈比点差还小」的荒唐配置。10 个点的止盈在点差 2 个点的账户上还有意义,在点差 20 个点的账户上纯粹是给经纪商送钱。
5.2 第二步:三段全表扫描合成一次快照
原始代码对订单池做了四次完整遍历(止盈、建对冲、清孤儿、清止盈),每次遍历里还有内层遍历,最坏 O(n²)。订单少的时候无所谓,一旦手动单堆到十几张,延迟肉眼可见。
我的做法是先扫一遍,把订单按角色分装进两个数组,后续所有判断都在内存里做:
struct OrderInfo { int ticket; int type; int magic; double lots; double openPrice; double sl; double tp; double profit; }; OrderInfo manuals[]; OrderInfo hedges[]; void SnapshotOrders() { ArrayResize(manuals, 0); ArrayResize(hedges, 0); int total = OrdersTotal(); for(int i = 0; i < total; i++) { if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue; if(OrderSymbol() != Symbol()) continue; OrderInfo o; o.ticket = OrderTicket(); o.type = OrderType(); o.magic = OrderMagicNumber(); o.lots = OrderLots(); o.openPrice = OrderOpenPrice(); o.sl = OrderStopLoss(); o.tp = OrderTakeProfit(); o.profit = OrderProfit(); if(o.magic == 0) { int n = ArraySize(manuals); ArrayResize(manuals, n+1); manuals[n] = o; } else { int m = ArraySize(hedges); ArrayResize(hedges, m+1); hedges[m] = o; } } }struct在 MQL4 里是支持的,配合动态数组用起来比反复OrderSelect舒服得多。分成manuals和hedges两个数组之后,「这张手动单有没有配对」就变成一次线性查找,ArraySize(hedges)通常只有个位数,成本可以忽略。
要注意OrderSelect之后拿到的字段值是当时快照,后续如果对某张单做了OrderModify,快照里的sl/tp就过期了,需不需要刷新取决于具体逻辑。我的习惯是在改单成功后立刻回写数组,别偷这个懒。
5.3 第三步:用状态标记替代 Sleep 阻塞
3.3 节那个Sleep(10000)是必须拿掉的。替代方案是给每张单记一个「上次操作时间」,一小时内不再重复处理:
int lastTicket[]; datetime lastActionTime[]; bool CanTouch(int ticket) { for(int i = 0; i < ArraySize(lastTicket); i++) if(lastTicket[i] == ticket) return (TimeCurrent() - lastActionTime[i]) > 3600; // 一小时冷静期 int n = ArraySize(lastTicket); ArrayResize(lastTicket, n+1); ArrayResize(lastActionTime, n+1); lastTicket[n] = ticket; lastActionTime[n] = TimeCurrent(); return true; }TimeCurrent()在回测里返回的是模拟时间,实盘里是服务器时间,两边行为一致。一小时这个值按策略周期调,日线级别的对锁给一天都合理。这样start()全程不阻塞,tick 处理连续,回测和实盘的差异立刻缩小一大截。
5.4 第四步:开仓前加一层风控闸门
这份源码从头到尾没有「该不该开」的判断,只要发现手动单没配对就挂对冲单。这在正常行情下没问题,在极端行情下会出事——比如周一开盘跳空,价格已经越过HedgingLevel,此时挂 SELLSTOP 会挂在市价上方,等于开一张立刻成交的空单,锁仓变成加仓。
闸门至少要检查四件事:
| 检查项 | 取值方式 | 拦截条件 |
|---|---|---|
| 当前点差 | MarketInfo(Symbol(), MODE_SPREAD) | 大于阈值不挂单 |
| 账户可用保证金 | AccountFreeMargin() | 不足以覆盖对冲单手数时拒绝 |
| 挂单价与市价距离 | MathAbs(price - MarketInfo(Symbol(), MODE_BID)) | 小于MODE_STOPLEVEL时上调 |
| 同向对冲单数量 | 快照数组长度 | 超过上限时告警不执行 |
点差这条最容易被忽略。锁仓策略对点差极其敏感,两条腿各吃一次点差,开锁再吃两次,四个点差下去,10 个点的止盈空间基本被吃干。把点差闸门设成「点差大于止盈点数的 30% 就不开新仓」,能挡掉相当一部分无效交易。
6. 从我改崩的那一版说起:三个可以立刻验证的改造点
我第一版改这个 EA 的时候,雄心勃勃地把Sleep全删了、把硬编码全参数化了、还顺手加了个移动止损。结果模拟盘跑三天,对冲单数量比手动单多出四倍,日志里一片OrderDelete failed。回头查了半天,问题出在我把倒序遍历改成了正序——当时觉得正序读起来顺,删完索引错位,一张单被处理了三次。这就是我说的血泪经验:订单池遍历的方向,比你写的策略逻辑更容易搞死你。
从那以后我每次改订单相关代码,都强制走一遍「三单验证」:手动开一张多单、一张空单,再手动开一张多单不设止损,然后盯半小时日志。三种状态覆盖了配对、反配对、无止损保护三条路径,能过的版本才允许上更大的模拟资金。
如果你也想动手改,我建议按这个顺序来,每一步都能独立验证:
第一步,只改入口兼容(2.3 节的OnTick转发),编译挂载,确认日志里OnInit的参数字符串打出来了。这一步不改任何业务逻辑,纯粹确认工程链路通。
第二步,只改止盈参数化(5.1 节),把GetTakeProfitPoints加进去,然后在AutoTakeProfit=true的情况下开一张 0.01 手单,看日志里TP的取值是否符合预期。验证点是「改了参数,日志里的数字跟着变」,如果不变,说明你改的那处没被调用到。
第三步,只改遍历方向(4.4 节),把所有正序for换成倒序,break换成continue。这一步改完,最直观的验证是手动平掉一张手动单,观察对应的对冲挂单在多长时间内被清掉。原来可能要等几分钟甚至一直不清,改完之后通常一两个 tick 就干净了。
第四步,才轮到加风控闸门(5.4 节)。这一步的风险最高,因为它会改变「什么时候不开仓」,回测结果可能一下子变难看,别慌,先确认拦截是合理的再调阈值。
最后一句掏心窝的话:这份源码的价值不在于它能赚钱,而在于它把一个完整的「订单配对 + 挂单管理」模型摊开给你看,两百行里藏着七八个真实的边界条件。拿到手先别急着挂实盘,在模拟盘上把第 2 章的冒烟测试跑一遍,再按上面的顺序一步步改,改一步验一步。希望帮到你。
本文还有配套的精品资源,点击获取