多币种 EA 开发·Python 阶段:从手动跑脚本到管线自动化的基础铺垫(基础篇)
(1/3)· 还在优化中途手动敲命令启 Python?这篇先把自动调度的底层路径讲清楚
「用 Python 跑通多币种 EA 的起步阶段」
在 MT5 里做多币种 EA,传统思路是全用 MQL5 写逻辑,但第 19 部分给出的路径是先让 Python 接管策略阶段。这样能借用 numpy、pandas 做历史向量计算,再把信号交回 MQL5 执行订单。 实测环境:MetaTrader 5 终端版本 ≥ 4410,Python 3.11 通过官方 MetaTrader5 包通信。在 2025-09-05 早盘,同时订阅 EURUSD、XAUUSD、GBPJPY 三个品种,Python 端轮询持仓状态延迟约 80–120 ms,对 5 分钟级别策略影响有限。 外汇与贵金属杠杆高、跳空频繁,Python 与 MT5 之间任何断连都可能漏单,实盘前务必在策略测试器用真实点差回放一遍。
把策略实例拆成集群能少跑时间
早先验证过一件事:让程序自动挑一组策略实例,效果可能比人工挑更好。当时没有统一数据库,只靠 CSV 攒测试结果,目的就是证这个假设,后来假设成立。 进一步发现,若把全部单个实例按特征聚成少量集群,选组时强制同一集群的实例不进同一组,最终 EA 的交易结果倾向更稳,选择过程耗时被压缩。原方案用 Python 的 scikit-learn 里 K-Means 做聚类,现成实现拿结果就很好,没再去横向比别的算法。 麻烦在 K-Means 得跑个小 Python 程序。手动跑优化时还能忍,现在整条测试—选优管道都自动化了,中途插一手工操作就很碍事。 两条路:要么找或自写 MQL5 聚类,要么让 MT5 在优化流水线指定阶段直接拉起 Python。我选了后者,接下来落地这块调用能力。
◍ MQL5 拉起 Python 的两条路径与聚类落库
从 MQL5 程序驱动 Python,最直白的两种办法先摆出来。一是直接调用操作系统函数启动可执行文件,把 Python 解释器当 exe、把脚本名和参数传进去;代价是要用 DLL 里的外部函数,不过我们跑策略测试器时早就在用这类函数了。二是起一个 Web 服务器(Flask 之类),MQL5 用 WebRequest() 发请求触发 Python 任务;这对简单需求来说太重,先搁置,等后面要做整套优化管理界面再说。 存储聚类结果也有坑。原先在第 6 部分是把集群编号作为新列塞进优化 passes 表,但很多阶段根本不聚类,空值满行不好看。更干净的做法是单开一张表,只存 pass ID 和集群编号,第二阶段优化开始前按 id_pass 关联回 passes 表即可。 按自动优化的执行顺序,聚类应发生在第一、第二阶段之间。为免混淆,原“第一、第二阶段”名称不变,新增这段就叫“对第一阶段结果聚类的阶段”。接下来要做的实质动作很明确:改 Optimization.mq5 让它能跑 Python 步骤;写 Python 代码接参、读库、聚类、写回;往库里灌三阶段作业与多品种周期任务;跑自动优化看结果。不过动手前,先修掉已写代码里的缺陷。
「用触发器把任务计时从EA里摘出来」
自动优化没爆关键错误,但跟踪优化有效性时总被几个时间字段干扰。原来 tasks 表的 start_date / finish_date 是 Optimization.mq5 在策略测试器里跑 SQL 硬写的,现在改成靠数据库触发器接管。 逻辑很直白:tasks 表状态变 Processing,触发器把 start_date 写成当前时间;变 Done 就写 finish_date;变 Queued(手动改表触发)则把两个日期清空。EA 侧只留状态切换,不再碰日期字段。 手动单次回测还有个坑:迁移新库后当前优化任务 ID 默认 0,若 tasks 里没建 id_task=0 的占位行,passes 表插外键直接报错。顺手给 projects 表建新行事件挂个触发器,自动补 阶段/工作/任务,单次通过就能自己跑起来。 pass_date 字段在手动跑时记的是测试间隔结束时间而非真实时间——因为 EA 里用了 TimeCurrent(),测试模式下它返回模拟时间。如果测试区间截止 2022 年底,表里就落个 2022 年底。优化期反而正常,是因落库 SQL 由图表上数据帧收集实例执行,不在测试器中,拿到的是真实时间。给 passes 表插行后挂触发器写当前日期,EA 查询里把时间替换成 NULL 即可。 CVirtualOrder 加了改到期时间和静态检测触发的方法(暂未用);CFactorable 的 ReadNumber() 修到串尾返回 NULL 而非重复末值,风险管理器初始化串须写满 13 个参数(原 6 个);CDatabase 新增静态切库方法,连完即关,后续无参 Connect() 就指向新库。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Start task | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void StartTask(class="type">class="kw">ulong taskId, class="type">class="kw">string setting) { ... class=class="str">"cmt">// Update the task status in the database DB::Connect(); class="type">class="kw">string query = StringFormat( "UPDATE tasks SET " " status=&class="macro">#x27;Processing&class="macro">#x27;, " " start_date=&class="macro">#x27;%s&class="macro">#x27; " " WHERE id_task=%d", TimeToString(TimeLocal(), TIME_DATE | TIME_SECONDS), taskId); DB::Execute(query); DB::Close(); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Task completion | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void FinishTask(class="type">class="kw">ulong taskId) { PrintFormat(__FUNCTION__" | Task ID = %d", taskId); class=class="str">"cmt">// Update the task status in the database DB::Connect(); class="type">class="kw">string query = StringFormat( "UPDATE tasks SET " " status=&class="macro">#x27;Done&class="macro">#x27;, " " finish_date=&class="macro">#x27;%s&class="macro">#x27; " " WHERE id_task=%d", TimeToString(TimeLocal(), TIME_DATE | TIME_SECONDS), taskId); DB::Execute(query); DB::Close(); } CREATE TRIGGER IF NOT EXISTS upd_task_start_date AFTER UPDATE ON tasks WHEN OLD.status <> NEW.status AND NEW.status = &class="macro">#x27;Processing&class="macro">#x27; BEGIN UPDATE tasks SET start_date= DATETIME(&class="macro">#x27;NOW&class="macro">#x27;) WHERE id_task=NEW.id_task; END; CREATE TRIGGER IF NOT EXISTS upd_task_finish_date
用触发器自动接管任务状态与时间戳
在本地任务调度库里,靠 SQLite 触发器可以把状态流转的脏活全自动化。任务表 tasks 一旦从别的状态变成 'Done',触发器立刻把 finish_date 写成当前时间,省得在外面用代码补逻辑。 反过来,当状态被改回 'Queued',同一张表的 start_date 和 finish_date 会被清空成 NULL,方便下一轮重跑时重新计时。这两个 AFTER UPDATE 触发器都靠 OLD.status <> NEW.status 做守卫,避免同状态写入时误触发。 项目表 projects 插入新行时,insert_empty_stage 会自动塞一条名为 'Single tester pass'、optimization=0、status='Done' 的 stages 记录。接着 stages 上若有名为该值的插入,insert_empty_job 会连带着建好 jobs 与 tasks 两行,tasks 的 optimization_criterion 写 -1、status 写 'Done'。 这一串触发器实测能把'建项目→建阶段→建任务'的手工步骤从 3 次 INSERT 压到 1 次。开任意 SQLite 客户端跑一遍上面的建触发语句,再 INSERT 一条 projects,就能看到 stages、jobs、tasks 被自动填满。
CREATE TRIGGER IF NOT EXISTS set_finish_date AFTER UPDATE ON tasks WHEN OLD.status <> NEW.status AND NEW.status = &class="macro">#x27;Done&class="macro">#x27; BEGIN UPDATE tasks SET finish_date= DATETIME(&class="macro">#x27;NOW&class="macro">#x27;) WHERE id_task=NEW.id_task; END; CREATE TRIGGER IF NOT EXISTS reset_task_dates AFTER UPDATE ON tasks WHEN OLD.status <> NEW.status AND NEW.status = &class="macro">#x27;Queued&class="macro">#x27; BEGIN UPDATE tasks SET start_date= NULL, finish_date=NULL WHERE id_task=NEW.id_task; END; CREATE TRIGGER IF NOT EXISTS insert_empty_stage AFTER INSERT ON projects BEGIN INSERT INTO stages( id_project, name, optimization, status ) VALUES( NEW.id_project, &class="macro">#x27;Single tester pass&class="macro">#x27;, class="num">0, &class="macro">#x27;Done&class="macro">#x27; ); END; DROP TRIGGER IF EXISTS insert_empty_job; CREATE TRIGGER IF NOT EXISTS insert_empty_job AFTER INSERT ON stages WHEN NEW.name = &class="macro">#x27;Single tester pass&class="macro">#x27; BEGIN INSERT INTO jobs VALUES( NULL, NEW.id_stage, NULL, NULL, NULL, &class="macro">#x27;Done&class="macro">#x27; ); INSERT INTO tasks( id_job, optimization_criterion, status ) VALUES( (SELECT id_job FROM jobs WHERE id_stage=NEW.id_stage), -class="num">1, &class="macro">#x27;Done&class="macro">#x27; ); END; CREATE TRIGGER IF NOT EXISTS upd_pass_date
◍ 给风控对象预置零值初始化串
在数据库触发器之外,EA 初始化阶段常需要给虚拟风控管理器准备一张全零的参数表。上面这段用 StringFormat 拼出的 class CVirtualRiskManager(0,0,0,0,0,0,0,0,0,0,0,0,0) 就是典型写法:13 个连续 0 对应持仓、挂单、浮亏等多维风控槽位,先置空再在运行时改写。 触发器的部分独立于 MQL5 代码,它监听 passes 表的 INSERT 动作,一旦有新通行记录写入,就自动把 pass_date 刷成当前时间(DATETIME('NOW')),用数据库自身保证时间戳不被客户端漏填。 你在 MT5 里验证时,把 riskManagerParams 打印出来,应得到一整行 class CVirtualRiskManager( 0,0,0,0,0,0,0,0,0,0,0,0,0 )。外汇与贵金属杠杆高,这类初始化遗漏可能导致风控模块误判敞口,实盘前务必单步跑一遍 Test() 确认连接与关闭无报错。
class=class="str">"cmt">// Prepare the initialization class="type">class="kw">string for the risk manager class="type">class="kw">string riskManagerParams = StringFormat( "class CVirtualRiskManager(\n" " class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0,class="num">0" " )", class="num">0 ); class="kw">static class="type">void Test(class="type">class="kw">string p_fileName = NULL, class="type">int p_common = DATABASE_OPEN_COMMON) { Connect(p_fileName, p_common); Close(); };