开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响(基础篇)
「同策略跨品种回测为何结果天差地别」
在 MT5 里跑同一套 EA,换个不同品种做历史测试,权益曲线经常完全不像同一个人写的策略。核心原因不在逻辑,而在各品种可用的报价历史深度与质量不一致。 以 2025 年 3 月实测为例,同一趋势突破 EA 在 XAUUSD(现货黄金)上可回溯约 8 年分钟级数据,而在某流动性较弱的 cross 货币对上 MT5 本地仅缓存不到 2 年同周期数据;回测样本长度差 4 倍以上,胜率和最大回撤自然不可比。 外汇与贵金属杠杆高、点差跳变频繁,历史覆盖短的品类容易在测试里『躲过』极端行情,给出偏乐观的概率分布。开 MT5 后,先到『显示→符号→报价』核对每个测试品种的实际 K 线起点,再决定要不要拿它当样本。
不同经纪商报价对EA结果的影响
多币种 EA 上真实账户前,除了支持多品种名、策略参数自动补全和重启恢复,还有一道常被忽略的关口:不同经纪商的同品种报价并不一致。即便你在 A 经纪商回测出“最优参数”,切到 B 经纪商行情上,结果可能明显漂移。 我们之前专门为某组报价做测试与优化,默认前提是:换一组报价差异很小,实盘结果差异也可忽略。这个假设必须被验证,而不是想当然。 接下来要做的,是把同一套 EA 放到不同经纪商的历史报价里跑测试,观察权益曲线和成交逻辑是否还能站得住。外汇与贵金属杠杆高、点差结构各异,报价微差在极端行情下会被放大,属于高风险验证项。
◍ 换家经纪商报价,账户一年归零
先在 MetaQuotes-Demo 服务器跑通 EA,首启时开了风险管理器,但后续发现它在别的报价里远早于测试期结束就平仓收工,为公平对比先把它关掉。 关掉风险管理器后,用同一组参数在 MetaQuotes-Demo 重测,账户曲线平稳走完测试期,这说明demo报价下策略没有致命缺口。 接着把终端连到另一家经纪商真实服务器,参数一字未改再跑一遍:图2显示账户在不到一年时间内被彻底清空。外汇与贵金属属高风险品种,不同经纪商点差、滑点和报价断层可能直接改写策略生死。 这种反差不是偶然。下一步要拆真实服务器报价里到底哪段行情把净值打穿,才能判断是参数水土不服还是报价结构本身有陷阱。
「开仓时间错位背后的真凶是成交量」
把两套服务器的回测报告存成 XML 并排看,前几行就暴露了:同策略在不同源数据下,仓位开立时间并不一致。相比报价在某一时刻的微小偏差,这种开放时间的错位是更直接的破坏因素。 顺着 SimpleVolumesStrategy.mqh 里的逻辑追,开仓决策根本不靠价格本身。SignalForOpen() 只看当前品种的报价成交量,价格仅在确定要开仓后用来定多空方向。所以差异大概率出在各服务器吐出的成交量数值上。 这里有个容易忽略的机制:经纪商只要每分钟给 4 个正确分时报价,就能拼出 M1 蜡烛的 OHLC,中间多塞或少塞几条分时、怎么分布全凭自己。模拟与真实账户即便同经纪商也可能不一致。外汇与贵金属杠杆高,这类数据源差异会直接放大策略表现漂移。 在动手绕开前,得先坐实原因——否则改了半天可能打偏。下面这段就是开仓信号的核心,逐行拆开看更清楚。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Signal for opening pending orders | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int CSimpleVolumesStrategy::SignalForOpen() { class=class="str">"cmt">// By class="kw">default, there is no signal class="type">int signal = class="num">0; class=class="str">"cmt">// Copy volume values from the indicator buffer to the receiving array class="type">int res = CopyBuffer(m_iVolumesHandle, class="num">0, class="num">0, m_signalPeriod, m_volumes); class=class="str">"cmt">// If the required amount of numbers have been copied if(res == m_signalPeriod) { class=class="str">"cmt">// Calculate their average value class="type">class="kw">double avrVolume = ArrayAverage(m_volumes); class=class="str">"cmt">// If the current volume exceeds the specified level, then if(m_volumes[class="num">0] > avrVolume * (class="num">1 + m_signalDeviation + m_ordersTotal * m_signaAddlDeviation)) { class=class="str">"cmt">// if the opening price of the candle is less than the current(closing) price, then if(iOpen(m_symbol, m_timeframe, class="num">0) < iClose(m_symbol, m_timeframe, class="num">0)) { signal = class="num">1; class=class="str">"cmt">// buy signal } else { signal = -class="num">1; class=class="str">"cmt">// otherwise, sell signal } } } class="kw">return signal; }
用双 EA 回放验证报价差异
要证伪「不同服务器报价导致成交差异」这个假设,先得把历史抓出来。给原 EA 加一段在回测结束时落盘的功能,把开仓和平仓记录写进文件就够了——眼下它只是个验证辅具,写文件比接数据库省事,以后真要常驻再扩成数据库写入。 另起一个纯回放 EA,自身不带任何开仓逻辑,只负责读上一个 EA 存下的历史文件,按记录原样复制开平。它接收一个文件名作输入,逐行吃进历史然后重放。 具体跑两遍:第一遍在 MT5 测试器里用 MetaQuotes Demo 服务器报价跑原 EA,把成交历史存文件;第二遍换一台服务器的报价,用回放 EA 读同一文件重放。若差异真来自分笔成交量结构而非价格序列本身,第二轮结果应和第一轮大致接近。外汇与贵金属杠杆高,回测结论仅指概率倾向,实盘仍需自担风险。
◍ 把回测历史落盘并重放
把 EA 的回测交易历史固化下来,最干净的做法是单独建一个静态类 CExpertHistory,而不是往 CVirtualAdvisor 里硬塞。类只暴露一个 Export() 公共方法,接收文件名与是否写共享文件夹两个参数;默认写共享数据文件夹,因为在测试代理下翻本地文件夹更麻烦。 Export() 内部先用 GetHistoryFileName() 拼文件名:前缀取 EA 名与版本,中间夹历史起止日期(起点取首笔交易日,终点取导出时的当前时间),后缀塞进初始余额、最终余额、回撤、夏普比率。实测一次两年期回测(2021.01.01–2022.12.30)产出的文件名为 SimpleVolumesExpert.1.19 [2021.01.01 - 2022.12.30] [10000, 34518, 1294, 3.75].history.csv,即起始 10000 美元、终点 34518 美元、最大相对回撤 1294 美元、夏普 3.75,Excel 打开后列数据有效。 重放端另写 CHistoryStrategy 继承 CVirtualStrategy,构造时只从初始化串读历史文件名,把 CSV 读进 15 列的二维数组,并顺手用 ADD() 宏收集涉及到的交易品种名。构造函数会卡一道:测试开始日必须不早于历史起始日,否则前面若干笔无法建模,结果会失真。 报价处理里按时间循环吃历史行:用有符号量(卖为负)叠加同品种已有虚拟仓量,先关旧仓,若合量非零再按正负开新买/卖仓。这样同一品种只占用一个虚拟仓位就能还原加减仓甚至反手。 外汇与贵金属品种用这套重放,点差与滑点会扭曲原历史,属高风险验证手段,结论仅代表历史样本在给定环境下的可能表现。 不想带风险管理器那堆输入,可缩成 VirtualHistoryAdvisor 类,初始化串只收文件名与“仅分钟柱触发”标志,EA 文件能砍到很短,跑出来的历史仍会自动存新 CSV。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Export trade history to file | class=class="str">"cmt">//+------------------------------------------------------------------+ class CExpertHistory { class="kw">private: class="kw">static class="type">class="kw">string s_sep; class=class="str">"cmt">// Separator character class="kw">static class="type">int s_file; class=class="str">"cmt">// File handle for writing class="kw">static class="type">class="kw">string s_columnNames[];class=class="str">"cmt">// Array of column names class=class="str">"cmt">// Write deal history to file class="kw">static class="type">void WriteDealsHistory(); class=class="str">"cmt">// Write one row of deal history to file class="kw">static class="type">void WriteDealsHistoryRow(const class="type">class="kw">string &fields[]);