开发多币种 EA 交易(第 9 部分):收集单一交易策略实例的优化结果·进阶篇
(2/3)· 还在手动抄测试报告里的参数?这一篇让 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_READWRITE | DATABASE_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 的部分会整段丢失,信号回溯可能缺段。
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 不执行而出错。 外汇与贵金属杠杆品种优化结果波动大,云代理回测不代表实盘概率,任何自定义标准都只是筛选倾向而非盈利保证。
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_ 去跑自己的波动率突破样本。
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 才生效。外汇与贵金属品种波动剧烈,这类结构改动请先在策略测试器用小周期验证,实盘存在滑点与断线导致初始化失败的高风险。
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 粒度差异可能让这条开关成为样本外失效的隐藏变量。
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,把这段代码塞进初始化函数,切到测试器跑一次就能看到文件名变化。 外汇与贵金属品类波动剧烈、杠杆风险高,回测结论只代表历史样本,实盘可能显著偏离。
(MQLInfoInteger(MQL_TESTER) ? ".test" : "") ); m_fromDate = TimeCurrent(); Add(p_group); class="kw">delete p_group; };