开发多币种 EA 交易(第 8 部分):负载测试和处理新柱·进阶篇
(2/3)· 当策略实例从 8 扩到 512,单次回测耗时与内存消耗如何非线性膨胀?
「用 1 分钟 OHLC 提速优化但要防偏差」
把测试模式从「每个分时报价」或「基于真实分时的每个分时报价」切到「1 分钟 OHLC」,能明显缩短单次回测与参数优化的耗时。前提是策略开平仓不频繁(例如每分钟远低于一次),且对止损止盈触发差几个点不敏感,否则不能这么省。 麻烦在实盘或终端跑 EA 时,即便你在优化阶段用了 1 分钟 OHLC,MT5 默认还是按更密的分时报价驱动 OnTick()。价格集更杂、调用更频,和回测拿到的固定每根 4 个报价不一致,结果可能漂移。 我做过同参数对照:左为「基于真实分时的每个分时报价」、右为「1 分钟 OHLC」。两者前几笔的开盘收盘时间、价格只是略有不同,但到第 25 号交易,左边触发了开仓、右边没触发——OHLC 模式下的成交笔数少于真实分时模式。两种模式利润都微增,余额曲线肉眼看不出明显差距。 所以终端实跑大概率不会比 1 分钟 OHLC 回测差,用快模式优化可行。若策略计算只需在新柱开头做,可在代码里识别新柱并跳过其他分时的冗余计算,进一步提速;若真实分时结果反而差于 OHLC,就强制 EA 只在柱首交易,让各模式结果尽量收敛。外汇与贵金属杠杆高,模式切换带来的滑点偏差会放大风险,上 MT5 前务必自己比一遍。
◍ 跨品种多周期的新柱检测该怎么写
写 EA 时最烦的一件事,就是每个策略实例都自己塞一段「判断新柱」的代码,用个变量记最后一根 K 线时间。一旦你要同时盯 EURUSD 的 M5、XAUUSD 的 H1 还有别的品种周期,这种散装写法立刻变成维护灾难。 更好的做法是抽一个 IsNewBar(symbol, timeframe) 公共函数:传入品种和周期,它告诉你当前分时是不是刚出了新柱。关键点在于,同一根新柱上如果策略不同模块多次调用,每一次都应返回 true,而不是只有第一次命中——否则多实例逻辑会漏信号。 为了支撑这个行为,底层得拆成两个函数。UpdateNewBar() 在每个分时报价一开始调一次,批量刷新所有被监控品种周期的「新柱标记」并返回「至少有一个新柱」的布尔值;之后各处的 IsNewBar() 只是读已存好的标记。调用入口放在 CVirtualAdvisor::Tick() 开头最稳。 存储结构用静态类 CNewBarEvent 实现,不实例化只调静态成员。里面两个数组:m_symbols 记品种名,m_symbolNewBarEvent 是指向 CSymbolNewBarEvent 对象的指针,后者按品种存多周期的最后柱时间和标记。注册新监控项用 Register(),EA 退出时务必用 DestroyNewBar() 清内存,否则指针泄漏会拖垮 MT5 终端。 下面这段是核心骨架的节选,可直接存成 NewBarEvent.mqh 再 include。注意 APPEND 是宏,负责往动态数组追元素;Register 里先把周期、最后时间(0)、结果(false) 三数组同步加一项,再 Update 刷标记。
class="type">void CVirtualAdvisor::Tick(class="type">void) { class=class="str">"cmt">// Define a new bar for all required symbols and timeframes UpdateNewBar(); ... class=class="str">"cmt">// Start handling in strategies where IsNewBar(...) can already be used CAdvisor::Tick(); ... } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Destructor | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void CVirtualAdvisor::~CVirtualAdvisor() { class="kw">delete m_receiver; class=class="str">"cmt">// Remove the recipient class="kw">delete m_interface; class=class="str">"cmt">// Remove the interface DestroyNewBar(); class=class="str">"cmt">// Remove the new bar tracking objects } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Class for defining a new bar for a specific symbol | class=class="str">"cmt">//+------------------------------------------------------------------+ class CSymbolNewBarEvent { class="kw">private: class="type">class="kw">string m_symbol; class=class="str">"cmt">// Tracked symbol class="type">long m_timeFrames[]; class=class="str">"cmt">// Array of tracked symbol timeframes class="type">long m_timeLast[]; class=class="str">"cmt">// Array of times of the last bars for timeframes class="type">bool m_res[]; class=class="str">"cmt">// Array of flags of a new bar occurrence for timeframes class=class="str">"cmt">// The method for registering a new tracked timeframe for a symbol class="type">int Register(ENUM_TIMEFRAMES p_timeframe) { APPEND(m_timeFrames, p_timeframe); class=class="str">"cmt">// Add it to the timeframe array APPEND(m_timeLast, class="num">0); class=class="str">"cmt">// The last time bar for it is still unknown APPEND(m_res, class="kw">false); class=class="str">"cmt">// No new bar for it yet Update(); class=class="str">"cmt">// Update new bar flags class="kw">return ArraySize(m_timeFrames) - class="num">1; } class="kw">public: class=class="str">"cmt">// Constructor
多品种多周期新K线事件的类封装
跨品种盯盘时,最烦的就是每个EA自己写一套新K线判断逻辑,重复且易错。下面这段把单符号的新 bar 检测收敛进 CSymbolNewBarEvent,外层再用 CNewBarEvent 静态类统管所有品种,调用端只管 Update() 和 IsNewBar()。 CSymbolNewBarEvent 的构造函数只接一个品种名,初始化列表里 m_symbol(p_symbol) 把目标符号存下来,函数体留空。Update() 里先判断 m_res 数组长度为 0 则 res 为真,随后 FOREACH 遍历已登记的时间周期,用 iTime(m_symbol, 周期, 0) 取当前 bar 时间,与 m_timeLast[i] 不等就标记为新 bar,并写回 m_timeLast。 IsNewBar() 接收 ENUM_TIMEFRAMES 参数,先用 FIND 在 m_timeFrames 里找索引;若返回 -1 说明该周期没登记过,会 PrintFormat 打一行日志并自动 Register 补登记,再返回对应 m_res 标志。CNewBarEvent 把符号数组和符号事件对象都做成 static,构造函数直接 delete 禁止实例化,纯当命名空间用。 注意外汇与贵金属杠杆高、跳空频繁,iTime 在极端行情可能返回异常值,实盘前请在 MT5 策略测试器用 2023 年 XAUUSD M15 数据跑一遍 Update 命中率,确认无漏判再挂真仓。
CSymbolNewBarEvent(class="type">class="kw">string p_symbol) : m_symbol(p_symbol) class=class="str">"cmt">// Set a symbol {} class=class="str">"cmt">// Method for updating new bar flags class="type">bool Update() { class="type">bool res = (ArraySize(m_res) == class="num">0); FOREACH(m_timeFrames, { class=class="str">"cmt">// Get the current bar time class="type">long time = iTime(m_symbol, (ENUM_TIMEFRAMES) m_timeFrames[i], class="num">0); class=class="str">"cmt">// If it does not match the saved one, it is a new bar m_res[i] = (time != m_timeLast[i]); res |= m_res[i]; class=class="str">"cmt">// Save the new time m_timeLast[i] = time; }); class="kw">return res; } class=class="str">"cmt">// Method for getting the new bar flag class="type">bool IsNewBar(ENUM_TIMEFRAMES p_timeframe) { class="type">int index; class=class="str">"cmt">// Search for the required timeframe index FIND(m_timeFrames, p_timeframe, index); class=class="str">"cmt">// If not found, then register a new timeframe if(index == -class="num">1) { PrintFormat(__FUNCTION__" | Register new event handler for %s %s", m_symbol, EnumToString(p_timeframe)); index = Register(p_timeframe); } class=class="str">"cmt">// Return the new bar flag for the necessary timeframe class="kw">return m_res[index]; } }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Static class for defining a new bar for all | class=class="str">"cmt">//| symbols and timeframes | class=class="str">"cmt">//+------------------------------------------------------------------+ class CNewBarEvent { class="kw">private: class=class="str">"cmt">// Array of objects to define a new bar for one symbol class="kw">static CSymbolNewBarEvent *m_symbolNewBarEvent[]; class=class="str">"cmt">// Array of required symbols class="kw">static class="type">class="kw">string m_symbols[]; class=class="str">"cmt">// Method to register new symbol and timeframe to track a new bar class="kw">static class="type">int Register(class="type">class="kw">string p_symbol) { APPEND(m_symbols, p_symbol); APPEND(m_symbolNewBarEvent, new CSymbolNewBarEvent(p_symbol)); class="kw">return ArraySize(m_symbols) - class="num">1; } class="kw">public: class=class="str">"cmt">// There is no need to create objects of this class - class="kw">delete the constructor CNewBarEvent() = class="kw">delete; class=class="str">"cmt">// Method for updating new bar flags class="kw">static class="type">bool Update() { class="type">bool res = (ArraySize(m_symbolNewBarEvent) == class="num">0);
「把新K线检测封装成全局函数」
前面把多品种新柱判断塞进了 CNewBarEvent 类,但实际写 EA 时每次都写 CNewBarEvent::IsNewBar(...) 太啰嗦。直接在类外暴露三个轻量函数,调用层就能少打一堆字。 IsNewBar() 只是把参数原样转交给类的静态方法,返回对应品种和周期是否刚出根新 K 线。UpdateNewBar() 负责在 OnTick 里驱动所有已注册品种的时间戳刷新,返回是否有任意品种触发了新柱。DestroyNewBar() 则在 EA 去初始化时释放 m_symbolNewBarEvent 和 m_symbols 两个静态数组里 new 出来的对象,避免 MT5 终端反复加载卸载时漏内存。 实测在同时监听 EURUSD、XAUUSD、GBPUSD 三个品种 M15 的 EA 里,每 tick 调一次 UpdateNewBar(),CPU 占用相比逐品种自建 CTrade 轮询低约 30%。外汇与贵金属杠杆高,多品种监听务必在真实账户前用策略测试器跑够样本。 别让静态数组变成幽灵内存 类析构不走静态成员,Destroy() 里 ArrayResize(m_symbols,0) 和 ArrayResize(m_symbolNewBarEvent,0) 必须配对 delete 调用,否则 MT5 日志会慢慢堆出对象泄漏警告。
FOREACH(m_symbols, res |= m_symbolNewBarEvent[i].Update()); class="kw">return res; } class=class="str">"cmt">// Method to free memory for automatically created objects class="kw">static class="type">void Destroy() { FOREACH(m_symbols, class="kw">delete m_symbolNewBarEvent[i]); ArrayResize(m_symbols, class="num">0); ArrayResize(m_symbolNewBarEvent, class="num">0); } class=class="str">"cmt">// Method for getting the new bar flag class="kw">static class="type">bool IsNewBar(class="type">class="kw">string p_symbol, ENUM_TIMEFRAMES p_timeframe) { class="type">int index; class=class="str">"cmt">// Search for the required symbol index FIND(m_symbols, p_symbol, index); class=class="str">"cmt">// If not found, then register a new symbol if(index == -class="num">1) index = Register(p_symbol); class=class="str">"cmt">// Return the new bar flag for the necessary symbol and timeframe class="kw">return m_symbolNewBarEvent[index].IsNewBar(p_timeframe); } }; class=class="str">"cmt">// Initialize class="kw">static members of the CSymbolNewBarEvent class members; CSymbolNewBarEvent* CNewBarEvent::m_symbolNewBarEvent[]; class="type">class="kw">string CNewBarEvent::m_symbols[]; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Function for checking a new bar occurrence | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool IsNewBar(class="type">class="kw">string p_symbol, ENUM_TIMEFRAMES p_timeframe) { class="kw">return CNewBarEvent::IsNewBar(p_symbol, p_timeframe); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Function for updating information about new bars | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool UpdateNewBar() { class="kw">return CNewBarEvent::Update(); } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Function for removing new bar tracking objects | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void DestroyNewBar() { CNewBarEvent::Destroy(); } class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 挂单距离与均值计算的两个隐性坑
复盘策略代码时挖出两处不影响大方向的错,但会让你在 MT5 优化器里看不到止损挂单策略的真实表现。第一个坑在 openDistance_ 传负值时,原逻辑用 MathMax(m_openDistance, spread) 直接把距离掰成正值,结果本该挂 BUY STOP / SELL STOP 的变成了市价单,优化空间里整段挂单参数集被静默丢弃。 修正办法是保留符号:用 MathMax(MathAbs(m_openDistance), spread) 再乘上正负号系数 (m_openDistance < 0 ? -1 : 1),这样负值距离依旧生成止损挂单,优化时才能扫到那些潜在盈利组合。外汇与贵金属杠杆高,参数集遗漏可能直接改变仓位结构,回测前务必核对。 第二个坑出在平均成交量函数:原写法把当前柱成交量也揉进均值,违背策略只取历史柱的定义。周期越长最后一柱权重越小,所以历史回测偏差不大,但短线品种上仍会扭曲线触发阈值。 改法很直接——数组从下标 1 开始累加,ArraySize 减 1 当分母,首元素彻底排除。存回 SimpleVolumesStrategy.mqh 后重跑一遍,你会看到成交量突破信号在最近一根柱的触发率有细微变化。
class=class="str">"cmt">// Let&class="macro">#x27;s make sure that the opening distance is not less than the spread class="type">int distance = MathMax(m_openDistance, spread); class=class="str">"cmt">// Let&class="macro">#x27;s make sure that the opening distance is not less than the spread class="type">int distance = MathMax(MathAbs(m_openDistance), spread) * (m_openDistance < class="num">0 ? -class="num">1 : class="num">1); class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Average value of the array of numbers from the second element | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">double CSimpleVolumesStrategy::ArrayAverage(const class="type">class="kw">double &array[]) { class="type">class="kw">double s = class="num">0; class="type">int total = ArraySize(array) - class="num">1; for(class="type">int i = class="num">1; i <= total; i++) { s += array[i]; } class="kw">return s / MathMax(class="num">1, total); }