开发回放系统  市场模拟(第 19 部分):必要的调整·进阶篇
🗂️

开发回放系统 市场模拟(第 19 部分):必要的调整·进阶篇

(2/3)·单资产随便跑,多资产数据库一乱就翻车?本篇把隐形目录摆到明处

偏理论 第 2/3 篇

不少交易者以为回放系统只要能播一种品种就够用,真接上五六个外汇贵金属对的大数据库,配置文件就开始打架。源代码里藏着能用的目录逻辑,但没人把它摆到台面,结果每次加新参数都得手动改一堆路径。本文接上篇跳价改造,专注把这套组织结构显性化。

脚本读不出 tick 就直接退场

这段逻辑出现在自定义配置读取函数末尾,用来处理文件解析失败或行情数据缺失的兜底。当系统拿不到可用 tick 时,会打印「No ticks to use. Closing the service...」并把错误码 cError 置为 -1,随后跳出循环。 case 1 与 case 2 分别捕获「命令行无法识别」和「系统未预期该行内容」两种解析异常,default 则笼统报「Error in line」并带上行号 iLine,方便定位配置文件哪一行出问题。 函数最终返回值为 (cError == 0 ? !_StopFlag : false),意味着只有零错误且未被强制停止标志打断时,才返回 true 继续跑;否则直接 false 收手。外汇与贵金属品种在周末或断网时极易触发 -1,属正常防护,但实盘前务必在 MT5 策略测试器用历史 tick 复跑确认路径宏展开无误。

MQL5 / C++
Print("No ticks to use. Closing the service...");
         cError = -class="num">1;
      }else if (!bBarsPrev) FirstBarNULL();
         class="kw">break;
      case class="num">1  : Print("Command in line ", iLine, " cannot be recognized by the system...");      class="kw">break;
      case class="num">2  : Print("The system did not expect the content of the line ", iLine);                    class="kw">break;
      class="kw">default : Print("Error in line ", iLine);
            }
            class="kw">return (cError == class="num">0 ? !_StopFlag : false);
class="macro">#undef macroFileName
   }
class="macro">#define macroFileName((m_szPath != NULL ? m_szPath + "\\" : "") + szInfo)
[Config]
Path = < NEW PATH >
[/CODE]

◍ 先把自定义品种的三个基础值归零

做回放或模拟订单系统前,至少得拿到三个硬参数:最小交易量、最小跳价值、最小跳价大小。原文里目前只有跳价大小被实现过,而且配置文件没写时它会漏值,合成品种的市场走势模拟直接被卡住。若不在初始化阶段把数据配平,后面操控订单系统时字段可能不一致,排查成本很高。 第一段代码在 C_Replay 构造函数里把计数、时间、跳价数组全清成 0,并顺手给自定义品种挂了段描述文本。描述不是必选项,但在 MT5 品种列表里看到“Symbol for replay / simulation”能少点迷惑。注意这里已经弃用旧全局变量,改为从品种自身结构里取数。 跳价大小在回放系统里被两处引用。第一处直接读品种内定义值;第二处在 C_Replay 类里,因为随机游走每根 1 分钟柱会触发三次访问,频繁取数拖慢绘图,所以模拟用的生成函数里声明了静态局部变量,只在服务生命周期内捕获一次 SYMBOL_TRADE_TICK_SIZE。回放真实跳价文件时不会降速,因为不需要额外信息画 1 分钟柱和推市场观察窗口。 不过静态变量只在模拟模式生效,回放模式下没用。跳价大小暂告一段落,最小交易量和最小跳价值的初始化还挂着零,留到下一阶段用同样手法补齐。外汇与贵金属模拟涉及高杠杆,自定义品种参数错配可能放大实盘验证风险。

MQL5 / C++
C_Replay(class="kw">const class="type">class="kw">string szFileConfig)
    {
        m_ReplayCount = class="num">0;
        m_dtPrevLoading = class="num">0;
        m_Ticks.nTicks = class="num">0;
        Print("************** Market Replay Service **************");
        srand(GetTickCount());
        GlobalVariableDel(def_GlobalVariableReplay);
        SymbolSelect(def_SymbolReplay, false);
        CustomSymbolDelete(def_SymbolReplay);
        CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\\%s", def_SymbolReplay), _Symbol);
        CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX);
        CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX);
        SymbolSelect(def_SymbolReplay, true);
        CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, class="num">0.0);
        CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
        m_IdReplay = (SetSymbolReplay(szFileConfig) ? class="num">0 : -class="num">1);
    }
class C_FileTicks
{
    class="kw">protected:
        class="kw">struct st00
        {
            class="type">MqlTick  Info[];
            class="type">MqlRates Rate[];
            class="type">int      nTicks,
                     nRate;
            class="type">bool     bTickReal;
        }m_Ticks;
        class="type">class="kw">double      m_PointsPerTick;
    class="kw">private :
        class="type">int         m_File;
};
class="kw">inline class="type">long RandomWalk(class="type">long pIn, class="type">long pOut, class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[], class="type">int iMode)
    {
        class="type">class="kw">double vStep, vNext, price, vHigh, vLow, PpT;
        class="type">char i0 = class="num">0;
        PpT = SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE);

「蒙特卡洛重排里的边界回缩逻辑」

这段循环是合成tick流的核心:用随机游走把真实K线的 high-low 区间拆成 PpT 步长的伪成交价,再按 iMode 决定边界如何处理。 vNext 与 vStep 先按 (pOut-pIn) 与区间幅度算好“每隔多少根伪tick动一次边界”,循环中 c2 计数连续越界次数,超过 3 次才允许 vNext 前推并收缩 vHigh/vLow。 iMode==1 时,price 同时命中 high 和 low(i0==3)才把边界锁回真实值;iMode==2 则按 close 相对位置交替内收上下轨,每两次越界换一次方向。外汇与贵金属波动大,这类合成数据仅用于压力测试,实盘高风险。 把下面代码贴进 MT5 的 mq5 脚本,改 PpT 和 iMode 就能直观看边界怎么被吃掉。

MQL5 / C++
   vNext = vStep = (pOut - pIn) / ((rate.high - rate.low) / PpT);
   vHigh = rate.high;
   vLow = rate.low;
   for (class="type">long c0 = pIn, c1 = class="num">0, c2 = class="num">0; c0 < pOut; c0++, c1++)
   {
      price = tick[c0 - class="num">1].last + (PpT * ((rand() & class="num">1) == class="num">1 ? -class="num">1 : class="num">1));
      price = tick[c0].last = (price > vHigh ? price - PpT : (price < vLow ? price + PpT : price));
      class="kw">switch (iMode)
      {
         case class="num">0:
            if (price == rate.close)
               class="kw">return c0;
            class="kw">break;
         case class="num">1:
            i0 |= (price == rate.high ? 0x01 : class="num">0);
            i0 |= (price == rate.low ? 0x02 : class="num">0);
            vHigh = (i0 == class="num">3 ? rate.high : vHigh);
            vLow = (i0 == class="num">3 ? rate.low : vLow);
            class="kw">break;
         case class="num">2:
            class="kw">break;
      }
      if ((class="type">int)floor(vNext) < c1)
      {
         if ((++c2) <= class="num">3) class="kw">continue;
         vNext += vStep;
         if (iMode == class="num">2)
         {
            if ((c2 & class="num">1) == class="num">1)
            {
               if (rate.close > vLow) vLow += PpT; else vHigh -= PpT;
            }else
            {
               if (rate.close < vHigh) vHigh -= PpT; else vLow += PpT;
            }
         } else
         {

回放引擎里一根合成 K 线的拼装逻辑

做 tick 级回放时,MT5 不会自动把逐笔成交聚合成 K 线,得自己维护 open/high/low/close 与成交量。下面这段内联函数就是每来一条 tick,往当前挂载的虚拟品种上刷新一根 bar 的核心路径。 关键判断在 bNew:当 tick_volume 为 0 说明是新 bar 的第一笔,open 直接取当前 close;否则 open 沿用,high/low 只在破位时更新。real_volume 累加成交手数,tick_volume 对每笔有效成交计 1,这两个字段决定后续成交量类指标能否正常算。 PointsPerTick 只在首次进入新时间桶时通过 SymbolInfoDouble 取 SYMBOL_TRADE_TICK_SIZE,之后静态复用,避免每 tick 调系统函数拖慢回放。注意外汇与贵金属杠杆品种点值跳动快,回放若用真实 tick 重演,滑点与 spread 模拟偏差可能让结果偏离实盘概率。 最后 CustomRatesUpdate 把拼好的 Rate 数组推给虚拟品种,若开启 bViewTicks 还会把 tick 结构直送图表。想验证就开 MT5 建个离线回放 EA,把 m_Ticks 换成你导出的真实 tick 文件,看合成 bar 与历史窗是否吻合。

MQL5 / C++
class="kw">inline class="type">void CreateBarInReplay(class="kw">const class="type">bool bViewMetrics, class="kw">const class="type">bool bViewTicks)
		{
class="macro">#define def_Rate m_MountBar.Rate[class="num">0]
		
					class="type">bool bNew;
			class="type">MqlTick tick[class="num">1];
			class="kw">static class="type">class="kw">double PointsPerTick = class="num">0.0;
			
			if (m_MountBar.memDT != macroRemoveSec(m_Ticks.Info[m_ReplayCount].time))
			{
						PointsPerTick = (PointsPerTick == class="num">0.0 ? SymbolInfoDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE) : PointsPerTick);
					if (bViewMetrics) Metrics();
					m_MountBar.memDT = (class="type">class="kw">datetime) macroRemoveSec(m_Ticks.Info[m_ReplayCount].time);
					def_Rate.real_volume = class="num">0;
					def_Rate.tick_volume = class="num">0;
			}
			bNew = (def_Rate.tick_volume == class="num">0);
			def_Rate.close = (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close);
			def_Rate.open = (bNew ? def_Rate.close : def_Rate.open);
			def_Rate.high = (bNew || (def_Rate.close > def_Rate.high) ? def_Rate.close : def_Rate.high);
			def_Rate.low = (bNew || (def_Rate.close < def_Rate.low) ? def_Rate.close : def_Rate.low);
			def_Rate.real_volume += (class="type">long) m_Ticks.Info[m_ReplayCount].volume_real;
			def_Rate.tick_volume += (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0 ? class="num">1 : class="num">0);
			def_Rate.time = m_MountBar.memDT;
			CustomRatesUpdate(def_SymbolReplay, m_MountBar.Rate);
			if (bViewTicks)
			{
					tick = m_Ticks.Info[m_ReplayCount];
					if (!m_Ticks.bTickReal)
					{
							class="kw">static class="type">class="kw">double BID, ASK;
							class="type">class="kw">double	dSpread;
							class="type">int		 iRand = rand();

◍ 用随机扰动模拟真实点差跳动

这段逻辑在回放 tick 时人为注入点差噪声,让历史重演更接近实盘摩擦。核心看 iRand 落在 29080 到 32767 之间时的处理:约占总区间 3679/32767 ≈ 11.2% 的概率会触发一个 PointsPerTick 的加点差,且按 iRand 奇偶决定加在 ask 还是免加。 当最新价 tick[0].last 高于当前 ASK,代码把 ASK 和 ask 重设为 last,BID 则减掉 dSpread;若 last 低于 BID,则 BID 和 bid 设为 last,ASK 加上 dSpread。这样买卖边界始终跟随最新价滑动,不会出现静止价差。 最后用 CustomTicksAdd 把构造好的 tick 推入重放符号,m_ReplayCount 自增,宏 def_Rate 取消定义。外汇与贵金属品种用这套模拟时需注意:随机层只是近似,真实滑点和流动性缺口仍可能导致回测与实盘偏差,属高风险验证。

MQL5 / C++
dSpread = PointsPerTick + ((iRand > class="num">29080) && (iRand < class="num">32767) ? ((iRand & class="num">1) == class="num">1 ? PointsPerTick : class="num">0 ) : class="num">0 );
if (tick[class="num">0].last > ASK)
{
   ASK = tick[class="num">0].ask = tick[class="num">0].last;
   BID = tick[class="num">0].bid = tick[class="num">0].last - dSpread;
}
if (tick[class="num">0].last < BID)
{
   ASK = tick[class="num">0].ask = tick[class="num">0].last + dSpread;
   BID = tick[class="num">0].bid = tick[class="num">0].last;
}
CustomTicksAdd(def_SymbolReplay, tick);
m_ReplayCount++;
class="macro">#undef def_Rate
让小布替你跑这套
多资产回放里那些目录扫描和参数初始化,小布盯盘的 AIGC 已内置,打开对应品种页就能看到诊断,你只管调策略逻辑。

常见问题

单资产时硬编码路径还能忍,多资产数据库一大,漏配或错配目录会让模拟数据串台,概率上显著增加排查成本。
可以,小布盯盘支持指向本地或云端的历史数据库目录,自动识别自定义品种字段,省去手动声明。
字段顺序和时区标记若与回放引擎预期不一致,跳价重放可能偏移,建议先小样本验证再扩库。
首次改造偏理论梳理,但之后加新函数基本只动配置,边际工作量倾向大幅下降。