说实话吧。大多数EA教程把
OrderSend()当成了一根魔法棒。你调用它,订单就出现了,然后大家都高高兴兴回家。接着你把它放到真实账户上,眼睁睁看着它反复失败,报的错你在回测器里从来没见到过。文档里确实列了这些错误码,但话说回来,谁会真的把那40多种错误码通读一遍,并为它们提前做好计划呢?两年前我搭建了一个交易系统,回测里表现得神挡杀神。实盘第一天,第一个小时就生成了17个订单错误。那个EA只是一遍又一遍地重试同一个失败的订单,把我的日志塞满了"OrderSend失败"的消息。就是在那个时候我意识到:错误处理不是一个锦上添花的功能。它是你的系统究竟是能活下来,还是自我毁灭的分水岭。
沉默的杀手:错误130和148
大部分人都知道错误130(无效止损)。但文档没有强调的一点是:这个错误通常是一个时机问题,而不是价格问题。
OrderSend()函数会在执行的那一刻,根据当前的买/卖价检查你的止损和止盈。但如果你调用RefreshRates()和实际下单之间市场发生了变动,你就会收获错误130。错误148才是真正的怪物。它表示"订单太多"。你达到了经纪商对未平仓头寸的限制。标准建议是什么?在下单之前检查一下
OrdersTotal()就行了。但这是一个等着发生的竞态条件。在你检查完毕和OrderSend()执行之间,可能刚好有别的订单插队进来。这里有一个大多数教程都不会提到的场景。你的EA限制最多持有3笔未平仓订单。你检查了
OrdersTotal(),结果是2。看起来安全,对吧?你发送了订单。但就在那毫秒之间,另一个EA(甚至是一笔手动交易)刚好开了一个订单。你的订单因为错误148失败了,而你的EA根本没有恢复逻辑。不一样的思路:带升级策略的重试机制
我没有仅仅依赖检查后祈祷的方式,而是构建了一套恢复逻辑,它把每一次
OrderSend()都视为一个可能失败的事务,并为每一种失败模式都计划了应对方案。其核心思想是带升级策略的重试。你从最激进的重试策略开始,然后逐步后退,根据需要改变订单参数。升级阶梯:
完整的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,原创内容,未经授权禁止转载。