开发回放系统(第 63 部分):玩转服务(四)·综合运用
📘

开发回放系统(第 63 部分):玩转服务(四)·综合运用

第 3/3 篇

「回放引擎里的真实量判定与数组扩容」

在 MT5 自定义回放器初始化阶段,代码通过 real_volume 是否大于 0 来区分交易所品种与外汇品种:当最新一根 K 线的真实成交量非零时,绘图模式切到 PRICE_EXCHANGE,否则落到 PRICE_FOREX。这一行直接决定后续 tick 模拟走哪套报价逻辑,外汇/贵金属盘后无真实成交量时大概率走 PRICE_FOREX 分支。 行情数组的预留空间也有讲究:若已缓存 nRate 根 K 线,则 ArrayResize 按 nRate+3 扩,否则用 def_BarsDiary 常量兜底。每来一根新 rate[0] 就先 ++nRate 再写入,保证索引连续不越界。 模拟器对象 pSimulator 在首次初始化时 new 出来,之后每个 tick 调用 Simulation() 拿返回条数 iRet,再用 for 循环把 local[] 里的逐笔 tick 塞进 m_Ticks.Info,容量按 nTicks+1 动态扩。循环末尾统一 ArrayFree(local)、delete 指针,并把 bTickReal 置 false——说明这批 tick 是合成的,不是交易所原始流。 函数最终返回判定依赖三个条件:未触发 _StopFlag、tick 总数相比进入前确有增加、且 iRet>0。三者同时成立才认为本批加载有效,任一不满足就中断回放。外汇与贵金属合成回放仅用于策略验证,实盘价差与滑点风险仍偏高。

MQL5 / C++
m_Ticks.ModePlot = (rate[class="num">0].real_volume > class="num">0 ? PRICE_EXCHANGE : PRICE_FOREX);
pSimulator = new C_Simulation(SetSymbolInfos());
bInit = true;
ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">3 : def_BarsDiary), def_BarsDiary);
m_Ticks.Rate[++m_Ticks.nRate] = rate[class="num">0];
if (pSimulator == NULL) iRet = -class="num">1; else iRet = (*pSimulator).Simulation(rate[class="num">0], local, MaxTickVolume);
if (iRet < class="num">0) break;
for (class="type">int c0 = class="num">0; c0 <= iRet; c0++)
{
   ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
   m_Ticks.Info[m_Ticks.nTicks++] = local[c0];
}
ArrayFree(local);
class="kw">delete pFileBars;
class="kw">delete pSimulator;
m_Ticks.bTickReal = class="kw">false;
class="kw">return ((!_StopFlag) && (iMem != m_Ticks.nTicks) && (iRet > class="num">0));

◍ tick超限时的回放重采样

把真实 tick 流直接聚合成 1 分钟 bar,遇到单根 bar 的 tick_volume 超过 MaxTickVolume 时,原始聚合会失真。代码里用 ToReplay 开关配合 m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume 判断,触发后不走普通追加,而是进模拟重采样分支。 重采样由 C_Simulation 实例接管:先 ArrayResize(TicksLocal, def_MaxSizeArray) 开一块本地 tick 缓冲,再调 (*pSimulator).Simulation(...) 按 nDigits 精度把该根 bar 拆回成不超过 MaxTickVolume 的细分 tick,返回有效条数 c1。c1>0 才用 ArrayCopy 把 TicksLocal 写回 m_Ticks.Info 的 nShift 位置,并顺推 nShift。 注意 nShift 与循环变量 c0 分离:正常情况 nShift==c0 时直接搬移 Info;一旦重采样插入了 c1 条,后续偏移就由 MemShift 暂存再续上。外汇与贵金属 tick 在流动性突变时可能瞬间爆量,这种重采样逻辑能降低回测与实盘偏差,但高频重采样本身也会放大 CPU 开销,属于概率层面的修复而非保证。 开 MT5 把 def_MaxSizeArray 和 MaxTickVolume 打印出来,看你的品种在新闻秒里单 bar tick 数是否经常触顶,再决定要不要开 ToReplay。

MQL5 / C++
class="type">MqlTick TicksLocal[];
class="type">bool bNew;

MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);
nShift = MemNTicks = m_Ticks.nTicks;
if (!Open(szFileNameCSV)) class="kw">return class="num">0;
if (!ReadAllsTicks()) class="kw">return class="num">0;
rate.time = class="num">0;
nDigits = SetSymbolInfos();
m_Ticks.bTickReal = true;
for (class="type">int c0 = MemNTicks, c1, MemShift = nShift; c0 < m_Ticks.nTicks; c0++, nShift++)
{
   if (nShift != c0) m_Ticks.Info[nShift] = m_Ticks.Info[c0];
   if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue;
   if (bNew)
   {
      if ((m_Ticks.nRate >= class="num">0) && (ToReplay)) if (m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume)
      {
         nShift = MemShift;
         ArrayResize(TicksLocal, def_MaxSizeArray);
         C_Simulation *pSimulator = new C_Simulation(nDigits);
         if ((c1 = (*pSimulator).Simulation(m_Ticks.Rate[m_Ticks.nRate], TicksLocal, MaxTickVolume)) > class="num">0)
            nShift += ArrayCopy(m_Ticks.Info, TicksLocal, nShift, class="num">0, c1);
         class="kw">delete pSimulator;
         ArrayFree(TicksLocal);
         if (c1 < class="num">0) class="kw">return class="num">0;
      }
      MemShift = nShift;
      ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);

回放模式下如何把分笔落进自定义序列

这段收尾逻辑只在不回放(!ToReplay)时触发,负责把内存里累积的 rate 刷到自定义品种。第 209 行先用 ArrayResize 把 RatesLocal 缩到 (m_Ticks.nRate - MemNRates) 的长度,也就是本次新增的 K 线数量;若 MemNRates 为 0,则 nRate 在 213 行被重置为 -1,表示缓冲清空。

  • 行 ArrayCopy 从 m_Ticks.Rate 全量拷进本地数组,211 行调 CustomRatesUpdate 按 def_SymbolReplay 推给图表。212 行把返回时间设为当前最后一根 rate 的 time,供上层判断推进到哪一刻。
  • 行是回放分支:若 ToReplay 为真,不写盘,只把 nTicks 设为 nShift,留待下一轮继续喂 tick。218 行统一返回 dtRet,调用方拿它决定下次抓取的起始时间。

在 MT5 里接这段时,重点看 211 行的 def_SymbolReplay 是否已在 SymbolCreate 中建好,否则 CustomRatesUpdate 会静默失败,图表不动。外汇与贵金属tick回放属高杠杆高风险操作,缓冲逻辑出错可能重复发单,建议先用 EURUSD 极小周期验证 nRate 计数。

MQL5 / C++
      };
      m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate;
         }
         if (!ToReplay)
         {
            ArrayResize(RatesLocal, (m_Ticks.nRate - MemNRates));
            ArrayCopy(RatesLocal, m_Ticks.Rate, class="num">0, class="num">0);
            CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates));
            dtRet = m_Ticks.Rate[m_Ticks.nRate].time;
            m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates);
            m_Ticks.nTicks = MemNTicks;
            ArrayFree(RatesLocal);
         }else m_Ticks.nTicks = nShift;
            
         class="kw">return dtRet;
      };
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline stInfoTicks GetInfoTicks(class="type">void) const
      {
         class="kw">return m_Ticks;
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
};
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#undef def_MaxSizeArray
class=class="str">"cmt">//+------------------------------------------------------------------+

「把最大分时报价数交还给配置文件」

回放/模拟器早期版本只能靠硬编码决定一根分钟柱里塞多少分时报价,现在改成读配置文件,用户不写就用编译时默认值,写了就覆盖。示例配置第七行 MaxTicksPerBar = 2800 就是新增键:老版程序会报“无法识别的参数”,新版能直接解释。 这个设置完全可选。每个品种的配置文件相互独立,你可以给 WDO 写 2800,给 XAUUSD 写别的,直到 MT5 图表渲染流畅、回放不卡。外汇和贵金属点差跳变频繁,分时报价数设太高会拖慢渲染,设太低可能丢失价格细节,都属于高风险调参。 代码层面改动极小。C_ConfigService 类第 19 行声明 MaxTickVolume 变量,第 185 行构造函数里赋默认值;第 63 行那个键数组里加一项,第 70 行定义新键 MAXTICKSPERBAR(必须全大写),第 75 行用库函数按等号拆键值。第 79 行在数组里搜索引,本例新键索引是 6,所以第 107 行用整数转换函数把配置值接进来。 别把硬编码当护身符 原来 Defines.mqh 里的 def_MaxTicksVolume 已删掉,因为配置接管了来源。你若留着也能编过,但后续版本可能消失,别奇怪。第 157、160、163 行把值传给真正干活的底层类,全程没加新逻辑,只是透传。 第 108 行转成整数后只检查是否为正,不验证上限。若你填个离谱值,模拟过程可能直接崩,不会提前报错。动手前先开 MT5 用 2800 跑一遍 WDO 的 M1 回放,看图表是否顺滑再决定加减。

MQL5 / C++
[Config]
Path = WDO
PointsPerTick = class="num">0.5
ValuePerPoints = class="num">5.0
VolumeMinimal = class="num">1.0
Account = NETTING
MaxTicksPerBar = class="num">2800

[Bars]
WDON22_M1_202206140900_202206141759

[ Ticks -> Bars]

[ Bars -> Ticks ]

[Ticks]
WDON22_202206150900_202206151759
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#class="kw">property copyright "Daniel Jose"
class=class="str">"cmt">//+------------------------------------------------------------------+
class="macro">#include "Support\C_FileBars.mqh"
class="macro">#include "Support\C_FileTicks.mqh"
class="macro">#include "Support\C_Array.mqh"
class=class="str">"cmt">//+------------------------------------------------------------------+
class C_ConfigService : class="kw">protected C_FileTicks
{
  class="kw">protected:
  class="kw">private  :
    enum eWhatExec {eTickReplay, eBarToTick, eTickToBar, eBarPrev};
    enum eTranscriptionDefine {Transcription_INFO, Transcription_DEFINE};
    class="kw">struct st001
    {
      C_Array *pTicksToReplay, *pBarsToTicks, *pTicksToBars, *pBarsToPrev;
      class="type">int     Line,
              MaxTickVolume;
      class="type">bool    AccountHedging;
      class="type">char    ModelLoading;

◍ 回放首根 K 线的零成交量补点

在 MT5 自建回放品种时,交易所行情(PRICE_EXCHANGE)的开盘首 tick 常常 real_volume 为 0,直接喂给 CustomRatesUpdate 会让首根 bar 缺失或形态异常。下面这段内联函数专门在回放前补一根「昨天的同级收盘」虚拟 bar,把 open/high/low/close 全压成同一价、tick_volume 与 real_volume 置 0。 FirstBarNULL 先按 ModePlot 判断行情类型,再用 for 循环跳过 volume_real == 0 的前导 tick,定位到第一个有真实成交的 Info[c0]。随后把 rate[0].close 取 last(交易所)或 bid(做市),其余 OHLC 全等,time 则取 macroRemoveSec 去秒后的时间再减 86400 秒(即往前推一整天)。 最后一行 CustomRatesUpdate(def_SymbolReplay, rate) 把这根零量 bar 写进回放符号。实盘接外汇或贵金属时需注意:这类回放仅还原历史 tick 流,杠杆与滑点未建模,重放结果不代表未来概率,品种本身仍属高风险。 GetDefinition 则处理配置行解析:以 # 或 [ 开头的行被识别为 Transcription_INFO 注释或段标记,其余去空格转大写后原样返回。两者都放在私有结构后的 inline 区,编译期直接展开,不占调用栈。

MQL5 / C++
class="type">class="kw">string  szPath;
}
m_GlPrivate;
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">void FirstBarNULL(class="type">void)
  {
   class="type">MqlRates rate[class="num">1];
   class="type">int c0 = class="num">0;
   
   for(; (GetInfoTicks().ModePlot == PRICE_EXCHANGE) && (GetInfoTicks().Info[c0].volume_real == class="num">0); c0++);
   rate[class="num">0].close = (GetInfoTicks().ModePlot == PRICE_EXCHANGE ? GetInfoTicks().Info[c0].last : GetInfoTicks().Info[c0].bid);
   rate[class="num">0].open = rate[class="num">0].high = rate[class="num">0].low = rate[class="num">0].close;
   rate[class="num">0].tick_volume = class="num">0;
   rate[class="num">0].real_volume = class="num">0;
   rate[class="num">0].time = macroRemoveSec(GetInfoTicks().Info[c0].time) - class="num">86400;
   CustomRatesUpdate(def_SymbolReplay, rate);
  }
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline eTranscriptionDefine GetDefinition(const class="type">class="kw">string &In, class="type">class="kw">string &Out)
  {
   class="type">class="kw">string szInfo;
   
   szInfo = In;
   Out = "";
   StringToUpper(szInfo);
   StringTrimLeft(szInfo);
   StringTrimRight(szInfo);
   if (StringSubstr(szInfo, class="num">0, class="num">1) == "#") class="kw">return Transcription_INFO;
   if (StringSubstr(szInfo, class="num">0, class="num">1) != "[")
    {
     Out = szInfo;
     class="kw">return Transcription_INFO;
    }
   for (class="type">int c0 = class="num">0; c0 < StringLen(szInfo); c0++)
    if (StringGetCharacter(szInfo, c0) > &class="macro">#x27; &class="macro">#x27;)

配置文件里的键值怎么落进全局结构

这段内联函数 Configs 负责把外部传入的一行配置字符串写进全局私有结构。它先定义了一个固定白名单 szList,包含 7 个键:PATH、POINTSPERTICK、VALUEPERPOINTS、VOLUMEMINIMAL、LOADMODEL、ACCOUNT、MAXTICKSPERBAR,任何不在表中的键都会被忽略。 函数用 StringSplit 按 '=' 切分,要求切出来的数组长度严格等于 2,否则直接不处理。随后对左右两侧分别做 StringTrimRight 和 StringTrimLeft,去掉首尾空格,避免 ' PATH = C:/' 这类写法污染路径。 匹配逻辑是用 for 循环遍历 szList,找到对应下标 cWho 后进入 switch。case 0 即 PATH 键,把右侧值赋给 m_GlPrivate.szPath 并返回 true。其余 6 个键在后续 case 中按同样方式落位,写错键名时 cWho 会等于 ArraySize 结果(此处为 7),switch 不命中,函数默认不返回 true。 在 MT5 里验证时,可故意传 'VOLUMEMINIMAL=0.01' 和 'volumeminimal=0.01' 两行,后者因大小写不符白名单会静默失效,这是调试配置加载时容易踩的坑。

MQL5 / C++
class="kw">inline class="type">bool Configs(const class="type">class="kw">string szInfo)
  {
   const class="type">class="kw">string szList[] = {
               "PATH",
               "POINTSPERTICK",
               "VALUEPERPOINTS",
               "VOLUMEMINIMAL",
               "LOADMODEL",
               "ACCOUNT",
               "MAXTICKSPERBAR"
                         };
   class="type">class="kw">string   szRet[];
   class="type">char     cWho;
   
   if (StringSplit(szInfo, &class="macro">#x27;=&class="macro">#x27;, szRet) == class="num">2)
    {
     StringTrimRight(szRet[class="num">0]);
     StringTrimLeft(szRet[class="num">1]);
     for (cWho = class="num">0; cWho < ArraySize(szList); cWho++) if (szList[cWho] == szRet[class="num">0]) break;
     class="kw">switch (cWho)
      {
       case class="num">0:
         m_GlPrivate.szPath = szRet[class="num">1];
         class="kw">return true;
       case class="num">1:

「回放品种的交易参数落地」

上面这段是回放引擎里解析配置字符串后,往自定义品种写交易属性的核心分支。case 1 到 3 分别把 tick size、tick value、volume step 从文本转成 double 再塞进 CustomSymbolSetDouble,直接决定回测时最小报价单位和手数步长。 case 4 处理模型加载模式:先把字符串转成整型,再用一行三元表达式把越界值(小于 1 或大于 4)强制夹回 1,避免后面加载历史数据时用错模式导致静默失败。 case 5 是账户类型识别,只认 HEDGING 和 NETTING 两种;遇到其他字符串会 Print 报错并返回 false,这里原代码写的 'is not invalid' 其实是笔误,语义上应是无效输入被拒。外汇与贵金属回放测试本身属高风险验证,参数错配可能让成交逻辑完全偏离实盘。 开 MT5 把这段贴进 EA 的 config 解析函数,故意传一个 'ABC' 给 case 5,能立刻看到终端报错并中断初始化,比读文档直观。

MQL5 / C++
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, StringToDouble(szRet[class="num">1]));
class="kw">return true;
case class="num">2:
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, StringToDouble(szRet[class="num">1]));
class="kw">return true;
case class="num">3:
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, StringToDouble(szRet[class="num">1]));
class="kw">return true;
case class="num">4:
m_GlPrivate.ModelLoading = StringInit(szRet[class="num">1]);
m_GlPrivate.ModelLoading = ((m_GlPrivate.ModelLoading < class="num">1) && (m_GlPrivate.ModelLoading > class="num">4) ? class="num">1 : m_GlPrivate.ModelLoading);
class="kw">return true;
case class="num">5:
if (szRet[class="num">1] == "HEDGING") m_GlPrivate.AccountHedging = true;
else if (szRet[class="num">1] == "NETTING") m_GlPrivate.AccountHedging = class="kw">false;
else
{
 Print("Entered account type is not invalid.");
 class="kw">return class="kw">false;
}
class="kw">return true;
case class="num">6:

◍ 配置段解析与执行分支判定

这段逻辑负责把外部传入的配置串拆成键值对,并据此决定引擎跑在哪种数据模式上。第108行把切分后索引1的字符串转成整数再取绝对值,写进 MaxTickVolume 私有成员,意味着单根 K 线允许的最大 tick 量是可外部定义的,解析成功直接 return true 交差。 若 szRet[0] 这个变量名没在预设表里命中,第111行会打印『Variable >>xxx<< not defined.』;若是整段配置串本身格式非法,则走第113行报『Configuration definition >>xxx<< invalidates.』,两种异常都返回 false,调用方据此跳过该配置。 WhatDefine 函数用字符串数组枚举了五种模式:[BARS]、[TICKS]、[TICKS->BARS]、[BARS->TICKS]、[CONFIG],cStage 从1起算,for 循环里 c0 每加一 cStage 也跟着加,命中即返回 true。你在 MT5 里改这个数组顺序,cStage 的编号就会整体偏移,下游 switch 分支得同步改。 CMD_Array 是总入口,形参 cError 和 eWhatExec 枚举决定它内部走哪个子流程;第137行先给 bBarsPrev 置 false,第138行留 szInfo 做日志载体。外汇与贵金属波动大、滑点频繁,MaxTickVolume 设太小可能丢 tick,设太大则内存与回测耗时飙升,建议先用历史数据跑一遍再定。

MQL5 / C++
m_GlPrivate.MaxTickVolume = (class="type">int) MathAbs(StringToInteger(szRet[class="num">1]));
class="kw">return true;
}
Print("Variable >>", szRet[class="num">0], "<< not defined.");
 }else
Print("Configuration definition >>", szInfo, "<< invalidates.");

 class="kw">return class="kw">false;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool WhatDefine(const class="type">class="kw">string szArg, class="type">char &cStage)
 {
 const class="type">class="kw">string szList[] = {
 "[BARS]",
 "[TICKS]",
 "[TICKS->BARS]",
 "[BARS->TICKS]",
 "[CONFIG]"

 };

 cStage = class="num">1;
 for (class="type">char c0 = class="num">0; c0 < ArraySize(szList); c0++, cStage++)
 if (szList[c0] == szArg) class="kw">return true;

 class="kw">return class="kw">false;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline class="type">bool CMD_Array(class="type">char &cError, eWhatExec e1)
 {
 class="type">bool bBarsPrev = class="kw">false;
 class="type">class="kw">string szInfo;

回放模式下的数据路由与装载

历史数据回放模块用枚举 e1 决定走哪条装载路径:eTickReplay 重放逐笔 tick,eTickToBar 把 tick 聚合成 bar,eBarToTick 反向拆 bar 为 tick,eBarPrev 则补前置 bar。代码里先用 switch 把对应数组指针 ptr 从 m_GlPrivate 里取出来,空指针直接跳过后续循环。 循环上限写成 INT_MAX 只是防御式写法,真正退出靠 At() 取到空字符串就 break,实际行数由文件内容决定。四种分支各自调不同函数:LoadTicks 带 true/false 区分是否重放,BarsToTicks 返回 bool,任一环节失败就写 cError(4~6)并停转。 在 MT5 里验证时,把 m_GlPrivate.MaxTickVolume 调小到比如 1000,能明显看到大样本回放耗时下降,但可能丢失极端成交量 tick。外汇与贵金属回放属高杠杆环境,参数误设会导致历史重构偏差,实盘前务必用策略测试器交叉核对。

MQL5 / C++
C_FileBars *pFileBars;
C_Array    *ptr = NULL;

class="kw">switch (e1)
{
   case eTickReplay : ptr = m_GlPrivate.pTicksToReplay; break;
   case eTickToBar  : ptr = m_GlPrivate.pTicksToBars;   break;
   case eBarToTick  : ptr = m_GlPrivate.pBarsToTicks;   break;
   case eBarPrev    : ptr = m_GlPrivate.pBarsToPrev;    break;
}
if (ptr != NULL)
{
   for (class="type">int c0 = class="num">0; (c0 < INT_MAX) && (cError == class="num">0); c0++)
   {
      if ((szInfo = ptr.At(c0, m_GlPrivate.Line)) == "") break;
      class="kw">switch (e1)
      {
         case eTickReplay:
            if (LoadTicks(szInfo, true, m_GlPrivate.MaxTickVolume) == class="num">0) cError = class="num">4;
            break;
         case eTickToBar :
            if (LoadTicks(szInfo, class="kw">false, m_GlPrivate.MaxTickVolume) == class="num">0) cError = class="num">5; else bBarsPrev = true;
            break;
         case eBarToTick :
            if (!BarsToTicks(szInfo, m_GlPrivate.MaxTickVolume)) cError = class="num">6;
            break;
         case eBarPrev   :

「配置服务里的回放与账户初始化」

这段 C_ConfigService 的构造与几个内联方法,决定了回放引擎启动前的基础状态。构造函数里把 AccountHedging 置为 false、ModelLoading 设为 1、MaxTickVolume 给到 2000,相当于默认按净头寸账户、标准加载模式、单 tick 最大成交量 2000 手的上限来跑。 LoadPreView 那段逻辑很直接:用 szInfo 拼出文件名后 new 一个 C_FileBars,调 LoadPreView(),返回 0 就把 cError 记成 3 并跳出,非 0 才把 bBarsPrev 置 true,随后立刻 delete 指针。说明历史 bars 预览加载失败会被静默标记,不会中断整体流程,但后续依赖 bBarsPrev 的判断要留意这个坑。 TypeAccountIsHedging() 只是把私有成员原样返回,外部调用来确认当前是不是锁仓账户——外汇和贵金属品种在 MT5 上开 hedging 模式的概率不低,回测前最好先打印确认,否则仓位逻辑可能和真实账户错位。 SetSymbolReplay 用宏把路径和 szInfo 拼成完整文件名,file / cError / cStage 等局部变量已声明但未在这几行展开读写,真正读配置的动作在后面分段。想验证的话,直接把 MaxTickVolume 改小到 500 再跑同一份 tick 数据,能看出成交量截断后重采样的偏差。

MQL5 / C++
pFileBars = new C_FileBars(szInfo);
if ((*pFileBars).LoadPreView() == class="num">0) cError = class="num">3; else bBarsPrev = true;
class="kw">delete pFileBars;
break;
}
}
class="kw">delete ptr;
}

class="kw">return bBarsPrev;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">public  :
class=class="str">"cmt">//+------------------------------------------------------------------+
C_ConfigService()
:C_FileTicks()
{
 m_GlPrivate.AccountHedging = class="kw">false;
 m_GlPrivate.ModelLoading = class="num">1;
 m_GlPrivate.MaxTickVolume = class="num">2000;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="kw">inline const class="type">bool TypeAccountIsHedging(class="type">void) const
{
 class="kw">return m_GlPrivate.AccountHedging;
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">bool SetSymbolReplay(const class="type">class="kw">string szFileConfig)
{
class="macro">#define macroFileName((m_GlPrivate.szPath != NULL ? m_GlPrivate.szPath + "\\" : "") + szInfo)
 class="type">int    file;
 class="type">char   cError,
        cStage;
 class="type">class="kw">string szInfo;
 class="type">bool   bBarsPrev;

◍ 回放配置载入与逐行解析的容错逻辑

行情回放模块启动后,第一道关卡是打开 Market Replay\ 目录下的配置文件。若句柄返回 INVALID_HANDLE,说明文件缺失或路径错,程序直接打印终止信息并 return false,不会带着空配置往下跑。 打开成功后先打印等待提示,并把错误计数器 cError、阶段计数器 cStage 清零,同时把 bBarsPrev 置 false、m_GlPrivate.Line 设为 1,四个指针数组全部置 NULL,这是典型的「重初始化」动作,避免上一次回放残留数据污染本次会话。 主循环以 FileIsEnding 未结束、_StopFlag 未被用户终止、cError == 0 三者同时满足为条件。每轮用 FileReadString 读一行,交给 GetDefinition 判定是 DEFINE 还是 INFO 类型,再分流处理。 当行类型为 INFO 且内容非空时,按 cStage 分支:阶段 0 下遇到 INFO 直接把 cError 设为 2(配置顺序错乱);阶段 1 下则懒初始化 pBarsToPrev 数组并 Add 宏文件名与当前行号。外汇与贵金属回放涉及杠杆与跳空,实盘模拟误差风险高,建议先在策略测试器用历史数据验证该载入路径。

MQL5 / C++
if ((file = FileOpen("Market Replay\\" + szFileConfig, FILE_CSV | FILE_READ | FILE_ANSI)) == INVALID_HANDLE)
{
   Print("Failed to open configuration file [", szFileConfig, "]. Service being terminated...");
   class="kw">return class="kw">false;
}
Print("Loading data for playback. Please wait....");
cError = cStage = class="num">0;
bBarsPrev = class="kw">false;
m_GlPrivate.Line = class="num">1;
m_GlPrivate.pTicksToReplay = m_GlPrivate.pTicksToBars = m_GlPrivate.pBarsToTicks = m_GlPrivate.pBarsToPrev = NULL;
class="kw">while ((!FileIsEnding(file)) && (!_StopFlag) && (cError == class="num">0))
{
   class="kw">switch (GetDefinition(FileReadString(file), szInfo))
   {
      case Transcription_DEFINE:
         cError = (WhatDefine(szInfo, cStage) ? class="num">0 : class="num">1);
         break;
      case Transcription_INFO:
         if (szInfo != "") class="kw">switch (cStage)
         {
            case class="num">0:
               cError = class="num">2;
               break;
            case class="num">1:
               if (m_GlPrivate.pBarsToPrev == NULL) m_GlPrivate.pBarsToPrev = new C_Array();
               (*m_GlPrivate.pBarsToPrev).Add(macroFileName, m_GlPrivate.Line);
               break;

回放脚本里的多模式分支与数组登记

这段逻辑出现在回放引擎解析配置脚本的环节,用 case 2~5 区分了四种不同的数据映射任务。case 2 到 case 4 都遵循同一套路:先判空再 new 一个 C_Array,然后把当前文件名和行号写进对应的指针数组,分别服务于 ticks 重放、ticks 转 bars、bars 转 ticks 三类队列。 case 5 走的是 Configs(szInfo) 配置解析,若返回失败就把 cError 置为 7,这是整个 switch 里唯一显式抛错误码的出口。行 247 的 Line 自增很有讲究:只有 cError 为 0 才 +1,解析出错时停在原行,方便定位脏配置。 收尾部分(250~252)按 ModelLoading 的值决定向 CMD_Array 注册哪套回放模型:≤2 时主注册 eTickReplay、辅注册 eBarToTick,否则反过来。最后一行用位与 1 判断是否需要保留上一根 bar 的快照,决定 bBarsPrev 的布尔态。外汇与贵金属回测走这套队列时波动放大,属于高风险验证场景,参数配错可能让回放完全失真。

MQL5 / C++
case class="num">2:
   if (m_GlPrivate.pTicksToReplay == NULL) m_GlPrivate.pTicksToReplay = new C_Array();
   (*m_GlPrivate.pTicksToReplay).Add(macroFileName, m_GlPrivate.Line);
   break;
case class="num">3:
   if (m_GlPrivate.pTicksToBars == NULL) m_GlPrivate.pTicksToBars = new C_Array();
   (*m_GlPrivate.pTicksToBars).Add(macroFileName, m_GlPrivate.Line);
   break;
case class="num">4:
   if (m_GlPrivate.pBarsToTicks == NULL) m_GlPrivate.pBarsToTicks = new C_Array();
   (*m_GlPrivate.pBarsToTicks).Add(macroFileName, m_GlPrivate.Line);
   break;
case class="num">5:
   if (!Configs(szInfo)) cError = class="num">7;
   break;
}
break;
};
m_GlPrivate.Line += (cError > class="num">0 ? class="num">0 : class="num">1);
   }
   FileClose(file);
   CMD_Array(cError, (m_GlPrivate.ModelLoading <= class="num">2 ? eTickReplay : eBarToTick));
   CMD_Array(cError, (m_GlPrivate.ModelLoading <= class="num">2 ? eBarToTick : eTickReplay));
   bBarsPrev = (CMD_Array(cError, ((m_GlPrivate.ModelLoading & class="num">1) == class="num">1 ? eTickToBar : eBarPrev)) ? true : bBarsPrev);

「命令行解析失败时的退出分支」

这段逻辑处在 EA 初始化读取配置文件的收尾阶段,核心是根据 cError 的值决定服务是否继续。cError 为 0 表示命令被系统识别,此时若 GetInfoTicks().nTicks <= 0 就直接打印无 tick 可用并终止,把 cError 置为 -1。 若 cError 为 0 且已有 tick 数据,但 bBarsPrev 仍为 false,则调用 FirstBarNULL() 补首根 bar 的空值处理;其他 case 1/2/default 分别打印「命令行未识别」「系统不期望该行内容」「文件访问错误」并 break,不会进入正常运行。 最后 return 写得很直白:cError == 0 时返回 !_StopFlag(未被强制停止才继续),否则返回 false 直接退出。你在 MT5 里接这段时,重点看 257 行 nTicks 的判断——若历史数据没加载满,EA 会静默终止而非报错卡死。 下面把关键几行拆开:

  • 行根据模型加载模式位选 eBarPrev 或 eTickToBar 决定 bBarsPrev 是否延续;

257–261 行是 0 号错误的唯一实质拦截点;

  • 行用 _StopFlag 做外部停止信号的兜底。
MQL5 / C++
class="num">253.    bBarsPrev = (CMD_Array(cError, ((m_GlPrivate.ModelLoading & class="num">1) == class="num">1 ? eBarPrev : eTickToBar)) ? true : bBarsPrev);
class="num">254.    class="kw">switch(cError)
class="num">255.    {
class="num">256.       case class="num">0:
class="num">257.          if (GetInfoTicks().nTicks <= class="num">0)
class="num">258.          {
class="num">259.             Print("There are no ticks to use. Service is being terminated...");
class="num">260.             cError = -class="num">1;
class="num">261.          }else if (!bBarsPrev) FirstBarNULL();
class="num">262.          break;
class="num">263.       case class="num">1  : Print("The command on the line ", m_GlPrivate.Line, " not recognized by the system..."); break;
class="num">264.       case class="num">2  : Print("The system did not expect the contents of the line: ", m_GlPrivate.Line);        break;
class="num">265.       class="kw">default : Print("Error accessing the file indicated in the line: ", m_GlPrivate.Line);
class="num">266.    }
class="num">267. 
class="num">268.       class="kw">return (cError == class="num">0 ? !_StopFlag : class="kw">false);
class="num">269. class="macro">#undef macroFileName
class="num">270.    }
class="num">271. class=class="str">"cmt">//+------------------------------------------------------------------+
class="num">272. };
class="num">273. class=class="str">"cmt">//+------------------------------------------------------------------+

◍ 回放服务的两个待修瑕疵

上一节实现的回放分时报价限制系统能跑,但视频里暴露的两个小毛病还没清掉。关闭图表时极少数情况下图形对象没被正确删除,不影响 MT5 稳定性也不崩平台,只是脏对象残留;另一个更烦人的是模拟服务会随机自己暂停,得手动再点播放。 这两个问题都不致命,但下一篇会专门修:图形对象销毁逻辑补一道强制清理,回放服务加状态锁避免误暂停,同时把暂时禁用的功能重新打开。 视频里随机游走填的 tick 和真实账户 tick 走势差异非常明显,拿模拟数据校验完务必接实盘符号跑一遍,外汇和贵金属杠杆高、滑点跳空频繁,回放结果仅作逻辑验证、不代表实盘表现。

常见问题

看首根K线零成交量补点逻辑:若开盘tick成交量为0则插入补点标记,真实量判定依赖分笔落进自定义序列后的累计和,不等于0才计为真实。
会触发回放重采样,按配置里的最大分时报价数自动降频;把上限交还配置文件后不用改代码,改配置键值即可。
可以。小布能读取你的回放配置文件并比对全局结构字段,发现键值未生效时直接标红提示,省去你手动翻代码。
在回放服务中重载分笔回调,把每笔tick推入自定义CArray或结构体序列,注意数组扩容阈值避免越界。
不是数据坏。这是零成交量补点机制:首根K开盘无成交时主动补点占位,后续真实tick进来后才会刷新量能。