使用限价订单替代止盈且无需修改 EA 的原始代码·进阶篇
◍ 用挂单接管盈利目标的逻辑骨架
在 MT5 的 EA 封装里,把止盈从持仓自带的 TP 字段挪到独立限价挂单,核心是先拦一道 OrderSend。下面这段 CLimitTakeProfit 类里的 protected 成员与重载函数,就是整套机制的入口:它用 i_Magic 和 b_OnlyOneSymbol 做范围过滤,只对指定魔幻数字、指定品种、且带正 TP 的 SLTP 动作插手。 拦截函数 OrderSend 的第一道判断很直接:若品种不匹配、魔幻数字不对,或根本不是改止盈的请求,直接转交系统原生 OrderSend,不碰逻辑。只有命中条件时,才调用 SetTakeProfits 把持仓 TP 清零,并试图挂出对应方向的限价单。 SetTakeProfits(ulong,double) 里有个容易被忽略的细节:min_sell_limit 用 Ask+StopsLevel*Point 归一化,max_buy_limit 用 Bid-StopsLevel*Point 归一化。这意味着挂单价格必须离市价至少一个 StopsLevel 点距,否则经纪商会拒单——实盘前应在 MT5 符号属性里核对 StopsLevel 具体数值(常见 0~50 点不等)。 别把魔幻数字当摆设 i_Magic 设成 -1 和设成具体 EA 编号,行为完全不同:代码里 i_Magic>=0 才拦截,负值等于放行全部。多 EA 同跑时,不显式设魔幻数字会互相串单。
class="kw">protected: class="kw">static class="type">int i_Magic; class=class="str">"cmt">//用于控制的魔幻数字 class="kw">static class="type">bool b_OnlyOneSymbol; class=class="str">"cmt">//仅针对一个品种的控制 class=class="str">"cmt">//--- class="kw">static class="type">bool SetTakeProfits(class="type">class="kw">ulong position_ticket, class="type">class="kw">double new_tp=class="num">0); class="kw">static class="type">bool SetTakeProfits(class="type">class="kw">string symbol, class="type">class="kw">double new_tp=class="num">0); class="kw">static class="type">bool CheckLimitOrder(class="type">MqlTradeRequest &request); class="kw">static class="type">void CheckLimitOrder(class="type">void); class="kw">static class="type">bool CheckOrderInHistory(class="type">class="kw">ulong position_id, class="type">class="kw">string comment, ENUM_ORDER_TYPE type, class="type">class="kw">double &volume, class="type">class="kw">ulong call_position=class="num">0); class="kw">static class="type">class="kw">double GetLimitOrderPriceByComment(class="type">class="kw">string comment); }; class="type">bool CLimitTakeProfit::OrderSend(class="type">MqlTradeRequest &request,class="type">MqlTradeResult &result) { if((b_OnlyOneSymbol && request.symbol!=_Symbol) || (i_Magic>=class="num">0 && request.magic!=i_Magic) || !(request.action==TRADE_ACTION_SLTP && request.tp>class="num">0)) class="kw">return(::OrderSend(request,result)); class=class="str">"cmt">//--- if(((request.position>class="num">0 && SetTakeProfits(request.position,request.tp)) || (request.position<=class="num">0 && SetTakeProfits(request.symbol,request.tp))) && request.tp>class="num">0) request.tp=class="num">0; if((request.position>class="num">0 && PositionSelectByTicket(request.position)) || (request.position<=class="num">0 && PositionSelect(request.symbol))) { if(PositionGetDouble(POSITION_SL)!=request.sl || PositionGetDouble(POSITION_TP)!=request.tp) class="kw">return(::OrderSend(request,result)); } class=class="str">"cmt">//--- class="kw">return true; } class="type">bool CLimitTakeProfit::SetTakeProfits(class="type">class="kw">ulong position_ticket, class="type">class="kw">double new_tp=class="num">0) { if(!PositionSelectByTicket(position_ticket) || (b_OnlyOneSymbol && PositionGetString(POSITION_SYMBOL)!=_Symbol)) class="kw">return class="kw">false; if(!c_Symbol.Name(PositionGetString(POSITION_SYMBOL)) || !c_Symbol.Select() || !c_Symbol.Refresh() || !c_Symbol.RefreshRates()) class="kw">return class="kw">false; class=class="str">"cmt">//--- class="type">class="kw">double min_sell_limit=c_Symbol.NormalizePrice(c_Symbol.Ask()+c_Symbol.StopsLevel()*c_Symbol.Point()); class="type">class="kw">double max_buy_limit=c_Symbol.NormalizePrice(c_Symbol.Bid()-c_Symbol.StopsLevel()*c_Symbol.Point()); class="type">MqlTradeRequest tp_request={class="num">0}; class="type">MqlTradeResult tp_result={class="num">0}; tp_request.action = TRADE_ACTION_PENDING; tp_request.magic = PositionGetInteger(POSITION_MAGIC);
按点位梯度拆仓挂止盈单
这段逻辑干的事,是把一个持仓按预设的多个 TP 梯度,用限价/止损单分批平仓。外汇与贵金属杠杆高,分批挂单能降低手动操作滑点,但无法消除方向误判风险,仓位仍可能整体亏损。 tp_int 把开仓价到止盈价的点差换算成整数点,循环里只处理「梯度点位小于 tp_int 且比例大于 0」的层。比如持仓 0.50 手、某层设 30%,实际挂单手数会按剩余可平仓位动态折算,而不是死板地挂 0.15 手。 tp_request.price 的算法分买卖:买仓用 open_price + 梯度点*Point 挂 SELL_LIMIT,卖仓用 open_price - 梯度点*Point 挂 BUY_STOP。挂单前过一遍 CheckLimitOrder,返回真才累加 closed 并重算 closed_perc,保证总平仓比例不越过 100%。 手数规范化那三行值得在 MT5 里单步跑:LotsMin 打底,按 LotsStep 取整倍,再 NormalizeDouble 到 2 位。经纪商最小步长不同,不这么处理某些平台会直接拒单。
tp_request.type_filling = ORDER_FILLING_RETURN; tp_request.position=position_ticket; tp_request.symbol=c_Symbol.Name(); class="type">int total=i_TakeProfit.Total(); class="type">class="kw">double tp_price=(new_tp>class="num">0 ? new_tp : PositionGetDouble(POSITION_TP)); if(tp_price<=class="num">0) tp_price=GetLimitOrderPriceByComment("TPP_"+IntegerToString(position_ticket)); class="type">class="kw">double open_price=PositionGetDouble(POSITION_PRICE_OPEN); class="type">int tp_int=(tp_price>class="num">0 ? (class="type">int)NormalizeDouble(MathAbs(open_price-tp_price)/c_Symbol.Point(),class="num">0) : INT_MAX); class="type">class="kw">double position_volume=PositionGetDouble(POSITION_VOLUME); class="type">class="kw">double closed=class="num">0; class="type">class="kw">double closed_perc=class="num">0; class="type">class="kw">double fix_closed_per=class="num">0; for(class="type">int i=class="num">0;i<total;i++) { tp_request.comment="TP"+IntegerToString(i)+"_"+IntegerToString(position_ticket); if(i_TakeProfit.At(i)<tp_int && d_TakeProfit.At(i)>class="num">0) { if(closed>=position_volume || fix_closed_perc>=class="num">100) class="kw">break; class=class="str">"cmt">//--- class="type">class="kw">double lot=position_volume*MathMin(d_TakeProfit.At(i),class="num">100-closed)/(class="num">100-fix_closed_perc); lot=MathMin(position_volume-closed,lot); lot=c_Symbol.LotsMin()+MathMax(class="num">0,NormalizeDouble((lot-c_Symbol.LotsMin())/c_Symbol.LotsStep(),class="num">0)*c_Symbol.LotsStep()); lot=NormalizeDouble(lot,class="num">2); tp_request.volume=lot; class="kw">switch((class="type">ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: tp_request.type=ORDER_TYPE_SELL_LIMIT; tp_request.price=c_Symbol.NormalizePrice(open_price+i_TakeProfit.At(i)*c_Symbol.Point()); class="kw">break; case POSITION_TYPE_SELL: tp_request.type=ORDER_TYPE_BUY_STOP; tp_request.price=c_Symbol.NormalizePrice(open_price-i_TakeProfit.At(i)*c_Symbol.Point()); class="kw">break; } if(CheckLimitOrder(tp_request)) { if(tp_request.volume>=class="num">0) { closed+=tp_request.volume; closed_perc=closed/position_volume*class="num">100; } else {
「挂单止盈的限价边界与尾量处理」
在分批平仓逻辑里,挂单类型的止盈请求必须先按持仓方向相反生成:买持仓对应 SELL_LIMIT,卖持仓对应 BUY_LIMIT,这一点由 PositionGetInteger(POSITION_TYPE) 分支决定。 如果剩余仓位大于已平量且 tp_price 有效,代码会把尾量凑成合规手数——先取 LotsMin() 保底,再按 LotsStep() 向上归一,最后 NormalizeDouble 到 2 位小数。若尾量低于 LotsMin() 直接返回 false,避免 MT5 拒单。 CheckLimitOrder 不通过时,BUY_LIMIT 价格会被 MathMin 压到 max_buy_limit 之下,SELL_LIMIT 则被 MathMax 抬到 min_sell_limit 之上。这套边界钳制能让挂单不至于越过市场允许的限价区,外汇与贵金属波动剧烈,越界挂单在高滑点时段被拒概率明显上升。 普通挂单发送前也走同样的价格钳制:tp_request.price 在 ORDER_TYPE_BUY_LIMIT 时取与 max_buy_limit 的最小值,SELL_LIMIT 时取与 min_sell_limit 的最大值,然后才进 OrderSend。开 MT5 把 max_buy_limit / min_sell_limit 打印出来,就能看到你的尾量止盈到底被压到了什么价位。
fix_closed_per-=tp_request.volume/(position_volume-tp_request.volume)*class="num">100; } class="kw">continue; } class="kw">switch(tp_request.type) { case ORDER_TYPE_BUY_LIMIT: tp_request.price=MathMin(tp_request.price,max_buy_limit); class="kw">break; case ORDER_TYPE_SELL_LIMIT: tp_request.price=MathMax(tp_request.price,min_sell_limit); class="kw">break; } if(::OrderSend(tp_request,tp_result)) { closed+=tp_result.volume; closed_perc=closed/position_volume*class="num">100; ZeroMemory(tp_result); } } } if(tp_price>class="num">0 && position_volume>closed) { tp_request.price=tp_price; tp_request.comment="TPP_"+IntegerToString(position_ticket); tp_request.volume=position_volume-closed; if(tp_request.volume<c_Symbol.LotsMin()) class="kw">return class="kw">false; tp_request.volume=c_Symbol.LotsMin()+MathMax(class="num">0,NormalizeDouble((tp_request.volume-c_Symbol.LotsMin())/c_Symbol.LotsStep(),class="num">0)*c_Symbol.LotsStep()); tp_request.volume=NormalizeDouble(tp_request.volume,class="num">2); class=class="str">"cmt">//--- class="kw">switch((class="type">ENUM_POSITION_TYPE)PositionGetInteger(POSITION_TYPE)) { case POSITION_TYPE_BUY: tp_request.type=ORDER_TYPE_SELL_LIMIT; class="kw">break; case POSITION_TYPE_SELL: tp_request.type=ORDER_TYPE_BUY_LIMIT; class="kw">break; } if(CheckLimitOrder(tp_request) && tp_request.volume>=class="num">0) { closed+=tp_request.volume; closed_perc=closed/position_volume*class="num">100; } else { class="kw">switch(tp_request.type) { case ORDER_TYPE_BUY_LIMIT: tp_request.price=MathMin(tp_request.price,max_buy_limit); class="kw">break; case ORDER_TYPE_SELL_LIMIT: tp_request.price=MathMax(tp_request.price,min_sell_limit);
◍ 挂单参数校验与残留限价单清理
上面这段逻辑紧接部分平仓流程:当挂出的限价单成交量算出来小于等于 0 时,代码会把剩余未平仓位减去已平部分作为挂单量,再按经纪商最小手数 LotsMin() 和步长 LotsStep() 做对齐,最后 NormalizeDouble 到 2 位小数。这一步直接决定你挂的 TP 限价单会不会因手数非法被服务器拒单。 平仓循环结束后,若已平总量 closed 达到原仓位 position_volume,且原持仓 TP 大于 0,会再用 TRADE_ACTION_SLTP 把原仓 TP 清零,只保留 SL。注意这里若 OrderSend 失败直接 return false,意味着整套平仓修饰逻辑中止,实盘里可能留下未清理的挂单。 CheckLimitOrder 函数负责扫挂单:用 StopsLevel()*Point() 算出卖限价单最低价和买限价单最高价边界,再按 comment 匹配自己发的单。若发现同 comment 但体积或类型不对的残留单,直接发 TRADE_ACTION_REMOVE 删除,删成功就 return false 并同步回写 request.volume。外汇与贵金属杠杆高,这类挂单残留若没清干净,遇到跳空可能触发非预期成交。
class="kw">break; } if(tp_request.volume<=class="num">0) { tp_request.volume=position_volume-closed; tp_request.volume=c_Symbol.LotsMin()+MathMax(class="num">0,NormalizeDouble((tp_request.volume-c_Symbol.LotsMin())/c_Symbol.LotsStep(),class="num">0)*c_Symbol.LotsStep()); tp_request.volume=NormalizeDouble(tp_request.volume,class="num">2); } if(::OrderSend(tp_request,tp_result)) { closed+=tp_result.volume; closed_perc=closed/position_volume*class="num">100; ZeroMemory(tp_result); } } } if(closed>=position_volume && PositionGetDouble(POSITION_TP)>class="num">0) { ZeroMemory(tp_request); ZeroMemory(tp_result); tp_request.action=TRADE_ACTION_SLTP; tp_request.position=position_ticket; tp_request.symbol=c_Symbol.Name(); tp_request.sl=PositionGetDouble(POSITION_SL); tp_request.tp=class="num">0; tp_request.magic=PositionGetInteger(POSITION_MAGIC); if(!OrderSend(tp_request,tp_result)) class="kw">return class="kw">false; } class="kw">return true; } class="type">bool CLimitTakeProfit::CheckLimitOrder(class="type">MqlTradeRequest &request) { class="type">class="kw">double min_sell_limit=c_Symbol.NormalizePrice(c_Symbol.Ask()+c_Symbol.StopsLevel()*c_Symbol.Point()); class="type">class="kw">double max_buy_limit=c_Symbol.NormalizePrice(c_Symbol.Bid()-c_Symbol.StopsLevel()*c_Symbol.Point()); for(class="type">int i=class="num">0;i<total;i++) { class="type">class="kw">ulong ticket=OrderGetTicket((class="type">uint)i); if(ticket<=class="num">0) class="kw">continue; if(OrderGetString(ORDER_COMMENT)!=request.comment) class="kw">continue; if(OrderGetDouble(ORDER_VOLUME_INITIAL) != request.volume || OrderGetInteger(ORDER_TYPE)!=request.type) { class="type">MqlTradeRequest del_request={class="num">0}; class="type">MqlTradeResult del_result={class="num">0}; del_request.action=TRADE_ACTION_REMOVE; del_request.order=ticket; if(::OrderSend(del_request,del_result)) class="kw">return class="kw">false; request.volume=OrderGetDouble(ORDER_VOLUME_INITIAL); }
挂单改价与历史单递归核对的实现细节
这段逻辑处理的是限价挂单的改价与成交后历史核对。先判断开仓价与请求价之差是否大于等于一个 Point,若成立就构造 TRADE_ACTION_MODIFY 请求去移动挂单;Buy Limit 改价若高于 max_buy_limit、Sell Limit 改价若低于 min_sell_limit 则直接 return true 不发送,相当于边界保护。 改价请求里复用原单的 magic、symbol 和 type,通过 OrderSend 发出。若持仓 ticket 已选不中(说明仓位不在了),函数也直接 return true,把后续交给历史记录去查。 CheckOrderInHistory 先用 HistorySelectByPosition 拉取某持仓的全部历史成交,再遍历 HistoryDealsTotal 笔。零售对冲账户下(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)会判断 ORDER_POSITION_ID 是否匹配,不匹配就递归查关联仓位,防止对冲单被漏掉。 外汇和贵金属杠杆高,这类自动改挂单逻辑在多点滑点行情中可能误触边界返回,建议开 MT5 用策略测试器跑一遍 hedge 账户样本验证递归深度。
if(MathAbs(OrderGetDouble(ORDER_PRICE_OPEN)-request.price)>=c_Symbol.Point()) { class="type">MqlTradeRequest mod_request={class="num">0}; class="type">MqlTradeResult mod_result={class="num">0}; mod_request.action=TRADE_ACTION_MODIFY; mod_request.price=request.price; mod_request.magic=request.magic; mod_request.symbol=request.symbol; class="kw">switch(request.type) { case ORDER_TYPE_BUY_LIMIT: if(mod_request.price>max_buy_limit) class="kw">return true; class="kw">break; case ORDER_TYPE_SELL_LIMIT: if(mod_request.price<min_sell_limit) class="kw">return true; class="kw">break; } class="type">bool mod=::OrderSend(mod_request,mod_result); } class="kw">return true; } if(!PositionSelectByTicket(request.position)) class="kw">return true; class=class="str">"cmt">//--- class="kw">return CheckOrderInHistory(PositionGetInteger(POSITION_IDENTIFIER),request.comment, request.type, request.volume); } class="type">bool CLimitTakeProfit::CheckOrderInHistory(class="type">class="kw">ulong position_id, class="type">class="kw">string comment, ENUM_ORDER_TYPE type, class="type">class="kw">double &volume, class="type">class="kw">ulong call_position=class="num">0) { if(!HistorySelectByPosition(position_id)) class="kw">return true; class="type">int total=HistoryDealsTotal(); class="type">bool hedging=(AccountInfoInteger(ACCOUNT_MARGIN_MODE)==ACCOUNT_MARGIN_MODE_RETAIL_HEDGING); class=class="str">"cmt">//--- for(class="type">int i=class="num">0;i<total;i++) { class="type">class="kw">ulong ticket=HistoryDealGetTicket((class="type">uint)i); ticket=HistoryDealGetInteger(ticket,DEAL_ORDER); if(!HistoryOrderSelect(ticket)) class="kw">continue; if(ticket<=class="num">0) class="kw">continue; if(hedging && HistoryOrderGetInteger(ticket,ORDER_POSITION_ID)!=position_id && HistoryOrderGetInteger(ticket,ORDER_POSITION_ID)!=call_position) { if(CheckOrderInHistory(HistoryOrderGetInteger(ticket,ORDER_POSITION_ID),comment,type,volume)) class="kw">return true; if(!HistorySelectByPosition(position_id)) class="kw">continue;
「用持仓注释反查对冲单并闭环平仓」
在零售对冲账户(ACCOUNT_MARGIN_MODE_RETAIL_HEDGING)下,限价挂单触发后生成的止盈持仓会在注释里写入 'TP_原单ticket' 这类标记。OnTrade 遍历 PositionsTotal 返回的当前持仓数,对每张持仓先按 Magic 与品种过滤,再抓注释做反查。 代码里用 StringFind(comment,"TP")==0 判定这是系统自己开的止盈仓,随后从下划线位置切出被对冲的原始 ticket。若 PositionSelectByTicket 能选中且两边 POSITION_TYPE 相反,就填 MqlTradeRequest 以 TRADE_ACTION_CLOSE_BY 把两仓互平。 这套逻辑实测在 MT5 _build 35xx 零售对冲账户上,能把手动误留的对冲腿自动清掉;外汇与贵金属杠杆高,自动互平若遇点差跳空可能只成交一端,剩余风险敞口需人工复核。
}
if(HistoryOrderGetString(ticket,ORDER_COMMENT)!=comment)
class="kw">continue;
if(HistoryOrderGetInteger(ticket,ORDER_TYPE)!=type)
class="kw">continue;
class=class="str">"cmt">//---
volume=-OrderGetDouble(ORDER_VOLUME_INITIAL);
class="kw">return true;
}
class="kw">return class="kw">false;
}
class="type">bool CLimitTakeProfit::OnTrade(class="type">void)
{
class="type">int total=PositionsTotal();
class="type">bool result=true;
class="type">bool hedhing=AccountInfoInteger(ACCOUNT_MARGIN_MODE)==ACCOUNT_MARGIN_MODE_RETAIL_HEDGING;
for(class="type">int i=class="num">0;i<total;i++)
{
class="type">class="kw">ulong ticket=PositionGetTicket((class="type">uint)i);
if(ticket<=class="num">0 || (b_OnlyOneSymbol && PositionGetString(POSITION_SYMBOL)!=_Symbol))
class="kw">continue;
class=class="str">"cmt">//---
if(i_Magic>class="num">0)
{
if(hedhing && PositionGetInteger(POSITION_MAGIC)!=i_Magic)
class="kw">continue;
}
if(hedhing)
{
class="type">class="kw">string comment=PositionGetString(POSITION_COMMENT);
if(StringFind(comment,"TP")==class="num">0)
{
class="type">int start=StringFind(comment,"_");
if(start>class="num">0)
{
class="type">long ticket_by=StringToInteger(StringSubstr(comment,start+class="num">1));
class="type">long type=PositionGetInteger(POSITION_TYPE);
if(ticket_by>class="num">0 && PositionSelectByTicket(ticket_by) && type!=PositionGetInteger(POSITION_TYPE))
{
class="type">MqlTradeRequest request ={class="num">0};
class="type">MqlTradeResult trade_result ={class="num">0};
request.action=TRADE_ACTION_CLOSE_BY;
request.position=ticket;
request.position_by=ticket_by;
if(::OrderSend(request,trade_result))
class="kw">continue;
}