开发多币种 EA 交易系统(第 16 部分):不同报价历史对测试结果的影响·进阶篇
(2/3)·同一套参数在MetaQuotes-Demo盈利、换真实服务器却一年爆仓,差异藏在哪一步
不少人在多币种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_WRITE | FILE_CSV | FILE_ANSI 组合,文件句柄大于 0 才进 WriteDealsHistory() 写数据并 FileClose;否则 PrintFormat 打出错误码。开 MT5 把这段塞进 EA 的 OnInit 后手动调一次,终端共享文件夹里应出现带 16 列表头的空壳文件,验证通路再填写入逻辑。 |
|---|
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 等属性。外汇与贵金属回测含高杠杆风险,统计数值仅反映历史样本,不代表未来概率。
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 的宏名是否拼错,而不是怀疑文件句柄。
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() 返回结果。外汇与贵金属品种点差跳空频繁,回放结果仅反映历史情境,实盘复制倾向存在滑点偏差,属高风险验证手段。
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' 和起止时间差,一眼能看出历史覆盖是否够回测窗口。外汇与贵金属回测对历史完整性极敏感,缺口可能导致样本偏差,属高风险验证。
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,跑一遍就能看到虚拟持仓随历史时间开平的全过程。
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)); }