开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响·综合运用
📉

开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响·综合运用

(3/3)·同一套参数在两家经纪商报价下竟从盈利走向爆仓,差异到底藏在哪一步

含代码示例实战向 第 3/3 篇
很多人以为多币种EA在一家经纪商测好的参数,换家服务器就能直接跑。现实是报价量触发开仓的时刻错位,能让账户在不到一年被清空。忽略报价历史差异,等于把实盘当运气游戏。

◍ 用历史成交文件驱动虚拟策略组

在 MT5 里做成交回放,第一步是把历史文件接进虚拟顾问。下面这段输入组只留一个文件名参数,空串代表不加载外部历史,实盘跑的时候多数情况留空由终端自带历史补充。 初始化字符串用 StringFormat 拼出一个嵌套类描述:最外层 CVirtualAdvisor 包一个策略组,组里只放一个 CHistoryStrategy,把文件名塞进去;后面紧跟风险控制器的十几个数值位(基准余额、日损限、总损限、总盈限等),最后挂接收器名 HistoryReceiver。这种写法让你改一组参数就能换整套风控,不用动类结构。 核心类 CVirtualHistoryAdvisor 继承自 CAdvisor,保护成员里只有一个 m_receiver 指针,职责是把虚拟持仓推到市场接口。也就是说,历史回放和真实下单的边界就收口在这一个接收对象上,屏蔽掉细节后,策略层只管算信号。 开 MT5 把 historyFileName_ 指到一个导出的成交 CSV,再调 scale_ 和 rmStartBaseBalance_ 两个值,就能让同一段代码在 0.01 手和 1 手环境下跑出不同的权益曲线,外汇与贵金属品种杠杆高,回测爆仓概率会随规模非线性放大。

MQL5 / C++
class="kw">input group "::: Testing the deal history"
class="kw">input class="type">class="kw">string historyFileName_      = "";      class=class="str">"cmt">// File with history
class=class="str">"cmt">// Prepare the initialization class="type">class="kw">string for an EA with a group of several strategies
  class="type">class="kw">string expertParams = StringFormat(
                              "class CVirtualAdvisor(\n"
                              "    class CVirtualStrategyGroup(\n"
                              "      [\n"
                              "        class CHistoryStrategy(\"%s\")\n"
                              "      ],%f\n"
                              "    ),\n"
                              "    class CVirtualRiskManager(\n"
                              "      %d,%.2f,%d,%.2f,%.2f,%d,%.2f,%.2f,%d,%.2f,%d,%.2f,%.2f"
                              "    )\n"
                              "    ,%d,%s,%d\n"
                              ")",
                              historyFileName_, scale_,
                              rmIsActive_, rmStartBaseBalance_,
                              rmCalcDailyLossLimit_, rmMaxDailyLossLimit_, rmCloseDailyPart_,
                              rmCalcOverallLossLimit_, rmMaxOverallLossLimit_, rmCloseOverallPart_,
                              rmCalcOverallProfitLimit_, rmMaxOverallProfitLimit_, rmMaxOverallProfitDate_,
                              rmMaxRestoreTime_, rmLastVirtualProfitFactor_,
                              magic_, "HistoryReceiver", useOnlyNewBars_
                   );
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Trade history replay EA class                                    |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CVirtualHistoryAdvisor : class="kw">public CAdvisor {
class="kw">protected:
  CVirtualReceiver *m_receiver;       class=class="str">"cmt">// Receiver object that brings positions to the market

虚拟历史顾问的构造与运行边界

在 MT5 回测环境里跑历史成交重演,核心类 CVirtualHistoryAdvisor 的构造函数先解析初始化串:拆出成交历史文件名与「仅新柱触发」开关。若当前不在策略测试器(MQL_TESTER 为假),对象直接置为无效并提示只能在 tester 中运行,实盘加载会秒退。 构造里用固定端口号 65677 拿到静态接收器实例 CVirtualHistoryAdvisor::Instance(65677),并把测试起始时间记到 m_fromDate = TimeCurrent()。这意味着重演的起点严格绑定测试器启动那一刻,改系统时钟不影响已记录的 fromDate。 析构时先判空再 delete 接收器,并调用 DestroyNewBar() 清理新柱跟踪对象。若你复制这段代码,留意 m_receiver 前的 !! 双非运算——它把指针转成布尔而非判空惯用的单 !,写成单叹号会在某些封装下误删有效对象。 外汇与贵金属回测本身属高风险验证,历史重演结果仅代表过去样本,对实盘概率无保证,参数请先在策略测试器小样本跑通再放大。

MQL5 / C++
  class="type">bool                m_useOnlyNewBar;     class=class="str">"cmt">// Handle only new bar ticks
  class="type">class="kw">datetime            m_fromDate;         class=class="str">"cmt">// Test start time
class="kw">public:
  CVirtualHistoryAdvisor(class="type">class="kw">string p_param);   class=class="str">"cmt">// Constructor
  ~CVirtualHistoryAdvisor();                class=class="str">"cmt">// Destructor
  class="kw">virtual class="type">void        Tick() class="kw">override;      class=class="str">"cmt">// OnTick event handler
  class="kw">virtual class="type">class="kw">double      Tester() class="kw">override;    class=class="str">"cmt">// OnTester event handler
  class="kw">virtual class="type">class="kw">string      class="kw">operator~() class="kw">override; class=class="str">"cmt">// Convert object to class="type">class="kw">string
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Constructor                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
CVirtualHistoryAdvisor::CVirtualHistoryAdvisor(class="type">class="kw">string p_params) {
class=class="str">"cmt">// Save the initialization class="type">class="kw">string
  m_params = p_params;
class=class="str">"cmt">// Read the file name from the initialization class="type">class="kw">string
  class="type">class="kw">string fileName = ReadString(p_params);
class=class="str">"cmt">// Read the work flag only at the bar opening
  m_useOnlyNewBar = (class="type">bool) ReadLong(p_params);
class=class="str">"cmt">// If there are no read errors,
  if(IsValid()) {
    if(!MQLInfoInteger(MQL_TESTER)) {
      class=class="str">"cmt">// Otherwise, set the object state to invalid
      SetInvalid(__FUNCTION__, "ERROR: This expert can run only in tester");
      class="kw">return;
    }
    if(fileName == "") {
      class=class="str">"cmt">// Otherwise, set the object state to invalid
      SetInvalid(__FUNCTION__, "ERROR: Set file name with deals history in ");
      class="kw">return;
    }
    class="type">class="kw">string strategyParams = StringFormat("class CHistoryStrategy(\"%s\")", fileName);
    CREATE(CHistoryStrategy, strategy, strategyParams);
    Add(strategy);
    class=class="str">"cmt">// Initialize the receiver with the class="kw">static receiver
    m_receiver = CVirtualReceiver::Instance(class="num">65677);
    class=class="str">"cmt">// Save the work(test) start time
    m_fromDate = TimeCurrent();
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Destructor                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CVirtualHistoryAdvisor::~CVirtualHistoryAdvisor() {
  if(!!m_receiver)    class="kw">delete m_receiver;      class=class="str">"cmt">// Remove the recipient
  DestroyNewBar();                            class=class="str">"cmt">// Remove the new bar tracking objects 
}
class=class="str">"cmt">//+------------------------------------------------------------------+

「回测里的虚拟仓位与年化拟合」

这段 CVirtualHistoryAdvisor 的 Tick 逻辑很直接:先调 UpdateNewBar 判断多品种多周期是否出新 K 线,若 m_useOnlyNewBar 为真且全无新柱就直接 return,避免无谓计算。随后把控制权交给 CAdvisor::Tick 跑策略,再由 m_receiver 处理虚拟持仓与手数校正。 Tester 函数才是回测优化的核心。它取 STAT_EQUITY_DD 做最大回撤、STAT_PROFIT 做利润,再按固定余额的 10% 除回撤算系数 coeff,把利润年化:fittedProfit = profit * coeff / totalYears,其中 totalYears 用 TimeCurrent 减起始日再除以 365*24*3600 秒。若没设 fixedBalance,则退回用 STAT_INITIAL_DEPOSIT 与相对回撤百分比重算,并用复利公式 MathPow(1+...,1/totalYears) 估年化。 operator~ 只是把类名和参数拼成字符串方便日志排查。输入区用 input group 把「历史成交文件」和「资金管理」分组,historyFileName_ 留空即代表不加载外部历史。 [CODE]void CVirtualHistoryAdvisor::Tick(void) { // 定义所有所需品种和时间框架上的新柱 bool isNewBar = UpdateNewBar(); // 若任何地方都没新柱,且只在新柱工作,则退出 if(!isNewBar && m_useOnlyNewBar) { return; } // 启动策略处理 CAdvisor::Tick(); // 接收器处理虚拟仓位 m_receiver.Tick(); // 调整市场成交量 m_receiver.Correct(); } double CVirtualHistoryAdvisor::Tester() { // 最大绝对回撤 double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD); // 利润 double profit = TesterStatistics(STAT_PROFIT); // 设置中的固定交易余额 double fixedBalance = CMoney::FixedBalance(); // 固定余额10%回撤对应的仓位放大系数 double coeff = fixedBalance * 0.1 / MathMax(1, balanceDrawdown); // 计算年化利润 long totalSeconds = TimeCurrent() - m_fromDate; double totalYears = totalSeconds / (365.0 * 24 * 3600); double fittedProfit = profit * coeff / totalYears; // 若未指定则取初始余额(结果可能失真) if(fixedBalance < 1) { fixedBalance = TesterStatistics(STAT_INITIAL_DEPOSIT); balanceDrawdown = TesterStatistics(STAT_EQUITY_DDREL_PERCENT); coeff = 0.1 / balanceDrawdown; fittedProfit = fixedBalance * MathPow(1 + profit * coeff / fixedBalance, 1 / totalYears); } return fittedProfit; } string CVirtualHistoryAdvisor::operator~() { return StringFormat("%s(%s)", typename(this), m_params); } input group "::: Testing the deal history" input string historyFileName_ = ""; // File with history input group "::: Money management"[/CODE] 别把拟合利润当实盘预期:回测里 coeff 依赖固定余额与回撤比值,外汇和贵金属杠杆高、滑点跳空频繁,实盘最大回撤可能倾向超出 STAT_EQUITY_DD 的测试值。开 MT5 把 historyFileName_ 填上自己导出的真实成交,跑一遍 Tester 看 fittedProfit 与空文件时的差异,比盲信优化曲线有用。

MQL5 / C++
class="type">void CVirtualHistoryAdvisor::Tick(class="type">void) {
class=class="str">"cmt">// 定义所有所需品种和时间框架上的新柱
  class="type">bool isNewBar = UpdateNewBar();
class=class="str">"cmt">// 若任何地方都没新柱,且只在新柱工作,则退出
  if(!isNewBar && m_useOnlyNewBar) {
    class="kw">return;
  }
class=class="str">"cmt">// 启动策略处理
  CAdvisor::Tick();
class=class="str">"cmt">// 接收器处理虚拟仓位
  m_receiver.Tick();
class=class="str">"cmt">// 调整市场成交量
  m_receiver.Correct();
}
class="type">class="kw">double CVirtualHistoryAdvisor::Tester() {
class=class="str">"cmt">// 最大绝对回撤
  class="type">class="kw">double balanceDrawdown = TesterStatistics(STAT_EQUITY_DD);
class=class="str">"cmt">// 利润
  class="type">class="kw">double profit = TesterStatistics(STAT_PROFIT);
class=class="str">"cmt">// 设置中的固定交易余额
  class="type">class="kw">double fixedBalance = CMoney::FixedBalance();
class=class="str">"cmt">// 固定余额10%回撤对应的仓位放大系数
  class="type">class="kw">double coeff = fixedBalance * class="num">0.1 / MathMax(class="num">1, balanceDrawdown);
class=class="str">"cmt">// 计算年化利润
  class="type">long totalSeconds = TimeCurrent() - m_fromDate;
  class="type">class="kw">double totalYears = totalSeconds / (class="num">365.0 * class="num">24 * class="num">3600);
  class="type">class="kw">double fittedProfit = profit * coeff / totalYears;
class=class="str">"cmt">// 若未指定则取初始余额(结果可能失真)
  if(fixedBalance < class="num">1) {
    fixedBalance = TesterStatistics(STAT_INITIAL_DEPOSIT);
    balanceDrawdown = TesterStatistics(STAT_EQUITY_DDREL_PERCENT);
    coeff = class="num">0.1 / balanceDrawdown;
    fittedProfit = fixedBalance * MathPow(class="num">1 + profit * coeff / fixedBalance, class="num">1 / totalYears);
  }
  class="kw">return fittedProfit;
}
class="type">class="kw">string CVirtualHistoryAdvisor::class="kw">operator~() {
  class="kw">return StringFormat("%s(%s)", class="kw">typename(this), m_params);
}
class="kw">input group "::: Testing the deal history"
class="kw">input class="type">class="kw">string historyFileName_ = "";   class=class="str">"cmt">// File with history
class="kw">input group "::: Money management"

◍ 把历史成交回放接进EA初始化

想在 MT5 里用历史成交记录驱动虚拟持仓,核心是把回放逻辑塞进 OnInit,而不是在 OnTick 里现算。下面这段声明先定死了两个外部参数:固定余额默认 10000(填 0 代表用账户全部净值),分组缩放倍数默认 1.00,二者直接喂给 CMoney 类的 DepoPart 与 FixedBalance。 sinput double fixedBalance_ = 10000; // - Used deposit (0 - use all) in the account currency input double scale_ = 1.00; // - Group scaling multiplier input group "::: Other parameters" input bool useOnlyNewBars_ = true; // - Work only at bar opening datetime fromDate = TimeCurrent(); // Operation start time CVirtualHistoryAdvisor *expert; // EA object //+------------------------------------------------------------------+

//Expert initialization function

//+------------------------------------------------------------------+ int OnInit() { // Set parameters in the money management class CMoney::DepoPart(scale_); CMoney::FixedBalance(fixedBalance_); // Prepare the initialization string for the deal history replay EA string expertParams = StringFormat( "class CVirtualHistoryAdvisor(\"%s\",%f,%d)", historyFileName_, useOnlyNewBars_ ); // Create an EA handling virtual positions expert = NEW(expertParams); // If the EA is not created, then return an error if(!expert) return INIT_FAILED; // Successful initialization return(INIT_SUCCEEDED); } //+------------------------------------------------------------------+

//Expert tick function

//+------------------------------------------------------------------+ void OnTick() { expert.Tick(); } //+------------------------------------------------------------------+

//Expert deinitialization function

//+------------------------------------------------------------------+ void OnDeinit(const int reason) { if(!!expert) delete expert; } //+------------------------------------------------------------------+

//Test results

//+------------------------------------------------------------------+ double OnTester(void) { return expert.Tester(); } //+------------------------------------------------------------------+ OnInit 里用 StringFormat 拼出 "class CVirtualHistoryAdvisor(\"文件名\",开关,0)" 这样的初始化串,再靠 NEW 宏实例化对象;若指针为空直接返 INIT_FAILED,避免后续 Tick 空指针崩掉。 OnTick 只有一行 expert.Tick(),所有虚拟仓位的推进都委托给回放对象;OnDeinit 里用 if(!!expert) delete 收尾,防止测试器反复加载时内存泄漏。 回测时 OnTester 直接 return expert.Tester(),把虚拟历史交易的统计值交还给 MT5 优化框架。外汇与贵金属品种点差滑点敏感,这类回放结果仅代表历史成交序列下的概率表现,实盘可能显著偏离。

MQL5 / C++
sinput class="type">class="kw">double fixedBalance_      = class="num">10000; class=class="str">"cmt">// - Used deposit(class="num">0 - use all) in the account currency
class="kw">input  class="type">class="kw">double scale_            = class="num">1.00;  class=class="str">"cmt">// - Group scaling multiplier
class="kw">input group "::: Other parameters"
class="kw">input class="type">bool   useOnlyNewBars_   = true;  class=class="str">"cmt">// - Work only at bar opening
class="type">class="kw">datetime fromDate = TimeCurrent();      class=class="str">"cmt">// Operation start time
CVirtualHistoryAdvisor  *expert;       class=class="str">"cmt">// EA object
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert initialization function                                     |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">int OnInit() {
class=class="str">"cmt">// Set parameters in the money management class
  CMoney::DepoPart(scale_);
  CMoney::FixedBalance(fixedBalance_);
class=class="str">"cmt">// Prepare the initialization class="type">class="kw">string for the deal history replay EA
  class="type">class="kw">string expertParams = StringFormat(
                              "class CVirtualHistoryAdvisor(\"%s\",%f,%d)",
                              historyFileName_, useOnlyNewBars_
                             );
class=class="str">"cmt">// Create an EA handling class="kw">virtual positions
  expert = NEW(expertParams);
class=class="str">"cmt">// If the EA is not created, then class="kw">return an error
  if(!expert) class="kw">return INIT_FAILED;
class=class="str">"cmt">// Successful initialization
  class="kw">return(INIT_SUCCEEDED);
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert tick function                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnTick() {
  expert.Tick();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Expert deinitialization function                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void OnDeinit(const class="type">int reason) {
  if(!!expert) class="kw">delete expert;
}
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">//+------------------------------------------------------------------+

跨服务器复跑验证差异

把写好的 EA 挂到 MetaQuotes-Demo 报价服务器上,指定好历史记录文件名后启动,测试输出和原始回测结果完全一致。这一步说明历史抓取与重放的逻辑链路是通的,没有隐性偏移。 换到另一家经纪商的真实服务器跑同一套 EA,余额曲线形状和 Demo 上几乎重合,但数值已经松动:总利润、归一化平均年利润、夏普比率都小幅下滑,回撤轻微走阔。 对比最初在那台真实服务器直接跑 EA 曾出现的全部存款损失,现在的偏差只是「略有不同」级别。外汇与贵金属实盘高波动高杠杆,这种跨源报价差异虽小,却足以把未经清洗的 EA 推到爆仓边缘,值得在实盘前单独处理。

「把EA拆开就能绕开报价服务器陷阱」

回测已经给出明确信号:同一套策略换报价服务器,净值曲线可能直接崩掉;但把开仓逻辑和原始报价绑在原生服务器,只把开仓平仓指令甩给新服务器,结果又能回到接近原来的水平。 我们顺手做了两个测试器专用工具,能把回放历史存下来再重演交易,证实这种责任切分确实有效——不过它们只在策略测试器里有意义,实盘用不上。 实盘要落地,就得把一个EA劈成两个:第一个只管判断和开仓,跑在你觉得报价最舒服的服务器上,同时把持仓列表广播出去;第二个在另一终端连别的报价源,只负责把未平仓位数量对齐广播值。开头提到的限制就这么绕过去了。 再激进点,这两台终端不必同机,甚至能分属不同电脑,只要第一个EA有通道把仓位信息递给第二个。对不卡精确入场时间的策略,通道延迟基本不是障碍;外汇和贵金属杠杆高、滑点凶,真要分仓广播先在小资金账户验证延迟容忍度。 下一篇我们按这个拆法继续写代码,不急着一口吃成胖子。

◍ 别急着下结论

这套多币种 EA 的工程文件最终落在 MQL5/Experts/Article.15330 目录下,共 27 个头文件与 EA 源文件,版本号从 1.00 到 1.19 不等,最近修改集中在第 16 部分。MQL5/Files 里附带了一份 SimpleVolumesExpert.1.19 的历史导出:区间 2021.01.01–2022.12.30,初始本金 10000、净盈利 34518、交易次数 1294、盈利因子 3.75,可用 SimpleHistoryReceiverExpert 或 HistoryReceiverExpert 在测试器里原样回放。 回放前得先确认数据库已建好且跑过优化前两步,否则像论坛里那位用户一样会撞上「无此表:通过」的 OnInit 报错。外汇与贵金属自动化回测的高风险在于:历史盈利因子不代表未来,3.75 只是那段样本的产物。 作者坦言当时还没做一键建库导优化的工具,相关坑留到第 22、23 部分才补。你真要验证,先下 ZIP 解到本地,用 MT5 打开 SimpleVolumesExpert.mq5 编译,再拿 csv 去测试器跑一遍,比看任何说明都直观。

把跨经纪商比对交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到不同源报价的开仓时刻偏移提示,你只需专注策略修正而不是手工翻 XML。

常见问题

核心差异常来自报价量序列不同导致SignalForOpen()触发时间错位,价格只定方向不定开仓时刻,历史源不同结果可能截然相反。
将XML测试报告并排查看交易列表开头几行即可,前三行通常已显示仓位开立时刻不同,无需跑完整个回测。
风险器可能提前终止交易掩盖真实表现,关掉它才能看到全周期下不同报价对净值的完整破坏路径。
可以,小布内置的AIGC比对会列出同品种多经纪商报价的开仓信号偏移,省去手动导出XML比对的重复劳动。
该策略开仓由当前工具报价量值决定,价格仅在确定开仓后影响方向,因此量序列差异比价格差更致命。