简介:《锁仓顺势加码EA源码》是一份面向外汇自动交易学习者与MetaTrader平台EA开发者的源码文档,围绕锁仓、顺势加码、手动订单止盈、自动平仓和对冲单管理等常见需求展开,适合希望理解MT4自动交易逻辑、梳理订单处理流程并参考参数设计的中级读者。压缩包共1个doc文件,约45KB,内容以源码注释、函数说明和参数配置为主,便于对照阅读。文档涉及USE_ATR、PercentATR、HedgingLevel、AutoTakeProfit等外部参数,并拆解Hedge_Assistante、start、OrderSelect、OrderClose等关键函数,同时结合ATR、iHigh与iLow说明波动率计算和价格高低点获取思路;预览中还保留了版本修订注释及BUY/SELL手动订单止盈、改单、平仓的处理片段,可帮助读者理解EA从信号判断到订单管理的组织方式。已有1381人学习,适合用作外汇EA入门拆解与二次开发参考。
1. 锁仓这件事,代码里真正在解的是一个“配对”问题
打开这份锁仓顺势加码 EA 源码,第一眼容易看错重点。文件名叫“顺势加码”,可start()里只有一行Hedge_Assistante(),通篇找不到真正意义上的加仓模块;代码最复杂的地方,是“怎么把一张对冲单认回它对应的手动单”。OrderSend的第九个参数本该填魔术号,这里填的却是一张手动单的票号——对冲单不靠品种、方向或价格认主,而是把主人的订单号标记在自己身上。这个反直觉的选择,决定了整套锁仓机制能否在一堆乱序订单里保持配对正确。搞懂它,比抄一份能跑的源码有用得多。
2. 订单配对:没有对象,就用整数当指针
MQL4 没有对象,也没有引用,订单池是一张线性表,访问方式是“游标 + 快照”——OrderSelect把全局游标移到某一单上,之后所有OrderOpenPrice()、OrderLots()读的都是这一单的字段。任何一次新的OrderSelect都会改写游标。所有配对逻辑都必须建立在这个前提上,否则代码看着没错,跑起来全是别人的数据。
2.1 MagicNumber 被当成外键用
先看“找手单、查配对、补对冲”这一整段,这是整个 EA 的核心循环:
// 扫描持仓,挑出"手动单":本品种、Magic=0、已经成交 for (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(); // 先存下来,后面任何 OrderSelect 都会改游标 Type = OrderType(); double Lots = OrderLots(); double OpenPrice = OrderOpenPrice(); for (int j = i; j < OrdersTotal(); j++) { if (OrderSelect(j, SELECT_BY_POS, MODE_TRADES) == false) break; if (Ticket == OrderMagicNumber()) Hedge++; // 对冲单把主人的票号存在 Magic 里 } if (Hedge == 0) { // 没找到配对,补一张反向 STOP 挂单 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 (Type == OP_SELL) Tic = OrderSend(Symbol(), OP_BUYSTOP, Lots, NormalizeDouble(OpenPrice + HedgingLevel, Digits), 0, 0, 0, "Hedging " + DoubleToStr(Ticket, 0), Ticket, 0, Red); } } }这段代码里藏着三个必须看懂的细节。第一,Ticket、Lots、OpenPrice必须先拷进局部变量,因为内层循环的OrderSelect(j, ...)一出,游标就跑到别人身上了,之后再调OrderLots()拿到的是 j 号单的手数。第二,配对判断是Ticket == OrderMagicNumber(),让对冲单的 Magic 反向指向手单票号,形成一条单向索引;手单本身 Magic 恒为 0,天然成为“根节点”。第三,OrderSend的第十个参数写的是Ticket,也就是说这道关联不是靠注释字符串"Hedging " + DoubleToStr(Ticket,0)建立的——那只是给人看的日志,机器认的是 Magic。
接着是反向清理:手单平掉以后,挂着的 STOP 单必须撤,否则它会在某个价位突然成交,变成一张没人管的裸单。
// 反向检查:对冲单的主人还在不在 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 去比对手单的票号 } // 主人不在了,且自己是挂单(type > 1),就撤掉 if (Hedge == 0 && OrderType() > 1) OrderDelete(Ticket); } }两个循环的方向正好相反:正向循环拿“手单票号”去比别人 Magic,反向循环拿“自己 Magic”去比别人票号,构成了一个没有数据库的双向指针。OrderType() > 1这个条件很关键,它把已成交的对冲单排除在自动删除之外——v2.1 的注释写得很直白:“对于已经成交的对冲单,系统不能自动平仓或删除,需要手动平仓”,因为一旦对冲单成交,两张单已经形成锁仓,自动撤掉任何一边都等于强行认亏。
2.2 OrderProfit() != 0 这个判据的巧妙与裂缝
判断一张单“是手动单”,代码用的条件是Magic == 0 && OrderProfit() != 0。后半句是在区分挂单和市价单:挂单没有浮动盈亏,OrderProfit()恒为 0;市价单成交后随价格波动,通常非 0。用浮盈来反推“已成交”,是个很省事的写法,省掉了OrderType() < 2的判断。
但这个判据在两种情况下会失效。一种是在开仓瞬间浮盈恰好为 0(老式账户没有手续费、买卖价刚好吃掉点差),此时这张已经成交的手单会被当作挂单跳过,于是永远不生成对冲单,其他订单更无法匹配上它。另一种是某些账户把库存费、手续费单独计入,OrderProfit()的取值区间和预期不一致。常见的实现思路是:真正稳的写法是OrderMagicNumber() == 0 && OrderType() < 2,直接按订单类型判定,跟盈亏数值脱钩。源码里注释写着/*Actived!*/,说明作者自己也知道这里是在“猜”状态,而不是在“读”状态。
还有一处更隐蔽:内层配对循环写成for (int j = i; ...),起点是 i 而不是 0。这意味着索引小于 i 的那些订单从来没被搜过。如果一张手单排在列表靠后、它的对冲单排在列表靠前,这一轮就会因为Hedge == 0而重复下单,凭空多出一张 STOP。对冲单本身也会参与外层循环(它OrderProfit()可能非 0,只是被Magic == 0挡住了),所以大多数时候不出事,但一旦 Magic 因为某些平台行为被重置成 0,重复下单就立刻发生。这类问题在回测里几乎看不出来,因为回测的订单排序和实盘不完全一致。
3. 对冲距离:一个变量装着两套单位
配对解决的是“谁和谁是一对”,对冲距离解决的是“这张 STOP 挂在哪个价位”。这里源码埋了一个会被大多数人忽略的语义分裂,它是后面所有价格类问题的源头。
3.1 同一个 HedgingLevel,两种量纲
extern bool USE_ATR = true; extern int PercentATR = 40; // 40% ATR extern double HedgingLevel = 30; // start() 里每个 tick 都会重算一次 if (USE_ATR) HedgingLevel = iATR(NULL, PERIOD_D1, 14, 0) * PercentATR / 100; else HedgingLevel = HedgingLevel * Point;看清楚这两行的量纲差异。ATR 分支里,iATR返回的是价格单位(比如 EURUSD 日线 ATR 是 0.0120),乘 40% 得 0.0048,这个数直接拿去减开仓价,是绝对价格距离。固定分支里,HedgingLevel * Point把 30 变成 30 个报价点,也是价格距离。两条路殊途同归,最后都指向“价格”,但中间变量的含义完全不同:一个存的是 0.0048,一个存的是 30。
提示:切换
USE_ATR开关时,HedgingLevel这个 extern 变量的语义会从“点数”变成“价格”,同一个数值 30 在两种模式下差了四个数量级。这种同名不同义的设计是参数面最容易翻车的地方。
再看iATR(NULL, PERIOD_D1, 14, 0)的最后一个参数 shift=0。0 号 K 线是当前还没走完的日线,这意味着 ATR 不是常量,而是每个 tick 都在变——亚洲盘算出来可能 0.0030,欧美盘开盘后半小时能翻一倍。源码的处理方式是把结果存回HedgingLevel这个全局变量,然后在下单时读它。于是对冲距离实际上是一份“创建那一刻的 ATR 快照”:一旦 STOP 单挂出去,后续 ATR 再变也不会重算,除非那张单被撤了重建。这个隐式快照有利有弊,好处是同一张单的挂单价不会来回跳(否则会无限改单),坏处是行情突变后,这个距离已经不能反映当下的波动,止损保护的强度悄悄失效了。
3.2 NormalizeDouble 和最小挂单距离
挂单价写的是NormalizeDouble(OpenPrice - HedgingLevel, Digits)。Digits是报价小数位,五位报价账户上就是 5,NormalizeDouble把价格截到 0.00001 的精度。这行不能省,OrderSend收到一个带 1e-8 尾巴的价格会直接返回 129(invalid price)。
即便做了归一化,OrderSend仍可能返回 130(invalid stops)。原因是对冲单属于 STOP 挂单,平台对“挂单价离现价至少多远”有硬性限制,这个值可以从MarketInfo(Symbol(), MODE_STOPLEVEL)读到。源码里完全没有做这项校验,它默认ATR * 40%一定大于平台最小距离。多数品种、多数日子确实成立,但遇到低波动品种、或者经纪商调高了最小距离,下单就会持续静默失败——OrderSend返回负数,代码只在Tic > 0时才弹 Alert,失败的分支什么也不打印,策略就这样“看起来在跑,实际没有对冲”。
还有OrderSend里那个滑点参数填的是 0。对市价单来说 0 意味着不允许偏离报价,容易频繁报 136(off quotes);对挂单来说这个参数本来就没意义,只有前面几个价格参数有效。同一个函数签名要同时服务市价单和挂单,参数语义会跟着订单类型漂移,这是 MQL4 接口设计里一个绕不过去的坑——参数位固定,含义由OrderType决定。
4. 主动止盈:一层巡检、一层修复
AutoTakeProfit这一段是整份源码里工程味最重的部分,它同时做了两件性质完全不同的事:主动平仓,和给服务器上的止盈价“打补丁”。理解这两层为什么都要有,比看懂每一行判断更有价值。
4.1 容差窗口是一个去抖区间
for (i = 0; i < OrdersTotal(); i++) { if (OrderSelect(i, SELECT_BY_POS, MODE_TRADES) == false) continue; if (Symbol() == OrderSymbol() && OrderMagicNumber() == 0 && OrderProfit() != 0) { 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) { ... return(0); } } // 第二层:止盈价不在 ±1 点容差内,就改单 if (OrderTakeProfit() - OrderOpenPrice() < (TP - 1) * Point || OrderTakeProfit() - OrderOpenPrice() > (TP + 1) * Point) { if (OrderModify(OrderTicket(), OrderOpenPrice(), OrderStopLoss(), OrderOpenPrice() + TP * Point, 0, 0) == true) { Sleep(10000); } } } // SELL 方向对称,表达式顺序不同,逻辑等价 } }第一层很好理解:浮盈够了就立刻兑现,不等服务器。第二层是“修复”:这段代码本身不知道止盈价有没有被设过,它只是每个 tick 拿当前 TP 和目标 TP 的差值去比,只要落在(TP-1, TP+1)这个 ±1 点的窗口之外,就调一次OrderModify把目标价写回去。这是个典型的 reconcile 循环——不关心历史,只看期望状态和实际状态的差。
±1 点的死区是这段代码里最值得抄的一笔。如果没有这个容差,价格每跳一次,浮盈判断都在变,OrderModify就可能被疯狂触发,服务器端会限流甚至拒单,日志里刷满改单记录。死区把“设置一次就够”的操作变成了幂等操作,代价是止盈价可能和理论值差一点点。Sleep(10000)是同一个思路的粗暴版:改单成功后硬等 10 秒,防止下一次 tick 又进来改一遍。这个写法在单线程 EA 里副作用很大——EA 不处理行情 10 秒,跳空时可能错过触发价,或者醒来时订单已经不在列表里了。
4.2 v2.2 为什么要把手单止盈清零
// v2.2: 对冲单一旦存在,就把手单的止盈清零 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, "对冲单已生成, 系统将止盈改为零,需要手动开锁!"); } }这段逻辑值得单独拎出来,因为它是整份源码里唯一一处“主动放弃原有保护”的设计。假设手单 10 点止盈、对冲 STOP 挂在 30 点外。价格往有利方向走 10 点,手单成交平仓,此时对冲单还在 20 点之外挂着,但它对应的“手单”已经不存在了。下一轮反向清理循环会把这张挂单识别为孤儿并删掉——本来也没事。真正的问题是时序:如果手单平仓和挂单删除之间价格反转,那张挂单可能先被触发,变成一张完全没有人管的反向市价单,锁仓就变成了反向开仓。
v2.2 的解法是把矛盾提前掐掉:只要对冲单存在,手单就不许有自己的止盈,止盈权收归系统统一管理。代价是手单失去了服务器端的保命单,终端一断线就没人止盈了,所以 Alert 里那句“需要手动开锁”不是客套,是责任转移声明。这里OrderModify把止损也一并写成 0,等于把手单彻底变成一个只受 EA 管、不受服务器管的仓位。
这里有一处真实的逻辑打架:如果AutoTakeProfit = true,4.1 那段巡检发现手单 TP 是 0,差值0 - OpenPrice显然落在容差之外,于是又把 10 点止盈写回去;下一个 tick,v2.2 再把它清零。两个功能在同一笔订单上来回改,日志刷屏,服务器限流。默认值AutoTakeProfit = false恰好让这个冲突不暴露,但这不是设计出来的互斥,是默认参数蒙对了。常见的实现思路是加一个“已被系统接管”的标记(比如用订单注释,或者维护一张已清零票号的表),让两段逻辑互斥而不是互殴。
4.3 Point 在五位报价账户上的一笔血泪账
TP = 10然后TP * Point,这里的Point是报价的最小变动单位,五位报价上是 0.00001。10 × 0.00001 = 0.0001,也就是 1 个 pip。而交易者口语里的“10 个点”通常指 10 个 pip,等于 0.0010。差了整整 10 倍。
这份源码的年代背景是四位报价为主流的时期,那时Point就等于 pip,TP = 10名副其实。放到五位报价账户上跑,止盈从 10 pip 缩水成 1 pip,利润大概率还不够覆盖点差和手续费,回测曲线会变成一条缓慢下滑的直线,排查半天才发现问题出在Digits上。GBPUSD那个 17 点的特判也是同理,17 pip 变成 1.7 pip,失去了任何意义。
注意:凡是用
Point直接乘以“点数”的地方,都要先确认账户报价位数。稳妥写法是先算pip = Point * (Digits == 3 || Digits == 5 ? 10 : 1),再做乘法,这样同一份代码在四位和五位账户上语义一致。
5. 避坑:五条只有跑起来才会暴露的记录
下面这些不是理论推演,是这类锁仓 EA 在实盘和长周期回测里反复出现的典型故障。每一条都按“现象 → 原因 → 解决”写,方便对照排查。
5.1 订单状态与游标相关
现象一:手单明明成交了,日志里却一直不生成对冲单,止盈也不动。原因:OrderProfit() != 0这个“已成交”判据在浮盈恰为 0 时返回 false,手单被当成挂单跳过。低点差账户、开仓瞬间、或者带手续费但浮盈刚好吃平的场景都会命中。 解决:把条件换成OrderMagicNumber() == 0 && OrderType() < 2,用订单类型判断而不是浮盈数值。改完记得同步改主动止盈那段,两处判据必须一致,否则一边认为它是手单、另一边不认。
现象二:撤销孤儿对冲单时,明明有两张该撤,实际只撤掉一张。原因:for (i = 0; i < OrdersTotal(); i++)里执行OrderDelete之后,订单立刻从列表移除,OrdersTotal()减一,而i++照常前进,紧邻的下一张就被跳过了。同样的坑也存在于OrderClose循环里。 解决:倒序遍历,或者“扫到没有再扫一遍”的收敛式写法:
// 倒序删除,避免索引漂移:删掉一个后,后面元素的索引不会往前挤 for (int k = OrdersTotal() - 1; k >= 0; k--) { if (OrderSelect(k, SELECT_BY_POS, MODE_TRADES) == false) continue; if (Symbol() != OrderSymbol()) continue; if (OrderMagicNumber() == 0 || OrderType() < 2) continue; // 只处理未成交的对冲挂单 if (!OrderSelect(OrderMagicNumber(), SELECT_BY_TICKET)) continue; // 主人已不在 if (OrderType() > 1 && OrderDelete(OrderTicket()) == false) Print("Delete failed: ", OrderTicket(), " err=", GetLastError()); }现象三:开了AutoTakeProfit,同一笔单的止盈价被反复修改,日志里改单记录刷屏。原因:与 v2.2 的清零逻辑互相覆盖,一个写回 10 点,一个清零,形成振荡。表面看是“策略在调仓”,实际是在空转。 解决:两个功能做成互斥开关,或者在清零时给订单打上标记(注释字段或内存中的票号集合),巡检时跳过已接管的单。这类“两个模块操作同一个字段”的冲突,靠默认参数躲过去是最不可靠的做法。
5.2 价格精度与接口约束相关
现象四:OrderSend返回 130,对冲单一张都挂不上,但 EA 没有任何报错。原因:ATR 算出的距离小于平台最小挂单距离MODE_STOPLEVEL,或者归一化后价格仍不对齐 tick size。代码只在Tic > 0时弹提示,失败分支完全静默。 解决:下单前先读MarketInfo(Symbol(), MODE_STOPLEVEL)和MODE_TICKSIZE,距离不足就抬高到最小合法值;同时把所有OrderSend的失败分支补上Print和错误码,别让失败无声无息。
现象五:五位报价账户上策略表现断崖式下滑,止盈像是没设一样。原因:Point从 0.0001 变成 0.00001,所有写死“点数”的地方实际距离都缩小了 10 倍,止盈被点差吃掉。 解决:在init()里统一算一次 pip 系数并存成全局变量,所有距离类参数都走这个系数换算,而不是直接用Point。改完拿Digits打一行日志出来确认,比在回测里猜快得多。
6. 设计取舍:锁住的位置不是风险,是被推迟的决策
把这份源码摊开看,它的核心不是“对冲掉风险”——两张反向单同时存在时,净头寸为零,价格怎么走都不会再产生新的盈亏,风险并没有消失,只是被冻结在锁仓那一刻的浮亏上。真正被交换出去的是决策时间:原本价格触到止损就必须认账,现在变成“先挂一张反向单等着,等我想清楚再开锁”。保证金占用翻倍,换来的是一个可以无限延长的观察窗口。这个取舍在数学上不划算,在心理上极其划算,所以这类 EA 一直有人写、有人用。
代码层面它有几处真正值得借鉴的工程手法。在没有对象、没有引用的语言里,用整数键做双向关联,把“谁是谁的对冲单”表达成两条单向索引,这是一个很朴素的指针模拟;用 ±1 点的死区把非幂等的改单操作变成幂等,这是所有“期望状态 vs 实际状态”巡检逻辑的通用套路;用Sleep粗暴限流虽然难看,但它至少意识到改单是有成本的。反过来,全表 O(n²) 扫描、每 tick 重算 ATR、把 17 和 10 这种业务参数硬编码在判断里、失败分支静默——这些都是原型代码的特征,能跑,但不能长期跑。
我一般会这么验证这类源码到底靠不靠谱:先在策略测试器里用“每个 tick”模式跑一段,把每次OrdersTotal()的变化和票号打印出来,肉眼对一遍手单和对冲单的数量关系是否始终是 1:1;然后把OrderProfit() != 0换成OrderType() < 2做 A/B,看结果差多少;最后手动在持仓里开一张单、再平掉,观察孤儿挂单有没有被及时撤掉。三步走完,一套锁仓逻辑的骨架就摸清了,剩下的加码、解锁、分批止盈,都得自己在这个骨架上补。
说个我自己踩过的坑:早年间我拿一份类似的源码直接改参数上实盘,回测漂亮得不像话,实盘第一周就发现对冲单压根没挂出去——原因是那个品种的最小挂单距离被平台调过,而代码里没有任何一处校验,也没有一行失败日志。从那以后我再看任何 EA 源码,第一件事不是看它的入场信号,而是把所有OrderSend、OrderModify、OrderDelete的返回值挨个检查一遍,确认每一条失败路径都有输出。希望这个习惯能帮你少走一段弯路,也建议你拿到这份源码后,先把订单配对和价格精度这两块吃透,再谈策略本身。
本文还有配套的精品资源,点击获取