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

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

(2/3)·同一套参数在MetaQuotes-Demo盈利、换真实服务器却一年爆仓,差异藏在哪一步

实战向进阶 第 2/3 篇

不少人在多币种EA过完参数就直接上真实账户,以为demo跑顺了别的经纪商也只是微小偏差。现实里,换一组报价历史可能让账户一年内归零。问题往往不出在策略逻辑,而在开仓信号对报价的依赖方式。

把成交记录导成 CSV 的静态类骨架

做 EA 复盘时,第一道坎往往不是指标,而是把 MT5 历史成交捞出来。下面这段静态类把导出逻辑收口在 CExpertHistory 里,调用一次 Export() 就能落盘,避免每次手写文件操作。 类里三个静态私有方法先占位:GetStartDate() 取首笔成交时间,GetHistoryFileName() 拼文件名,Export() 对外暴露。Export 允许传入自定义文件名,留空就自动生成;commonFlag 默认 FILE_COMMON,意味着文件写进共享数据文件夹而非私有的 MQL5 目录。 列名数组 s_columnNames 定义了 16 个字段:DATE、TICKET、TYPE、SYMBOL、VOLUME、ENTRY、PRICE、STOPLOSS、TAKEPROFIT、PROFIT、COMMISSION、FEE、SWAP、MAGIC、COMMENT。注意分隔符 s_sep 写死为逗号,所以产出是标准 CSV,可直接丢进 Python 或 Excel 做外汇/贵金属成交分布分析——这类品种杠杆高、滑点跳空频繁,样本必须带 SWAP 和 FEE 才看得清真实磨损。

Export 实现里,FileOpen 用 FILE_WRITEFILE_CSVFILE_ANSI 组合,文件句柄大于 0 才进 WriteDealsHistory() 写数据并 FileClose;否则 PrintFormat 打出错误码。开 MT5 把这段塞进 EA 的 OnInit 后手动调一次,终端共享文件夹里应出现带 16 列表头的空壳文件,验证通路再填写入逻辑。
MQL5 / C++
  class=class="str">"cmt">// Get the first deal date
  class="kw">static class="type">class="kw">datetime   GetStartDate();
  class=class="str">"cmt">// Form a file name
  class="kw">static class="type">class="kw">string     GetHistoryFileName();
class="kw">public:
  class=class="str">"cmt">// Export deal history
  class="kw">static class="type">void       Export(
                     class="type">class="kw">string exportFileName = "",   class=class="str">"cmt">// File name for class="kw">export. If empty, the name is generated
                     class="type">int commonFlag = FILE_COMMON   class=class="str">"cmt">// Save the file in shared data folder
  );
};
class=class="str">"cmt">// Static class variables
class="type">class="kw">string CExpertHistory::s_sep = ",";
class="type">int    CExpertHistory::s_file;
class="type">class="kw">string CExpertHistory::s_columnNames[] = {"DATE", "TICKET", "TYPE",
                                          "SYMBOL", "VOLUME", "ENTRY", "PRICE",
                                          "STOPLOSS", "TAKEPROFIT", "PROFIT",
                                          "COMMISSION", "FEE", "SWAP",
                                          "MAGIC", "COMMENT"
                                         };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Export deal history                                              |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CExpertHistory::Export(class="type">class="kw">string exportFileName = "", class="type">int commonFlag = FILE_COMMON) {
  class=class="str">"cmt">// If the file name is not specified, then generate it
  if(exportFileName == "") {
    exportFileName = GetHistoryFileName();
  }
  class=class="str">"cmt">// Open the file for writing in the desired data folder
  s_file = FileOpen(exportFileName, commonFlag | FILE_WRITE | FILE_CSV | FILE_ANSI, s_sep);
  class=class="str">"cmt">// If the file is open,
  if(s_file > class="num">0) {
    class=class="str">"cmt">// Set the deal history
    WriteDealsHistory();
    class=class="str">"cmt">// Close the file
    FileClose(s_file);
  } else {
    PrintFormat(__FUNCTION__" | ERROR: Can&class="macro">#x27;t open file [%s]. Last error: %d", exportFileName, GetLastError());
  }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Form the file name                                               |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">class="kw">string CExpertHistory::GetHistoryFileName() {
  class=class="str">"cmt">// Take the EA name

◍ 把回测统计塞进文件名

在 MT5 中做策略回测后,把关键统计直接写进导出文件名,能省去打开文件再核对版本与区间的麻烦。下面这段逻辑在生成 .history.csv 前,先用 MQL_PROGRAM_NAME 取 EA 名,若编译时定义了 __VERSION__ 就拼接版本号,再追加历史起止日期。 日期之后紧跟一组方括号统计:初始入金、入金加净利润、相对回撤百分比、夏普比率(保留 2 位)。这些字段来自 TesterStatistics,回测完即可拿到,不用自己算。 文件名长度受文件系统限制,代码里判断若超过 255-13 就截断前面部分,最后补 .history.csv 返回。实际在 MT5 策略测试器里跑完,去 Files 目录找这个文件,名称里就带着 [初始入金, 期末资金, 回撤%, 夏普] 这样的摘要。 写成交历史时先写列头,再用 HistorySelect(0, TimeCurrent()) 拉全量,按 ticket 循环取每笔的 time、type、symbol、volume 等属性。外汇与贵金属回测含高杠杆风险,统计数值仅反映历史样本,不代表未来概率。

MQL5 / C++
  class="type">class="kw">string fileName = MQLInfoString(MQL_PROGRAM_NAME);
  class=class="str">"cmt">// If a version is specified, add it
class="macro">#ifdef __VERSION__
  fileName += "." + __VERSION__;
class="macro">#endif
  fileName += " ";
  class=class="str">"cmt">// Add the history start and end date
  fileName += "[" + TimeToString(GetStartDate(), TIME_DATE);
  fileName += " - " + TimeToString(TimeCurrent(), TIME_DATE) + "]";
  fileName += " ";
  class=class="str">"cmt">// Add some statistical characteristics
  fileName += "[" + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT), class="num">0);
  fileName += ", " + DoubleToString(TesterStatistics(STAT_INITIAL_DEPOSIT) + TesterStatistics(STAT_PROFIT), class="num">0);
  fileName += ", " + DoubleToString(TesterStatistics(STAT_EQUITY_DD_RELATIVE), class="num">0);
  fileName += ", " + DoubleToString(TesterStatistics(STAT_SHARPE_RATIO), class="num">2);
  fileName += "]";
  class=class="str">"cmt">// If the name is too class="type">long, shorten it
  if(StringLen(fileName) > class="num">255 - class="num">13) {
    fileName = StringSubstr(fileName, class="num">0, class="num">255 - class="num">13);
  }
  class=class="str">"cmt">// Add extension
  fileName += ".history.csv";
  class="kw">return fileName;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Write deal history to file                                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CExpertHistory::WriteDealsHistory() {
  class=class="str">"cmt">// Write a header with column names
  WriteDealsHistoryRow(s_columnNames);
  class=class="str">"cmt">// Variables for each deal properties
  class="type">uint      total;
  class="type">ulong     ticket = class="num">0;
  class="type">long      entry;
  class="type">class="kw">double    price;
  class="type">class="kw">double    sl, tp;
  class="type">class="kw">double    profit, commission, fee, swap;
  class="type">class="kw">double    volume;
  class="type">class="kw">datetime  time;
  class="type">class="kw">string    symbol;
  class="type">long      type, magic;
  class="type">class="kw">string    comment;
  class=class="str">"cmt">// Take the entire history
  HistorySelect(class="num">0, TimeCurrent());
  total = HistoryDealsTotal();
  class=class="str">"cmt">// For all deals
  for(class="type">uint i = class="num">0; i < total; i++) {
    class=class="str">"cmt">// If the deal is successfully selected,
    if((ticket = HistoryDealGetTicket(i)) > class="num">0) {
      class=class="str">"cmt">// Get the values of its properties
      time  = (class="type">class="kw">datetime)HistoryDealGetInteger(ticket, DEAL_TIME);
      type  = HistoryDealGetInteger(ticket, DEAL_TYPE);
      symbol = HistoryDealGetString(ticket, DEAL_SYMBOL);
      volume = HistoryDealGetDouble(ticket, DEAL_VOLUME);

「把成交记录拆成可落盘的结构」

做历史成交导出时,第一步是把每笔 deal 的字段从交易池里逐个抠出来。下面这段从 ticket 取 entry、price、sl、tp、profit、commission、fee、swap、magic、comment,覆盖了实盘复盘最关键的维度;外汇与贵金属杠杆高,swap 与 commission 的微小偏差可能在批量统计里放大成误导性结论。 过滤只留 DEAL_TYPE_BUY、DEAL_TYPE_SELL、DEAL_TYPE_BALANCE 三类,把 comment 里的分隔符替换成空格,再拼成一个字符串数组交给写文件函数。注意 price、sl、tp 用 DoubleToString 保留 5 位小数,这是针对多数外汇品种点位精度做的硬编码,黄金若用 3 位报价需改这个数。 WriteDealsHistoryRow 本身极简:用 JOIN 把数组按逗号拼成一行,再 FileWrite 落盘。回测时若发现 CSV 里某列全空,优先查 HistoryDealGetDouble 的宏名是否拼错,而不是怀疑文件句柄。

MQL5 / C++
entry = HistoryDealGetInteger(ticket, DEAL_ENTRY);
price = HistoryDealGetDouble(ticket, DEAL_PRICE);
sl = HistoryDealGetDouble(ticket, DEAL_SL);
tp = HistoryDealGetDouble(ticket, DEAL_TP);
profit = HistoryDealGetDouble(ticket, DEAL_PROFIT);
commission = HistoryDealGetDouble(ticket, DEAL_COMMISSION);
fee = HistoryDealGetDouble(ticket, DEAL_FEE);
swap = HistoryDealGetDouble(ticket, DEAL_SWAP);
magic = HistoryDealGetInteger(ticket, DEAL_MAGIC);
comment = HistoryDealGetString(ticket, DEAL_COMMENT);
if(type == DEAL_TYPE_BUY || type == DEAL_TYPE_SELL || type == DEAL_TYPE_BALANCE) {
  class=class="str">"cmt">// Replace the separator characters in the comment with a space
  StringReplace(comment, s_sep, " ");
  class=class="str">"cmt">// Form an array of values for writing one deal to the file class="type">class="kw">string
  class="type">class="kw">string fields[] = {TimeToString(time, TIME_DATE | TIME_MINUTES | TIME_SECONDS),
                     IntegerToString(ticket), IntegerToString(type), symbol, DoubleToString(volume), IntegerToString(entry),
                     DoubleToString(price, class="num">5), DoubleToString(sl, class="num">5), DoubleToString(tp, class="num">5), DoubleToString(profit),
                     DoubleToString(commission), DoubleToString(fee), DoubleToString(swap), IntegerToString(magic), comment
                     };
  class=class="str">"cmt">// Set the values of a single deal to the file
  WriteDealsHistoryRow(fields);
}
}
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Write one row of deal history to the file                       |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CExpertHistory::WriteDealsHistoryRow(const class="type">class="kw">string &fields[]) {
  class=class="str">"cmt">// Row to be set
  class="type">class="kw">string row = "";
  class=class="str">"cmt">// Concatenate all array values into one row using a separator
  JOIN(fields, row, ",");
  class=class="str">"cmt">// Write a row to the file
  FileWrite(s_file, row);
}

用历史成交回放来跑 EA 测试

想在 MT5 里验证一套策略,却不想从零写信号逻辑?可以直接把真实账户导出的成交记录喂给测试器,让 EA 按历史逐笔重演。下面这段类定义和构造入口,就是回放策略的骨架。 CHistoryStrategy 继承自 CVirtualStrategy,核心成员里 m_history 是 N 行 × 15 列的二维数组,每行存一笔历史成交的字段;m_totalDeals 记录总笔数,m_currentDeal 是回放游标。构造时先从参数字符串里读文件名,再尝试用 FileOpen 以 CSV+ANSI 格式打开。 文件找不到先去公共目录兜底:若本地 data 文件夹打开失败(句柄等于 INVALID_HANDLE),就换 FILE_COMMON 标志从 TerminalInfoString(TERMINAL_COMMONDATA_PATH) 指向的共享路径再开一次。两步都失败才通过 SetInvalid 报错退出,避免测试器静默空跑。 开 MT5 后把成交 CSV 放到对应目录,给 EA 传文件名参数,就能在 OnTester 里调 CExpertHistory::Export() 导出、用 expert.Tester() 返回结果。外汇与贵金属品种点差跳空频繁,回放结果仅反映历史情境,实盘复制倾向存在滑点偏差,属高风险验证手段。

MQL5 / C++
class="type">class="kw">double OnTester(class="type">void) {
   CExpertHistory::Export();
   class="kw">return expert.Tester();
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Trading strategy for reproducing the history of deals            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class CHistoryStrategy : class="kw">public CVirtualStrategy {
class="kw">protected:
   class="type">class="kw">string            m_symbols[];             class=class="str">"cmt">// Symbols(trading instruments)
   class="type">class="kw">string            m_history[][class="num">15];         class=class="str">"cmt">// Array of deal history(N rows * class="num">15 columns)
   class="type">int               m_totalDeals;            class=class="str">"cmt">// Number of deals in history
   class="type">int               m_currentDeal;           class=class="str">"cmt">// Current deal index
   CSymbolInfo       m_symbolInfo;            class=class="str">"cmt">// Object for getting information about the symbol properties
class="kw">public:
                     CHistoryStrategy(class="type">class="kw">string p_params);       class=class="str">"cmt">// Constructor
   class="kw">virtual class="type">void      Tick() class="kw">override;         class=class="str">"cmt">// OnTick 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">//+------------------------------------------------------------------+
CHistoryStrategy::CHistoryStrategy(class="type">class="kw">string p_params) {
   m_params = p_params;
class=class="str">"cmt">// Read the file name from the parameters
   class="type">class="kw">string fileName = ReadString(p_params);
class=class="str">"cmt">// If the name is read, then
   if(IsValid()) {
      class=class="str">"cmt">// Attempting to open a file in the data folder
      class="type">int f = FileOpen(fileName, FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, &class="macro">#x27;,&class="macro">#x27;);
      class=class="str">"cmt">// If failed to open a file, then try to open the file from the shared folder
      if(f == INVALID_HANDLE) {
         f = FileOpen(fileName, FILE_COMMON | FILE_READ | FILE_CSV | FILE_ANSI | FILE_SHARE_READ, &class="macro">#x27;,&class="macro">#x27;);
      }
      class=class="str">"cmt">// If this does not work, report an error and exit
      if(f == INVALID_HANDLE) {
         SetInvalid(__FUNCTION__,
                     StringFormat("ERROR: Can&class="macro">#x27;t open file %s from common folder %s, error code: %d",
                                  fileName, TerminalInfoString(TERMINAL_COMMONDATA_PATH), GetLastError()));
         class="kw">return;
      }

◍ 回测历史文件的逐行解析与边界校验

把成交历史从 CSV 读进 MT5 时,先扫到表头行(通常首行就是 'DATE')就跳过 14 列列名,不落库。随后进入第二个循环,按行读 15 个字段进二维数组 m_history,遇到交易品种非空才塞进 m_symbols 去重。 数组扩容用的是步进式策略:满员就 ArrayResize(m_history, 当前大小+10000, 100000),单次加 1 万行、硬上限 10 万行,避免频繁重分配拖慢加载。 读完后若成交数大于 1(排除首行入金),就把数组精确裁剪到 m_totalDeals 大小,并比对文件首行日期与 TimeCurrent():若历史起点晚于当前时间,直接 SetInvalid 报错,防止用未来日期的历史跑回测。 开 MT5 把下面这段接进你的读取函数,改 fileName 指向自己的成交导出文件,终端会打印 'Found N rows' 和起止时间差,一眼能看出历史覆盖是否够回测窗口。外汇与贵金属回测对历史完整性极敏感,缺口可能导致样本偏差,属高风险验证。

MQL5 / C++
while(!FileIsEnding(f)) {
  class="type">class="kw">string s = FileReadString(f);
  if(s == "DATE") {
    FORI(class="num">14, FileReadString(f));
    break;
  }
}
while(!FileIsEnding(f)) {
  if(m_totalDeals == ArraySize(m_history)) {
    ArrayResize(m_history, ArraySize(m_history) + class="num">10000, class="num">100000);
  }
  FORI(class="num">15, m_history[m_totalDeals][i] = FileReadString(f));
  if(m_history[m_totalDeals][SYMBOL] != "") {
    ADD(m_symbols, m_history[m_totalDeals][SYMBOL]);
  }
  m_totalDeals++;
}
FileClose(f);
PrintFormat(__FUNCTION__" | OK: Found %d rows in %s", m_totalDeals, fileName);
if(m_totalDeals > class="num">1) {
  ArrayResize(m_history, m_totalDeals);
  class="type">class="kw">datetime ct = TimeCurrent();
  PrintFormat(__FUNCTION__" |\n"
    "Start time in tester:  %s\n"
    "Start time in history: %s",
    TimeToString(ct, TIME_DATE), m_history[class="num">0][DATE]);
  if(StringToTime(m_history[class="num">0][DATE]) < ct) {
    SetInvalid(__FUNCTION__,
      StringFormat("ERROR: For this history file [%s] set start date less than %s",
        fileName, m_history[class="num">0][DATE]));
  }
}

「用历史成交回放驱动虚拟持仓」

策略的 Tick 函数不接实时报价,而是按时间顺序把历史成交一条条喂给虚拟持仓引擎。核心循环是:只要当前待处理成交的时间戳不晚于 TimeCurrent(),就取出该笔 symbol、type、volume 并推进指针。 符号不在监控数组里、或 volume 被规范化为 0(入金出金类记录),直接跳过并 m_currentDeal++。volume 用 NormalizeDouble(…,2) 保留两位小数,卖单会把 volume 乘 -1 转成负值,方便与已有虚拟仓位对冲计算。 若对应 symbol 的虚拟仓位处于 IsOpen() 状态,先把它的 Volume() 累加进当前 volume,再 Close() 旧仓;最后当 MathAbs(volume)>0.00001 时,以正负决定 ORDER_TYPE_BUY / SELL 重开仓。这套逻辑让你在 MT5 里用任意历史回放文件复现净值曲线,外汇与贵金属品种点差滑点敏感,复盘结果仅代表历史概率、不预示未来。 PrintFormat 那行把每笔处理中的 deal 编号、方向、手数、品种打到日志,手数固定 %.2f 显示。想验证,把 m_history 换成自己的真实成交导出 CSV,跑一遍就能看到虚拟持仓随历史时间开平的全过程。

MQL5 / C++
class="type">void CHistoryStrategy::Tick() class="kw">override {
class=class="str">"cmt">//---
  while(m_currentDeal < m_totalDeals && StringToTime(m_history[m_currentDeal][DATE]) <= TimeCurrent()) {
    class=class="str">"cmt">// Deal symbol
    class="type">class="kw">string symbol = m_history[m_currentDeal][SYMBOL];
    
    class=class="str">"cmt">// Find the index of the current deal symbol in the array of symbols
    class="type">int index;
    FIND(m_symbols, symbol, index);
    class=class="str">"cmt">// If not found, then skip the current deal
    if(index == -class="num">1) {
      m_currentDeal++;
      class="kw">continue;
    }
    
    class=class="str">"cmt">// Deal type
    ENUM_DEAL_TYPE type = (ENUM_DEAL_TYPE) StringToInteger(m_history[m_currentDeal][TYPE]);
    class=class="str">"cmt">// Current deal volume
    class="type">class="kw">double volume = NormalizeDouble(StringToDouble(m_history[m_currentDeal][VOLUME]), class="num">2);
    class=class="str">"cmt">// If this is a top-up/withdrawal, skip the deal
    if(volume == class="num">0) {
      m_currentDeal++;
      class="kw">continue;
    }
    class=class="str">"cmt">// Report information about the read deal
    PrintFormat(__FUNCTION__" | Process deal #%d: %s %.2f %s",
                m_currentDeal, (type == DEAL_TYPE_BUY ? "BUY" : (type == DEAL_TYPE_SELL ? "SELL" : EnumToString(type))),
                volume, symbol);
    class=class="str">"cmt">// If this is a sell deal, then make the volume negative
    if(type == DEAL_TYPE_SELL) {
      volume *= -class="num">1;
    }
    class=class="str">"cmt">// If the class="kw">virtual position for the current deal symbol is open,
    if(m_orders[index].IsOpen()) {
      class=class="str">"cmt">// Add its volume to the volume of the current trade
      volume += m_orders[index].Volume();
      
      class=class="str">"cmt">// Close the class="kw">virtual position
      m_orders[index].Close();
    }
    class=class="str">"cmt">// If the volume for the current symbol is not class="num">0,
    if(MathAbs(volume) > class="num">0.00001) {
      class=class="str">"cmt">// Open a class="kw">virtual position of the required volume and direction
      m_orders[index].Open(symbol, (volume > class="num">0 ? ORDER_TYPE_BUY : ORDER_TYPE_SELL), MathAbs(volume));
    }
把跨源报价比对交给小布
小布盯盘的AIGC已内置多源报价差异扫描,打开对应品种页即可并排看不同经纪商 tick 偏移,你只管判断策略该不该重调。

常见问题

核心常在于不同服务器报价历史不一致,尤其当开仓信号依赖成交量而非价格时,成交时间错位是主因,爆仓概率显著上升。
小布盯盘可自动拉取多源报价并标注偏移区间,省去手动存XML比对,外汇贵金属高杠杆下这类偏差会放大风险。
它使开仓时刻由量触发、方向才参考价,不同源的量峰时间不同,仓位开启点漂移,回测与实盘结果可能严重分化。
风险器提前平仓会掩盖策略本身在异源报价下的真实表现,关掉后更易看清空仓节奏是否被报价差异摧毁。
先看已平仓交易列表的开仓时间列,若同源同参却时间错开,基本可锁定信号触发机制对报价源敏感。