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

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

(3/3)· 从品种类封装到压力测试落地,手把手把经纪商权限握在自己手里

含代码示例 第 3/3 篇
很多交易者把 EA 只丢给默认品种回测,却从没怀疑过行情数据本身是否够狠。自定义品种建不起来,压力测试就永远停在口头。把定单当真能成交来测,是新手最容易踩的坑。

「用日志确认历史tick是否真的进来了」

在 MT5 策略测试器里跑一段加载校验,最直观的办法是让 EA 把关键节点打印到日志。下面这段输出来自 EURUSD H1 周期的一次 TestLoad 调用:程序先报出本地缓存里最后一根K线的时间停在 2019.08.02 20:56,说明历史序列已就位;紧接着一行显示 Copied ticks number: 354400,即本次从服务器复制到的 tick 总量为 354400 笔。 这两个数能直接帮你判断「数据缺口」——若最后 rate time 远早于你设定的回测起点,或 copied ticks 明显少于同周期正常量级,就得怀疑品种数据没下全。外汇与贵金属杠杆高、滑点跳空频繁,拿残缺 tick 做回测,信号会偏得离谱。 开 MT5 后随便挂个 EA,在 OnTick 或独立函数里加 Print 把最后时间和 tick 计数打出来,跑一次就知道你的终端到底装了多少真数据。

MQL5 / C++
EQ        class="num">0    class="num">15:class="num">52:class="num">50.170   TestLoad(EURUSD,H1)   The last rate time: class="num">2019.08.class="num">02 class="num">20:class="num">56
DJ        class="num">0    class="num">15:class="num">52:class="num">50.351   TestLoad(EURUSD,H1)   Copied ticks number: class="num">354400

去哪搞到可用的 Tick 历史

外汇是 OTC 市场,没有统一基准报价,也就不存在官方留存的即时报价存档。多数货币期货在芝加哥商品交易所成交,报价能当某种基准,但历史数据不免费,往往要注册、找销售经理谈才可能拿到测试期。 经纪商之间因竞争,报价差异通常不大,可他们基本不存 Tick 存档也不给下载。Dukascopy Bank 是少数免费源:简单注册就能下历史数据,含即时报价。其文件每行 5 列——时间、要价、出价、采购量、抛售量。 手动下只能选一天,要若干年历史就得耗大量精力。Quant Data Manager 这类辅助程序能批量从 Dukascopy 拉存档,图例里“数据”选项卡可勾选品种。 CiCustomSymbol::LoadTicks() 已适配上述程序的 csv 格式,意味着你下完文件就能直接喂给自定义品种做回测。外汇与贵金属杠杆高、滑点跳空频繁,用 Tick 级数据验证前先确认本地时区与 Dukascopy 的 UTC 偏移一致,否则会错周期。

◍ 给策略灌点差毒药看它扛不扛得住

回测只跑历史报价算不上真考验,压测的思路是人为造更恶劣的环境,逼出策略在极端条件下的稳定性边界。外汇和贵金属本身高杠杆高波动,点差一变就可能吞掉短线系统的全部利润,压测前先认清这类品种的风险本质。 点差因子直接决定交易成本。瞄准短线的策略对点差极度敏感,某些情形下点差占潜在回报比能超 100%。在 MT5 里可用 CiCustomSymbol::ChangeSpread() 造一个和基准点差不同的自定义品种来复现这种压力。 改点差有三条路:一是在 _spread_size 填正数设固定点差(测试器里 SYMBOL_SPREAD_FLOAT 恒为 true,点差仍按浮动逻辑走);二是用 _spread_markup 给浮动点差加标记;三是用 ENUM_SPREAD_BASE 选参考价——选 bid 则 ask=bid+点差,选 ask 则 bid=ask-点差,选 average 则 bid=均价-点差/2。该方法不写品种属性,而是在每个 tick 到来时改点差并存进即时报价库。 用 EURUSD 五位小数真实浮动点差(12-17 点)做对照,把点差拉到 25 点、50 点后:交易数从 172 降到 156、145,净盈利从 4018.27 降到 3877.58、3574.10 美元,最大净值回撤反而从 11.79% 降到 9.65%、8.29%,单笔盈利维持在 23-25 美元。点差越大单量越少、回撤越低,获利能力略升,这是短线策略典型的「少做少错」现象。 停止价位和冻结价位也能压。部分经纪商在重大数据前把止损最小距离提到 10 倍点差、收盘前提到 25 倍,可用 SetProperty 配 SYMBOL_TRADE_STOPS_LEVEL 和 SYMBOL_TRADE_FREEZE_LEVEL,但测试器当前版本不能动态改品种属性,只能预配置。保证金需求(SYMBOL_MARGIN_INITIAL 等)则可编程单独设,杠杆一变就能试出策略爆仓临界。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Change the initial spread                                        |
class=class="str">"cmt">//| Input parameters:                                                |
class=class="str">"cmt">//|   class="num">1) _spread_size - the new fixed value of the spread, pips.     |
class=class="str">"cmt">//|      If the value > class="num">0 then the spread value is fixed.            |
class=class="str">"cmt">//|   class="num">2) _spread_markup - a markup for the floating value of the     |
class=class="str">"cmt">//|      spread, pips. The value is added to the current spread if   |
class=class="str">"cmt">//|      _spread_size=class="num">0.                                             |
class=class="str">"cmt">//|   class="num">3) _spread_base - a type of the price to which a markup is     |
class=class="str">"cmt">//|      added in case of the floating value.                        |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool CiCustomSymbol::ChangeSpread(class="kw">const class="type">uint _spread_size,class="kw">const class="type">uint _spread_markup=class="num">0,
                                 class="kw">const ENUM_SPREAD_BASE _spread_base=SPREAD_BASE_BID)
  {
  if(_spread_size==class="num">0)
    if(_spread_markup==class="num">0)
      {
       ::PrintFormat(__FUNCTION__+":  neither the spread size nor the spread markup are set!",
                     m_name,::GetLastError());
       class="kw">return class="kw">false;
      }
   class="type">int symbol_digs=(class="type">int)this.GetProperty(SYMBOL_DIGITS);
   ::ZeroMemory(m_tick);
class=class="str">"cmt">//--- copy ticks
   class="type">int tick_idx=class="num">0;
   class="type">uint tick_cnt=class="num">0;
   class="type">class="kw">ulong from=class="num">1;

「逐日回放自定义品种 tick 并重建点差」

做自定义品种回测时,MT5 不会替你把真实 tick 按天切片,得自己用 CopyTicksRange 按日边界拉数据。下面这段逻辑的核心,是把回放区间拆成以日为单位的小段,每天单独取 COPY_TICKS_INFO 类型的 tick 数组,再逐笔算买卖价与点差。 代码里先把起始时刻的时分许秒清零,用 StructToTime 得到当日 0 点 datetime;结束时刻直接取回放截止的毫秒换算值。循环里 t1 为当日 0 点毫秒、t2 为次日 0 点前 1 毫秒,这样一天最多拉满 86400 秒内的 tick。 点差处理分固定和浮动两种:固定时用 curr_point*_spread_size 直接算点差点数;浮动时取 ask-bid 再加可选加点 curr_point*_spread_markup。外汇与贵金属自带高杠杆与跳空风险,回放出的点差仅反映历史样本,实盘可能瞬间扩大数倍。 这段可直接塞进自定义品种类的回放方法里验证:把 m_name 换成你生成的离线品种名,跑完打印某天的 ticks_copied,若连续多日为 0 说明那段没写入 tick,倾向是数据源断档而非代码错。

MQL5 / C++
  class="type">class="kw">double curr_point=this.GetProperty(SYMBOL_POINT);
  class="type">int ticks_copied=class="num">0;
  class="type">MqlDateTime t1_time;
  TimeToStruct((class="type">int)(m_from_msc/class="num">1e3),t1_time);
  t1_time.hour=t1_time.min=t1_time.sec=class="num">0;
  class="type">class="kw">datetime start_datetime,stop_datetime;
  start_datetime=::StructToTime(t1_time);
  stop_datetime=(class="type">int)(m_to_msc/class="num">1e3);
  do
    {
      class="type">MqlTick custom_symbol_ticks[];
      class="type">class="kw">ulong t1,t2;
      t1=(class="type">class="kw">ulong)class="num">1e3*start_datetime;
      t2=(class="type">class="kw">ulong)class="num">1e3*(start_datetime+PeriodSeconds(PERIOD_D1))-class="num">1;
      ::ResetLastError();
      ticks_copied=::CopyTicksRange(m_name,custom_symbol_ticks,COPY_TICKS_INFO,t1,t2);
      if(ticks_copied<class="num">0)
        {
         ::PrintFormat(__FUNCTION__+": failed to copy ticks for a %s symbol! Error code: %d",
                       m_name,::GetLastError());
         class="kw">return class="kw">false;
        }
      class=class="str">"cmt">//--- there are some ticks for the current day
      else
        if(ticks_copied>class="num">0)
          {
           for(class="type">int t_idx=class="num">0; t_idx<ticks_copied; t_idx++)
             {
              class="type">MqlTick curr_tick=custom_symbol_ticks[t_idx];
              class="type">class="kw">double curr_bid_pr=::NormalizeDouble(curr_tick.bid,symbol_digs);
              class="type">class="kw">double curr_ask_pr=::NormalizeDouble(curr_tick.ask,symbol_digs);
              class="type">class="kw">double curr_spread_pnt=class="num">0.;
              class=class="str">"cmt">//--- if the spread is fixed
              if(_spread_size>class="num">0)
                {
                 if(_spread_size>class="num">0)
                   curr_spread_pnt=curr_point*_spread_size;
                }
              class=class="str">"cmt">//--- if the spread is floating
              else
                {
                 class="type">class="kw">double spread_markup_pnt=class="num">0.;
                 if(_spread_markup>class="num">0)
                   spread_markup_pnt=curr_point*_spread_markup;
                 curr_spread_pnt=curr_ask_pr-curr_bid_pr+spread_markup_pnt;
                }
              class="kw">switch(_spread_base)

用基准价重建买卖盘口与 tick 标记

自定义品种回放里,盘口价格不一定齐整。下面这段按三种基准模式把 bid / ask 补全,再打上变动标记,最后一次性替换进行情池。 SPREAD_BASE_BID 表示以点差加在 bid 上得到 ask;SPREAD_BASE_ASK 则反过来从 ask 减点差推 bid。两种都只用一次 NormalizeDouble,按 symbol_digs 截断小数位,避免浮点尾巴污染后续比较。 SPREAD_BASE_AVERAGE 最绕:先取 bid 与 ask 均值,再向两侧各摊 half-spread。这里连续三次 NormalizeDouble,均值那行除以 2. 而非 2,强制走浮点除法,否则整数除法会直接丢精度。 价格算完写回 curr_tick 后,用 flags 记录哪一侧动了:bid 变置 TICK_FLAG_BID,ask 变置 TICK_FLAG_ASK;若两边都没变,则强制双标,防止静默 tick 被行情订阅方误判为断流。 替换不是一次就完。代码用 ATTEMTS 次重试调 TicksReplace,只有返回值等于 ticks_copied 才 break——这意味着自定义品种在高频回放时可能存在写冲突,重试机制能兜住部分丢失。开 MT5 把 ATTEMTS 调到 1 对比一下,能直观看到 tick 丢失数量变化。 外汇与贵金属自定义符号同样适用此逻辑,但点差跳变频繁,重写盘口时须留意滑点放大风险。

MQL5 / C++
         {
                  case SPREAD_BASE_BID:
                     {
                      curr_ask_pr=::NormalizeDouble(curr_bid_pr+curr_spread_pnt,symbol_digs);
                      class="kw">break;
                     }
                  case SPREAD_BASE_ASK:
                     {
                      curr_bid_pr=::NormalizeDouble(curr_ask_pr-curr_spread_pnt,symbol_digs);
                      class="kw">break;
                     }
                  case SPREAD_BASE_AVERAGE:
                     {
                      class="type">class="kw">double curr_avg_pr=::NormalizeDouble((curr_bid_pr+curr_ask_pr)/class="num">2.,symbol_digs);
                      curr_bid_pr=::NormalizeDouble(curr_avg_pr-curr_spread_pnt/class="num">2.,symbol_digs);
                      curr_ask_pr=::NormalizeDouble(curr_bid_pr+curr_spread_pnt,symbol_digs);
                      class="kw">break;
                     }
                  }
            class=class="str">"cmt">//--- new ticks
            curr_tick.bid=curr_bid_pr;
            curr_tick.ask=curr_ask_pr;
            class=class="str">"cmt">//--- flags
            curr_tick.flags=class="num">0;
            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;
            custom_symbol_ticks[t_idx]=curr_tick;
            m_tick=curr_tick;
            }
         class=class="str">"cmt">//--- replace ticks
         class="type">int ticks_replaced=class="num">0;
         for(class="type">int att=class="num">0; att<ATTEMTS; att++)
            {
             ticks_replaced=this.TicksReplace(custom_symbol_ticks);
             if(ticks_replaced==ticks_copied)
                class="kw">break;

◍ 逐日刷新 tick 后的替换计数与价差基准

上面这段收尾逻辑跑在日循环里:每天把 start_datetime 往后推一个 PERIOD_D1 的秒数,只要没超过 stop_datetime 且脚本没被终止,就继续往下刷。刷完一轮会用 PrintFormat 把累计替换的 tick 数打出来,变量 tick_cnt 在每次替换成功后累加 ticks_replaced。 实际在 EURUSD 的 M1 周期上跑过一次,终端日志显示 2019.08.30 12:49:59.678 输出了「Replaced ticks number: 354400」——也就是约 35.4 万笔 tick 被成功重写。这个数字直接反映你覆盖的历史样本量,想验证重放精度,先盯这个数够不够大。 紧接着定义了一个 ENUM_SPREAD_BASE 枚举,给后面算价差留了三种基准:0 用 bid、1 用 ask、2 用两者平均。回测里改 spread 时,选哪个基准会直接影响成交价推算,外汇和贵金属杠杆高、点值敏感,基准选错可能让回测结果偏离实盘概率较大。

MQL5 / C++
   ::Sleep(PAUSE);
      }
      if(ticks_replaced!=ticks_copied)
        {
         ::Print(__FUNCTION__+": failed to replace the refreshed ticks!");
         class="kw">return class="kw">false;
        }
      tick_cnt+=ticks_replaced;
      }
   class=class="str">"cmt">//--- next datetimes
   start_datetime=start_datetime+::PeriodSeconds(PERIOD_D1);
   }
 class="kw">while(start_datetime<=stop_datetime && !::IsStopped());
 ::PrintFormat("\nReplaced ticks number: %I32u",tick_cnt);
class=class="str">"cmt">//---
  class="kw">return true;
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| Spread calculation base                                          |
class=class="str">"cmt">//+------------------------------------------------------------------+
enum ENUM_SPREAD_BASE
  {
  SPREAD_BASE_BID=class="num">0,     class=class="str">"cmt">// bid price
  SPREAD_BASE_ASK=class="num">1,     class=class="str">"cmt">// ask price
  SPREAD_BASE_AVERAGE=class="num">2, class=class="str">"cmt">// average price
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">2019.08.class="num">30 class="num">12:class="num">49:class="num">59.678 TestChangeSpread(EURUSD,M1)    Replaced ticks number: class="num">354400

「自定义品种压测的落地余味」

前面几节把自定义品种的参数配置、市场深度模拟和 tick 数据回放都拆开了,这一节只补一句实在的:压测不是跑通就完事,你得用自己策略真正依赖的那组参数去建品种。 示例包里 EURUSD1_tick.zip 有 3087.31 KB 的逐笔报价,Custom.zip 仅 10.37 KB 却带了脚本源码和品种类,直接拖进 MT5 的 Files 目录就能复现文里的测试环境。 外汇和贵金属自带高杠杆与跳空风险,自定义品种再逼真也只是近似市况,实盘前建议先用策略测试器跑至少三种点差档位。 真要验证市场深度那一项有没有用,开 MT5 建个自定义品种,把 Level 2 数据喂进去,比读十遍文档来得快。

把重复建品种交给小布
这些诊断和小布盯盘里的 AIGC 已内置,打开对应品种页即可看到自定义品种的创建状态与报价加载进度,你只需专注调参和决策。

常见问题

返回 0 表示该自定义品种在此前已经被创建过,本次调用没有重复新建,可直接沿用已有品种环境继续后续操作。
通常可用历史真实 tick 导出、随机生成序列或外部文件注入;重点是分批加载到报价库,避免一次性占用过大内存。
先移除再删除能降低终端引用冲突的概率,若窗口中仍选中该品种,底层删除调用可能直接失败。
可以,小布盯盘已内置品种创建与报价进度查看,省去手动跑脚本核对日志的重复劳动,你专注看策略曲线即可。
不能,自定义环境只是模拟极端行情概率,实盘还受 broker 滑点和执行影响,结果仅作风险倾向参考。