Summary: 本文探讨了容易被忽视的OrderSend错误码,并提供了一套完整的恢复逻辑实现。内容直面回测永远不会暴露的实盘执行问题。




说实话吧。大多数EA教程把OrderSend()当成了一根魔法棒。你调用它,订单就出现了,然后大家都高高兴兴回家。接着你把它放到真实账户上,眼睁睁看着它反复失败,报的错你在回测器里从来没见到过。文档里确实列了这些错误码,但话说回来,谁会真的把那40多种错误码通读一遍,并为它们提前做好计划呢?

两年前我搭建了一个交易系统,回测里表现得神挡杀神。实盘第一天,第一个小时就生成了17个订单错误。那个EA只是一遍又一遍地重试同一个失败的订单,把我的日志塞满了"OrderSend失败"的消息。就是在那个时候我意识到:错误处理不是一个锦上添花的功能。它是你的系统究竟是能活下来,还是自我毁灭的分水岭。

沉默的杀手:错误130和148



大部分人都知道错误130(无效止损)。但文档没有强调的一点是:这个错误通常是一个时机问题,而不是价格问题。OrderSend()函数会在执行的那一刻,根据当前的买/卖价检查你的止损和止盈。但如果你调用RefreshRates()和实际下单之间市场发生了变动,你就会收获错误130。

错误148才是真正的怪物。它表示"订单太多"。你达到了经纪商对未平仓头寸的限制。标准建议是什么?在下单之前检查一下OrdersTotal()就行了。但这是一个等着发生的竞态条件。在你检查完毕和OrderSend()执行之间,可能刚好有别的订单插队进来。

这里有一个大多数教程都不会提到的场景。你的EA限制最多持有3笔未平仓订单。你检查了OrdersTotal(),结果是2。看起来安全,对吧?你发送了订单。但就在那毫秒之间,另一个EA(甚至是一笔手动交易)刚好开了一个订单。你的订单因为错误148失败了,而你的EA根本没有恢复逻辑。

不一样的思路:带升级策略的重试机制



我没有仅仅依赖检查后祈祷的方式,而是构建了一套恢复逻辑,它把每一次OrderSend()都视为一个可能失败的事务,并为每一种失败模式都计划了应对方案。其核心思想是带升级策略的重试。你从最激进的重试策略开始,然后逐步后退,根据需要改变订单参数。

升级阶梯:

  • <strong>立即重试(3次尝试)</strong> :只刷新报价然后重试。用于捕捉瞬时问题。

  • <strong>调整价格</strong>:轻微移动入场价格(在容忍范围内)以绕过止损水平的限制。

  • <strong>缩减订单</strong>:如果是限价单,减少手数并重试。有些经纪商有动态手数限制。

  • <strong>暂停并重新评估</strong>:等待一个新的报价,重新计算整个策略状态,并重新评估该交易是否仍然有意义。

  • <strong>放弃并记录</strong>:如果所有方法都失败,记录完整的上下文信息然后继续。不要阻塞EA。


  • 完整的MQL4实现代码



    下面是一个实现了上述升级逻辑的SendOrderSafe()函数。它被设计成可以直接替换你标准的OrderSend()调用。

    ``mql4
    //+------------------------------------------------------------------+
    //| SafeOrderSender.mq4 |
    //| Practical MQL4 |
    //| https://www.fxear.com |
    //+------------------------------------------------------------------+
    #property copyright "Practical MQL4"
    #property link "https://www.fxear.com"
    #property version "1.00"
    #property strict

    //+------------------------------------------------------------------+
    //| 用于重试时保存订单参数的结构体 |
    //+------------------------------------------------------------------+
    struct OrderParams
    {
    int cmd;
    double volume;
    double price;
    int slippage;
    double stoploss;
    double takeprofit;
    string comment;
    int magic;
    datetime expiration;
    color arrow_color;
    };

    //+------------------------------------------------------------------+
    //| 带升级恢复策略的安全订单发送函数 |
    //+------------------------------------------------------------------+
    int SendOrderSafe(OrderParams ¶ms, string &errorMsg)
    {
    int ticket = -1;
    errorMsg = "";

    // 定义重试配置
    int maxRetries = 5;
    int retryCount = 0;
    double priceTolerance = 10.0 Point; // 10点容忍度
    double volumeMultiplier = 0.8; // 每次调整减少20%手数

    while(retryCount < maxRetries)
    {
    // 步骤1:刷新报价
    RefreshRates();

    // 步骤2:根据订单类型调整价格(如果需要)
    double sendPrice = params.price;
    if(retryCount > 2)
    {
    // 两次失败后,开始调整价格
    if(params.cmd == OP_BUY || params.cmd == OP_BUYLIMIT || params.cmd == OP_BUYSTOP)
    {
    // 买单尝试略低的价格(更有利)
    sendPrice = params.price - (priceTolerance
    (retryCount - 2));
    if(sendPrice < Ask - 50 Point) sendPrice = Ask - 50 Point;
    }
    else
    {
    // 卖单尝试略高的价格
    sendPrice = params.price + (priceTolerance (retryCount - 2));
    if(sendPrice > Bid + 50
    Point) sendPrice = Bid + 50 Point;
    }
    }

    // 步骤3:3次重试后调整手数
    double sendVolume = params.volume;
    if(retryCount > 3)
    {
    sendVolume = params.volume
    MathPow(volumeMultiplier, retryCount - 3);
    sendVolume = NormalizeDouble(sendVolume, 2);
    if(sendVolume < 0.01) sendVolume = 0.01; // 最小手数
    }

    // 步骤4:尝试下单
    ticket = OrderSend(
    Symbol(),
    params.cmd,
    sendVolume,
    sendPrice,
    params.slippage,
    params.stoploss,
    params.takeprofit,
    params.comment,
    params.magic,
    params.expiration,
    params.arrow_color
    );

    // 步骤5:检查结果
    if(ticket > 0)
    {
    // 成功!
    if(retryCount > 0)
    {
    Print("经过 ", retryCount, " 次重试后OrderSend成功。订单号: ", ticket);
    }
    return ticket;
    }

    // 步骤6:处理错误
    int error = GetLastError();
    errorMsg = "错误 " + IntegerToString(error) + ": " + ErrorDescription(error);

    // 带上下文信息记录错误
    Print("OrderSend尝试 ", retryCount + 1, " 失败。错误: ", errorMsg);
    Print(" 价格: ", DoubleToString(sendPrice, Digits), " 手数: ", DoubleToString(sendVolume, 2));

    // 步骤7:特定错误的恢复逻辑
    switch(error)
    {
    case ERR_INVALID_STOPS: // 130
    // 止损无效。根据订单类型调整。
    if(params.cmd == OP_BUY || params.cmd == OP_BUYSTOP)
    {
    // 确保止损至少低于入场价20点
    if(params.stoploss > 0 && params.stoploss >= sendPrice - 20 Point)
    {
    params.stoploss = sendPrice - 30
    Point;
    Print("调整止损至: ", DoubleToString(params.stoploss, Digits));
    }
    }
    else if(params.cmd == OP_SELL || params.cmd == OP_SELLSTOP)
    {
    if(params.stoploss > 0 && params.stoploss <= sendPrice + 20 Point)
    {
    params.stoploss = sendPrice + 30
    Point;
    Print("调整止损至: ", DoubleToString(params.stoploss, Digits));
    }
    }
    // 增加本次尝试的滑点容忍度
    params.slippage = MathMax(params.slippage, 10);
    break;

    case ERR_MARKET_CLOSED: // 132
    case ERR_TRADE_CONTEXT_BUSY: // 注意:148是订单过多
    // 等待后重试,给市场时间恢复
    Sleep(100);
    break;

    case ERR_TOO_MANY_ORDERS: // 148
    // 持仓过多。等待持仓减少或放弃。
    Print("订单过多。等待减少...");
    Sleep(200);
    // 检查是否有可以平掉的仓位
    CloseRedundantPositions();
    break;

    case ERR_INVALID_TRADE_VOLUME: // 134
    // 手数过高或过低。激进地减少。
    params.volume = params.volume 0.5;
    if(params.volume < 0.01) params.volume = 0.01;
    Print("手数缩减至: ", DoubleToString(params.volume, 2));
    break;

    case ERR_BROKER_BUSY: // 137
    case ERR_REQUOTE: // 138
    // 经纪商繁忙或重新报价。等待重试。
    Sleep(50);
    break;

    default:
    // 未知错误。再尝试一次后放弃。
    Print("未处理的错误: ", errorMsg);
    if(retryCount >= maxRetries - 1)
    {
    // 最后一次尝试,使用完全宽松的参数
    params.slippage = 30;
    params.stoploss = 0; // 最后一次尝试移除止损
    params.takeprofit = 0; // 移除止盈
    Print("最后一次尝试,移除止损止盈");
    }
    break;
    }

    retryCount++;

    // 防止无限循环的安全措施
    if(retryCount > maxRetries)
    break;
    }

    // 所有重试均失败
    Print("所有重试已耗尽。最终错误: ", errorMsg);
    return -1;
    }

    //+------------------------------------------------------------------+
    //| 平掉冗余仓位以释放容量 |
    //+------------------------------------------------------------------+
    void CloseRedundantPositions()
    {
    int total = OrdersTotal();
    int toClose = total - 2; // 最多保留2个仓位

    if(toClose <= 0) return;

    int closed = 0;
    for(int i = total - 1; i >= 0 && closed < toClose; i--)
    {
    if(OrderSelect(i, SELECT_BY_POS, MODE_TRADES))
    {
    if(OrderSymbol() == Symbol() && OrderMagicNumber() == 0) // 只平非EA订单
    {
    // 平仓
    if(OrderType() == OP_BUY)
    OrderClose(OrderTicket(), OrderLots(), Bid, 10, clrNONE);
    else if(OrderType() == OP_SELL)
    OrderClose(OrderTicket(), OrderLots(), Ask, 10, clrNONE);
    closed++;
    }
    }
    }
    }

    //+------------------------------------------------------------------+
    //| 根据错误码获取错误描述 |
    //+------------------------------------------------------------------+
    string ErrorDescription(int error)
    {
    switch(error)
    {
    case ERR_INVALID_STOPS: return "无效止损";
    case ERR_MARKET_CLOSED: return "市场已关闭";
    case ERR_TOO_MANY_ORDERS: return "订单过多";
    case ERR_INVALID_TRADE_VOLUME: return "无效交易手数";
    case ERR_BROKER_BUSY: return "经纪商繁忙";
    case ERR_REQUOTE: return "重新报价";
    case ERR_PRICE_CHANGED: return "价格已变动";
    case ERR_OFF_QUOTES: return "报价中断";
    default: return "未知错误";
    }
    }
    //+------------------------------------------------------------------+
    `

    大多数文档遗漏的关键细节



    MQL4文档中提到,每次
    OrderSend()之前都应该调用RefreshRates()。但它
    没有告诉你的是,RefreshRates()只会更新当前交易品种的Bid和Ask变量。如果你的EA交易多个品种,你需要分别为每个品种调用SymbolInfoDouble()。这是一个众所周知的坑点,我见过即使是有经验的开发者也会在这里栽跟头。

    官方文档(docs.mql4.com/trading/OrderSend)也没有提到,
    slippage参数的解读方式取决于你经纪商的执行模式。对于市价执行的经纪商,滑点是你请求价格的最大偏差。对于即时执行的经纪商,它是在触发重新报价之前允许的最大价格偏差。这个微妙的差异,可能会让你的EA在不同经纪商那里表现出完全不同的行为。

    "没坏就不修"的谬误



    在EA开发中有一个模式,我称之为"没坏就不修"的心态。你写了一个基础的
    OrderSend()包装器,它95%的情况下都能工作,然后你就继续往下走了。但正是那5%的失败率,才是真金白银流失的地方。在一个高频剥头皮EA中,5%的失败率意味着你的EA每100笔交易就会错过5笔。一个月下来,那可能就是几百次错失的机会。

    我参考了CFA Institute Research Foundation关于"算法交易中的执行成本"的论文(2019年),该论文指出,执行缺口可能占到总交易成本的15%。你的EA未能执行一个订单,不仅仅是一笔丢失的交易;它是一种你的回测从未考虑过的成本。

    跨平台迁移视角



    这里有一个能帮你在迁移到MQL5时节省大量时间的事情。错误码是完全不同的。MQL4中的错误130在MQL5中变成了
    TRADE_RETCODE_INVALID_STOPS(10016)。但更重要的是,OrderSend()的结构是完全不同的。你现在要面对的是CTrade和COrder对象。我的建议是?不要尝试翻译函数调用。从头重写整个订单管理层。概念是相同的,但实现是根本不同的。MQL5迁移指南(docs.mql5.com/en/migration)明确警告不要逐行翻译,我完全赞同。

    最后的一点感悟



    我从构建这个恢复逻辑中学到的最重要的教训,并不是技术层面的。而是心理层面的。大多数开发者假设他们的代码能正常工作。一个具有防御性思维的开发者会假设它会失败,并为此做好计划。这个
    SendOrderSafe()`函数就是这种防御性思维的产物。它并不优雅。它冗长,甚至有点丑陋。但它在五个不同的经纪商那里扛过了三年的实盘交易,这比我那些"整洁"的实现所能说的要多得多。

    参考来源



    MQL4 Documentation. "OrderSend" and "RefreshRates". docs.mql4.com.
    CFA Institute Research Foundation. "Execution Costs in Algorithmic Trading." 2019.
    MQL5 Migration Guide. docs.mql5.com/en/migration.

    本文首发于FXEAR.com,原创内容,未经授权禁止转载。