风险提示:本文为软件开发与技术服务类技术文章,仅用于MQL5程序化开发的技术交流与软件工程实践探讨。文中涉及的订单发送参数、滑点采样方法、retcode处理策略、重试与幂等设计等内容,均为软件工程原理演示与教学用途,不构成任何投资建议、交易策略推荐或收益预期。外汇、贵金属、CFD等杠杆交易存在较高风险,可能导致重大本金损失。文中代码片段仅供学习参考,需经人工审查、编译测试与充分的模拟盘验证后方可使用,直接投入实盘可能造成资金损失。本网站仅提供软件开发技术服务,不涉及任何交易平台运营、经纪业务或投资咨询业务。
回测曲线漂亮,实盘却总差一截。多数人本能反应是策略失效,但把成交明细导出来逐笔看,问题常常出在下单这一环:成交价和下单时的报价差了几个点,重试逻辑把一笔单发成了两笔,请求1.0手只回来0.3手。这些损耗不会抛异常,也不会写进日志的错误行,只会安静地吃利润。
一、执行损耗的四个来源
不铺垫概念,先把实盘里会真实发生的失败列清楚。下面四类在排查中出现频率较高,注意它们的报错特征并不一样。
| 失败类型 | 典型现象 | 常见 retcode | 危害 |
|---|---|---|---|
| 滑点 | 成交价比请求价差3至8点 | 无报错,静默发生 | 每笔多吃成本,短周期策略直接由盈转亏 |
| 拒单 / 重新报价 | OrderSend 返回 false | 10004 REQUOTE、10006 REJECT | 信号丢失,实盘行为与回测不符 |
| 部分成交 | 请求1.0手只成交0.3手 | 10009 DONE,但 volume 变小 | 仓位不符合风控假设,敞口算错 |
| 重复下单 | 同一信号开出两笔 | 无报错 | 风险翻倍,极端行情下可能触发强平 |
四类里只有第二类会让接口返回失败,其余三类都是「调用成功、结果不对」。这就是为什么很多 EA 的日志干干净净,账户曲线却在慢性失血——你在等一个报错,而它不会来。
重点提示:判断下单链路是否健康,不能只看 OrderSend 的返回值。滑点、部分成交、重复下单都不会报错,需要单独的采样与对账机制才能发现,这也是本文后面四个环节要解决的问题。
二、滑点不是随机数,是可以统计的
deviation 的语义是「允许的价格偏差上限」,超出就拒单或重新报价。它是个开关,不是测量工具:设成20点不代表滑点就是20点,更不代表没有滑点。只看这个数字调参,等于闭着眼睛做优化。
要知道实际滑了多少,只能自己采样。做法四步:下单前把 MqlTick 的 ask/bid 留一份作为请求价基准;成交后读回报里的成交价;按买卖方向换算成点数,正数表示对你不利;样本累积后算中位数、90分位与超限率。
double g_slip[]; // EA 顶部声明,滑点样本缓冲
double RecordSlippage(const MqlTradeRequest &req, const MqlTradeResult &res)
{
if(res.retcode != TRADE_RETCODE_DONE && res.retcode != TRADE_RETCODE_PLACED)
return 0.0;
double point = SymbolInfoDouble(_Symbol, SYMBOL_POINT);
double slip = (req.type == ORDER_TYPE_BUY)
? (res.price - req.price) / point // 买:成交价更高为正滑点
: (req.price - res.price) / point; // 卖:成交价更低为正滑点
int n = ArraySize(g_slip);
ArrayResize(g_slip, n + 1);
g_slip[n] = slip;
return slip;
}
void PrintSlippageStats(double deviationPts)
{
int n = ArraySize(g_slip);
if(n == 0) return;
ArraySort(g_slip);
int over = 0;
for(int i = 0; i < n; i++)
if(g_slip[i] > deviationPts) over++;
PrintFormat("[滑点] n=%d 中位=%.1f p90=%.1f 超限率=%.1f%%",
n, g_slip[n / 2], g_slip[(int)(n * 0.9)], over * 100.0 / n);
}
知识点:res.price 是券商回报的成交价,req.price 是你提交的请求价,两者不相等才是滑点的定义。很多人拿图表当前价和成交价比,那是两个不同时点的报价,比出来的数字没有意义。
统计周期建议按周,样本少于30笔时先别下结论。中位数告诉你常态成本,90分位告诉你坏情况有多坏,超限率则直接反映 deviation 设得合不合理——如果超限率长期高于5%,说明你给的偏差空间偏窄,拒单会明显增多,信号丢失率跟着上升。
进阶思考:滑点水平与账户执行模型强相关。ECN/STP 与做市账户的报价来源不同,同一策略在两个账户上跑出的滑点分布不能横向比较。建议按品种、按时段(亚盘、欧盘、美盘,以及重要数据发布前后)分层统计,才能定位到底是哪一段在漏钱,而不是笼统地归因于「券商不行」。
三、retcode 分类:哪些该重试,哪些该放弃
拿到 retcode 之后,要先做的不是重发,是分类。有些错误重发一次就成,有些错误重发只会把亏损放大。下表按 MQL5 官方文档的枚举含义整理,可作为重试策略的判定依据。
| retcode | 含义 | 是否可重试 | 重试前提 | 建议次数 |
|---|---|---|---|---|
| 10004 | REQUOTE 重新报价 | 可 | 重新读取报价后再发 | 2 |
| 10020 | PRICE_CHANGED 价格已变 | 可 | 同步最新价 | 2 |
| 10021 | NO_PRICES 无报价 | 可 | 等报价恢复,拉开间隔 | 3 |
| 10006 | REJECT 被拒绝 | 不可 | 多为保证金或参数问题 | 0 |
| 10013 | INVALID 请求无效 | 不可 | 检查手数、止损距离是否违反券商规则 | 0 |
| 10018 | MARKET_CLOSED 市场关闭 | 不可 | 等下一个交易时段 | 0 |
| 10012 | TIMEOUT 请求超时 | 条件性 | 须先回查服务器侧是否已成交 | 见下一章 |
bool IsRetryable(uint code)
{
return (code == TRADE_RETCODE_REQUOTE || // 10004 重新报价
code == TRADE_RETCODE_PRICE_CHANGED || // 10020 价格已变
code == TRADE_RETCODE_NO_PRICES); // 10021 无报价
// 10012 超时不在此列,必须回查后再决定,见第4章
}
bool SendWithRetry(MqlTradeRequest &req, MqlTradeResult &res, int maxTry = 2)
{
MqlTick tick;
for(int i = 0; i <= maxTry; i++)
{
if(OrderSend(req, res) && res.retcode == TRADE_RETCODE_DONE)
return true;
if(!IsRetryable(res.retcode) || i == maxTry)
return false;
Sleep(300 * (i + 1)); // 退避,避免连续打券商
SymbolInfoTick(_Symbol, tick);
req.price = (req.type == ORDER_TYPE_BUY) ? tick.ask : tick.bid; // 换最新价
}
return false;
}
风险提醒:10012(TRADE_RETCODE_TIMEOUT)是最容易出事的一类。超时不等于失败,订单可能已经在服务器侧成交,只是回报没回来。此时直接重发,结果就是两笔仓位。这类错误不能进通用重试分支,必须先回查再决定。
重点提示:重试要设上限,并且每次重发前重新取价。用上一轮的旧价格重发,等于主动制造一次滑点。一个可用的经验配置是:10004 与 10020 重试2次,10021 重试3次并拉开间隔,其余错误不重试。
还有一种情况要单独处理:请求1.0手,返回10009(DONE),但回报里的 volume 只有0.3。这是部分成交,在流动性偏薄的时段属于正常现象。此时仓位与你的风控假设不符,要么按剩余量补齐,要么接受并在资金管理里按实际持仓重算敞口——但不能默认「发多少就成交多少」。
四、幂等:重试前先确认「到底发出去没有」
所有涉及超时、连接中断、返回值为空的场景,都不能凭返回值判断成败。判断依据只有一个:服务器侧到底有没有这笔单。三个回查手段,成本从低到高:扫活动订单、扫近期成交历史、用请求标识精确匹配。
bool AlreadySent(string sigId, long magic, int windowSec = 120)
{
// 1) 扫活动订单
for(int i = OrdersTotal() - 1; i >= 0; i--)
{
if(!OrderSelect(i, SELECT_BY_POS, MODE_TRADES)) continue;
if(OrderGetInteger(ORDER_MAGIC) != magic) continue;
if(OrderGetString(ORDER_COMMENT) == sigId) return true;
}
// 2) 扫近期成交历史
datetime from = TimeCurrent() - windowSec;
if(!HistorySelect(from, TimeCurrent()))
return true; // 取不到历史就当已发:宁可漏单,不重单
for(int j = HistoryDealsTotal() - 1; j >= 0; j--)
{
ulong t = HistoryDealGetTicket(j);
if(HistoryDealGetString(t, DEAL_COMMENT) == sigId) return true;
}
return false;
}
// 调用示例:下单前先落幂等键
string sigId = StringFormat("sig_%s_%d", _Symbol, (int)TimeCurrent());
req.comment = sigId;
if(AlreadySent(sigId, InpMagic)) return; // 已发过,直接退出
知识点:comment 是券商侧会原样带回的字段,长度有限(通常几十个字符以内),适合放短信号 ID,例如 sig_EURUSD_1730_01。不要放长文本或中文说明,一旦被截断,匹配就会失败,幂等形同虚设。
进阶思考:幂等设计的取舍原则是「宁可漏一笔,不要重一笔」。漏单只损失一次机会,重单是敞口翻倍,在杠杆品种上可能直接触发强平。因此回查失败时的默认动作应当是放弃本次下单并记录告警,而不是继续发。
顺带说一个常见坑:HistorySelect 的时间窗不能开得太窄。券商回报有延迟,用 now-30秒 去查,很可能查不到刚刚那笔,于是判定「没发出去」再发一次。时间窗建议120秒起步,并且查不到历史时按「已发」处理,让代码走保守分支。
五、把执行质量做成每日报表
滑点分布、重试成功率、部分成交率这三项指标,每天收盘后打印一行,连续积累20个交易日,就能回答一个关键问题:这段亏损是策略逻辑的问题,还是执行环节的问题。前者要改模型,后者要改工程,方向完全不同。
[执行质量] 2026-11-12 滑点中位=1.2点 p90=4.8点 超限率=2.1% | 重试3次 成功2次 | 部分成交1笔(30%)
操作参考:落地顺序建议如下——① 本周加滑点采样,先有数据再谈优化;② 本周把 retcode 分类表落成 IsRetryable(),替换掉散落在各处的 if(retry);③ 下周加幂等键,comment 写信号 ID;④ 持续做每日一行报表,20个交易日后回看,再决定要不要调 deviation。
最后说清一件事:执行质量改善不改变策略本身的盈亏属性。一个负期望的策略,把滑点从4点压到1点仍然是负期望,只是亏得慢一些;但对一个正期望的策略,这部分损耗往往正是「回测能赚、实盘不赚」的那条缝。先有数据,再谈优化,顺序反了都是空转。
风险提示:本文内容仅供软件开发与技术学习参考,不构成任何投资建议或交易指导。程序化交易存在较高风险,可能导致重大本金损失。文中涉及的订单发送参数、滑点采样、retcode 处理、重试与幂等设计等代码,均为技术原理演示,未经完整工程化封装,直接复制使用可能存在逻辑缺陷、边界条件遗漏与券商兼容性问题,需经人工审查、编译测试与充分的模拟盘验证。retcode 数值与含义依据 MQL5 官方文档整理,实际行为以对应版本官方文档与实测结果为准。执行质量的改善不改变策略本身的盈亏属性。实盘应用前请自行决策并承担相应风险。本网站仅提供软件开发技术服务,不涉及任何交易平台运营、经纪业务或投资咨询业务。
第三方免责声明:本文仅代表作者个人技术观点,与任何经纪商、交易平台或金融机构无关。文中提及的 MetaTrader 5、MQL5、MQL4、MetaEditor、OrderSend、MqlTradeRequest、MqlTradeResult、MqlTick、TRADE_RETCODE_* 系列常量、HistorySelect、OrderSelect 等名称与产品,其商标与权利归各自所有者所有,本文仅作技术说明与交流引用,不构成任何推荐、背书或商业合作关系。文中关于券商执行模型与回报延迟的描述基于公开资料与工程实践,具体表现以各平台官方说明为准。读者应自行核实相关信息,因使用本文内容而产生的直接或间接损失,作者不承担任何责任。本文不推荐任何具体的经纪商、交易平台或金融产品。
扫码关注,获取更多EA开发干货
微信公众号
视频号
