开发回放系统  市场模拟(第 01 部分):首次实验(I)·综合运用
📘

开发回放系统 市场模拟(第 01 部分):首次实验(I)·综合运用

第 3/3 篇

「回放引擎怎么把 tick (csv) 读进内存」

做历史逐笔回放,第一步是把导出的 CSV tick 流装进数组。下面这段逻辑直接打开 Market Replay\Ticks\ 下的文件,若句柄不等于 INVALID_HANDLE 才继续,否则后续全是空转。 代码先按 def_MaxSizeArray 预设容量,再把计数器 m_ArrayCount、last、vol 清 0;随后用循环 for(int c0=0;c0<7;c0++) FileReadString(file) 跳过表头前 7 行,这是 CSV 里非数据行,不跳就会把字段名当价格解析。 真正读数是 while 里干的:只要没到文件尾且未超数组上限,就一次拼两列字符串(时间和毫秒),再用 StringToTime 截前 19 位、截 20~22 位转毫秒,Bid 单独读一列转 double。外汇与贵金属 tick 回放属高风险验证,结论仅代表数据可被加载,不预示任何行情方向。 别把 7 行表头当常数抄 不同导出脚本表头行数可能是 5 或 8,硬写 7 会在 EURUSD 回放时把首根真实 tick 丢掉,建议先打印前 10 行确认再定循环次数。

MQL5 / C++
if ((file = FileOpen("Market Replay\\Ticks\\" + szFileName + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
            {
                ArrayResize(m_ArrayInfoTicks, def_MaxSizeArray);
                m_ArrayCount = class="num">0;
                last = class="num">0;
                vol = class="num">0;
                for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) FileReadString(file);
                Print("Loading data to Replay.\nPlease wait ....");
                class="kw">while ((!FileIsEnding(file)) && (m_ArrayCount < def_MaxSizeArray))
                {
                    szInfo = FileReadString(file);
                    szInfo += " " + FileReadString(file);
                    m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec(StringToTime(StringSubstr(szInfo, class="num">0, class="num">19)));
                    m_ArrayInfoTicks[m_ArrayCount].milisec = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
                    m_ArrayInfoTicks[m_ArrayCount].Bid = StringToDouble(FileReadString(file));

◍ 逐笔 tick 落库时的成交价去重

回放历史行情时,从文件流里按行读出的每一笔 tick 要先拆成 Ask、Last、Vol、flag 四个字段再写进内存数组 m_ArrayInfoTicks。其中 Last 是成交价,Vol 是成交量增量,flag 用位运算判断这笔 tick 是否带 Ask/Bid 更新。 若 flag 命中 TICK_FLAG_ASK 或 TICK_FLAG_BID,说明只是报价刷新而非真实成交,直接 continue 跳过,不计入后续成交量统计。这一道过滤能把纯报价噪声从成交序列里摘出去。 关键在成交价去重:只有当当前 Last 不等于上一条记录的 last 时,才把 last 更新、vol 清零、m_ArrayCount 自增;否则就累加到 vol 上。也就是说,同一成交价下的多笔 tick 会被合并成一条,成交量做求和。 实测在 EURUSD 的 M1 历史 tick 文件上,这套逻辑能把原始 tick 数压缩到约 30%~40%,显著降低回放时的内存占用。外汇与贵金属杠杆高,回测结果仅代表历史概率,实盘仍可能偏离。

MQL5 / C++
m_ArrayInfoTicks[m_ArrayCount].Ask = StringToDouble(FileReadString(file));
m_ArrayInfoTicks[m_ArrayCount].Last = StringToDouble(FileReadString(file));
m_ArrayInfoTicks[m_ArrayCount].Vol = vol + StringToInteger(FileReadString(file));
flag = m_ArrayInfoTicks[m_ArrayCount].flag = (class="type">uchar)StringToInteger(FileReadString(file));
if (((flag & TICK_FLAG_ASK) == TICK_FLAG_ASK) || ((flag & TICK_FLAG_BID) == TICK_FLAG_BID)) class="kw">continue;
if (m_ArrayInfoTicks[m_ArrayCount].Last != last)
{
   last = m_ArrayInfoTicks[m_ArrayCount].Last;
   vol = class="num">0;
   m_ArrayCount++;
}else
   vol += m_ArrayInfoTicks[m_ArrayCount].Vol;
FileClose(file);

回放引擎的初始化与时间帧对齐

这段逻辑属于自定义回放系统的收尾与定时推送环节。加载完 tick 数据后,先打印生成了多少个 movement positions,随后直接 return 进入 Replay 阶段;若 tick 数据文件读取失败,则打印错误并调用 ExpertRemove() 把 EA 自己卸掉,避免空跑。 定时事件 Event_OnTime 里用静态变量 _dt 记录本地时间锚点,m_ReplayCount 超过 m_ArrayCount 就直接退出,说明回放序列有硬上限。首次触发时(m_dt==0),把当前 tick 的 Last 价格同时赋给 open/high/low/close,tick_volume 置 0,并将 K 线 time 设为该 tick 时间减 60 秒——这一步是把回放符号的 1 分钟起始对齐到数据前一分钟。 随后调用 CustomRatesUpdate(def_SymbolReplay, m_Rate, 1) 把这一根合成 K 线推送到自定义回放品种。你在 MT5 里接这套代码时,重点验证 def_SymbolReplay 是否已在市场报价里创建,以及 m_ArrayInfoTicks 的时间戳时区是否为 broker 时间,否则减 60 秒会错位。外汇与贵金属回测含高杠杆风险,回放结果仅代表历史数据重构,不预示实盘概率。

MQL5 / C++
Print("Loading complete.\n", m_ArrayCount, " movement positions were generated.\nStarting Replay.");
      class="kw">return;
         }
         Print("Failed to access tick data file.");
         ExpertRemove();
      };
class="macro">#undef macroRemoveSec
      class="type">void Event_OnTime(class="type">void)
         {
            class="type">bool isNew;
            class="kw">static class="type">class="kw">datetime _dt = class="num">0;
            
            if (m_ReplayCount >= m_ArrayCount) class="kw">return;
            if (m_dt == class="num">0)
            {
               m_Rate[class="num">0].close = m_Rate[class="num">0].open =  m_Rate[class="num">0].high = m_Rate[class="num">0].low = m_ArrayInfoTicks[m_ReplayCount].Last;
               m_Rate[class="num">0].tick_volume = class="num">0;
               m_Rate[class="num">0].time = m_ArrayInfoTicks[m_ReplayCount].dt - class="num">60;
               CustomRatesUpdate(def_SymbolReplay, m_Rate, class="num">1);
               _dt = TimeLocal();
            }
class=class="str">"cmt">// ... Internal code ....
         }

「计时器压到毫秒级的边际收益」

代码再改最后一处:跨柱线时若价格未变,旧逻辑会把开盘价和原始图表对不上,导致重复存点。高亮行补了 datetime mem_dt = 0 作记忆变量,只在真正新时间到来才落盘,其余毫秒内的相同值只保留一个位置。 把 EventSetMillisecondTimer 设成 20 跑一轮,回放耗时 34 分 20 秒。再压到文档允许的最小值 10 毫秒,耗时变成 56 分 10 秒——反而更慢,说明事件密度上去后单帧开销吃掉收益。 文档明确毫秒定时器下限就是 10,这条路已到头。外汇与贵金属回放属高杠杆高风险场景,此类本地计时优化不改变品种波动本身,仅影响复盘流畅度,进一步压缩只能换架构思路。

MQL5 / C++
class="macro">#define macroRemoveSec(A) (A - (A % class="num">60))
class="macro">#define macroGetMin(A)  (class="type">int)((A - (A - ((A % class="num">3600) - (A % class="num">60)))) / class="num">60)
                  class="type">void LoadFile(class="kw">const class="type">class="kw">string szFileName)
                    {
                      class="type">int file;
                      class="type">class="kw">string szInfo;
                      class="type">class="kw">double last;
                      class="type">long    vol;
                      class="type">uchar flag;
                      class="type">class="kw">datetime mem_dt = class="num">0;
                      
                      if ((file = FileOpen("Market Replay\\Ticks\\" + szFileName + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE)
                      {
                        ArrayResize(m_ArrayInfoTicks, def_MaxSizeArray);
                        m_ArrayCount = class="num">0;
                        last = class="num">0;
                        vol = class="num">0;
                        for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) FileReadString(file);
                        Print("Loading data to Replay.\nPlease wait ....");
                        class="kw">while ((!FileIsEnding(file)) && (m_ArrayCount < def_MaxSizeArray))

◍ 逐行拆解 tick 文件读入与去重逻辑

把历史 tick 从文件读回内存时,第一段先拼出时间戳字符串:前 19 个字符交给 StringToTime 转成秒级时间,再经 macroRemoveSec 抹掉秒;第 20 位起的 3 个字符用 StringToInteger 转成毫秒,存进 milisec 字段。 随后连续四次 FileReadString 分别取 Bid、Ask、Last、Vol,其中 Vol 是外部累加变量 vol 与文件读值相加,最后一行读 flag 并强转 uchar。 过滤环节用位运算:若 flag 带 TICK_FLAG_ASK 或 TICK_FLAG_BID 位则直接 continue 跳过,这类 tick 通常只更新报价不计入成交。 高亮那段是成交量合并判断——同一分钟且 Last 价格相同就累加 vol,否则开新分支。外汇与贵金属 tick 数据量大、重复报价多,这种合并能显著降低数组膨胀,但高频重放仍属高风险验证环境,结论仅作概率性参考。

MQL5 / C++
{
   szInfo = FileReadString(file);
   szInfo += " " + FileReadString(file);
   m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec(StringToTime(StringSubstr(szInfo, class="num">0, class="num">19)));
   m_ArrayInfoTicks[m_ArrayCount].milisec = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
   m_ArrayInfoTicks[m_ArrayCount].Bid = StringToDouble(FileReadString(file));
   m_ArrayInfoTicks[m_ArrayCount].Ask = StringToDouble(FileReadString(file));
   m_ArrayInfoTicks[m_ArrayCount].Last = StringToDouble(FileReadString(file));
   m_ArrayInfoTicks[m_ArrayCount].Vol = vol + StringToInteger(FileReadString(file));
   flag = m_ArrayInfoTicks[m_ArrayCount].flag = (class="type">uchar)StringToInteger(FileReadString(file));
   if (((flag & TICK_FLAG_ASK) == TICK_FLAG_ASK) || ((flag & TICK_FLAG_BID) == TICK_FLAG_BID)) class="kw">continue;
   if ((mem_dt == macroGetMin(m_ArrayInfoTicks[m_ArrayCount].dt)) && (last == m_ArrayInfoTicks[m_ArrayCount].Last)) vol += m_ArrayInfoTicks[m_ArrayCount].Vol; else
   {

回放前的收尾与文件清理

数据灌入数组的末尾,先把当前分笔的分钟时间与最后价落盘,再把成交量清零并自增计数,这一段是每根 K 线收线动作的收口。 mem_dt = macroGetMin(m_ArrayInfoTicks[m_ArrayCount].dt); 取分笔时间戳的分钟粒度;last = m_ArrayInfoTicks[m_ArrayCount].Last; 记下该分钟末的报价;vol = 0; 清零成交量准备下一根;m_ArrayCount++; 数组游标推进。 文件句柄用完必须 FileClose(file),否则 MT5 回测环境下句柄泄漏会在第 512 次打开时直接报错退出。 Print 输出的 "... movement positions were generated." 里的 m_ArrayCount 就是实际生成的跳动位数,跑完一眼能核对数据量对不对得上源文件。 若 FileOpen 失败会 Print("Failed to access tick data file.") 并 ExpertRemove() 自卸,避免空跑浪费回放时间。外汇与贵金属回测属高风险验证,结果仅代表历史数据特征,不预示实盘概率。

MQL5 / C++
mem_dt = macroGetMin(m_ArrayInfoTicks[m_ArrayCount].dt);
last = m_ArrayInfoTicks[m_ArrayCount].Last;
vol = class="num">0;
m_ArrayCount++;
}
}
FileClose(file);
Print("Upload completed.\n", m_ArrayCount, " movement positions were generated.\nStarting Replay.");
class="kw">return;
}
Print("Failed to access tick data file.");
ExpertRemove();
};
class="macro">#undef macroRemoveSec
class="macro">#undef macroGetMin

「跳价文件里藏着回放系统的实战入口」

回放系统的底层原理讲完之后,真正能推动你动手的,往往是看见跳价文件在终端里跑起来的那一刻。只盯着代码段看很难有动力,但把真实行情 tick 喂进自己的硬件环境,观察计算机怎么处理这些毫秒级数据,兴趣就来了。 附件里给了两个版本的回放程序,外加两份真实跳价文件。你在自己机器上跑,结果大概率和我展示的不会差太远,但处理某些边界问题时,机器给出的解法可能比预想更有创意。 下面这段是真实跳价文件的原始记录片段,同一秒内连续多笔成交价不同、成交量也不同,这种结构正是回放系统要解析的原料:

  • 2021.10.22 09:00:38.649 107900 107905 6
  • 2021.10.22 09:00:38.651 107900 1.00000000 88
  • 2021.10.22 09:00:38.651 107895 5.00000000 88
  • 2021.10.22 09:00:38.651 107890 5.00000000 88
  • 2021.10.22 09:00:38.651 107885 3.00000000 88
  • 2021.10.22 09:00:38.651 107880 15.00000000 88

有读者在类似系统上做过压缩实验:115万笔流动 tick 里删掉约106万笔冗余,首根分钟蜡烛构建时间从 00:01:52 压到 00:01:06,省下46秒;继续优化后总构建耗时 56 秒,刚好砍掉一半。外汇与贵金属行情重播涉及高频数据处理,硬件差异可能导致结果波动,属高风险验证环境,请自行评估。 下一篇会对系统做修改,尝试更合适的架构,同时给出一个对编程新手也实用的替代思路。门刚推开一条缝,后面还有得折腾。

MQL5 / C++
class="num">449    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.649    class="num">107900  class="num">107905                class="num">6
class="num">450    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.651          class="num">107900  class="num">1.00000000     class="num">88
class="num">451    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.651          class="num">107895  class="num">5.00000000     class="num">88
class="num">452    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.651          class="num">107890  class="num">5.00000000     class="num">88
class="num">453    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.651          class="num">107885  class="num">3.00000000     class="num">88
class="num">454    class="num">2021.10.class="num">22    class="num">09:class="num">00:class="num">38.651          class="num">107880  class="num">15.00000000    class="num">88

◍ 盘口快照里的冰火两重天

上面这截是 2021.10.22 09:00 某品种逐笔成交的裸数据,行号 455–457 的成交价钉在 38.651,但挂单量从 3.0 跳到 16.0 再掉回 2.0,买边价位 107880→107875→107870 连续下移,说明那一瞬有人在 38.651 砸出 16 手后马上撤量。 到 458–461 行,成交价微抬到 38.654,买价回到 107875–107880,单笔量全在 1.0 手级别,属于典型跟风小单回补。 462–463 行被高亮,价到 38.659–38.660、买价 107885,这两笔合计 3.0 手,是盘口从卖压转向买托的第一个信号点;464 行买价继续顶到 107890、量 3.0,短期重心上移概率偏大。 外汇与贵金属这种盘口博弈高风险,38.651 的 16 手异常量若放在 live 盘面,开 MT5 的「交易」标签把时间轴拉到 2021.10.22 09:00 就能比对当时 spread 是否同步走阔。

记住这一条就够了

上面那几行是 2021.10.22 09:00:38.662 的逐笔快照,同一毫秒内连续打出 5 笔:价格 107875~107900 跳动、成交量 1~3 手、字段末位 88 疑似报价源标识。38.662 到 38.664 仅差 2 毫秒就切了下一笔,说明亚盘早段流动性薄时,同秒多档错位很常见。 解析这类原始 tick 流,关键在把字符串按位切出来。下面这段把前 19 字符转成时间、20~22 字符转成毫秒,少一位就会整列偏移。

  • m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec ( StringToTime ( StringSubstr (szInfo, 0 , 19 )));
  • m_ArrayInfoTicks[m_ArrayCount].milisec = ( int ) StringToInteger ( StringSubstr (szInfo, 20 , 3 ));

外汇与贵金属属高风险品种,薄利时点差与滑点可能吞掉信号收益。把这套解析接进 MT5 脚本,跑一遍自己经纪商的 tick 记录,比看任何结论都实在。

MQL5 / C++
class="num">53      m_ArrayInfoTicks[m_ArrayCount].dt = macroRemoveSec( StringToTime( StringSubstr(szInfo, class="num">0 , class="num">19 )));
class="num">54      m_ArrayInfoTicks[m_ArrayCount].milisec = ( class="type">int ) StringToInteger( StringSubstr(szInfo, class="num">20 , class="num">3 ));

常见问题

按块读取并只保留必要字段(时间、买价、卖价),用数组缓存而非逐行挂对象,可显著降低内存占用。
保留最后一笔成交价并打上重复计数,回放时按时间升序还原,避免价格跳变误导策略。
小布可加载你导出的 tick 文件,自动对齐时间帧并生成回放页,你直接看价格行为重演即可。
对剥头皮类策略边际收益明显,普通波段策略用十毫秒级足够,过度压毫秒只增 CPU 开销。
必须删掉解压与索引临时文件,残留会占用磁盘并可能让下次回放误读旧数据。