简介:MQL5-JSON-API源码包是面向MetaTrader 5开发者的完整通信示例,旨在解决MQL5程序与外部系统之间采用JSON格式交换报价数据、下达交易指令等问题,适用于量化策略接入、行情数据转发等场景。资源压缩包约49KB,共11个文件,以3个mqh头文件(负责JSON解析、枚举转换与错误控制)、2个mq5主程序(分别作为指标与EA示例)、2个md说明文档为核心,另附带许可证、Git配置及清理脚本,便于直接导入MT5环境查阅与运行。该资源已有368人学习下载,内容涵盖JSON API设计、Socket通信、ZeroMQ集成、MQL5编程及交易接口安全处理等关键环节。通过阅读代码和文档,使用者能掌握构造JSON请求、解析响应数据、建立套接字连接以及利用ZeroMQ实现高效消息传递的方法,从而构建更稳定、实时性更高的自动化交易系统。
1. 项目定位:给 MT5 一个开箱即用的 JSON 接口层
MQL5-JSON-API 解决的是个很具体的场景:MetaTrader 5 里的行情、持仓、账户只能看不能给别人用。策略的算力在 Python 里,信号要从外部程序发进来,或者想做一个网页看盘页把 MT5 数据实时推过去——这些需求靠 MT5 自带能力都做不到。这个项目用 ZeroMQ 在 MT5 里开一个本地端口,把历史数据、实时 TICK、账户信息和下单能力全部包装成 JSON 接口,外部程序发一段 JSON 文本就能取数或者执行交易。
项目适合两类人。一类是写量化策略、但交易执行放在 MT5 上的开发者,想绕开文件导来导去的笨重方式,换成实时请求-响应;另一类是想做自用交易仪表盘或风险监控页的开发者,需要一条稳定链路把 MT5 数据送出来。它的核心价值,是把 MQL5 从"只能待在编辑器里"的语言,变成"可以被任何语言调用"的服务。下文从架构选型拆起,最后落到跑通和改造。
2. 架构设计:ZeroMQ 长连接 + JSON 协议,为什么 REST 不是最优解
2.1 技术选型取舍:REST、WebSocket、ZeroMQ
刚接触 MT5 外部对接时,很容易问"MT5 有没有 REST API"。答案是没有官方 REST API,MT5 对外暴露数据的能力全部来自 MQL5 语言本身。要在外部访问数据,历史上常用的路线无非三种:读写文件、Windows 命名管道、在 EA 内部引入网络库。MQL5-JSON-API 选的是第三条路,而且传输层没有用 HTTP,用了 ZeroMQ,选型逻辑可以在对比里看得更清楚。
先看 REST/HTTP 的问题。MT5 终端本质是一个普通桌面程序,不是一个 Web 服务器,你需要在 EA 里自己实现 HTTP 服务,这意味着要引入或者自己写 HTTP 解析器,处理请求头、响应头、状态码、keep-alive 这些琐碎逻辑。MT5 的线程模型是单线程的事件循环,HTTP 服务的并发模型在这上面很别扭。实测下来,即使把库调通了,REST 每来一个请求都要面向一次完整 TCP 连接,对高频查询场景的延迟也不友好。
再看 WebSocket。双向通信确实是个加分项,但 MQL5 里没有官方 WebSocket 库,需要手动实现帧解析、掩码处理和握手协议。这个东西写出来容易,调试起来相当痛苦——字节序、长度字段、分片,任何一个细节错了都表现为"连接建立后拿不到数据"。ZeroMQ 则把这些细节全部屏蔽了,它自带 REQ/REP 和 PUB/SUB 两种成熟模式,不用管三次握手,TCP 层的网络传输也被封装干净。另一个对量化场景很关键的天然特性是消息边界完整,ZMQ 的一条消息就是一个整体,不会出现 TCP 流的粘包和半包问题,在 MQL5 里不用自己维护接收缓冲区。
如果非要用文件作为数据交换方式,那就是另一套思路了:MT5 定时写 CSV,外部程序去读,或者反过来外部程序写一个指令文件,MT5 定时扫描。这个方案的实现成本最低,但实时性和可靠性都很差——你没办法知道文件写到一半算不算有效,也没办法做到毫秒级的响应。MQL5-JSON-API 走 ZeroMQ 的原因很简单,它需要的是一个"可实时交互的信道",而不是一个"文件摆渡台"。
2.2 目录结构与模块边界:一个请求在代码里的完整旅行
项目的 MQL5 端不是单个脚本,是一个完整的 EA 工程,标准做法是放在终端数据目录的 MQL5/Experts/ 下。核心文件按职责拆分,大致是这个样子:
| 文件 | 职责 | 是否需要按环境改 |
|---|---|---|
| JsonApi.mq5 | EA 入口,OnInit/OnTimer/OnTick 生命周期管理 | 不常改 |
| Config.mqh | 端口号、IP 白名单、K 线上限、API Key 等配置 | 部署时必改 |
| Include/Transport.mqh | ZeroMQ 封装,连接生命周期、收发超时、重连状态机 | 不需要 |
| Include/Protocol.mqh | action 字符串常量、timeframe 映射、错误码约定 | 加接口时改 |
| Include/History.mqh | CopyRates/TICK 读取与 JSON 序列化 | 加数据接口时改 |
| Include/TradeGate.mqh | CTrade 封装、账户与持仓序列化 | 加交易接口时改 |
Transport 是这个工程里技术含量比较高的文件。它不直接暴露原生 zmq 调用,而是把"连接是否就绪""上一次消息是否已回复""超时是否触发"封装成一个状态机。原因前面提过,REQ/REP 模式是有严格先后顺序的,服务端必须先收后发,如果中间状态错乱,套接字会进入一个无法自我恢复的锁死态。Transport 在处理下一条消息之前,会先检查上一次的响应是否已经送达,没有送达就先重发,而不是傻等新请求。这一块是实打实的血泪经验,很多自测翻车的案例都集中在这里。
Protocol.mqh 负责的是 action 字符串与处理函数的映射。MQL5 没有反射机制,不能像 Python 那样用字典把字符串映射到函数对象,所以分发逻辑只能老老实实写 if-else。这件事看起来笨,但在 MQL5 里是最可靠的。有人试过用函数指针存数组,结果发现 MQL5 对类成员函数指针的限制非常多,报错信息也让人摸不着头脑,维护成本远高于收益,后来还是换回了分支判断。
一个请求在代码里的完整旅行过程是:Python 客户端通过 REQ socket 发一条 JSON 文本;JsonApi.mq5 的定时器回调轮询 REP socket,收到后调用 Transport.RecvMessage;Transport 把收到的字节流以字符串交给 JSON 解析器;解析器取出 action 字段;Protocol 的分支链命中对应处理函数;处理函数调用 MQL5 相关 API(如 CopyRates)后,把结果组装成 JSON object;最后 Transport.SendMessage 把响应发回。全程不依赖全局状态,不阻塞 OnTick 的正常行情处理。这个清晰的单向数据流,是整套 API 能稳定跑起来的基础。
2.3 数据格式约定:字段命名、类型映射与错误码
协议设计遵循一个原则:所有请求必须带 action,所有响应统一包装成 status + data 两层。status 里放 code 和 message,客户端只认 code 就可以判断成功失败,不需要每个接口单独定义错误结构。
请求示例:
{ "action": "get_history", "symbol": "EURUSD", "timeframe": "M15", "count": 500, "start_pos": 0 }响应示例:
{ "status": {"code": 0, "message": "success"}, "data": { "symbol": "EURUSD", "timeframe": "M15", "bars": [ {"time": 1735689600, "open": 1.0845, "high": 1.0861, "low": 1.0838, "close": 1.0852, "tick_volume": 1234} ] } }time 字段统一用 Unix 时间戳(秒),而不是 MT5 原生 datetime 格式。这个决定很小,但直接影响联调效率,Python 端用 datetime.fromtimestamp 一行就能转本地时间,省掉了一层类型转换。timeframe 字段在协议里用字符串 "M15",在 MQL5 内部转换成 ENUM_TIMEFRAMES 枚举,转换函数放在 Protocol.mqh 里。之所以不用数字,是因为日志里打印 "M15" 比打印 15 可读性好得多,排查联调问题的时候少猜一层。
错误码也要单独说明。协议层统一返回 code=0 表示成功,负数表示本地侧错误,正数表示 MT5 交易服务器错误码。比如 -1 是未知 action,-2 是参数格式错误,10014 是请求被拒绝,10018 是市场关闭。这个约定的意思是:客户端拿到负数可以先查本地协议文档,拿到正数则直接对照 MT5 错误码表,两条路互不干扰。实际用下来,这个分层比"所有错误都返回整数 1"高明很多,至少服务器和本地的问题一眼就能分清。
3. 核心模块拆解:从连接握手到数据与交易返回的完整链路
3.1 服务端初始化与请求路由:REP 套接字的生命周期管理
先看 OnInit 里的初始化逻辑。这个阶段的重点是"只做资源准备,不做阻塞等待",否则 MT5 的界面会跟着遭殃。
// JsonApi.mq5 的 OnInit 部分 int OnInit() { // zmq_* 函数来自项目中对 libzmq.dll 的 import 声明 // 一个进程只允许一个上下文,重复创建会占满文件句柄 m_ctx = zmq_ctx_new(); // REP 套接字:服务端专用,严格执行"收-发-收-发"交替 m_sock = zmq_socket(m_ctx, ZMQ_REP); // 端口被占用或权限受限时这里会失败 int rc = zmq_bind(m_sock, "tcp://*:5555"); if(rc != 0) { PrintFormat("JsonApi bind error: %s", zmq_strerror(zmq_errno())); return INIT_FAILED; } // 100ms 接收超时,避免轮询时无限阻塞EA主线程 int rcvTimeout = 100; zmq_setsockopt(m_sock, ZMQ_RCVTIMEO, &rcvTimeout, sizeof(rcvTimeout)); // PUB 端口用于实时TICK推送,与REP分开 m_pub = zmq_socket(m_ctx, ZMQ_PUB); zmq_bind(m_pub, "tcp://*:5556"); EventSetMillisecondTimer(100); return INIT_SUCCEEDED; }zmq_ctx_new 对应 Python 端的 zmq.Context(),一个进程只建一个上下文是 ZeroMQ 的硬性规定,多建不会带来并发优势,反而容易把文件描述符占满。ZMQ_REP 模式承担"一收一发"的同步接口,适合查询历史K线和账户信息这类请求-响应型操作。ZMQ_RCVTIMEO 设 100 毫秒很关键,不设的话 socket 会在定时器回调里无限阻塞,MT5 会提示 "Expert is not responding",这就是最常见的挂上 EA 之后界面假死的原因。
定时器选择毫秒级而不是秒级,是因为如果客户端在 1 秒内发送并等待响应,秒级定时器会让请求明显变慢。100 毫秒的轮询间隔对实时性和 CPU 占用是平衡的。真要做到更低延迟,可以改成在 OnTick 里顺带轮询,但那样的话行情不活跃时消息就没人处理,所以定时器更可靠。
请求路由的主循环放在 OnTimer 里:
// 定时器回调,每100ms触发一次,对REP套接字做非阻塞轮询 void OnTimer() { // 没消息时 RecvMessage 会在超时后返回 false,不会卡住 ZmqMsg request; if(!m_transport.RecvMessage(request)) return; // 解析 JSON 并取出 action 字段,后续分发只看它 json::object req = json::parse(request.data()); string action = req["action"]; // 默认响应:未知 action json::object resp; resp["status"] = json::object{ {"code", -1}, {"message", "unknown action"} }; // 分发链路,每个分支对应一个处理函数 if(action == "get_history") resp = HandleGetHistory(req); else if(action == "get_tick") resp = HandleGetTick(req); else if(action == "get_account") resp = HandleGetAccount(req); else if(action == "get_positions") resp = HandleGetPositions(req); else if(action == "order") resp = HandleOrder(req); // 统一序列化并发送,客户端收到的永远是同一种结构 m_transport.SendMessage(resp.dump()); }RecvMessage 内部调 zmq_recv 并带着超时,所以没消息时也会很快返回,不会卡 EA。action 的匹配用 if-else 链,虽然看起来土,但这是 MQL5 里最不容易出错的分发写法。每个 Handle 函数接收完整的 JSON object 而不是只传参数,因为后续扩接口时,处理函数可能需要读取公共字段,比如版本号、时间戳、签名信息。响应对象统一走 status + data 结构,客户端只要检查 status.code 是否为 0 就可以决定继续还是报错。
3.2 历史数据接口:CopyRates 到 JSON 序列化
历史K线是整个 API 里调用频率最高的接口。策略回测、图表初始化、数据补录都靠它,所以这个函数值得写细:
json::object HandleGetHistory(json::object& req) { string symbol = req["symbol"]; string timeframe = req["timeframe"]; int count = req["count"]; int startPos = req.get("start_pos", 0); // 字符串周期转MQL5枚举,转不了就返回参数错误 ENUM_TIMEFRAMES tf = StringToTimeframe(timeframe); if(tf == PERIOD_CURRENT) return JsonResponse(-2, "invalid timeframe"); // CopyRates 一次性拷贝到数组,返回实际拷贝数 MqlRates rates[]; int copied = CopyRates(symbol, tf, startPos, count, rates); if(copied < 0) return JsonResponse(GetLastError(), "CopyRates failed"); json::array bars; for(int i = 0; i < copied; i++) { bars.push_back(json::object{ {"time", (long)rates[i].time}, {"open", rates[i].open}, {"high", rates[i].high}, {"low", rates[i].low}, {"close", rates[i].close}, {"tick_volume", rates[i].tick_volume} }); } json::object data; data["symbol"] = symbol; data["timeframe"] = timeframe; data["bars"] = bars; return JsonResponse(0, "success", data); }CopyRates 是 MT5 读取历史K线的标准函数,第三个参数是起始位置,0 表示从最新一根K线往前数。这里没有调 ArraySetAsSeries,所以数组方向是 index 0 为最早、index copied-1 为最新,这样的顺序在 JSON 里更符合直觉,Python 端拿到以后按索引挨个取即可,不用再做一次反转。
返回结构里的 time 字段强转成 long,MQL5 的 datetime 实际是 int 秒数,转 long 一方面是为了协议统一,另一方面也避免将来出现 2038 年问题。需要注意 StringToTimeframe 是程序里自己写的映射函数,把 "M5" 映射到 PERIOD_M5,"H1" 映射到 PERIOD_H1,里面要单独处理 "MN1" 这种特殊周期,漏掉的话客户端传月度K线就会收到 invalid timeframe 的错误。
3.3 实时行情订阅:PUB/SUB 模式下的 TICK 广播
历史K线适合请求-响应,但实时行情用请求-响应就浪费了。这套 API 单独开了一个 PUB 套接字绑定在 5556 端口,专门推送 TICK 数据,客户端用 SUB 模式连接并设置订阅过滤器。
// 在 OnInit 里创建PUB套接字,绑定到独立端口 m_pub = zmq_socket(m_ctx, ZMQ_PUB); zmq_bind(m_pub, "tcp://*:5556"); // 在 OnTick 里向订阅者推送当前报价 void OnTick() { if(m_pub == NULL) return; MqlTick tick; if(!SymbolInfoTick(_Symbol, tick)) return; json::object msg; msg["symbol"] = _Symbol; msg["bid"] = tick.bid; msg["ask"] = tick.ask; msg["time"] = (long)tick.time_msc; // 约定的 topic 前缀用 symbol,方便客户端做订阅过滤 m_transport.PublishMessage("TICK:" + msg.dump()); }PUB 操作是广播写,没有确认机制,客户端掉线后消息会直接丢。这个设计是故意的——实时行情本来就是"过时不候"的数据,客户端延迟太高的话,补拉一次历史K线比缓存未送达的 TICK 更高效。订阅过滤可以利用 ZeroMQ 的 topic 前缀机制,客户端 setsockopt(ZMQ_SUBSCRIBE, "TICK:EURUSD") 之后,只收到 EURUSD 的消息,不会把全部行情都拉下来。如果 MT5 里同时挂了多个品种,这个约定能省不少带宽和解析开销。
3.4 交易与账户接口:Trading API 的可控暴露
交易指令是整套 API 里风险最高的模块,下单不仅影响数据,还直接影响资金。这里用 CTrade 类封装,而不是裸调 OrderSend,因为 CTrade 会自动处理订单状态的同步,省去很多手工检查的麻烦。
json::object HandleOrder(json::object& req) { string symbol = req["symbol"]; string opType = req["type"]; // buy / sell / close double volume = req["volume"]; // 可选参数,不传就用默认值 double sl = req.get("sl", 0.0); double tp = req.get("tp", 0.0); string comment = req.get("comment", "json-api"); if(opType == "buy") { if(!m_trade.Buy(volume, symbol, 0.0, sl, tp, comment)) return JsonResponse(m_trade.ResultRetcode(), "buy failed"); } else if(opType == "sell") { if(!m_trade.Sell(volume, symbol, 0.0, sl, tp, comment)) return JsonResponse(m_trade.ResultRetcode(), "sell failed"); } else if(opType == "close") { // 先查持仓拿到ticket,再按ticket平仓 ulong ticket = req["ticket"]; if(!m_trade.PositionClose(ticket)) return JsonResponse(m_trade.ResultRetcode(), "close failed"); } // 返回订单号,客户端以它作为后续查询依据 json::object data; data["ticket"] = m_trade.ResultOrder(); data["deal"] = m_trade.ResultDeal(); return JsonResponse(0, "success", data); }m_trade.ResultRetcode() 拿到的错误码是交易服务器返回码,常见的有 10004 连接丢失、10006 无报价、10014 请求被拒绝、10018 市场关闭。这些错误码在 MT5 文档里有专门页面,但实际对接时你会发现服务器返回的错误码和客户端本地反馈经常不一致,所以响应里既要返回 retcode,也要带一段人类可读的 message。Buy 的第三个参数 0.0 是 price,传 0 表示由系统按市价执行,如果传了具体价格,MT5 会当成限价单执行,语义完全不一样,新手容易在这里踩坑。
账户查询和持仓查询是风控面板的刚需:
json::object HandleGetAccount(json::object& req) { json::object data; data["balance"] = AccountInfoDouble(ACCOUNT_BALANCE); data["equity"] = AccountInfoDouble(ACCOUNT_EQUITY); data["margin"] = AccountInfoDouble(ACCOUNT_MARGIN); data["free_margin"] = AccountInfoDouble(ACCOUNT_MARGIN_FREE); data["profit"] = AccountInfoDouble(ACCOUNT_PROFIT); data["currency"] = AccountInfoString(ACCOUNT_CURRENCY); data["leverage"] = AccountInfoInteger(ACCOUNT_LEVERAGE); return JsonResponse(0, "success", data); } json::object HandleGetPositions(json::object& req) { json::array positions; for(int i = PositionsTotal() - 1; i >= 0; i--) { ulong ticket = PositionGetTicket(i); if(ticket == 0) continue; json::object pos; pos["ticket"] = ticket; pos["symbol"] = PositionGetString(POSITION_SYMBOL); pos["volume"] = PositionGetDouble(POSITION_VOLUME); pos["price_open"] = PositionGetDouble(POSITION_PRICE_OPEN); pos["price_current"] = PositionGetDouble(POSITION_PRICE_CURRENT); pos["profit"] = PositionGetDouble(POSITION_PROFIT); pos["type"] = (string)(PositionGetInteger(POSITION_TYPE) == 0 ? "buy" : "sell"); positions.push_back(pos); } json::object data; data["positions"] = positions; return JsonResponse(0, "success", data); }AccountInfo 系列读的是当前 MT5 登录账户的上下文,不需要额外传参。这里建议在取数前先检查 MQLInfoInteger(MQL_TRADE_ALLOWED),如果返回 0,说明 MT5 设置里没勾选"允许算法交易",这时候下单接口会一直报错。很多外部程序集成后连不上订单接口,查了半天发现是 MT5 界面右上角的自动交易按钮没点亮。持仓遍历从大到小是因为按索引访问时,平仓会导致索引错位,从尾部往前扫更安全。POSITION_TYPE 在协议层直接转成 "buy"/"sell" 字符串,比让客户端猜数字少了维护两张表的成本。
4. 部署与运行:把 EA 挂上图表,用 20 行 Python 拿到第一根 K 线
4.1 环境准备:MT5、ZMQ DLL 与项目目录
运行时依赖其实就两样:MetaTrader 5 终端和一个 libzmq.dll。MT5 版本方面,近几年的正式版本都能跑,只要支持 MQL5 标准库里的 CTrade 类即可。libzmq.dll 建议放到终端根目录,这样不用改系统 PATH,也不容易和其他软件冲突。
项目代码放进数据目录的 MQL5/Experts/ 下,保持 JsonApi.mq5 与 Include 文件夹的相对关系。如果目录结构乱了,编译时会报找不到 Include/Transport.mqh,那是路径问题,不是代码问题。放好之后,在 MetaEditor 里打开 JsonApi.mq5,按 F7 编译,确认"编译成功"且没有报错,这一步完成后环境准备就算结束。
4.2 在 MT5 里挂载 API EA
挂载过程有几个容易被忽略的状态开关。先把 MT5 右上角的"算法交易"按钮点亮,这是总开关;然后把 JsonApi.ex5 拖到一张图表上,弹窗里勾选"允许算法交易";最后确认"工具→选项→EA 交易"里没有额外拦截。三个开关缺一个,后面的下单接口都会静默失败。
挂载完成后,切到"工具箱→专家"标签,应该能看到类似 "JsonApi bind success on 5555" 的日志。这里有个细节:EA 挂到哪张图表上,OnTick 就只处理哪个品种的 TICK。如果你只挂了 EURUSD 图表,那么 PUB 推送里就只有 EURUSD 的消息。想推多个品种,要么同时挂多个图表,要么在代码里对关注的品种逐个调用 SymbolSelect 和 MarketBookAdd,把数据先取到内存再推。
4.3 Python 客户端联调
服务端就绪后,用下面的 Python 代码验证整条链路:
import zmq import json context = zmq.Context() socket = context.socket(zmq.REQ) socket.connect("tcp://127.0.0.1:5555") socket.send_string(json.dumps({ "action": "get_history", "symbol": "EURUSD", "timeframe": "M15", "count": 10 })) # 用 Poller 设置 10 秒超时,避免 socket 无限等下去 poller = zmq.Poller() poller.register(socket, zmq.POLLIN) socks = dict(poller.poll(10000)) if socks and socket in socks: resp = json.loads(socket.recv_string()) print("code:", resp["status"]["code"]) print("bars:", len(resp["data"]["bars"])) else: print("timeout: MT5 side not responding")这段代码就是外部程序的通用调用骨架。connect 的地址是 127.0.0.1:5555,和服务端 bind 的端口对应;poller 的 10000 是毫秒,如果 MT5 端在处理一个很重的请求,10 秒内没回应就会触发超时分支。实际项目里你会希望把这个 request 函数抽出来,把 context 和 socket 做成模块级复用,不要每次请求都重新创建连接,否则高频调用时光握手开销就能占掉一半延迟。
4.4 配置参数说明
Config.mqh 里集中了部署时最常调整的参数,表格如下:
| 参数 | 默认值 | 说明 |
|---|---|---|
| ZMQ_REP_PORT | 5555 | 请求-响应端口,历史数据、账户、交易都走这里 |
| ZMQ_PUB_PORT | 5556 | 实时TICK推送端口 |
| MAX_BARS | 500 | 单次 get_history 最大K线数,防止MT5卡顿 |
| ALLOWED_IPS | 127.0.0.1 | IP白名单,逗号分隔,留空表示只允许本机 |
| API_KEY | 空 | 启用后请求头必须带 sign 字段,用于安全校验 |
| PING_INTERVAL | 30000 | 心跳接口 get_ping 的最小调用间隔,毫秒 |
MAX_BARS 是保护性参数,把它设成 500 而不是 50000,是因为 CopyRates 一次性拉几万根K线会阻塞 EA 主线程。外部程序要全量历史时,正确的姿势是循环调用,每次从 start_pos 递增 500 根往下拉。PING_INTERVAL 则是给监控端用的,外部监控程序定期调用 get_ping,如果超过 30 秒没有响应,就说明 MT5 端可能卡死或掉线,这时候需要告警而不是继续发单。
5. 避坑指南:对接 MT5 时最容易翻车的六个细节
从部署到联调,最容易把时间扔进黑洞的地方集中在下面六处。每一条都按 现象 → 原因 → 解决 的顺序写,可以直接对着症状查。
5.1 EA 一挂上去 MT5 界面就转圈
现象:EA 拖到图表后,MT5 整个客户端卡住,图表右键都弹不出来,过一会儿提示 "Expert is not responding"。
原因:大多不是业务代码的问题,而是 OnInit 里做了同步阻塞操作。比如在初始化里直接调 zmq_recv 等待第一条消息,或者 CopyRates 拉了一个很大的范围,把主线程堵死了。
解决:OnInit 只做套接字创建和 bind 配置,所有消息接收都放到 OnTimer 或 OnTick 里异步轮询。首屏数据延迟到第一次定时器回调里拉,同时 MAX_BARS 控制单次数量,需要更多就分页。
5.2 客户端第二次请求必超时,REP 模式状态卡死
现象:Python 端第一次 send/recv 正常,第二次 send 之后 recv 永远超时,重启 EA 又能好一会。
原因:REQ/REP 套接字有严格的状态约束。服务端调用 zmq_recv 之后如果没有完成 zmq_send,或者客户端在服务端回复前再次发送,状态机就错位了。MT5 端处理太快,客户端赶在下一次 OnTimer 轮询之前发了第二条,两边就互相等死。
解决:Transport 层加"未回复标志位",上一次 Send 未完成就不处理新消息。客户端一侧保证严格的"发-收-发-收"交替,同时 poller 设短超时,超时后重建新的 REQ socket,绕开卡死的连接。
5.3 CopyRates 返回的时间差了几个小时
现象:Python 端画K线图时发现时间轴整体偏移,常见的偏移量是 8 小时,也有 4 小时的。
原因:CopyRates 返回的时间是经纪商服务器时间,不是 UTC,也不是本地时间。很多经纪商的服务器时间设置在 GMT+2 或 GMT+3,并且不随夏令时变化,所以偏移量是固定的。
解决:在协议里增加 timezone_offset 字段,返回历史数据时带上服务器相对 UTC 的小时偏移。客户端先转 UTC 再转自己的时区,不要在客户端里写死 8 小时。另外判断"今天"要用服务器时区的自然日,别拿客户端的系统时钟切K线。
5.4 JSON 字段里带双引号导致解析失败
现象:某次返回的文本里包含双引号或换行,Python 端 json.loads 直接抛异常。
原因:MQL5 端手拼 JSON 字符串时没有对特殊字符做转义。有人为了省事,先拼字符串再做全文替换,把所有双引号换成单引号,结果数据格式被彻底破坏。
解决:所有值都走 JSON 库的序列化接口,不要手动拼文本。联调期在响应里塞一条带引号、换行、反斜杠的测试字段,先把转义问题测完再上真实数据。
5.5 下单返回 ticket 但持仓列表里查不到
现象:order 接口返回 code=0 且有 ticket,但 MT5 里看不到持仓,账户历史里也没有委托记录。
原因:CTrade.Buy 返回 true 只表示请求被接受,不等于最终成交。订单可能处于 pending 状态,也可能被服务器以资金不足、非交易时段等理由拒绝,但方法本身的返回值仍然是 true。
解决:下单后检查 m_trade.ResultRetcode() 的最终状态码,再等 500ms 用 PositionSelect(ticket) 确认持仓真的存在。如果查不到,响应里返回 pending 状态,让客户端决定继续轮询还是撤单。
5.6 换了台机器就连不上 5555 端口
现象:本机 Python 客户端一切正常,换一台局域网机器访问同一 IP 的 5555 端口,连接超时。
原因:ZeroMQ 绑定 tcp://*:5555 已经监听了所有网卡,但 Windows 防火墙默认拦截了新端口的入站连接。而且 MT5 所在的机器如果有多块网卡,DNS 解析出来的 IP 未必是局域网内能访问的那个。
解决:先用 netstat -an | findstr 5555 确认监听地址,再检查防火墙放行。ALLOWED_IPS 里要加上客户端机器的 IP,还要注意 MT5 端和 Python 端必须能通过内网路由互通。这个场景默认是局域网,不要把端口直接暴露到公网,不然任何能扫到端口的人都能对你下单。
6. 二次开发:把接口网关升级成你的策略执行中台
这个项目拆完以后,最值得改的地方不是补接口,而是把"每个请求独立处理"升级成"带会话和鉴权的服务化模型"。目前的 REQ/REP 是无状态的,每次请求都要带完整参数,外部程序持续运行时,如果每次下单都重新授权、每秒都拉一次账户信息,流量和安全性都不理想。
我的改造思路分三步。第一步,在 Config.mqh 里增加 API_KEY 和 API_SECRET,外部请求必须在 JSON 头部携带 sign 字段,用 HMAC-SHA256 对请求体签名,服务端在 3.1 的分发函数开头加一段校验逻辑,验证不通过直接返回鉴权失败。第二步,增加一个订阅注册表,客户端发送 subscribe 请求声明"我要监听 EURUSD 的持仓变化",服务端在每次 OnTradeTransaction 触发时向对应的 PUB topic 推一条持仓变更消息,这样外部程序不需要高频轮询账户接口,也能实时感知成交和风控事件。第三步,把错误码映射表改成可配置的,在 Config.mqh 里维护一个 int 到 string 的映射数组,让不同经纪商的异常信息能直接在配置里改,不用重新编译 EA。
改造完之后的验证方式也值得说。我通常先在 MT5 策略测试器里挂上 EA 跑模拟盘,同时用 Python 脚本发 1000 条随机请求,统计响应时间分布、超时率和内存占用。这个测试能一次性暴露并发、超时、内存泄漏三个问题,比手工点按更可靠。通过之后,再上小资金实盘跑一天,重点看日志里有没有断线重连的记录,以及持仓查询和实际账户是否一致。
这个项目给我最大的教训是"先定协议再写实现"。早期版本我先写 MQL5 端代码,再写 Python 联调,结果字段命名、时间格式、错误码三段全对不上,来回改了三轮。从那以后,我每次做跨语言接口,都强制先在文档里把 JSON 请求和响应的样例写死,两端同时照着样例开发,谁先写完谁自测,联调基本一次过。希望帮到你。
本文还有配套的精品资源,点击获取