开发多币种 EA 交易(第 19 部分):创建用 Python 实现的阶段·综合运用
(3/3)· 当 K-Means 聚类卡在手动启动 Python 这一步,整个自动优化管道就断了,本篇补上最后拼图
「把数据库报错打到日志里」
在 MT5 的 MQL5 脚本里做 SQLite 查询时,失败点往往藏在行读取或请求本身。上面这段把两类错误都通过 PrintFormat 抛到专家日志,方便你直接在终端里对照 query 内容和 GetLastError 返回的整型代码定位。 注意 __FUNCTION__ 宏会自动填入当前函数名,不用手硬写;%s 接 SQL 语句、%d 接错误码,日志出来就是「函数名 | ERROR: request ... failed with code 5」这种格式。 外汇和贵金属行情数据入库后做回测或信号缓存,数据库读写失败会静默拖慢策略,建议每次 Close 前都保留这类报错分支,开 MT5 按 F4 编译后跑一遍空查询就能看到日志效果。
class=class="str">"cmt">// Report an error if necessary PrintFormat(__FUNCTION__" | ERROR: Reading row for request \n%s\nfailed with code %d", query, GetLastError()); } } else { class=class="str">"cmt">// Report an error if necessary PrintFormat(__FUNCTION__" | ERROR: request \n%s\nfailed with code %d", query, GetLastError()); } class=class="str">"cmt">// Close the database DB::Close(); } } else { res = true; } class="kw">return res; }
把聚类脚本从 CSV 搬到 SQLite
上一阶段我们已经在 Python 里跑通了 KMeans 初版,但那是读 CSV 的玩具版。真正要批量处理 MT5 优化 passes,得让脚本直接啃数据库,并且能用命令行参数切换任务与聚类数。 核心改动有六处:用 argparse 接 db_path、id_task、n_clusters 等参数;数据源从 CSV 换成 passes 表;任务状态写回 tasks 表(Processing / Done);聚类特征列改用统计结果(profit、trades、sharpe_ratio 等)而非单独参数列;结果只留 id_task、id_pass、cluster 三列;写出到 passes_clusters 表而非文件。 脚本默认 n_clusters=256,min_trades=40,min_sharpe_ratio=0.7,意味着只有年化夏普 ≥0.7 且成交 ≥40 的 pass 才会进聚类池,噪声 EA 变体被前置过滤。 聚类后用 df.sort_values(['cluster','custom_ontester']) 再 groupby('cluster').agg('last'),等于在每个簇里只保留归一化利润最高的一条 pass。最终表里每个簇仅剩一个代表,可直接回 MT5 做二次优化。外汇与贵金属品种优化结果受样本外漂移影响大,实盘前务必重验。
class="kw">import pandas as pd from sklearn.cluster class="kw">import KMeans class="kw">import sqlite3 class="kw">import argparse # Setting up the command line argument parser parser = argparse.ArgumentParser(description=&class="macro">#x27;Clustering passes for previous job(s)&class="macro">#x27;) parser.add_argument(&class="macro">#x27;db_path&class="macro">#x27;, type=str, help=&class="macro">#x27;Path to database file&class="macro">#x27;) parser.add_argument(&class="macro">#x27;id_task&class="macro">#x27;, type=class="type">int, help=&class="macro">#x27;ID of current task&class="macro">#x27;) parser.add_argument(&class="macro">#x27;--id_parent_job&class="macro">#x27;, type=str, help=&class="macro">#x27;ID of parent job(s)&class="macro">#x27;) parser.add_argument(&class="macro">#x27;--n_clusters&class="macro">#x27;, type=class="type">int, class="kw">default=class="num">256, help=&class="macro">#x27;Number of clusters&class="macro">#x27;) parser.add_argument(&class="macro">#x27;--min_custom_ontester&class="macro">#x27;, type=class="type">float, class="kw">default=class="num">0, help=&class="macro">#x27;Min value for `custom_ontester`&class="macro">#x27;) parser.add_argument(&class="macro">#x27;--min_trades&class="macro">#x27;, type=class="type">float, class="kw">default=class="num">40, help=&class="macro">#x27;Min value for `trades`&class="macro">#x27;) parser.add_argument(&class="macro">#x27;--min_sharpe_ratio&class="macro">#x27;, type=class="type">float, class="kw">default=class="num">0.7, help=&class="macro">#x27;Min value for `sharpe_ratio`&class="macro">#x27;) # Read the values of command line arguments into variables args = parser.parse_args() db_path = args.db_path id_task = args.id_task id_parent_job = args.id_parent_job n_clusters = args.n_clusters min_custom_ontester = args.min_custom_ontester min_trades = args.min_trades min_sharpe_ratio = args.min_sharpe_ratio # Establishing a connection to the database connection = sqlite3.connect(db_path) cursor = connection.cursor() # Mark the start of the task cursor.execute(f&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;UPDATE tasks SET status=&class="macro">#x27;Processing&class="macro">#x27; WHERE id_task={id_task};&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;) connection.commit() # Create a table for clustering results if there is none cursor.execute(&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;CREATE TABLE IF NOT EXISTS passes_clusters( id_task INTEGER, id_pass INTEGER, cluster INTEGER );&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;) # Clear the results table from previously obtained results cursor.execute(f&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;DELETE FROM passes_clusters WHERE id_task={id_task};&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;) # Load data about parent job passes for this task into the dataframe query = f&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;SELECT p.* FROM passes p JOIN tasks t ON t.id_task = p.id_task JOIN jobs j ON j.id_job = t.id_job WHERE p.profit > class="num">0 AND j.id_job IN({id_parent_job}) AND p.custom_ontester >= {min_custom_ontester} AND p.trades >= {min_trades} AND p.sharpe_ratio >= {min_sharpe_ratio};&class="macro">#x27;&class="macro">#x27;&class="macro">#x27; df = pd.read_sql(query, connection) # Let&class="macro">#x27;s look at the dataframe print(df) # List of dataframe columns print(*enumerate(df.columns), sep=&class="macro">#x27; &class="macro">#x27;) # Run clustering on some columns of the dataframe kmeans = KMeans(n_clusters=n_clusters, n_init=&class="macro">#x27;auto&class="macro">#x27;, random_state=class="num">42).fit(df.iloc[:, [class="num">7, class="num">8, class="num">9, class="num">24, class="num">29, class="num">30, class="num">31, class="num">32, class="num">33, class="num">36, class="num">45, class="num">46]]) # Add cluster numbers to the dataframe df[&class="macro">#x27;cluster&class="macro">#x27;] = kmeans.labels_ # Set the current task ID df[&class="macro">#x27;id_task&class="macro">#x27;] = id_task # Sort the dataframe by clusters and normalized profit df = df.sort_values([&class="macro">#x27;cluster&class="macro">#x27;, &class="macro">#x27;custom_ontester&class="macro">#x27;]) # Let&class="macro">#x27;s look at the dataframe print(df) # Group the lines by cluster and take one line at a time # with the highest normalized profit from each cluster df = df.groupby(&class="macro">#x27;cluster&class="macro">#x27;).agg(&class="macro">#x27;last&class="macro">#x27;).reset_index() # Let&class="macro">#x27;s leave only id_task, id_pass and cluster columns in the dataframe df = df.iloc[:, [class="num">2, class="num">1, class="num">0]] # Let&class="macro">#x27;s look at the dataframe print(df) # Save the dataframe to the passes_clusters table(replacing the existing one) df.to_sql(&class="macro">#x27;passes_clusters&class="macro">#x27;, connection, if_exists=&class="macro">#x27;append&class="macro">#x27;, index=False) # Mark the task completion cursor.execute(f&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;UPDATE tasks SET status=&class="macro">#x27;Done&class="macro">#x27; WHERE id_task={id_task};&class="macro">#x27;&class="macro">#x27;&class="macro">#x27;) connection.commit()
◍ 收尾时记得断开连接
在 MT5 的 Python 桥接脚本里,行情拉取或订单操作结束后必须显式关闭会话,否则本地 socket 会一直占用,多次运行后可能触发终端连接数上限。
上面这行 connection.close() 就是做资源释放用的,漏掉它的话,下次 MetaTrader5.initialize() 可能返回 False,概率随运行频次上升。
实盘外接脚本建议包在 try-finally 里,确保异常退出也能断连;外汇与贵金属杠杆高,连接泄漏叠加重连失败会直接卡掉你的自动风控链路。
connection.close()「让同一个EA兼顾聚类与第二阶段筛选」
此前做优化第二阶段要单独跑一个 EA,现在可以合并:给 EA 加一个 useClusters_ 逻辑开关,决定在第一阶段产出的策略实例里挑组时,是否引用聚类结果。关掉它就能直接进第二阶段,不依赖前期聚类;打开则把 passes_clusters 表按 pass ID 拼进 SQL,每个聚类只回查一次,省掉重复扫描。 筛选条件也做成外部输入:标准化年均收益下限、最少成交笔数、夏普比率门槛都能在参数面板调。代码里默认 minTrades_=40、minSharpeRatio_=0.7,意味着低于 40 笔或夏普不足 0.7 的通道会被剔除,外汇与贵金属品种波动大,这类过滤仅降低过拟合概率,不预示任何收益。 改动落在输入参数块和 CreateTaskDB() 上。保存 SimpleVolumesStage2.mq5 到当前目录后直接测,MT5 里把 useClusters_ 切 true/false 各跑一遍,能对比带聚类与裸第二阶段的结果差异。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Inputs | class=class="str">"cmt">//+------------------------------------------------------------------+ input class="type">int idTask_ = class="num">0; class=class="str">"cmt">// - Optimization task ID sinput class="type">class="kw">string fileName_ = "db.sqlite"; class=class="str">"cmt">// - Main database file input group "::: Selection for the group" input class="type">int idParentJob_ = class="num">1; class=class="str">"cmt">// - Parent job ID input class="type">bool useClusters_ = true; class=class="str">"cmt">// - Use clustering input class="type">class="kw">double minCustomOntester_ = class="num">0; class=class="str">"cmt">// - Min normalized profit input class="type">int minTrades_ = class="num">40; class=class="str">"cmt">// - Min number of trades input class="type">class="kw">double minSharpeRatio_ = class="num">0.7; class=class="str">"cmt">// - Min Sharpe ratio input class="type">int count_ = class="num">16; class=class="str">"cmt">// - Number of strategies in the group(class="num">1 .. class="num">16) ... class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Creating a database for a separate stage task | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CreateTaskDB(const class="type">class="kw">string fileName, const class="type">int idParentJob) { class=class="str">"cmt">// Create a new database for the current optimization task DB::Connect(PARAMS_FILE, class="num">0); DB::Execute("DROP TABLE IF EXISTS passes;"); DB::Execute("CREATE TABLE passes(id_pass INTEGER PRIMARY KEY AUTOINCREMENT, params TEXT);"); DB::Close(); class=class="str">"cmt">// Connect to the main database DB::Connect(fileName); class=class="str">"cmt">// Clustering class="type">class="kw">string clusterJoin = ""; if(useClusters_) { clusterJoin = "JOIN passes_clusters pc ON pc.id_pass = p.id_pass"; } class=class="str">"cmt">// Request to obtain the required information from the main database class="type">class="kw">string query = StringFormat("SELECT DISTINCT p.params" " FROM passes p" " JOIN " " tasks t ON p.id_task = t.id_task " " JOIN "
用 SQL 过滤出达标遗传优化结果
在 MT5 遗传优化跑完之后,原始结果表里会混着大量无效 pass。上面这段拼接逻辑直接用一个 SQL 查询把符合阈值的结果捞出来,避免人工翻页筛选。 查询约束了四个硬条件:同父代任务 id(id_job)、custom_ontester ≥ 设定的浮点阈值、trades ≥ 最小成交次数、sharpe_ratio ≥ 最小夏普率,且按 custom_ontester 降序排列,最优解排最前。 实际调参时,minCustomOntester_ 与 minSharpeRatio_ 建议先设 0.00 和 0.50 跑一遍,再按回测样本量逐步抬高;外汇与贵金属品种波动大,遗传优化过拟合概率高,阈值放宽可能捞出样本内虚假优解。 把这段拼进你自己的 CJob 管理类,替换 clusterJoin 字符串即可在终端里直接验证输出顺序是否符合预期。
" jobs j ON t.id_job = j.id_job "
" %s "
"WHERE(j.id_job = %d AND "
" p.custom_ontester >= %.2f AND "
" trades >= %d AND "
" p.sharpe_ratio >= %.2f) "
"ORDER BY p.custom_ontester DESC;",
clusterJoin,
idParentJob_,
minCustomOntester_,
minTrades_,
minSharpeRatio_);
class=class="str">"cmt">// Execute the request◍ 聚类筛选能否跳过第二阶段优化
在数据库里建好四个阶段(第一阶段、第一阶段聚类、第二阶段、第二阶段聚类),每个阶段对 EURGBP 与 GBPUSD 在 H1 各开两个工作。第一阶段跑三个不同标准的优化任务,其余阶段各一个,优化区间取 2018–2023,33 个代理跑完整套大约要 17 小时。 EURGBP 上聚不聚类的最佳组标准化年均回报都接近 4060 美元,差别不大;但 GBPUSD 分化明显,不聚类约 4500 美元,聚类后约 7500 美元。这种跨品种差异可能和样本分布有关,暂且留到后续多品种验证时再查。 一个自然想法是:既然每个聚类取一个最优实例能凑出 256 个候选,能否直接挑标准化年均利润最高的 16 个单实例组队,省掉第二阶段那轮重跑?光这一组问题就能省约 6 小时,占 17 小时的不小比例。 用 SQL 把第二阶段前的单实例按 custom_ontester 降序列出,指标最高的实例序号正排在最前。把索引 1–16 喂给第二阶段 EA 后发现,GBPUSD 组标准化年均利润掉到 3300 美元,比聚类二阶段最佳组的 7500 美元缩水两倍多,主因是回撤大幅抬升;EURGBP 也类似但降幅稍缓。 把 EURGBP 与 GBPUSD 各自最佳组合在一起跑,联合结果居中:标准化年均利润 4900 美元,高于 EURGBP 组但低于 GBPUSD 组。外汇与贵金属优化属高风险实验,结论仅基于历史区间,实盘前务必在 MT5 重跑确认。 跳过聚类的第二阶段看似省时,但单实例直凑的组回撤失控,大概率不划算。真要压时间,得另找过滤阈值而非简单截断。
SELECT t.id_task,
t.optimization_criterion,
s.name AS stage_name,
s.expert AS stage_expert,
j.id_job,
j.symbol AS job_symbol,
j.period AS job_period,
j.tester_inputs AS job_tester_inputs
FROM tasks t
JOIN
jobs j ON j.id_job = t.id_job
JOIN
stages s ON s.id_stage = j.id_stage
WHERE t.id_task > class="num">0;
SELECT DISTINCT ROW_NUMBER() OVER(ORDER BY custom_ontester DESC) AS [index],
p.id_pass,
pc.cluster,
p.custom_ontester,
p.params
FROM passes p
JOIN
tasks t ON p.id_task = t.id_task
JOIN
jobs j ON t.id_job = j.id_job
JOIN
passes_clusters pc ON pc.id_pass = p.id_pass
WHERE(j.id_job = class="num">5 AND
p.custom_ontester >= class="num">0 AND
trades >= class="num">40 AND
p.sharpe_ratio >= class="num">0.7)
ORDER BY p.custom_ontester DESC;「集群抽样的下一步可挖空间」
目前这套自动优化流程已经能跑通 Python 第三方程序,顺带把第一阶段策略实例按聚类砍到少数代表样本。实测下来,副本减少让第二阶段耗时下降,结果没有恶化,部分跑批质量反而更好,说明剪枝没白做。 但边界还没封死:当某个实例被指定的聚类数小于实际聚类数量时,现在代码会直接报错,这是聚类逻辑里明显的未覆盖分支。 后续可扩的方向有两个——一是修掉上述异常让小集群也能正常归并,二是把策略池口径放大并重组自动优化工程结构。外汇与贵金属品种波动剧烈,此类研究性框架仅基于历史测试,实盘存在高风险,改动请自行在 MT5 中回测验证。
画得少,看得清
整套多币种 EA 的源码以 38 个文件落地,压缩包 MQL5.zip 体积 89.49 KB,从 EA 基类 Advisor.mqh 到聚类脚本 ClusteringStage1.py 全部归档在 MQL5/专家/文章.15911 路径下。版本号跨度能直接看出模块成熟度:SimpleVolumesExpert.mq5 已迭代到 1.20,而 VirtualSymbolReceiver.mqh 停在 1.00,说明接收器层早已稳定。 用户实测里有个坑值得记一笔:跑聚类时 n_clusters 设 256 却只有 150 个样本,直接抛 ValueError,降到 150 后数据库才写出 passes_clusters 新表。这提醒我们,Python 聚类阶段的样本数必须 ≥ 簇数,否则第二阶段优化不会报错但也不会产生新数据。 外汇与贵金属策略回测天然带高杠杆风险,归档代码仅代表某作者某一版的工程实现,实盘前务必在 MT5 策略测试器用小额验证。把文件树收进视野,比盯着单个 EA 参数更能看清整套系统的边界。
class="macro">#include <Math\Alglib\alglib.mqh>