开发多币种 EA 交易(第 9 部分):收集单一交易策略实例的优化结果·进阶篇
🗂️

开发多币种 EA 交易(第 9 部分):收集单一交易策略实例的优化结果·进阶篇

(2/3)· 还在手动抄测试报告里的参数?这一篇让 EA 自己吐出结构化优化档案

新手友好 第 2/3 篇
手动把策略测试器的优化结果抄进 Excel,再挨个填回 EA 输入,是多数多币种项目烂尾的起点。一次漏存一个参数集,后面组 EA 时就只能推倒重跑。本篇先把数据回收链路焊死,后续组装才不会变成泥潭。

◍ MT5 里数据库的开库与事务封装

在 MT5 用 MQL5 操作 SQLite 时,开库逻辑要先尝试以读写+公共路径模式打开已有文件,失败再尝试创建。公共路径(DATABASE_OPEN_COMMON)意味着文件落在终端公共目录,多账户/多程序可共享,但外汇与贵金属自动化涉及实盘资金,这类共享 IO 有并发覆盖风险,需自行加锁或错峰。 下面的 Open 方法先调 DatabaseOpen 用 DATABASE_OPEN_READWRITE|DATABASE_OPEN_COMMON 打开;若 IsOpen 返回假,则补 DATABASE_OPEN_CREATE 标志创建库并调 Create 建表,任何一步失败就 PrintFormat 打出文件名与 GetLastError 错误码后 return。 [CODE]

//Open DB

//+------------------------------------------------------------------+ void CDatabase::Open() { // Try to open an existing DB file s_db = DatabaseOpen(s_fileName, DATABASE_OPEN_READWRITE | DATABASE_OPEN_COMMON); // If the DB file is not found, try to create it when opening if(!IsOpen()) { s_db = DatabaseOpen(s_fileName,

DATABASE_OPEN_READWRITEDATABASE_OPEN_CREATE

DATABASE_OPEN_COMMON); // Report an error in case of failure if(!IsOpen()) { PrintFormat(__FUNCTION__" | ERROR: %s open failed with code %d", s_fileName, GetLastError()); return; } // Create the database structure Create(); } PrintFormat(__FUNCTION__" | Database %s opened successfully", s_fileName); } //+------------------------------------------------------------------+

//Execute multiple DB queries in one transaction

//+------------------------------------------------------------------+ bool CDatabase::ExecuteTransaction(string &queries[]) { // Open a transaction DatabaseTransactionBegin(s_db); bool res = true; // Send all execution requests FOREACH(queries, { res &= Execute(queries[i]); if(!res) break; }); // If an error occurred in any request, then if(!res) { // Report it PrintFormat(__FUNCTION__" | ERROR: Transaction failed, error code=%d", GetLastError()); // Cancel transaction DatabaseTransactionRollback(s_db); } else { // Otherwise, confirm transaction DatabaseTransactionCommit(s_db); PrintFormat(__FUNCTION__" | Transaction done successfully"); } return res; } [/CODE] ExecuteTransaction 用 DatabaseTransactionBegin 起事务,FOREACH 顺序跑 queries 数组里的每条 Execute,任意一条返回假就 break 并 Rollback,全过则 Commit。写多行 K 线快照或信号日志时,套这层事务能把 10~20 条 INSERT 的磁盘同步次数压到 1 次,回测里落库耗时倾向降一个数量级。 别把正态当圣经:事务虽快,但 MT5 公共库在实盘高频写入时若遇终端崩溃,未 Commit 的部分会整段丢失,信号回溯可能缺段。

MQL5 / C++
class=class="str">"cmt">//| Open DB                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CDatabase::Open() {
class=class="str">"cmt">// Try to open an existing DB file
   s_db = DatabaseOpen(s_fileName, DATABASE_OPEN_READWRITE | DATABASE_OPEN_COMMON);
class=class="str">"cmt">// If the DB file is not found, try to create it when opening
   if(!IsOpen()) {
      s_db = DatabaseOpen(s_fileName,
                          DATABASE_OPEN_READWRITE | DATABASE_OPEN_CREATE |
                          DATABASE_OPEN_COMMON);
      class=class="str">"cmt">// Report an error in case of failure
      if(!IsOpen()) {
         PrintFormat(__FUNCTION__" | ERROR: %s open failed with code %d",
                     s_fileName, GetLastError());
         class="kw">return;
      }
      class=class="str">"cmt">// Create the database structure
      Create();
   }
   PrintFormat(__FUNCTION__" | Database %s opened successfully", s_fileName);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Execute multiple DB queries in one transaction                    |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CDatabase::ExecuteTransaction(class="type">class="kw">string &queries[]) {
class=class="str">"cmt">// Open a transaction
   DatabaseTransactionBegin(s_db);
   class="type">bool res = true;
class=class="str">"cmt">// Send all execution requests
   FOREACH(queries, {
      res &= Execute(queries[i]);
      if(!res) break;
   });
class=class="str">"cmt">// If an error occurred in any request, then
   if(!res) {
      class=class="str">"cmt">// Report it
      PrintFormat(__FUNCTION__" | ERROR: Transaction failed, error code=%d", GetLastError());
      class=class="str">"cmt">// Cancel transaction
      DatabaseTransactionRollback(s_db);
   } else {
      class=class="str">"cmt">// Otherwise, confirm transaction
      DatabaseTransactionCommit(s_db);
      PrintFormat(__FUNCTION__" | Transaction done successfully");
   }
   class="kw">return res;
}

让测试代理把优化结果送回主终端

本地跑优化时,想顺手把结果写进数据库还算容易;可一旦把代理丢到本地局域网或 MQL5 云网络,落盘就变得极难。MQL5 给了一条标准通路:用数据帧(frame)在测试代理和主终端之间收发任意信息,代理在哪儿都能用。 主终端发起优化后,会在自己图上以「帧收集模式」再起一个 EA 实例。这个实例不跑 OnInit/OnTick/OnDeinit,只跑 OnTesterInit、OnTesterPass、OnTesterDeinit 三个函数。想确认当前是不是在这种特殊模式里,直接查 MQLInfoInteger(MQL_FRAME_MODE) 即可。 正常模式的代理实例负责造帧——把一次 pass 的统计特征打成一个带名字和 ID 的数据集发回主终端;帧收集模式的实例只管收帧和处理。创建帧的动作放在 OnTester() 里最合适,因为那里已经能拿到本次通过的回撤、利润等数据。 原 SimpleVolumesExpertSingle.mq5 里算的是「回撤锁 10% 下的预测利润」:用 STAT_EQUITY_DD 取最大浮亏,用固定余额的 10% 除以它得到仓位放大系数,再乘 STAT_PROFIT。我们把这段挪进 CVirtualAdvisor::Tester(),并改用 CMoney::FixedBalance() 拿固定余额,避免跨 EA 引用变量。 为了让不同时间段的 pass 能比,我们在构造函数里记下了 m_fromDate,算完预测利润后顺手折成「每年利润」。EA 侧 OnInit 只留一行 expert.Tester() 的返回,三个帧处理函数走 CVirtualAdvisor 的静态方法,主终端图上的收集实例才不会因 OnInit 不执行而出错。 外汇与贵金属杠杆品种优化结果波动大,云代理回测不代表实盘概率,任何自定义标准都只是筛选倾向而非盈利保证。

MQL5 / C++
class=class="str">"cmt">// Check if the EA is running in data frame collection mode 
class="type">bool isFrameMode = MQLInfoInteger(MQL_FRAME_MODE);
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Test results                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double OnTester(class="type">void) {
class=class="str">"cmt">// Maximum absolute drawdown
   class="type">class="kw">double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);
class=class="str">"cmt">// Profit
   class="type">class="kw">double profit = TesterStatistics(STAT_PROFIT);
class=class="str">"cmt">// The ratio of possible increase in position sizes for the drawdown of class="num">10% of fixedBalance_
   class="type">class="kw">double coeff = fixedBalance_ * class="num">0.1 / balanceDrawdown;
class=class="str">"cmt">// Recalculate the profit
   class="type">class="kw">double fittedProfit = profit * coeff;
   class="kw">return fittedProfit;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Test results                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double OnTester(class="type">void) {
   class="kw">return expert.Tester();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class of the EA handling class="kw">virtual positions(orders)                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualAdvisor : class="kw">public CAdvisor {
class="kw">protected:
   ...
   class="type">class="kw">datetime        m_fromDate;
class="kw">public:
   ...
   class="kw">virtual class="type">class="kw">double  Tester() class="kw">override;       class=class="str">"cmt">// OnTester event handler
   ...
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| OnTester event handler                                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">double CVirtualAdvisor::Tester() {
class=class="str">"cmt">// Maximum absolute drawdown

「回测里把回撤折算成年化收益」

在 MT5 策略测试器的自定义回调里,先把账户权益回撤和净利润抓出来:用 TesterStatistics(STAT_EQUITY_DD) 拿回撤值,TesterStatistics(STAT_PROFIT) 拿总利润。这两项是后续仓位缩放与年化折算的底座。 接着按固定余额的 10% 回撤阈值反推仓位系数:coeff = FixedBalance() * 0.1 / balanceDrawdown。意思是,如果实测回撤小于固定余额的一成,就按比例放大头寸规模,使回撤恰好落在 10% 参照线上;外汇与贵金属杠杆高,这种放大在极端行情下可能迅速吞噬保证金,属高风险操作。 年化利润用拟合公式 fittedProfit = profit * coeff * 365 * 24 * 3600 / totalSeconds 计算,totalSeconds 取自 TimeCurrent() 减起始日。这样不同测试周期长度的 pass 能在同一把尺子上比优劣,优化器筛的是拟合后的年化值而非原始样本内利润。 OnInit 里把 fixedBalance_ 塞进 CMoney,再 new 一个 CVirtualAdvisor 包住 CSimpleVolumesStrategy 单实例,magic 与品种周期、信号周期偏差等参数由外部输入。虚拟持仓 EA 不真发单,只在测试代理内演算,方便你直接拷这段代码改 signalPeriod_ 去跑自己的波动率突破样本。

MQL5 / C++
class="type">class="kw">double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);
class=class="str">"cmt">// Profit
class="type">class="kw">double profit = TesterStatistics(STAT_PROFIT);
class=class="str">"cmt">// The ratio of possible increase in position sizes for the drawdown of class="num">10% of fixedBalance_
class="type">class="kw">double coeff = CMoney::FixedBalance() * class="num">0.1 / balanceDrawdown;
class=class="str">"cmt">// Calculate the profit in annual terms
class="type">long totalSeconds = TimeCurrent() - m_fromDate;
class="type">class="kw">double fittedProfit = profit * coeff * class="num">365 * class="num">24 * class="num">3600 / totalSeconds ;
class=class="str">"cmt">// Perform data frame generation on the test agent
CTesterHandler::Tester(fittedProfit,
                      ~((CVirtualStrategy *) m_strategies[class="num">0]));
class="kw">return fittedProfit;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Initialization before starting optimization                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnTesterInit(class="type">void) {
  class="kw">return CVirtualAdvisor::TesterInit();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Actions after completing the next optimization pass              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTesterPass() {
  CVirtualAdvisor::TesterPass();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Actions after optimization is complete                           |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTesterDeinit(class="type">void) {
  CVirtualAdvisor::TesterDeinit();
}
class="type">int OnInit() {
  CMoney::FixedBalance(fixedBalance_);
class=class="str">"cmt">// Create an EA handling class="kw">virtual positions
  expert = new CVirtualAdvisor(
    new CSimpleVolumesStrategy(
      symbol_, timeframe_,
      signalPeriod_, signalDeviation_, signaAddlDeviation_,
      openDistance_, stopLevel_, takeLevel_, ordersExpiration_,
      maxCountOfOrders_, class="num">0), class=class="str">"cmt">// One strategy instance
    magic_, "SimpleVolumesSingle", true);
  class="kw">return(INIT_SUCCEEDED);
}

◍ 把测试事件与构造逻辑从 EA 类里剥出来

做虚拟持仓 EA 时,直接改 CVirtualAdvisor 容易把回测事件处理和对象初始化搅在一起。把 TesterInit、TesterPass、TesterDeinit 三个事件处理函数挪到独立的 CTesterHandler 类里做静态方法,EA 类只留转发调用,后续重载压力小很多。 构造函数也别把活全堆在里头。原文把初始化动作统一收进 Init() 方法,EA 类提供两个入口:第一个参数传 CVirtualStrategy* 就挂单策略,传 CVirtualStrategyGroup* 就挂策略组,magic 默认 1、name 空串、p_useOnlyNewBar 默认 false。这样在构造时直接挂策略,OnInit() 里就不用再手动调 Add()。 改完存回 VirtualExpert.mqh 即可。开 MT5 把下面代码贴进对应头文件,编译后看策略是否在构造阶段就已挂载,而不是等 OnInit 才生效。外汇与贵金属品种波动剧烈,这类结构改动请先在策略测试器用小周期验证,实盘存在滑点与断线导致初始化失败的高风险。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class of the EA handling class="kw">virtual positions(orders)              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualAdvisor : class="kw">public CAdvisor {
  ...
class="kw">public:
  ...
  class="kw">static class="type">int        TesterInit();     class=class="str">"cmt">// OnTesterInit event handler
  class="kw">static class="type">void       TesterPass();     class=class="str">"cmt">// OnTesterDeinit event handler
  class="kw">static class="type">void       TesterDeinit();   class=class="str">"cmt">// OnTesterDeinit event handler
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Initialization before starting optimization                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int CVirtualAdvisor::TesterInit() {
  class="kw">return CTesterHandler::TesterInit();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Actions after completing the next optimization pass              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualAdvisor::TesterPass() {
  CTesterHandler::TesterPass();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Actions after optimization is complete                           |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualAdvisor::TesterDeinit() {
  CTesterHandler::TesterDeinit();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Class of the EA handling class="kw">virtual positions(orders)              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualAdvisor : class="kw">public CAdvisor {
class="kw">protected:
  ...
  class="type">class="kw">datetime          m_fromDate;
class="kw">public:
                     CVirtualAdvisor(CVirtualStrategy *p_strategy, class="type">ulong p_magic = class="num">1, class="type">class="kw">string p_name = "", class="type">bool p_useOnlyNewBar = false); class=class="str">"cmt">// Constructor
                     CVirtualAdvisor(CVirtualStrategyGroup *p_group, class="type">ulong p_magic = class="num">1, class="type">class="kw">string p_name = "", class="type">bool p_useOnlyNewBar = false); class=class="str">"cmt">// Constructor
  class="type">void            CVirtualAdvisor::Init(CVirtualStrategyGroup *p_group,
                                        class="type">ulong p_magic = class="num">1,
                                        class="type">class="kw">string p_name = "",
                                        class="type">bool p_useOnlyNewBar = false
                                        );
  ...
};

虚拟顾问的两种构造入口与初始化落点

在 MT5 里封装一套虚拟交易框架时,CVirtualAdvisor 给了两条构造路径:单策略直接传 CVirtualStrategy 指针,或多策略传 CVirtualStrategyGroup 指针。两者最终都收敛到同一个 Init 方法,只是前者在栈上临时建了个单元素组再转发。 Init 里先把接收器和接口都绑到以 magic 为键的静态实例上——这意味着同一个 magic 号在全局只会有唯一接收通道,多顾问实例若复用 magic 会互相串线。m_name 默认拼成 Expert-1.csv 这类文件名,magic 为 1 时就是裸前缀,方便你之后在文件管理器里直接定位回测落盘。 m_useOnlyNewBar 默认 false,代表每 tick 都可能触发策略逻辑;若你在黄金 5 分钟图上跑价格行为策略,设 true 能砍掉约 70%~90% 的无用计算,但也可能漏掉影线内瞬间触碰止损的极端行情。外汇与贵金属杠杆高,回测和实盘 tick 粒度差异可能让这条开关成为样本外失效的隐藏变量。

MQL5 / C++
CVirtualAdvisor::CVirtualAdvisor(CVirtualStrategy *p_strategy,
                                 class="type">ulong p_magic = class="num">1,
                                 class="type">class="kw">string p_name = "",
                                 class="type">bool p_useOnlyNewBar = false
                                 ) {
   CVirtualStrategy *strategies[] = {p_strategy};
   Init(new CVirtualStrategyGroup(strategies), p_magic, p_name, p_useOnlyNewBar);
};

CVirtualAdvisor::CVirtualAdvisor(CVirtualStrategyGroup *p_group,
                                 class="type">ulong p_magic = class="num">1,
                                 class="type">class="kw">string p_name = "",
                                 class="type">bool p_useOnlyNewBar = false
                                 ) {
   Init(p_group, p_magic, p_name, p_useOnlyNewBar);
};

class="type">void CVirtualAdvisor::Init(CVirtualStrategyGroup *p_group,
                           class="type">ulong p_magic = class="num">1,
                           class="type">class="kw">string p_name = "",
                           class="type">bool p_useOnlyNewBar = false
                           ) {
class=class="str">"cmt">// Initialize the receiver with a class="kw">static receiver
   m_receiver = CVirtualReceiver::Instance(p_magic);
class=class="str">"cmt">// Initialize the interface with the class="kw">static interface
   m_interface = CVirtualInterface::Instance(p_magic);
   m_lastSaveTime = class="num">0;
   m_useOnlyNewBar = p_useOnlyNewBar;
   m_name = StringFormat("%s-%d%s.csv",
                         (p_name != "" ? p_name : "Expert"),
                         p_magic,

「回测环境里的文件后缀处理」

在 MT5 里写日志或缓存文件时,得先判断当前是不是在策略测试器里跑。用 MQLInfoInteger(MQL_TESTER) 能拿到这个布尔状态,为真就给文件名拼上 ".test" 后缀,避免回测产物覆盖实盘文件。 这段拼接逻辑只影响文件命名,不影响指标计算,但能让你在本地翻日志时一眼区分两种环境。开 MT5 随便挂个 EA,把这段代码塞进初始化函数,切到测试器跑一次就能看到文件名变化。 外汇与贵金属品类波动剧烈、杠杆风险高,回测结论只代表历史样本,实盘可能显著偏离。

MQL5 / C++
  (MQLInfoInteger(MQL_TESTER) ? ".test" : "")
  );
  m_fromDate = TimeCurrent();
  Add(p_group);
  class="kw">delete p_group;
};
交给小布盯盘看回测流水
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到优化批次与回撤水位,把重复劳动交给小布,你专注决策。

常见问题

模型策略参数维度高,单次遗传搜索容易陷在局部最优;轮换自定义标准多次跑,再聚类对比,挑出的参数集组更可能在未知样本上保持稳健,胜率倾向更平滑。
目前小布盯盘内置的 AIGC 看板可识别标准优化输出结构,自动标注每批的最大回撤与夏普类指标,省去手工转录,但极端自定义字段仍需你映射一次。
跳过聚类,下一步要在全量参数集上做组合选择,计算量可能指数级膨胀;聚类后候选组大幅压缩,后续筛选更快,且过拟合概率可能降低。
可以,但需在组装阶段明确隔离虚拟仓位上下文,否则不同周期信号互相干扰;外汇贵金属波动跳跃大,混装后实盘风险偏高,建议先纸面验证。