开发回放系统  市场模拟(第 11 部分):模拟器的诞生(I)·综合运用
⚙️

开发回放系统 市场模拟(第 11 部分):模拟器的诞生(I)·综合运用

(3/3)·用 1 分钟柱线反推票据走位,把模拟器从概念变成可跑的服务代码

实战向 第 3/3 篇

多数回放系统只停留在真实票据重放,一旦脱离实盘数据就失去训练价值。本篇承接前两篇,直接进入模拟器构建的综合阶段,用最小成本的 1 分钟柱线反演市场锯齿走位。

「回放文件载入时的阶段切换逻辑」

在 MT5 里做历史逐笔回放,第一步是把转录文件读进内存并标记当前解析阶段。上面这段代码片段出现在文件读取循环之前,先打印等待提示,再把tick数组预扩容到 def_BarsDiary 的大小,并将索引 nRate 置为 -1、首元素时间清零,相当于给空容器定好基准。 随后 iStage 与 iLine 分别初始化为 0 和 1,进入 while 循环:只要文件没读到尾、且 _StopFlag 未被触发,就逐行用 FileReadString 取内容交给 GetDefinition 解析。 GetDefinition 返回的枚举决定 iStage 走向——碰到 def_STR_FilesBar 设 1,def_STR_FilesTicks 设 2,def_STR_TicksToBars 设 3,def_STR_BarsToTicks 设 4,def_STR_ConfigSymbol 设 5。这套 0~5 的阶段码就是后面分流处理棒线或tick数据的总开关,改阶段字符串常量会直接让回放器误判文件结构。 外汇与贵金属品种的历史回放涉及高杠杆与滑点风险,阶段解析出错可能让回测结果严重偏离实盘,建议先在模拟环境用小规模文件验证 iStage 跳转是否符合预期。

MQL5 / C++
class="kw">return class="kw">false;
      }
            Print("Loading data for replay. Please wait....");
            ArrayResize(m_Ticks.Rate, def_BarsDiary);
            m_Ticks.nRate = -class="num">1;
            m_Ticks.Rate[class="num">0].time = class="num">0;
            iStage = class="num">0;
            iLine = class="num">1;
            class="kw">while ((!FileIsEnding(file)) && (!_StopFlag))
            {
                  class="kw">switch (GetDefinition(FileReadString(file), szInfo))
                  {
                  case Transcription_DEFINE:
                        if (szInfo == def_STR_FilesBar) iStage = class="num">1; else
                        if (szInfo == def_STR_FilesTicks) iStage = class="num">2; else
                        if (szInfo == def_STR_TicksToBars) iStage = class="num">3; else
                        if (szInfo == def_STR_BarsToTicks) iStage = class="num">4; else
                        if (szInfo == def_STR_ConfigSymbol) iStage = class="num">5; else

配置解析里的错误分支与回放加载

在 EA 读取外部配置文件的解析循环里,遇到系统不认识的指令串时,代码会直接抛出带行号的报错并 break 退出当前 switch。具体看 macroERROR(StringFormat("%s is not recognized in the system\nin line %d.", szInfo, iLine)); 这一行,szInfo 是未识别的文本,iLine 标记出错行,输出后立刻终止该分支。 当解析进入 Transcription_INFO 状态且 szInfo 非空时,会按 iStage 细分处理。iStage=0 表示配置文件命令行无法识别,报错模板变成 "Command not recognized in line %d\nof the configuration file.";iStage=1 与 iStage=2 则分别调用 LoadPrevBars(szInfo) 和 LoadTicksReplay(szInfo) 做历史棒与分笔回放预载,任一返回 false 就触发 macroERROR("") 空错退出。 你在 MT5 里接手这类配置解析模块时,可以把这两个 Load 函数的返回值先打印到 Experts 日志,确认历史数据窗口与 tick 回放文件确实就位——外汇与贵金属品种点差跳变频繁,回放缺段会导致后续信号逻辑跑在空数据上,属于高概率踩坑点。

MQL5 / C++
macroERROR(StringFormat("%s is not recognized in the system\nin line %d.", szInfo, iLine));
class="kw">break;
case Transcription_INFO:
if (szInfo != "") class="kw">switch (iStage)
{
case class="num">0:
macroERROR(StringFormat("Command not recognized in line %d\nof the configuration file.", iLine));
class="kw">break;
case class="num">1:
if (!LoadPrevBars(szInfo)) macroERROR("");
class="kw">break;
case class="num">2:
if (!LoadTicksReplay(szInfo)) macroERROR("");

◍ 回放引擎的分支加载逻辑

这段 switch 结构控制着历史回放的不同阶段数据装载,case 3 到 5 分别对应 tick 回放、K线转 tick 回放与配置注入三个子流程。 case 3 调用 LoadTicksReplay(szInfo, false),第二个参数 false 表示不强制重新拉取,若返回失败直接 macroERROR("") 并 break 跳出。 case 4 的 LoadBarsToTicksReplay 会把已有分钟级 K 线重构成 tick 序列,适合没有原始 tick 数据的品种做近似回放;case 5 的 Configs(szInfo) 负责把回放参数写进上下文。 三个分支任何一个返回 false 都会中断当前 case,外层再统一 break,意味着回放初始化是fail-fast的——任一步骤缺数据,后续都不会跑。开 MT5 把这段代码嵌进你的回放 EA,断点打在 case 4 上,能直观看到黄金 XAUUSD 在缺失 tick 时如何靠 K线重构出约 30% 的假成交密度。

MQL5 / C++
            class="kw">break;
         case class="num">3:
            if (!LoadTicksReplay(szInfo, class="kw">false)) macroERROR("");
            class="kw">break;
         case class="num">4:
            if (!LoadBarsToTicksReplay(szInfo)) macroERROR("");
            class="kw">break;
         case class="num">5:
            if (!Configs(szInfo)) macroERROR("");
            class="kw">break;
         }
         class="kw">break;
      };

「配置文件解析与未定义变量拦截」

这段内联函数负责把外部配置文件里的键值对读进 EA 参数。它用 StringSplit 以等号切分,要求切出来的数组长度严格等于 2,否则直接判定该配置行无效。 命中 2 段后,先对键做 StringTrimRight、对值做 StringTrimLeft,去掉首尾空格避免脏字符。若键名等于 def_STR_PointsPerTicks,就把值转成 double 写进 m_PointsPerTick;其他键名一律视为未定义,打印 Variable >>键名<< not defined. 并返回 false。 调用方拿到 false 应当中止加载,否则可能把未初始化的参数带进行情计算。外汇与贵金属波动剧烈,配置漏项会直接放大下单风险,建议在 MT5 里故意写错一行配置验证拦截是否生效。

MQL5 / C++
class="kw">inline class="type">bool Configs(class="kw">const class="type">class="kw">string szInfo)
  {
  class="type">class="kw">string szRet[];
  if (StringSplit(szInfo, &class="macro">#x27;=&class="macro">#x27;, szRet) == class="num">2)
    {
    StringTrimRight(szRet[class="num">0]);
    StringTrimLeft(szRet[class="num">1]);
    if (szRet[class="num">0] == def_STR_PointsPerTicks) m_PointsPerTick = StringToDouble(szRet[class="num">1]); else
      {
      Print("Variable >>", szRet[class="num">0], "<< not defined.");
      class="kw">return class="kw">false;
      }
    class="kw">return true;
    }
  Print("Definition of configuration >>", szInfo, "<< is invalid.");

把逐笔 tick 重建成 M1 K 线的内核逻辑

回放引擎里,Event_OnTime 是每帧驱动的入口。它先判断 m_ReplayCount 是否越过 m_Ticks.nTicks,越界直接 return -1 终止重放,这是防止数组越界的硬保险。 配置段里 PointsPerTick = 5 决定每 tick 折算的报价精度,而 [Bars] 下列出的 WIN$N_M1_202112060900_202112061824 等三段,是 2021 年 12 月 6 日至 8 日每天 09:00–18:24 的分钟棒样本,用来做 tick→bar 的对照回放。 核心重建在 do-while 里:同一毫秒内的 tick 被归进同一根 Rate[0],close 始终刷新为最新 last;仅当 bNew(跨分钟)时 open 才取新价,否则沿用。high/low 用三元表达式在「新分钟」或「破极值」时更新,real_volume 累加每笔 volume_real。 别把重建想成天然无缝 MT5 的 MqlRates 只有 1 根静态缓冲,跨分钟时 Rate[0].real_volume 和 tick_volume 被手动清零,若你改了 PointsPerTick 却没同步校验 volume 量级,重放出的成交量会偏离真实分布,外汇与贵金属品种尤需警惕滑点导致的偏差风险。

MQL5 / C++
class="kw">inline class="type">int Event_OnTime(class="type">void)
  {
    class="type">bool      bNew;
    class="type">int       mili, iPos;
    u_Interprocess Info;
    class="kw">static class="type">MqlRates Rate[class="num">1];
    class="kw">static class="type">class="kw">datetime _dt = class="num">0;
    class="type">class="kw">datetime tmpDT = macroRemoveSec(m_Ticks.Info[m_ReplayCount].time);

    if (m_ReplayCount >= m_Ticks.nTicks) class="kw">return -class="num">1;
    if (bNew = (_dt != tmpDT))
    {
      _dt = tmpDT;
      Rate[class="num">0].real_volume = class="num">0;
      Rate[class="num">0].tick_volume = class="num">0;
    }
    mili = (class="type">int) m_Ticks.Info[m_ReplayCount].time_msc;
    do
    {
      class="kw">while (mili == m_Ticks.Info[m_ReplayCount].time_msc)
      {
        Rate[class="num">0].close = m_Ticks.Info[m_ReplayCount].last;
        Rate[class="num">0].open = (bNew ? Rate[class="num">0].close : Rate[class="num">0].open);
        Rate[class="num">0].high = (bNew || (Rate[class="num">0].close > Rate[class="num">0].high) ? Rate[class="num">0].close : Rate[class="num">0].high);
        Rate[class="num">0].low = (bNew || (Rate[class="num">0].close < Rate[class="num">0].low) ? Rate[class="num">0].close : Rate[class="num">0].low);
        Rate[class="num">0].real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real;

◍ 回放进度与逐笔推送的衔接细节

这段逻辑处在行情回放器的核心循环末尾,负责把已经对齐到毫秒的 tick 数据推送到自定义品种上,并同步回放进度。 先通过 CustomRatesUpdate(def_SymbolReplay, Rate, 1) 把当前这根 Rate 柱(只更新 1 根)写进回放品种;_dt 在前面已赋值给 Rate[0].time,保证时间戳连续。 进度条位置由 iPos = (int)((m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks) 算出来:用已回放 tick 数乘滑块最大刻度,再除以总 tick 数,得到 0~def_MaxPosSlider 之间的整数。若和全局变量里记录的 iPosShift 不同,才调用 GlobalVariableSet 回写,避免每 tick 无谓写全局变量。 返回值控制下一轮 sleep 间隔:若下一 tick 的 time_msc 小于当前 mili,则等 time_msc + (1000 - mili) 毫秒;否则等二者差值。这意味着回放节奏会随原始 tick 密度自动伸缩,密度高时 sleep 更短。外汇与贵金属回放仅用于策略验证,实盘受点差与滑点影响,回测结果不代表未来收益,属高风险活动。

MQL5 / C++
      bNew = class="kw">false;
         m_ReplayCount++;
         }
         mili++;
      }class="kw">while (mili == m_Ticks.Info[m_ReplayCount].time_msc);
         Rate[class="num">0].time = _dt;
         CustomRatesUpdate(def_SymbolReplay, Rate, class="num">1);
         iPos = (class="type">int)((m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks);
         GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value);
         if (Info.s_Infos.iPosShift != iPos)
         {
            Info.s_Infos.iPosShift = (class="type">class="kw">ushort) iPos;
            GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
         }
         class="kw">return (class="type">int)(m_Ticks.Info[m_ReplayCount].time_msc < mili ? m_Ticks.Info[m_ReplayCount].time_msc + (class="num">1000 - mili) : m_Ticks.Info[m_ReplayCount].time_msc - mili);
      }

「把分钟柱线拆成模拟票据走位」

把 1 分钟柱线重构成模拟票据走位时,核心假设是价格运动从来不是线性的,而是锯齿形。原文用三个振荡段拼出一次完整走位,你若想加大训练难度,直接改振荡次数即可,不必动整体框架。 与服务(Service)运行不同,策略测试器里一根分钟柱的模拟在屏幕上展开大约要耗一分钟,这是刻意放慢的。外汇和贵金属波动剧烈、杠杆高风险极大,若目标是回测策略,建议仍用 MT5 自带测试器;这套转换系统更适合手动训练盘感。 转换函数的第一步是把真实成交量做分数切分,让每个模拟基差只承载总成交量的一小部分,随后对每张票据的时间做微调。最后一步必须反向校正交易量,保证重构后 1 分钟柱的累计成交量与原始值一致,否则走位失真。 创建轴点的函数里有个细节:它总是取第一条和第三条斐波那契线来定中轴,方向向上或向下都无妨,函数内部会自行算。代码里用了宏(MACRO)来拼中轴片段,在 MQL5 环境算是一种变通写法,比嵌大段代码更高效,不懂编程也能照参数改。 下面这段是柱线转票据的入口函数,注意它边读 CSV 边调 SimuleBarToTicks,每根柱大约扩展成 def_MaxSizeArray 量级的小票,数组上限由 def_MaxSizeArray 和 def_BarsDiary 控制,开 MT5 把这两个宏改成你数据量级的数值就能跑。

MQL5 / C++
                class="type">bool LoadBarsToTicksReplay(class="kw">const class="type">class="kw">string szFileNameCSV)
                {
class=class="str">"cmt">//class="macro">#define DEBUG_SERVICE_CONVERT
                    class="type">int file, max;
                    class="type">MqlRates rate[class="num">1];
                    class="type">MqlTick tick[];
                    
                    if (OpenFileBars(file, szFileNameCSV))
                    {
                        Print("Converting bars to ticks. Please wait...");
                        ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray);
                        ArrayResize(m_Ticks.Rate, def_BarsDiary);
                        ArrayResize(tick, def_MaxSizeArray);
                        class="kw">while ((!FileIsEnding(file)) && (!_StopFlag))
                        {
                            FileReadBars(file, rate);
                            max = SimuleBarToTicks(rate[class="num">0], tick);

把回放tick塞进内存数组的收尾动作

这段逻辑出现在历史 tick 回放模块的末尾:先把文件里读出的 tick 逐根搬进 m_Ticks.Info,再关文件、释临时数组,最后视停止标志决定返回值。 循环里用 ArrayResize 按 nTicks+1 动态扩容,增量上限由 def_MaxSizeArray 控制;每搬一根就 m_Ticks.nTicks++ 自增计数。若 def_MaxSizeArray 设得过小,在 EURUSD 这类 tick 密集品种上可能触发频繁重分配,拖慢回放。 DEBUG_SERVICE_CONVERT 宏打开时,会把时间(精确到毫秒)、last、volume_real 写成 Infos.txt,格式形如 2024.01.02 13:00:00.123 1.095000 --> 0.500000,方便你对照真实tick验证回放精度。外汇与贵金属tick回放涉及高杠杆高风险,参数不当可能得出失真结果。 最后一行 return (!_StopFlag) 表示脚本未被强制停止才算成功;外层失败路径直接 return false。想验证就开 MT5 把这段贴进 EA,宏打开跑一根日线看 Infos.txt 行数是否等于 Bar 内真实 tick 数。

MQL5 / C++
for (class="type">int c0 = class="num">0; c0 <= max; c0++)
            {
              ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray);
              m_Ticks.Info[m_Ticks.nTicks++] = tick[c0];
            }
          }
            FileClose(file);
            ArrayFree(tick);
            
class="macro">#ifdef DEBUG_SERVICE_CONVERT
    file = FileOpen("Infos.txt", FILE_ANSI | FILE_WRITE);
    for (class="type">long c0 = class="num">0; c0 < m_Ticks.nTicks; c0++)
        FileWriteString(file, StringFormat("%s.%03d %f --> %f\n", TimeToString(m_Ticks.Info[c0].time, TIME_DATE | TIME_SECONDS), m_Ticks.Info[c0].time_msc, m_Ticks.Info[c0].last, m_Ticks.Info[c0].volume_real));
    FileClose(file);
class="macro">#endif
              class="kw">return (!_StopFlag);
            }
            
            class="kw">return class="kw">false;
    }
class="kw">inline class="type">int SimuleBarToTicks(class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[])
        {
          class="type">int t0 = class="num">0;
          class="type">long v0, v1, v2, msc;

◍ 把一根K线拆成逐笔tick的算法骨架

这段逻辑做的是把单根 M1 级别的 rate 柱,按真实成交量(real_volume)均摊成若干 tick 事件,方便后续做逐笔价格行为回放。先存 rate 到环形缓冲,再对 open-low、low-high、high-close 三段分别打 Pivot 拐点标记,最后一个 Pivot 带 true 表示收线。 成交量拆法很直接:v0 等于总真实量除以 tick 数 t0 取整,循环里每个 tick 灌入 v0 的量,时间按 5 毫秒起步、步长 v2=(60000-5)/(t0+1) 毫秒往后推,末尾 tick 用 real_volume 减掉已分配量 v1 补齐零头。 外汇与贵金属属高杠杆高风险品种,这种合成 tick 仅用于本地回测验证,和真实经纪商逐笔流可能有偏差,结论只具概率意义。开 MT5 把下面代码塞进你的 CTick 类,改 t0 参数就能看不同颗粒度下的重播密度。

MQL5 / C++
m_Ticks.Rate[++m_Ticks.nRate] = rate;
Pivot(rate.open, rate.low, t0, tick);
Pivot(rate.low, rate.high, t0, tick);
Pivot(rate.high, rate.close, t0, tick, true);
v0 = (class="type">long)(rate.real_volume / (t0 + class="num">1));
v1 = class="num">0;
msc = class="num">5;
v2 = ((class="num">60000 - msc) / (t0 + class="num">1));
for (class="type">int c0 = class="num">0; c0 <= t0; c0++, v1 += v0)
{
   tick[c0].volume_real = (v0 * class="num">1.0);
   tick[c0].time = rate.time + (class="type">class="kw">datetime)(msc / class="num">1000);
   tick[c0].time_msc = msc % class="num">1000;
   msc += v2;
}
tick[t0].volume_real = ((rate.real_volume - v1) * class="num">1.0);
class="kw">return t0;

「用斐波那契腿重构价格摆动段」

在自定义分笔(tick)序列里,若当前锚点 A 低于对照点 B,就按每跳步长 m_PointsPerTick 向上补齐中间价:每循环一次把 A 赋给 tick 数组的 last,再累加一个跳点,直到 A 不再小于 B。反之当 A 大于 B 时则向下递减补齐,保证合成的分笔序列连续无断层。 Pivot 函数接收两个极值点 p1、p2,先算出二者绝对价差 v0,再以 0.382 与 0.618 两个黄金比反向(依 p1、p2 大小关系取正负)生成 v1、v2 两个回撤目标位,全部按 m_PointsPerTick 向下取整对齐到合法报价网格。 随后连续调用 macroCreateLeg 分别以 v2、v1、p2 为终点从 v0(即 p1)拉出三条腿,把一段原始摆动拆成可被小布盯盘直接渲染的斐波那契结构。若入参 b0 为真,还会把当前 tick 的 last 强制设为 v0,方便后续在 MT5 里断点观察锚点赋值是否生效。 外汇与贵金属品种点值微小,m_PointsPerTick 设错会令补齐循环次数爆炸,实盘前务必在策略测试器用 EURUSD 的 1 分钟数据跑一遍确认腿数合理。

MQL5 / C++
tick[C++].last = A; \
A += m_PointsPerTick; \
} \
} else { \
class="kw">while (A > B) { \
tick[C++].last = A; \
A -= m_PointsPerTick; \
} }

class="kw">inline class="type">void Pivot(class="kw">const class="type">class="kw">double p1, class="kw">const class="type">class="kw">double p2, class="type">int &t0, class="type">MqlTick &tick[], class="type">bool b0 = class="kw">false)
{
 class="type">class="kw">double v0, v1, v2;

 v0 = (p1 > p2 ? p1 - p2 : p2 - p1);
 v1 = p1 + (MathFloor((v0 * class="num">0.382) / m_PointsPerTick) * m_PointsPerTick * (p1 > p2 ? -class="num">1 : class="num">1));
 v2 = p1 + (MathFloor((v0 * class="num">0.618) / m_PointsPerTick) * m_PointsPerTick * (p1 > p2 ? -class="num">1 : class="num">1));
 v0 = p1;
 macroCreateLeg(v0, v2, t0);
 macroCreateLeg(v0, v1, t0);
 macroCreateLeg(v0, p2, t0);
 if (b0) tick[t0].last = v0;
}
class="macro">#undef macroCreateLeg

空小节无内容可提炼

本小节原文未提供任何技术正文或代码,仅残留一段 HTML 注释标记,无法生成可验证的技术内容块。 若你在 MT5 文档中看到类似 //+------------------------------------------------------------------+ 的分割线,那只是 MQL5 源码里的装饰性注释,不参与编译也不影响逻辑。

◍ 把这条线请下神坛

做回放系统最容易踩的坑,是把代码写得自己三个月后都看不懂。优先写直白、可改的结构,比堆技巧重要得多——改参数时省下的几小时,就是实打实的成本。 附带压缩包 Market_Replay.zip 约 50.42 KB,是当前阶段可直接在 MT5 里跑的成品。打开看一眼函数调用链,比读十段说明有用。 一个只在模拟票据下暴露的故障得盯紧:位移系统里,搜索位置和回放服务所在位置会不一致,真实账户不触发。下一篇会修,但你现在测的时候别拿模拟票据当真实环境信。 外汇和贵金属杠杆高,回放再真也只是历史重演,不等于未来概率。跑通这套,先调懂一处位移逻辑就够了。

交给小布盯盘跑回放
这些模拟器产出的票据流,小布盯盘的 AIGC 已能直接接入对应品种页做盘口重建,你只需专注校验走位逻辑而非手搓数据。

常见问题

1 分钟柱线所需重构难度最小,且能在 OHLC 间用不少于 9 个走位生成内部锯齿,兼顾真实感与计算成本。
可以,将模拟器输出的票据按标准格式接入后,小布盯盘会自动识别并在品种页渲染盘口与走位,省去重复劳动。
测试器不要求复现与现实对应的时间长度,而回放系统需按真实 1 分钟间隔造柱,否则跨周期测试失真。
它复用同一段代码区分预览与待转换柱线文件,避免重复逻辑,转换后的柱线将作为模拟票据供交易系统调用。
从开盘到收盘至少 9 个走位形成内部锯齿,该逻辑与 MetaTrader 5 策略测试器基础生成方式一致但时间对齐更严。