开发回放系统  市场模拟(第 20 部分):外汇(I)·进阶篇
💱

开发回放系统 市场模拟(第 20 部分):外汇(I)·进阶篇

(2/3)· 从股票到外汇,出价要价同值与点差消失如何逼你重写模拟逻辑

含代码示例偏理论 第 2/3 篇
直接把股票回放系统套到外汇数据上,跳价和柱线混跑会抛出一连串你没见过的错误。很多人以为外汇只是换了个品种名,结果出价要价同值就把原有撮合假设打穿。这篇先拆特性,再谈怎么改才稳。

◍ 从 CSV 回填逐笔 tick 的结构拆解

这段逻辑干的事很直接:把外部 CSV 里的逐笔报价读进自定义 tick 结构,再按成交量是否大于 0 决定要不要合成 1 分钟 bar。注意 time_msc 的拼法——用秒级 time 乘 1000 再加上字符串第 20 位起 3 位毫秒子串转整,才能拿到亚秒精度时间戳。 def_Ticks.bid / ask / last / volume_real / flags 全部走 FileReadString 后做类型转换,说明文件里每一列都是纯文本顺序存储。当 volume_real > 0.0 时,ModePlot 切到 PRICE_EXCHANGE,并触发 ArrayResize 以 def_BarsDiary 为步长扩容,再调 BuiderBar1Min 尝试落一根 rate。 读满 def_LIMIT 笔会直接 Print 报错并返回 false,这是防止历史 tick 文件过大把回放卡死的保护。最后用 !_StopFlag 作为继续回放的闸门,脚本终止信号一来就停。 开 MT5 把这段塞进你的回放类,先拿一个小于 1 MB 的 tick CSV 跑通,重点看 volume_real 为 0 的行情是否真的被跳过、1 分钟 bar 数量是否符合预期。

MQL5 / C++
def_Ticks.time_msc = (def_Ticks.time * class="num">1000) + (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
			def_Ticks.bid = StringToDouble(FileReadString(m_File));
			def_Ticks.ask = StringToDouble(FileReadString(m_File));
			def_Ticks.last = StringToDouble(FileReadString(m_File));
			def_Ticks.volume_real = StringToDouble(FileReadString(m_File));
			def_Ticks.flags = (class="type">uchar)StringToInteger(FileReadString(m_File));
			m_Ticks.ModePlot = (def_Ticks.volume_real > class="num">0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot);
			if (def_Ticks.volume_real > class="num">0.0)
			{
				ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary);
				m_Ticks.nRate += (BuiderBar1Min(rate, def_Ticks) ? class="num">1 : class="num">0);
				m_Ticks.Rate[m_Ticks.nRate] = rate;
			}
			m_Ticks.nTicks++;
		}
		FileClose(m_File);
		if (m_Ticks.nTicks == def_LIMIT)
		{
			Print("Too much data in the tick file.\nCannot class="kw">continue...");
			class="kw">return class="kw">false;
		}
		class="kw">return (!_StopFlag);
class="macro">#undef def_Ticks
class="macro">#undef def_LIMIT
	}
class="type">class="kw">datetime LoadTicks(class="kw">const class="type">class="kw">string szFileNameCSV, class="kw">const class="type">bool ToReplay = true)
	{
		class="type">int		MemNRates,
				MemNTicks;
		class="type">class="kw">datetime dtRet = TimeCurrent();
		class="type">MqlRates RatesLocal[];
		MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate);

回放引擎的初始化与自定义品种重建

这段代码展示了 Market Replay 服务在构造时如何清空旧状态并重建一个自定义回放品种。构造函数 C_Replay 先把计数与加载时间归零,m_Ticks.nTicks 置为 0,m_Infos.bInit 标记为未初始化,随后打印服务标识并用 GetTickCount 播种随机数。 接下来是清理动作:删除全局变量 def_GlobalVariableReplay,从市场观察去掉并物理删除 def_SymbolReplay,再调用 CustomSymbolCreate 以当前图表品种 _Symbol 为基准在 Custom 目录下重建该回放符号。 重建后立刻用 CustomRatesDelete 与 CustomTicksDelete 以时间区间 0 到 LONG_MAX 清空所有历史 K 线与 tick,避免残留数据污染回放。随后重新 SymbolSelect 为 true,并把 SYMBOL_TRADE_TICK_SIZE 与 SYMBOL_TRADE_TICK_VALUE 均设为 0,使回放品种不自带点值干扰。 前面的片段还暴露了非回放模式下的衔接逻辑:若 ToReplay 为假,会把 RatesLocal 按 (m_Ticks.nRate - MemNRates) 重设大小并拷贝后推送给 CustomRatesUpdate,再把 nRate 回退到 MemNRates(为 0 时置 -1),保留 MemNTicks 作为记忆点。外汇与贵金属自定义回放涉及杠杆与报价模式,实操前务必清楚这类品种的高风险属性,建议直接在 MT5 用这段构造逻辑验证符号重建是否干净。

MQL5 / C++
MemNTicks = m_Ticks.nTicks;
if (!Open(szFileNameCSV)) class="kw">return class="num">0;
if (!ReadAllsTicks(ToReplay)) class="kw">return class="num">0;
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
{
   CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_TRADE_CALC_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CALC_MODE_EXCH_STOCKS : SYMBOL_CALC_MODE_FOREX);
   CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_CHART_MODE, m_Ticks.ModePlot == PRICE_EXCHANGE ? SYMBOL_CHART_MODE_LAST : SYMBOL_CHART_MODE_BID);
}
m_Ticks.bTickReal = true;
class="kw">return dtRet;
};
class C_Replay : class="kw">private C_ConfigService
{
   class="kw">private :
      class="type">long       m_IdReplay;
      class="kw">struct st01
      {
         class="type">MqlRates Rate[class="num">1];
         class="type">class="kw">datetime memDT;
         class="type">int      delay;
      }m_MountBar;
      class="kw">struct st02
      {
         class="type">bool     bInit;
      }m_Infos;
class=class="str">"cmt">// ... The rest of the class code....
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;
   m_Infos.bInit = class="kw">false;
   Print("************** Serviço Market Replay **************");
   srand(GetTickCount());
   GlobalVariableDel(def_GlobalVariableReplay);
   SymbolSelect(def_SymbolReplay, class="kw">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);
   CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, class="num">0);

「回放符号的初始化与逐笔推进逻辑」

在 MT5 里自建回放品种时,先把成交量步长设为 0、报价小数位强制 8 位,避免模拟tick被最小变动单位卡住。描述字段写清楚用途,后续在自定义品种列表里一眼能认出这是回放专用符号。 LoopEventOnTime 是回放主循环。首次进入时按品种类型切显示线:外汇模式只开 Ask/Bid 线,交易所模式开 Last 线,这个判断只跑一次,之后靠 m_Infos.bInit 锁住。 循环体用 GlobalVariableGet 读共享变量拿到播放状态,未播放就 Sleep(100) 空转;一旦 isPlay 为真,就按相邻 tick 的 time_msc 差值(毫秒)当作推进间隔,逐根 CreateBarInReplay 灌入。外汇与贵金属回放属高风险验证环境,实盘参数切勿直接套用。 想验证这套节奏,把 def_SymbolReplay 的品种建出来,用全局变量 def_GlobalVariableReplay 切 isPlay,观察图表是否按毫秒级间隔重绘——能跑通说明你的回放内核接线没问题。

MQL5 / C++
CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, class="num">0);
CustomSymbolSetString(def_SymbolReplay, SYMBOL_DESCRIPTION, "Symbol for replay / simulation");
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, class="num">8);
m_IdReplay = (SetSymbolReplay(szFileConfig) ? class="num">0 : -class="num">1);
}
class="type">bool LoopEventOnTime(class="kw">const class="type">bool bViewBuider, class="kw">const class="type">bool bViewMetrics)
    {
        u_Interprocess Info;
        class="type">int iPos, iTest;
        if (!m_Infos.bInit)
        {
            ChartSetInteger(m_IdReplay, CHART_SHOW_ASK_LINE, m_Ticks.ModePlot == PRICE_FOREX);
            ChartSetInteger(m_IdReplay, CHART_SHOW_BID_LINE, m_Ticks.ModePlot == PRICE_FOREX);
            ChartSetInteger(m_IdReplay, CHART_SHOW_LAST_LINE, m_Ticks.ModePlot == PRICE_EXCHANGE);
            m_Infos.bInit = true;
        }
        iTest = class="num">0;
        class="kw">while ((iTest == class="num">0) && (!_StopFlag))
        {
            iTest = (ChartSymbol(m_IdReplay) != "" ? iTest : -class="num">1);
            iTest = (GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value) ? iTest : -class="num">1);
            iTest = (iTest == class="num">0 ? (Info.s_Infos.isPlay ? class="num">1 : iTest) : iTest);
            if (iTest == class="num">0) Sleep(class="num">100);
        }
        if ((iTest < class="num">0) || (_StopFlag)) class="kw">return class="kw">false;
        AdjustPositionToReplay(bViewBuider);
        m_MountBar.delay = class="num">0;
        class="kw">while ((m_ReplayCount < m_Ticks.nTicks) && (!_StopFlag))
        {
            CreateBarInReplay(bViewMetrics, true);
            iPos = (class="type">int)(m_ReplayCount < m_Ticks.nTicks ? m_Ticks.Info[m_ReplayCount].time_msc - m_Ticks.Info[m_ReplayCount - class="num">1].time_msc : class="num">0);

◍ 回放节奏的延迟补偿与退出判定

这段逻辑解决回放时 tick 推进节奏的人为减速问题。iPos 小于 0 时按 iPos+1000 累加进 m_MountBar.delay,否则直接加 iPos,相当于把负偏移换算成可等待的毫秒数。 当累计 delay 超过 400 毫秒,先查 ChartSymbol(m_IdReplay) 是否为空字符串,空了就 break 跳出循环,避免对着已关闭的回放图表空跑。 接着从全局变量 def_GlobalVariableReplay 读回 Info 结构,若 Info.s_Infos.isPlay 为 false 直接 return true 结束函数,说明外部已叫停回放。仍在播放则按 m_ReplayCount * def_MaxPosSlider / m_Ticks.nTicks 算进度滑块位置写回全局变量,再 Sleep(delay-20) 让出 CPU,最后 delay 清零。 函数末尾返回 m_ReplayCount == m_Ticks.nTicks,即已跑完所有 tick 才视为 true。外汇与贵金属回测请意识到:滑点、点差与断线会让实际 delay 分布偏离单机回放,高风险。

MQL5 / C++
m_MountBar.delay += (iPos < class="num">0 ? iPos + class="num">1000 : iPos);
if (m_MountBar.delay > class="num">400)
{
   if (ChartSymbol(m_IdReplay) == "") class="kw">break;
   GlobalVariableGet(def_GlobalVariableReplay, Info.u_Value.df_Value);
   if (!Info.s_Infos.isPlay) class="kw">return true;
   Info.s_Infos.iPosShift = (class="type">class="kw">ushort)((m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks);
   GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value);
   Sleep(m_MountBar.delay - class="num">20);
   m_MountBar.delay = class="num">0;
}
class="kw">return (m_ReplayCount == m_Ticks.nTicks);

把跳价塞进图表前先补两处坑

想在 MT5 图表上画出回放柱线,直接跑会撞上数组越界。根因出在跳价跳过逻辑:原检查没区分证券显示模式,循环在外币类品种上会越界崩掉。补一道模式判断——若显示类型匹配外汇类(PRICE_EXCHANGE),循环直接跳过,就能进下一步。 插入柱线时只需告诉系统收盘价,其余交给模拟函数。外汇与证券两种模式此处写法一致,关键就一行按显示类型生成收盘价。注意,这阶段只是回放文件里的数据,还没到模拟。 两个遗留问题里,定时器更紧迫:某些品种可能几小时无成交(停牌、拍卖或其它),原定时器假设连续跳动,会卡死回放。下面这段是调整回放位置的函数骨架,能看清跳过前导零量跳价和按滑块定位的逻辑。 外汇与贵金属回放属高风险验证环境,参数没调好可能画出畸形柱,仅建议在策略测试器隔离品种上跑。

MQL5 / C++
class="type">void AdjustPositionToReplay(class="kw">const class="type">bool bViewBuider)
{
    u_Interprocess Info;
    class="type">MqlRates      Rate[def_BarsDiary];
    class="type">int           iPos, nCount;
    Info.u_Value.df_Value = GlobalVariableGet(def_GlobalVariableReplay);
    if ((m_ReplayCount == class="num">0) && (m_Ticks.ModePlot == PRICE_EXCHANGE))
    for (; m_Ticks.Info[m_ReplayCount].volume_real == class="num">0; m_ReplayCount++);
    if (Info.s_Infos.iPosShift == (class="type">int)((m_ReplayCount * def_MaxPosSlider * class="num">1.0) / m_Ticks.nTicks)) class="kw">return;
    iPos = (class="type">int)(m_Ticks.nTicks * ((Info.s_Infos.iPosShift * class="num">1.0) / (def_MaxPosSlider + class="num">1)));
    Rate[class="num">0].time = macroRemoveSec(m_Ticks.Info[iPos].time);
    if (iPos < m_ReplayCount)
    {
        CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, LONG_MAX);
        CustomTicksDelete(def_SymbolReplay, m_Ticks.Info[iPos].time_msc, LONG_MAX);
        if ((m_dtPrevLoading == class="num">0) && (iPos == class="num">0)) FirstBarNULL(); else
        {
            for(Rate[class="num">0].time -= class="num">60; (m_ReplayCount > class="num">0) && (Rate[class="num">0].time <= macroRemoveSec(m_Ticks.Info[m_ReplayCount].time)); m_ReplayCount--);
            m_ReplayCount++;
        }
    }else if (iPos > m_ReplayCount)
    {
        if (bViewBuider)
        {
            Info.s_Infos.isWait = true;

「回放引擎里的K线合成细节」

这段逻辑是历史 tick 回放的核心收口:当检测到新一根 Bar 的时间戳(去掉秒级精度)与缓存不一致时,先取一次 SYMBOL_TRADE_TICK_SIZE 作为每跳点值,之后除非重置否则不再重复查询,能少踩几次终端 API 的坑。 新 Bar 判定用 bNew 标志,为真时 open 直接等于当前 close,high/low 也以 close 重置;否则只在 close 突破原 high/low 时更新极值。real_volume 与 tick_volume 每次新 Bar 清零,由后续 tick 累加。 收盘价取值分两种模式:PRICE_EXCHANGE 下看 volume_real 是否大于 0,用 last 价;其余情况用 bid 价,且都做了大于 0 的防御,避免脏数据写进回放图。 直接把下面片段丢进你的回放类里,配合 CustomRatesUpdate 往虚拟品种灌数据,开 MT5 用 EURUSD 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 (bNew = (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;
    }
    def_Rate.close = (m_Ticks.ModePlot == PRICE_EXCHANGE ? (m_Ticks.Info[m_ReplayCount].volume_real > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].last : def_Rate.close) :
                                                                  (m_Ticks.Info[m_ReplayCount].bid > class="num">0.0 ? m_Ticks.Info[m_ReplayCount].bid : 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);
把外汇特性核对交给小布
小布盯盘的 AIGC 已内置常见外汇品种的出价要价同值检测,打开对应货币对页就能看到实时结构差异,你只需判断要不要进回放。

常见问题

股票里出价和要价恒有点差,而外汇常出现两者同值,原有柱线加跳价解析逻辑未处理该情形,便会触发异常。
可以,小布盯盘的品种页会标注出价要价是否同值、点差波动区间,帮你快速判断该货币对适不适合直接进回放系统。
央行动作可能令美元等货币瞬间翻转持仓逻辑,回放时应单独标记此类时段,否则训练出的策略会低估真实风险。
它让你直观看到无跳价仅有柱线时如何近似模拟,以及真实跳价下同值现象的频率,比纯文字描述更易建立概念。