1. 为什么大模型不能直接连 MT5,非要绕一层 MCP
先说结论:Claude、GPT 这类大模型本身没有任何办法直接碰你的 MT5 终端。它们能做的只是「生成文本」,而「读持仓」「下单」「改 EA 参数」这些动作,必须由一段跑在你本机的程序去执行。中间这层把动作暴露成模型可调用函数的协议,就是 MCP(Model Context Protocol)。
你可以把 MCP 理解成「给大模型配的一套标准插座」。你写一个 MCP Server,在里面声明一组工具:工具名、入参 schema、具体实现。任何支持 MCP 的 Agent(Claude Code、Claude Desktop、Cline、Fay 等)接上就能用,不用为每家模型单独写一套 function calling 适配。这就是为什么现在做交易类 Agent,大家更愿意走 MCP 而不是绑死某家的工具调用格式。
那具体到 MT5 场景,链路是这样的:Agent 收到你的自然语言指令 → 模型推理该调哪个工具 → 通过 MCP 协议把调用请求发给本机的 MCP Server → Server 用 Python 的 MetaTrader5 库操作 MT5 终端 → 结果以 JSON 返回给模型 → 模型继续推理或给你结论。整个过程你不需要写 if-else 去编排,模型自己用 ReAct(推理-行动循环)决定下一步。
我试过把这套跑通之后,最直观的变化是:以前你问「我黄金那单亏多少」,模型只能瞎猜或者让你自己去看;现在它会真的去调list_all_mt5_status读你的真实持仓,再告诉你浮亏数字。这个差别就是「聊交易」和「能操作交易」的分水岭。
不过这里有个前提必须先讲清楚:动钱的工具默认不能开。原因很简单,模型再聪明也会有幻觉,一旦它能无条件下单,风险不可控。所以整套设计的第一原则是读写分层、权限分离,后面会详细展开。
本篇聚焦两条最小链路:行情读取和下单/撤单闭环,再补齐鉴权、错误重试与日志。全程用模拟账户验证,不碰真金白银。TaoToken 在这里的角色是统一 Key/API 通道——你不需要为 Claude、GPT 分别管理一堆 Key,一个通道搞定模型侧的调用,官网见 https://taotoken.net/?utm_source=taotoken_aicg_blog_end 。
适合谁看:有 Python 基础、想给自己的交易 Agent 加「手脚」的开发者;或者你已经在用 Claude Code / Cline 写代码,想让它顺手把 MT5 也管起来。不需要你懂 MQL5 底层,但得能看懂 Python 和 JSON。
2. 前置准备:TaoToken 统一 Key 与 MT5 环境搭建
在写 MCP Server 之前,先把两边的环境弄干净:模型侧和交易侧。
模型侧,我建议用 TaoToken 做统一入口。原因是你在开发调试阶段会频繁切换模型——有时候用 Claude 测工具调用,有时候用 GPT 对比效果,如果每家都单独申请 Key、单独配 Base URL,配置会乱成一团。TaoToken 提供一个统一的 API 通道,Base URL 是https://taotoken.net/api,你拿一个 Key 就能调不同模型。API Key 在控制台生成:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。
交易侧,你需要:
- 安装 MT5 终端(Windows 原生,Linux 用 Wine,macOS 也能跑但坑多)。
- 在 MT5 里登录一个模拟账户,别用实盘调试。
- 安装 Python 的 MetaTrader5 库:
pip install MetaTrader5。 - 确认 MT5 终端里「工具 → 选项 → 智能交易系统」勾选了「允许算法交易」,否则
order_send会直接返回失败。
这里有个容易忽略的点:MetaTrader5 这个 Python 库是跟本机 MT5 终端进程通信的,不是走网络 API。所以你的 MCP Server 必须和 MT5 跑在同一台机器上。如果你想让远程的 Agent 调用,得自己在中间加一层转发,但那是另一个话题,本篇不展开。
环境变量这块,我建议一开始就规划好权限闸:
| 环境变量 | 作用 | 默认值 |
|---|---|---|
EASYDEAL_TRADING_WRITE | 开启平仓/改单类工具 | 关闭 |
EASYDEAL_TRADING_WRITE_OPEN | 开启开仓类工具 | 关闭 |
MT5_LOGIN | 模拟账户账号 | 无 |
MT5_PASSWORD | 账户密码 | 无 |
MT5_SERVER | 券商服务器名 | 无 |
没开权限时,动钱工具根本不出现在工具列表里——模型连「乱下单」的入口都看不到。这比「工具存在但内部拦截」安全得多,因为模型不会去尝试调用一个它看不见的工具。
模型侧的配置,如果你用 Claude Code,可以在 settings 里指定 Base URL 和 Key;如果用 Cline,在 MCP 配置里填。下面给一份可复制的配置片段,路径按你自己的实际安装位置改。
{ "mcpServers": { "easydeal-mt5": { "command": "python", "args": ["/path/to/easydeal_mcp_server.py"], "env": { "MT5_LOGIN": "你的模拟账号", "MT5_PASSWORD": "你的密码", "MT5_SERVER": "你的券商服务器", "EASYDEAL_TRADING_WRITE": "0", "EASYDEAL_TRADING_WRITE_OPEN": "0" } } } }注意这里两个写权限都设成0,先只跑只读链路。等只读验证通过了,再单独开写权限测下单。这个顺序别颠倒,否则你会在一个「工具能下单但行情还没读对」的混乱状态里排障。
模型 ID 这块,Claude 系可以用claude-sonnet-4-5之类,GPT 系用gpt-4o之类,具体以 TaoToken 文档为准:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。工具调用能力强的模型,ReAct 循环会更稳,别用太小的模型测交易类工具,容易在参数格式上翻车。
3. 可复制配置:MCP Server 工具函数签名与权限闸
这一节是核心,直接给可复制的代码骨架。你要做的是把 MT5 的每个操作封装成一个工具,工具分两层:只读层和动钱层。
先看只读工具。这类工具永远可用,负责读状态、读行情、读参数:
import os import json import MetaTrader5 as mt5 def list_all_mt5_status() -> dict: """读取账户、持仓、挂单的汇总状态""" if not mt5.initialize(): return {"ok": False, "error": "mt5_initialize_failed"} account = mt5.account_info() positions = mt5.positions_get() orders = mt5.orders_get() return { "ok": True, "account": { "login": account.login, "balance": account.balance, "equity": account.equity, "margin_free": account.margin_free, }, "positions": [p._asdict() for p in (positions or [])], "orders": [o._asdict() for o in (orders or [])], } def get_symbol_tick(symbol: str) -> dict: """读取某品种的最新买卖价""" info = mt5.symbol_info(symbol) if info is None: picked = _pick_chartable_symbol([symbol]) if picked: symbol = picked else: return {"ok": False, "error": f"symbol_not_found:{symbol}"} tick = mt5.symbol_info_tick(symbol) return { "ok": True, "symbol": symbol, "bid": tick.bid, "ask": tick.ask, "time": tick.time, }注意get_symbol_tick里的模糊匹配逻辑。不同券商的黄金叫法不一样:XAUUSD、XAUUSDm、XAUUSD.c都可能是它。工具内部别写死品种名,拿通用名去券商全品种表里找变体:
def _pick_chartable_symbol(candidates: list) -> tuple: """在全品种表里找含候选名的、且行情可见的品种""" all_symbols = mt5.symbols_get() if not all_symbols: return None, None for cand in candidates: for s in all_symbols: if cand.upper() in s.name.upper() and s.visible: return s.name, s return None, None这个模糊匹配是真实踩过的坑。我一开始写死XAUUSD,结果换了个券商账户,工具一直报symbol_not_found,排查半天才发现人家叫XAUUSDm。
再看动钱工具。这类工具默认不暴露,靠环境变量闸控制:
def _write_enabled() -> bool: return os.getenv("EASYDEAL_TRADING_WRITE", "0") == "1" def _open_enabled() -> bool: return os.getenv("EASYDEAL_TRADING_WRITE_OPEN", "0") == "1" def open_position(symbol: str, volume: float, order_type: str, sl: float = 0.0, tp: float = 0.0) -> dict: """开仓。需要 EASYDEAL_TRADING_WRITE_OPEN=1""" if not _open_enabled(): return {"ok": False, "error": "open_disabled"} info = mt5.symbol_info(symbol) if info is None: picked, _ = _pick_chartable_symbol([symbol]) if not picked: return {"ok": False, "error": f"symbol_not_found:{symbol}"} symbol = picked info = mt5.symbol_info(symbol) # 手数量化:按券商 volume_step / min / max 取整 step = info.volume_step volume = max(info.volume_min, min(info.volume_max, round(volume / step) * step)) price = mt5.symbol_info_tick(symbol).ask if order_type == "buy" \ else mt5.symbol_info_tick(symbol).bid req = { "action": mt5.TRADE_ACTION_DEAL, "symbol": symbol, "volume": volume, "type": mt5.ORDER_TYPE_BUY if order_type == "buy" else mt5.ORDER_TYPE_SELL, "price": price, "sl": sl, "tp": tp, "magic": _pick_magic(), "comment": "mcp_agent", "type_time": mt5.ORDER_TIME_GTC, "type_filling": mt5.ORDER_FILLING_IOC, } result = mt5.order_send(req) ok = result is not None and result.retcode == mt5.TRADE_RETCODE_DONE return { "ok": ok, "ticket": result.order if ok else None, "retcode": getattr(result, "retcode", None), "message": None if ok else f"order_send retcode={result.retcode}", }这里有几个关键设计点,逐个说。
魔术数隔离。_pick_magic()要避开当前在跑策略的持仓魔术数,否则 AI 新开的单会被那只 EA 误当成自己的单去平/改/统计。实现上可以先读一遍现有持仓的 magic 集合,然后取一个不在集合里的值。
手数量化。不同券商对最小手数、步长、最大手数要求不同。直接传0.1可能因为步长是0.01而报Invalid volume。上面这段按volume_step取整,能挡掉大部分退单。
返回值统一带ok字段。这是给 Agent 判断成败用的。如果工具返回一个没有ok字段的错误对象,上层很容易误判成功。这个坑我在早期版本踩过:order_send返回None时,代码没检查,结果 Agent 以为下单成功了,实际什么都没发生。
平仓和改单工具同理,只是权限闸用EASYDEAL_TRADING_WRITE:
def close_position(ticket: int) -> dict: """平仓。需要 EASYDEAL_TRADING_WRITE=1""" if not _write_enabled(): return {"ok": False, "error": "write_disabled"} pos = mt5.positions_get(ticket=ticket) if not pos: return {"ok": False, "error": f"position_not_found:{ticket}"} p = pos[0] req = { "action": mt5.TRADE_ACTION_DEAL, "symbol": p.symbol, "volume": p.volume, "type": mt5.ORDER_TYPE_SELL if p.type == mt5.POSITION_TYPE_BUY else mt5.ORDER_TYPE_BUY, "position": ticket, "price": mt5.symbol_info_tick(p.symbol).bid if p.type == mt5.POSITION_TYPE_BUY else mt5.symbol_info_tick(p.symbol).ask, "magic": p.magic, "comment": "mcp_agent_close", "type_time": mt5.ORDER_TIME_GTC, "type_filling": mt5.ORDER_FILLING_IOC, } result = mt5.order_send(req) ok = result is not None and result.retcode == mt5.TRADE_RETCODE_DONE return { "ok": ok, "ticket": ticket, "retcode": getattr(result, "retcode", None), "message": None if ok else f"close retcode={result.retcode}", }工具声明完之后,MCP Server 启动时根据环境变量决定暴露哪些工具。只读工具无条件注册,动钱工具按开关注册。这样模型看到的工具列表就是「当前允许它做的事」的精确映射。
模型侧配置,如果你用 Claude Code,可以在~/.claude/settings.json里配 MCP Server;如果用 Cline,在 MCP 配置面板里填。三件套(Base URL + Key + Model ID)缺一不可:
# 示例:Cline MCP 配置片段 [mcp_servers.easydeal-mt5] command = "python" args = ["/path/to/easydeal_mcp_server.py"] [mcp_servers.easydeal-mt5.env] MT5_LOGIN = "你的模拟账号" MT5_PASSWORD = "你的密码" MT5_SERVER = "你的券商服务器" EASYDEAL_TRADING_WRITE = "0" EASYDEAL_TRADING_WRITE_OPEN = "0"模型 ID 和 Key 走 TaoToken 的话,Base URL 填https://taotoken.net/api,Key 在控制台拿。这样你切模型只改 Model ID,不用动其他配置。
4. 验证请求:模拟账户跑通下单与撤单闭环
配置写完,先别急着开写权限。第一步是验证只读链路。
启动 MCP Server,然后在 Agent 里问一句:「帮我看看当前账户状态和持仓」。如果一切正常,模型会调用list_all_mt5_status,返回你的模拟账户余额、净值、持仓列表。这一步能过,说明 MCP 协议通了、MT5 连接通了、工具注册对了。
如果这一步就失败,先查三个地方:MT5 终端是否在运行、是否登录了模拟账户、Python 的 MetaTrader5 库版本是否和终端匹配。mt5.initialize()返回False时,用mt5.last_error()看具体错误码。
只读通了之后,开写权限测下单。把环境变量改成:
export EASYDEAL_TRADING_WRITE=1 export EASYDEAL_TRADING_WRITE_OPEN=1重启 MCP Server,然后在 Agent 里下指令:「用模拟账户买入 0.01 手 XAUUSD,带 50 点止损」。模型会调open_position,返回里应该有ok: true和一个 ticket 号。
拿到 ticket 后,验证撤单闭环。这里分两种情况:如果单子已经成交变成持仓,用close_position平掉;如果还是挂单,用mt5.order_send发TRADE_ACTION_REMOVE撤掉。模拟账户上跑一遍,确认 ticket 从持仓列表里消失。
一个完整的验证流程大概是这样:
# 验证脚本:跑一遍下单-查询-平仓 import MetaTrader5 as mt5 mt5.initialize() # 1. 下单 r = open_position("XAUUSD", 0.01, "buy", sl=0.0, tp=0.0) print("open:", r) assert r["ok"], r ticket = r["ticket"] # 2. 查持仓 positions = mt5.positions_get(ticket=ticket) print("positions:", positions) assert positions and len(positions) == 1 # 3. 平仓 c = close_position(ticket) print("close:", c) assert c["ok"], c # 4. 确认已平 positions = mt5.positions_get(ticket=ticket) print("after close:", positions) assert not positions print("闭环验证通过")跑通这个脚本,说明你的 MCP 交易工具最小可用版本成了。接下来才是接模型、让模型自主调用。
接模型验证时,用 TaoToken 的统一通道,模型对话入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。你可以先在对话里测「读持仓」这类只读指令,确认模型能正确解析工具返回的 JSON。再测「买入 0.01 手」这类写指令,观察模型是否正确填充了 symbol、volume、order_type 三个参数。
这里有个细节:模型填参数时可能会把order_type填成"BUY"而不是"buy",或者把 volume 填成字符串"0.01"。你的工具函数入口要做一次规范化,别假设模型一定填对。这是 ReAct 循环里最常见的翻车点。
日志这块,建议在 MCP Server 里加一层请求日志:每次工具调用记录工具名、入参、返回、耗时。出问题时翻日志比猜快得多。日志写到文件,别只打 stdout,因为 MCP 的 stdout 是协议通道,混入日志会污染通信。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节列几个真实会撞上的报错,以及排查方向。
401 Unauthorized。模型侧报 401,基本是 Key 或 Base URL 配错了。检查三件套:Base URL 是不是https://taotoken.net/api(注意别多加路径)、Key 有没有复制全、Model ID 是不是当前通道支持的。如果 Key 是从控制台新生成的,确认没有多余空格。401 和 403 要分清:401 是没认证,403 是认证了但没权限,后者可能是模型 ID 不在你的套餐里。
local proxy failed / connection refused。这个通常出现在 MCP Server 启动阶段。原因可能是:Python 路径不对、脚本路径不对、依赖没装全。先在命令行手动跑一遍python /path/to/easydeal_mcp_server.py,看能不能起来。如果报ModuleNotFoundError: MetaTrader5,就是库没装。如果报mt5.initialize() failed,就是 MT5 终端没开或没登录。
reading 'choices' of undefined。这是模型返回结构解析失败,常见于流式响应处理。如果你用的是某个 SDK,检查它是否兼容当前 API 的返回格式。有时候是 Model ID 填错了,通道返回了一个非预期的结构。换成文档里明确支持的模型 ID 再试。
OAuth / token expired。如果你用的是 Claude Code 或某些需要 OAuth 的客户端,token 过期会报这个。重新走一遍授权流程,或者换成 API Key 方式接入。用 TaoToken 的统一 Key 通道可以绕开这类客户端 OAuth 的坑,因为认证走的是标准 API Key。
order_send retcode 非 0。这是交易侧最常见的。retcode对照表里,10004是 requote(重新报价),10006是 rejected(被拒),10013是 invalid request(请求无效),10014是 invalid volume(手数无效),10016是 invalid stops(止损止盈无效),10018是 market closed(市场关闭)。10014就回去查手数量化逻辑,10016就查止损止盈跟当前价的距离是否满足券商最小 stop level。
工具列表里看不到动钱工具。先确认环境变量设了没、MCP Server 重启了没。环境变量是在 Server 启动时读的,改了不重启不生效。另外确认变量名拼写,EASYDEAL_TRADING_WRITE_OPEN别写成EASYDEAL_TRADE_WRITE_OPEN。
模型不调用工具,只在那聊天。这通常是模型能力问题,换工具调用能力更强的模型。另外检查工具描述(description)写得够不够清楚,模型靠描述判断什么时候该调哪个工具。描述里把「什么时候用」写明白,比只写「这个工具做什么」有效。
品种名匹配到错误的品种。模糊匹配太宽会误伤,比如XAU可能匹配到XAUUSD也可能匹配到XAUCNH。匹配逻辑里加一层「行情可见」过滤,再按名称长度排序取最接近的。实在拿不准,让工具返回候选列表,让模型或用户确认。
排障时有个通用原则:先隔离层。模型侧的问题和交易侧的问题分开测。只读工具能通,说明 MCP 和 MT5 连接没问题,问题在写权限或模型参数填充;只读工具都不通,说明底层连接有问题,先别碰写逻辑。
6. 把交易能力接进你的 Agent 工作流
跑通最小闭环之后,下一步是把它接进日常开发流。如果你长期用 Claude Code 或 Cline 写代码,顺手把 MT5 工具挂上,就能在写策略的同时让 Agent 读实时持仓、查行情、甚至按你的指令调仓。这种「编码 + 交易」一体的工作流,用 Coding Plan 会更顺:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。
几个实战建议。
第一,写操作永远保留人工确认。哪怕权限开了,也建议在 Agent 的提示词里加一句「执行任何开仓/平仓前,先向我复述一遍参数并等待确认」。模型会照做,这一步能挡掉大部分误操作。
第二,改 EA 源码要备份。如果你的工具里有「改 EA 参数」甚至「改 MQL5 源码」的能力,改前自动备份、可回滚。而且 MQL5 改完通常要人工在 MetaEditor 里编译才生效,别假设 AI 改完就自动生效。
第三,日志留全。每次工具调用的入参、返回、时间戳都记下来。交易类操作出问题,事后复盘全靠日志。
第四,模拟账户先跑够。别急着上实盘。模拟账户上把各种边界情况跑一遍:手数超限、止损太近、市场关闭、网络抖动重试。这些在模拟盘上暴露出来,比在实盘上暴露便宜得多。
第五,错误重试要有上限。order_send遇到 requote 可以重试,但别无限重试。设个 3 次上限,超了就返回失败让模型决定下一步。无限重试在交易场景里可能造成重复下单。
如果你不想从零写这套工具,可以参考开源的 EasyDeal 实现,它把读状态、改参数、授权下单这一整套都做好了,GPL-3.0 协议。你可以在它的基础上改,或者对照它的设计检查自己的实现。
最后说个我自己的体会:给大模型加交易能力,难点从来不是「怎么让模型调工具」,而是「怎么设计一组安全的工具」。读写分层、权限闸、品种模糊匹配、返回带 ok、写操作隔离与确认——这五条做到了,剩下的就是工程细节。模型侧用 TaoToken 统一通道,省掉多模型 Key 管理的麻烦,把精力放在工具设计上,这才是正事。