MQL5 酷宝书:利用自定义品种进行交易策略压力测试·进阶篇
🧪

MQL5 酷宝书:利用自定义品种进行交易策略压力测试·进阶篇

(2/3)· 当经纪商定单不可达时,如何用自定义品种在本地复刻极端行情逼出策略漏洞

含代码示例偏理论 第 2/3 篇
很多交易者把 EA 回测只跑在默认品种上,遇到滑点断层就归咎于市场。其实终端早已能自建品种,把历史重排成你想逼问策略的任意形态,只是多数人没碰过这套类封装。

「自定义品种克隆时的属性搬运逻辑」

下面这段来自自定义品种类的 Clone 方法,演示了如何把一个已有品种(如 EURUSD)的数值型与整型属性批量复制到新生成的离线品种上。先前的删除测试日志显示:2019.08.11 19:13:59 在 EURUSD,M1 上成功删除了自定义品种 "EURUSD_1",说明这套封装在实盘测试环境下可跑通。 方法开头就拦了一道:若目标名和源品种名相同,直接 Print 报错并返回 false,避免自我覆盖。时间区间参数若给了 _to_msc>0,还要求 _to_msc 必须大于等于 _from_msc,否则判为 wrong settings 退出。 真正干活的是两段属性数组循环。dbl_props 里列了 22 个 ENUM_SYMBOL_INFO_DOUBLE 项,从 SYMBOL_POINT、SYMBOL_SWAP_LONG/SHORT 到 SYMBOL_VOLUME_MIN/STEP 等一个不落;随后用 ArraySize 驱动 for 循环,对每个属性调用 this.CloneProperty 做单体拷贝,任一失败即整体返回 false。整型属性数组 int_props 在截断处刚开始声明,克隆思路同 double 段一致。 在 MT5 里验证时,你可以直接把这段 CiCustomSymbol::Clone 的前半部分抄进 EA,把 m_name 设成 "EURUSD_1"、_origin_symbol 设成 "EURUSD",观察自定义品种能否完整继承点值、合约大小和隔夜息。外汇与贵金属自定义品种存在报价断层与流动性假象的高风险,克隆出的数据仅用于回测参照,不等于真实成交环境。

MQL5 / C++
::PrintFormat(__FUNCTION__+": failed to class="kw">delete the custom symbol \"%s\". Error code: %d",m_name,::GetLastError());
   class="kw">return class="kw">false;
   }
class=class="str">"cmt">//---
   class="kw">return true;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">2019.08.class="num">11 class="num">19:class="num">13:class="num">59.276 TestDeletion(EURUSD,M1) A custom symbol "EURUSD_1" has been successfully deleted.
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Clone a symbol                                                                 |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CiCustomSymbol::Clone(class="kw">const class="type">class="kw">string _origin_symbol,class="kw">const class="type">class="kw">ulong _from_msc=class="num">0,class="kw">const class="type">class="kw">ulong _to_msc=class="num">0)
  {
  if(!::StringCompare(m_name,_origin_symbol))
    {
     ::Print(__FUNCTION__+": the origin symbol name must be different!");
     class="kw">return class="kw">false;
    }
  ::ResetLastError();
class=class="str">"cmt">//--- if to load history
  if(_to_msc>class="num">0)
    {
     if(_to_msc<_from_msc)
       {
        ::Print(__FUNCTION__+": wrong settings for a time interval!");
        class="kw">return class="kw">false;
       }
     m_from_msc=_from_msc;
     m_to_msc=_to_msc;
    }
  else
     m_from_msc=m_to_msc=class="num">0;
class=class="str">"cmt">//--- class="type">class="kw">double
  ENUM_SYMBOL_INFO_DOUBLE dbl_props[]=
    {
     SYMBOL_MARGIN_HEDGED,
     SYMBOL_MARGIN_INITIAL,
     SYMBOL_MARGIN_MAINTENANCE,
     SYMBOL_OPTION_STRIKE,
     SYMBOL_POINT,
     SYMBOL_SESSION_PRICE_LIMIT_MAX,
     SYMBOL_SESSION_PRICE_LIMIT_MIN,
     SYMBOL_SESSION_PRICE_SETTLEMENT,
     SYMBOL_SWAP_LONG,
     SYMBOL_SWAP_SHORT,
     SYMBOL_TRADE_ACCRUED_INTEREST,
     SYMBOL_TRADE_CONTRACT_SIZE,
     SYMBOL_TRADE_FACE_VALUE,
     SYMBOL_TRADE_LIQUIDITY_RATE,
     SYMBOL_TRADE_TICK_SIZE,
     SYMBOL_TRADE_TICK_VALUE,
     SYMBOL_VOLUME_LIMIT,
     SYMBOL_VOLUME_MAX,
     SYMBOL_VOLUME_MIN,
     SYMBOL_VOLUME_STEP
    };
  for(class="type">int prop_idx=class="num">0; prop_idx<::ArraySize(dbl_props); prop_idx++)
    {
     ENUM_SYMBOL_INFO_DOUBLE curr_property=dbl_props[prop_idx];
     if(!this.CloneProperty(_origin_symbol,curr_property))
       class="kw">return class="kw">false;
    }
class=class="str">"cmt">//--- integer
  ENUM_SYMBOL_INFO_INTEGER int_props[]=

◍ 克隆品种时枚举了哪些属性

上面这段实现把品种信息拆成三块批量拷贝:整数类、字符串类、以及可选的历史 tick。整数属性列了 25 个宏,从背景色、图表模式、报价小数位,到过期时间、挂单 GTC 模式、止损冻结级别都涵盖;字符串属性 9 个,含基础资产、币种、ISIN 码和路径等。 循环里用 ArraySize 取数组长度,逐个调 CloneProperty 复制到自定义品种对象,任一项失败就直接 return false。如果传入的 _to_msc 大于 0,再额外调 CloneTicks 拉历史 tick,失败同样返回 false。 日志里有一行实跑记录:2019.08.11 19:21:06.402 在 EURUSD,M1 上测试,输出 "EURUSD" 已成功克隆。你可以开 MT5 把这段塞进 CiCustomSymbol 类,挂个 EURUSD 的 M1 图表验证克隆是否走通。

MQL5 / C++
   SYMBOL_BACKGROUND_COLOR,
   SYMBOL_CHART_MODE,
   SYMBOL_DIGITS,
   SYMBOL_EXPIRATION_MODE,
   SYMBOL_EXPIRATION_TIME,
   SYMBOL_FILLING_MODE,
   SYMBOL_MARGIN_HEDGED_USE_LEG,
   SYMBOL_OPTION_MODE,
   SYMBOL_OPTION_RIGHT,
   SYMBOL_ORDER_GTC_MODE,
   SYMBOL_ORDER_MODE,
   SYMBOL_SPREAD,
   SYMBOL_SPREAD_FLOAT,
   SYMBOL_START_TIME,
   SYMBOL_SWAP_MODE,
   SYMBOL_SWAP_ROLLOVER3DAYS,
   SYMBOL_TICKS_BOOKDEPTH,
   SYMBOL_TRADE_CALC_MODE,
   SYMBOL_TRADE_EXEMODE,
   SYMBOL_TRADE_FREEZE_LEVEL,
   SYMBOL_TRADE_MODE,
   SYMBOL_TRADE_STOPS_LEVEL
   };
   for(class="type">int prop_idx=class="num">0; prop_idx<::ArraySize(int_props); prop_idx++)
   {
      ENUM_SYMBOL_INFO_INTEGER curr_property=int_props[prop_idx];
      if(!this.CloneProperty(_origin_symbol,curr_property))
         class="kw">return class="kw">false;
   }
class=class="str">"cmt">//--- class="type">class="kw">string
   ENUM_SYMBOL_INFO_STRING str_props[]=
   {
      SYMBOL_BASIS,
      SYMBOL_CURRENCY_BASE,
      SYMBOL_CURRENCY_MARGIN,
      SYMBOL_CURRENCY_PROFIT,
      SYMBOL_DESCRIPTION,
      SYMBOL_FORMULA,
      SYMBOL_ISIN,
      SYMBOL_PAGE,
      SYMBOL_PATH
   };
   for(class="type">int prop_idx=class="num">0; prop_idx<::ArraySize(str_props); prop_idx++)
   {
      ENUM_SYMBOL_INFO_STRING curr_property=str_props[prop_idx];
      if(!this.CloneProperty(_origin_symbol,curr_property))
         class="kw">return class="kw">false;
   }
class=class="str">"cmt">//--- history
   if(_to_msc>class="num">0)
   {
      if(this.CloneTicks(_origin_symbol)==-class="num">1)
         class="kw">return class="kw">false;
   }
class=class="str">"cmt">//---
   class="kw">return true;
   }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">2019.08.class="num">11 class="num">19:class="num">21:class="num">06.402 TestClone(EURUSD,M1) A base symbol "EURUSD" has been successfully cloned.
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Load ticks                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CiCustomSymbol::LoadTicks(class="kw">const class="type">class="kw">string _src_file_name)
  {
   class="type">int symbol_digs=(class="type">int)this.GetProperty(SYMBOL_DIGITS);;
class=class="str">"cmt">//--- class="kw">delete ticks

从 CSV 灌入历史 tick 的批处理写法

把外部 CSV 里的历史 tick 回灌进本地行情库,核心是先清掉旧 tick 再开文件逐批读。代码里用 TicksDelete(0, LONG_MAX) 清空全部,返回值小于 0 直接 return false,避免脏数据混进来。 文件打开用 CFile 的 Open,模式挂 FILE_READ|FILE_CSV 且分隔符是逗号。若句柄等于 INVALID_HANDLE,打印失败并退出——这一步在 MT5 里跑时,若文件名路径不对会立刻暴露,方便你定位。 读取环节按 m_batch_size 用 ArrayResize 预分配 MqlTick 数组,分配失败同样返 false。do-while 里靠 IsEnding() 判断文件尾,每行先读时间字符串,按 '.' 拆成 4 段(日期、时、分、毫秒),拼回 datetime 再乘 1e3 加毫秒转成 time_msc。若拆分不是 4 段或时间串为空,当前 tick 跳过并 continue,不中断整轮导入。 这种批处理在回测 EURUSD 这类高频品种时,单批设 1024 或 4096 通常比逐条写入快数倍,但外汇和贵金属杠杆高、滑点风险大,实盘前务必用历史数据验证导入完整性。

MQL5 / C++
  if(this.TicksDelete(class="num">0,LONG_MAX)<class="num">0)
        class="kw">return class="kw">false;
class=class="str">"cmt">//--- open a file
  CFile curr_file;
  ::ResetLastError();
  class="type">int file_ha=curr_file.Open(_src_file_name,FILE_READ|FILE_CSV,&class="macro">#x27;,&class="macro">#x27;);
  if(file_ha==INVALID_HANDLE)
    {
      ::PrintFormat(__FUNCTION__+": failed to open a %s file!",_src_file_name);
      class="kw">return class="kw">false;
    }
  curr_file.Seek(class="num">0,SEEK_SET);
class=class="str">"cmt">//--- read data from a file
  class="type">MqlTick batch_arr[];
  if(::ArrayResize(batch_arr,m_batch_size)!=m_batch_size)
    {
      ::Print(__FUNCTION__+": failed to allocate memory for a batch array!");
      class="kw">return class="kw">false;
    }
  ::ZeroMemory(batch_arr);
  class="type">uint tick_idx=class="num">0;
  class="type">bool is_file_ending=class="kw">false;
  class="type">uint tick_cnt=class="num">0;
  do
    {
      is_file_ending=curr_file.IsEnding();
      class="type">class="kw">string dates_str[class="num">2];
      if(!is_file_ending)
        {
          class=class="str">"cmt">//--- time
          class="type">class="kw">string time_str=::FileReadString(file_ha);
          if(::StringLen(time_str)<class="num">1)
            {
              ::Print(__FUNCTION__+": no class="type">class="kw">datetime class="type">class="kw">string - the current tick skipped!");
              ::PrintFormat("The unprocessed class="type">class="kw">string: %s",time_str);
              class="kw">continue;
            }
          class="type">class="kw">string sep=".";
          class="type">class="kw">ushort u_sep;
          class="type">class="kw">string result[];
          u_sep=::StringGetCharacter(sep,class="num">0);
          class="type">int str_num=::StringSplit(time_str,u_sep,result);
          if(str_num!=class="num">4)
            {
              ::Print(__FUNCTION__+": no substrings - the current tick skipped!");
              ::PrintFormat("The unprocessed class="type">class="kw">string: %s",time_str);
              class="kw">continue;
            }
          class=class="str">"cmt">//--- class="type">class="kw">datetime
          class="type">class="kw">datetime date_time=::StringToTime(result[class="num">0]+"."+result[class="num">1]+"."+result[class="num">2]);
          class="type">long time_msc=(class="type">long)(class="num">1e3*date_time+::StringToInteger(result[class="num">3]));
          class=class="str">"cmt">//--- bid
          class="type">class="kw">double bid_val=::FileReadNumber(file_ha);
          if(bid_val<.class="num">0)

「逐笔回填自定义品种的 tick 构造」

把历史文件里的 bid / ask 读出来后,先判空:若 FileReadNumber 返回小于 0,说明该笔 tick 缺价,直接 Print 跳过并 continue,避免脏数据进自定义品种。 读价之后用循环空跑两次 FileReadNumber 吃掉成交量字段,再把 date_time、毫秒时间、归一化后的 bid/ask 填进 MqlTick 结构;归一化位数取 symbol_digs,否则跨品种回填时报价精度会错位。 flags 的写法值得抄:仅当本次 bid 或 ask 相对上一笔有变化才置 TICK_FLAG_BID / TICK_FLAG_ASK,若全无变化则强制双标,保证 MT5 至少认一次更新。 攒满 m_batch_size 笔后分两种情况——当前品种被选中就用 TicksAdd 增量追加,否则 TicksReplace 整批替换;两者返回值不等于 m_batch_size 直接 return false,这一步是离线回放不丢 tick 的关键。外汇与贵金属自定义品种回测属高风险操作,报价断层可能导致成交逻辑失真。

MQL5 / C++
     {
       ::Print(__FUNCTION__+": no bid price - the current tick skipped!");
       class="kw">continue;
     }
    class=class="str">"cmt">//--- ask
    class="type">class="kw">double ask_val=::FileReadNumber(file_ha);
    if(ask_val<class="num">0)
      {
       ::Print(__FUNCTION__+": no ask price - the current tick skipped!");
       class="kw">continue;
      }
    class=class="str">"cmt">//--- volumes
    for(class="type">int jtx=class="num">0; jtx<class="num">2; jtx++)
      ::FileReadNumber(file_ha);
    class=class="str">"cmt">//--- fill in the current tick
    class="type">MqlTick curr_tick= {class="num">0};
    curr_tick.time=date_time;
    curr_tick.time_msc=(class="type">long)(class="num">1e3*date_time+::StringToInteger(result[class="num">3]));
    curr_tick.bid=::NormalizeDouble(bid_val,symbol_digs);
    curr_tick.ask=::NormalizeDouble(ask_val,symbol_digs);
    class=class="str">"cmt">//--- flags
    if(m_tick.bid!=curr_tick.bid)
      curr_tick.flags|=TICK_FLAG_BID;
    if(m_tick.ask!=curr_tick.ask)
      curr_tick.flags|=TICK_FLAG_ASK;
    if(curr_tick.flags==class="num">0)
      curr_tick.flags=TICK_FLAG_BID|TICK_FLAG_ASK;;
    if(tick_idx==m_batch_size)
      {
       class=class="str">"cmt">//--- add ticks to the custom symbol
       if(m_is_selected)
         {
          if(this.TicksAdd(batch_arr)!=m_batch_size)
            class="kw">return class="kw">false;
         }
       else
         {
          if(this.TicksReplace(batch_arr)!=m_batch_size)
            class="kw">return class="kw">false;
         }
       tick_cnt+=m_batch_size;
       class=class="str">"cmt">//--- log
       for(class="type">uint idx=class="num">0,batch_idx=class="num">0; idx<::ArraySize(dates_str); idx++,batch_idx+=(m_batch_size-class="num">1))
         dates_str[idx]=::TimeToString(batch_arr[batch_idx].time,TIME_DATE|TIME_SECONDS);

◍ 收尾批处理与首tick校验

文件读取循环退出后,先把本批有效 tick 数 new_size 算出来,若大于 0 才做数组拷贝与落地。ArrayCopy 失败会直接返回 false,这种硬失败在自定义品种批量灌数据时最容易暴露文件截断或字段错位。 落地逻辑分两种:当前品种已被选中就用 TicksAdd 增量追加,否则用 TicksReplace 整批替换;两者返回值不等于 new_size 同样判错。tick_cnt 累加后,用 TimeToString 把批次首尾时间转成日期秒级字符串,PrintFormat 打出 "Ticks loaded from %s to %s" 方便肉眼核对断点。 循环结束打印总加载量 Loaded ticks number: %I32u,随后立刻用 CopyTicks(m_name, ticks_arr, COPY_TICKS_INFO, 1, 1) 取首 tick。若取不到 1 条就返回 false,并把首 tick 的 time_msc 存进 m_from_msc——这一步是后续按毫秒对齐重放的前提,外汇与贵金属tick重放高风险,time_msc 错位会导致回测偏移。

MQL5 / C++
::PrintFormat("\nTicks loaded from %s to %s.",dates_str[class="num">0],dates_str[class="num">1]);
class=class="str">"cmt">//--- reset
::ZeroMemory(batch_arr);
tick_idx=class="num">0;
 }
 batch_arr[tick_idx]=curr_tick;
 m_tick=curr_tick;
 tick_idx++;
 }
class=class="str">"cmt">//--- end of file
else
 {
 class="type">uint new_size=tick_idx;
 if(new_size>class="num">0)
  {
  class="type">MqlTick last_batch_arr[];
  if(::ArrayCopy(last_batch_arr,batch_arr,class="num">0,class="num">0,new_size)!=new_size)
   {
   ::Print(__FUNCTION__+": failed to copy a batch array!");
   class="kw">return class="kw">false;
   }
  class=class="str">"cmt">//--- add ticks to the custom symbol
  if(m_is_selected)
   {
   if(this.TicksAdd(last_batch_arr)!=new_size)
    class="kw">return class="kw">false;
   }
  else
   {
   if(this.TicksReplace(last_batch_arr)!=new_size)
    class="kw">return class="kw">false;
   }
  tick_cnt+=new_size;
  class=class="str">"cmt">//--- log
  for(class="type">uint idx=class="num">0,batch_idx=class="num">0; idx<::ArraySize(dates_str); idx++,batch_idx+=(tick_idx-class="num">1))
   dates_str[idx]=::TimeToString(batch_arr[batch_idx].time,TIME_DATE|TIME_SECONDS);
  ::PrintFormat("\nTicks loaded from %s to %s.",dates_str[class="num">0],dates_str[class="num">1]);
  }
 }
 }
 class="kw">while(!is_file_ending && !::IsStopped());
::PrintFormat("\nLoaded ticks number: %I32u",tick_cnt);
 curr_file.Close();
class=class="str">"cmt">//---
 class="type">MqlTick ticks_arr[];
 if(::CopyTicks(m_name,ticks_arr,COPY_TICKS_INFO,class="num">1,class="num">1)!=class="num">1)
  {
  ::Print(__FUNCTION__+": failed to copy the first tick!");
  class="kw">return class="kw">false;
  }
 m_from_msc=ticks_arr[class="num">0].time_msc;

用毫秒时间戳锁住最后一颗tick

在自定义品种加载器里,抓最后一颗 tick 不能靠猜,得用 CopyTicks 带 COPY_TICKS_INFO 标志精确拉取。下面这段就是只取 1 条最新 tick 并把它毫秒时间存下来的核心判断。 [CODE] if(::CopyTicks(m_name,ticks_arr,COPY_TICKS_INFO,0,1)!=1) { ::Print(__FUNCTION__+": failed to copy the last tick!"); return false; } m_to_msc=ticks_arr[0].time_msc; return true; [/CODE] 逐行看:CopyTicks 以 m_name 为品种名,把结果塞进 ticks_arr,模式选 COPY_TICKS_INFO 表示只拿带附加信息的 tick,从下标 0 起取 1 条;若返回值不等于 1,说明没拿到,打印函数名加失败提示并 return false。拿到后把 ticks_arr[0].time_msc 赋给 m_to_msc,这就是该 tick 的毫秒级更新时间,最后 return true 收尾。 MqlTick 结构体里真正决定高频精度的字段是 time_msc(long 型毫秒时间)和 flags(uint 型 tick 标记),其余 bid/ask/last/volume 等是常规报价与成交量。做重采样或跨周期对齐时,time_msc 比 time(秒级 datetime)更不容易撞车。 实测日志里,EURUSD,H1 从 2019.08.01 00:00:00 到 2019.08.02 20:59:56 一共载入了 354400 条 tick,同窗口另存出 2697 根 1 分钟 K 线。外汇与贵金属tick数据量大、跳空频繁,实盘用这套逻辑前建议在 MT5 策略测试器先跑一遍自定义品种,确认 time_msc 在你的 broker 环境里真的有值,高风险品种尤其要核对时间戳连续性。

MQL5 / C++
if(::CopyTicks(m_name,ticks_arr,COPY_TICKS_INFO,class="num">0,class="num">1)!=class="num">1)
  {
  ::Print(__FUNCTION__+": failed to copy the last tick!");
  class="kw">return class="kw">false;
  }
m_to_msc=ticks_arr[class="num">0].time_msc;
class="kw">return true;
把重排行情的脏活交给小布
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到自定义品种的回放状态,你只需专注判断策略在断层下的真实表现。

常见问题

CSymbolInfo 依赖与服务器通信获取属性,而 CiCustomSymbol 去掉了连接方法,增加了创建、删除等本地操控自定义品种的能力,适合离线测试环境。
批次过小会导致即时报价数据库被过于频繁刷新,回放效率下降,但内存占用较低;默认一百万的辅助数组已在吞吐和内存间做了折中。
Delete 方法会先尝试从市场观察移除再删品种,若窗口占用导致移除失败,整个删除流程会中断,因此清理顺序不能反。
目前小布盯盘内置的是品种回放状态诊断与异常提示,脚本编写仍需在终端完成,但可把重复的重排与监控劳动交给小布,你专注决策。
可以,自定义品种允许本地重排报价,外汇贵金属本身高杠杆高风险,这类测试只能暴露逻辑脆弱点,实盘仍可能受经纪商执行差异影响。