开发回放系统 市场模拟(第 10 部分):仅用真实数据回放·综合运用
◍ 逐行拆解 tick 回放里的合并与写入
下面这段逻辑常见于把历史 tick 文件重新灌入结构体数组的场景:先按行读入 bid / ask / last / volume_real,再用 StringToDouble 转成数值;flags 用 StringToInteger 强转 uchar。 [CODE] tick.bid = StringToDouble(FileReadString(file)); tick.ask = StringToDouble(FileReadString(file)); tick.last = StringToDouble(FileReadString(file)); tick.volume_real = StringToDouble(FileReadString(file)); tick.flags = (uchar)StringToInteger(FileReadString(file)); if ((m_Ticks.Info[old].last == tick.last) && (m_Ticks.Info[old].time == tick.time) && (m_Ticks.Info[old].time_msc == tick.time_msc)) m_Ticks.Info[old].volume_real += tick.volume_real; else { m_Ticks.Info[m_Ticks.nTicks] = tick; if (tick.volume_real > 0.0) { m_Ticks.nRate += (BuiderBar1Min(rate, tick) ? 1 : 0); rate.spread = (ToReplay ? m_Ticks.nTicks : 0); m_Ticks.Rate[m_Ticks.nRate] = rate; m_Ticks.nTicks++; } old = (m_Ticks.nTicks > 0 ? m_Ticks.nTicks - 1 : old); } if ((!FileIsEnding(file)) && (!_StopFlag)) { Print("Too much data in the tick file.\nCannot continue..."); FileClose(file); } [/CODE] 判重条件是 last、time、time_msc 三者全相等才视为同一 tick 的增量,只把 volume_real 累加进旧槽位;否则开新槽写入。注意 nTicks 自增只在 volume_real > 0.0 时触发,零成交量 tick 不会进 Rate 数组。 循环尾部用 FileIsEnding 和 _StopFlag 双条件保底,若文件没读完却被打断会 Print 提示并 FileClose。外汇与贵金属 tick 重放属高频数据操作,实盘回测误差可能放大,建议先在 MT5 策略测试器用小样本验证这段代码的内存增长曲线。
tick.bid = StringToDouble(FileReadString(file)); tick.ask = StringToDouble(FileReadString(file)); tick.last = StringToDouble(FileReadString(file)); tick.volume_real = StringToDouble(FileReadString(file)); tick.flags = (class="type">uchar)StringToInteger(FileReadString(file)); if ((m_Ticks.Info[old].last == tick.last) && (m_Ticks.Info[old].time == tick.time) && (m_Ticks.Info[old].time_msc == tick.time_msc)) m_Ticks.Info[old].volume_real += tick.volume_real; else { m_Ticks.Info[m_Ticks.nTicks] = tick; if (tick.volume_real > class="num">0.0) { m_Ticks.nRate += (BuiderBar1Min(rate, tick) ? class="num">1 : class="num">0); rate.spread = (ToReplay ? m_Ticks.nTicks : class="num">0); m_Ticks.Rate[m_Ticks.nRate] = rate; m_Ticks.nTicks++; } old = (m_Ticks.nTicks > class="num">0 ? m_Ticks.nTicks - class="num">1 : old); } if ((!FileIsEnding(file)) && (!_StopFlag)) { Print("Too much data in the tick file.\nCannot class="kw">continue..."); FileClose(file); }
「回放结束后的内存回收与自定义报价推送」
这段逻辑出现在 tick 数据回放收尾阶段,核心是把已读入的 MqlRates 批量推给自定义品种,并复位内部计数器。 若文件没找到,直接 Print 报错并返回 false,回放线程不会继续往下走。 当 ToReplay 为 false 且 _StopFlag 未置位时,先用 ArrayResize 把 RatesLocal 缩到 (m_Ticks.nRate - MemNRates) 的长度,再用 ArrayCopy 从 m_Ticks.Rate 整体拷入,随后调用 CustomRatesUpdate 把这段 K 线喂给 def_SymbolReplay。 推送完成后把 m_dtPrevLoading 记成最后一根Rate的时间,并将 nRate 重置为 MemNRates 或 -1(MemNRates 为 0 时),nTicks 退回 MemNTicks,最后 ArrayFree 释放临时数组。 返回值就是 !_StopFlag,意味着只要没收到停止信号,这个函数就认为加载流程正常结束。
class="kw">return false; } FileClose(file); }else { Print("Tick file ", szFileNameCSV,".csv not found..."); class="kw">return false; } if ((!ToReplay) && (!_StopFlag)) { 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)); m_dtPrevLoading = m_Ticks.Rate[m_Ticks.nRate].time; m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates); m_Ticks.nTicks = MemNTicks; ArrayFree(RatesLocal); } class="kw">return (!_StopFlag); };
清不掉回放资产?先扫一遍所有图表
回放服务在关闭时有个恼人的细节:若你同时开着同一回放资产的多个图表,服务只关掉自己建的那张,市场观察里资产删不干净。手动删能解决,但我们要的是全自动零残留。 原版 CloseReplay 只调 ChartClose(m_IdReplay) 关自身图表,随后 SymbolSelect(...,false) 尝试移除资产。一旦别的图表还引用该自定义品种,SymbolSelect 返回失败,资产就留在市场观察里。 改法是用 ChartFirst 拿首个图表 ID,不要求它是回放图;do-while 里用 ChartNext 遍历平台图表列表,遇到 ChartSymbol 等于回放品种就 ChartClose。ChartNext 返回小于 0 时循环结束,这样无论用户开几张回放图都会被关掉。 关完图后,用 for 循环做最多 2 次 SymbolSelect 删除尝试:仅服务自身图表时一次足够;用户另有图表时需第二次。即便自定义品种没删尽,内部 tick 与 rate 也已清空,不会留无用数据。外汇与贵金属回放属高风险模拟,实盘前务必在 MT5 策略测试器外另开图表自测该逻辑。
class="type">void CloseReplay(class="type">void) { ArrayFree(m_Ticks.Info); ArrayFree(m_Ticks.Rate); ChartClose(m_IdReplay); SymbolSelect(def_SymbolReplay, false); CustomSymbolDelete(def_SymbolReplay); GlobalVariableDel(def_GlobalVariableReplay); GlobalVariableDel(def_GlobalVariableIdGraphics); } class="type">void CloseReplay(class="type">void) { ArrayFree(m_Ticks.Info); ArrayFree(m_Ticks.Rate); m_IdReplay = ChartFirst(); do { if (ChartSymbol(m_IdReplay) == def_SymbolReplay) ChartClose(m_IdReplay); }class="kw">while ((m_IdReplay = ChartNext(m_IdReplay)) > class="num">0); for (class="type">int c0 = class="num">0; (c0 < class="num">2) && (!SymbolSelect(def_SymbolReplay, false)); c0++); CustomRatesDelete(def_SymbolReplay, class="num">0, LONG_MAX); CustomTicksDelete(def_SymbolReplay, class="num">0, LONG_MAX); CustomSymbolDelete(def_SymbolReplay); GlobalVariableDel(def_GlobalVariableReplay); GlobalVariableDel(def_GlobalVariableIdGraphics); }
◍ 别急着把EA挂进回放服务
回放/模拟系统还在迭代,作者明确提示:在系列文章完整发布前,不要试图把自有智能交易系统、指标或脚本和回放服务耦合。早期代码(例如第 11 篇之前的版本)已经过时,连 tick 释放机制都改过,现在能在市场观察窗口 DOM 里直接追踪。 有用户反馈,自己改了 C_Replay 类的 Event_OnTime,在前 47 个 tick 计数内 EA 能逐根接收,之后 tick 变得非常离散。作者回复说这套机制早已变化,建议直接跟葡萄牙语第 28 部分之后的文章,旧实现不值得调试。 下方这段是当时讨论里贴出的 Event_OnTime 片段,黄色高亮处新建了 MqlTick 数组准备往图表推 tick——现在看只具有考古价值,真要验证请下最新 Market_Replay.zip(约 13 MB)跑新版本。外汇与贵金属品种叠加回测天然高风险,离散 tick 可能导致滑点假设失真。 系统后续会短暂停更来演示支持模块写法,那时再考虑集成更稳妥。现在最划算的动作,是开 MT5 把最新文里的服务跑通,观察 DOM 中 tick 流是否连续。
<span class="keyword">class="kw">inline</span> <span class="keyword">class="type">int</span> Event_OnTime(<span class="keyword">class="type">void</span>) { <span class="keyword">class="type">bool</span> bNew; <span class="keyword">class="type">int</span> mili, iPos; u_Interprocess Info; <span style="background-class="type">color:rgb(class="num">255, class="num">221, class="num">0);"> <span class="predefines">class="type">MqlTick</span> TickToAdd[<span class="number">class="num">1</span>]; </span> <span class="keyword">class="kw">static</span> <span class="predefines">class="type">MqlRates</span> Rate[<span class="number">class="num">1</span>]; <span class="keyword">class="kw">static</span> <span class="keyword">class="type">class="kw">datetime</span> _dt = <span class="number">class="num">0</span>; <span class="keyword">if</span> (m_ReplayCount >= m_Ticks.nTicks) <span class="keyword">class="kw">return</span> -<span class="number">class="num">1</span>; <span class="keyword">if</span> (bNew = (_dt != m_Ticks.Info[m_ReplayCount].time)) {
「逐笔 tick 拼出当前 K 线的写法」
回放历史 tick 时,同一毫秒内可能压着多笔成交,不能每来一个 tick 就开一根新 K。上面这段逻辑先把当前回放位置的毫秒时间戳抓出来,再用 do-while 把同一 time_msc 下的所有 tick 全部吃进 Rate[0],避免 1 毫秒内被切成十几根碎柱。 内层 while 只认毫秒相等:close 永远跟着最新 last 走;open 仅在新柱标志 bNew 为真时取首笔 close,否则保留;high / low 用三目运算做条件刷新,只有新柱或价格突破原极值才更新。real_volume 用 += 累加该毫秒内每笔 volume_real,tick 级真实成交量就是这样攒出来的。 bNew 在吃完一笔后立刻置 false,m_ReplayCount 自增指向下一笔。外层 while 靠 mili++ 再比对下一笔的 time_msc,不等就跳出,此时 Rate[0] 才是这根毫秒柱的完整形态。外汇与贵金属 tick 回放属高频数据操作,实盘重放存在滑点与时序偏差风险,验证时建议先用 EURUSD 的 2023 年某日 M1 数据跑通再上真金。
_dt = m_Ticks.Info[m_ReplayCount].time;
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;
bNew = false;
m_ReplayCount++;
}
mili++;
}class="kw">while (mili == m_Ticks.Info[m_ReplayCount].time_msc);回放引擎里的逐tick推送与阻塞确认
这段逻辑干的事,是把录好的 tick 按序喂给回放图表:先把当前 tick 的时间写进 Rate[0],再调用 CustomRatesUpdate 更新 1 根自定义周期 K 线;同时用 iPos 算出回放进度条位置,比例就是 (m_ReplayCount * def_MaxPosSlider) / m_Ticks.nTicks,进度变化时才写全局变量。 只有检测到 def_GlobalVariableTick 这个全局变量存在,才会把 tick 真正推给图表。TickToAdd[0] 赋值后 Print 打出 time / bid / ask / last / volume_real,随后 GlobalVariableSet 置 1,CustomTicksAdd 把 tick 塞进回放符号。 推送后有个硬等待:while 里只要全局变量还大于 0(EA 在 OnTick 里会把它清零)且没收到 _StopFlag,就 Sleep(50) 循环,最多 50 次也就是约 2.5 秒。这能避免回放速度跑过图表接收能力,外汇和贵金属回测本身高风险,这种阻塞确认可减少 tick 丢失概率。 最后 return 的等待毫秒数也值得留意:若当前 tick 的 time_msc 小于 mili 基准,就补到下一秒的偏移;否则直接减掉差值,控制回放节奏更平滑。
Rate[class="num">0].time = m_Ticks.Info[m_ReplayCount].time; 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=class="str">"cmt">// Apenas plotará o tick no grafico se o robo tiver criado a variavel "def_GlobalVariableTick" if (GlobalVariableCheck(def_GlobalVariableTick)) { TickToAdd[class="num">0]=m_Ticks.Info[m_ReplayCount]; Print("Tick enviado: Time: ", TickToAdd[class="num">0].time, " Time_msc: ", TickToAdd[class="num">0].time_msc, " Bid: ", TickToAdd[class="num">0].bid, " Ask: ", TickToAdd[class="num">0].ask, " Last: ", TickToAdd[class="num">0].last, " Volume: ", TickToAdd[class="num">0].volume_real); GlobalVariableSet(def_GlobalVariableTick, class="num">1); class=class="str">"cmt">// Quando o EA receber o tick(OnTick) irá alterar para ZERO CustomTicksAdd(def_SymbolReplay, TickToAdd); class="type">class="kw">short ctd=class="num">0; class="kw">while (GlobalVariableGet(def_GlobalVariableTick) > class="num">0 && (!_StopFlag) && ctd <= class="num">50) { ctd++; Sleep(class="num">50) }; } 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);
◍ 用全局变量掐住回放 tick 的节拍
在 MT5 里做历史回放(Replay)类工具,难点不在画图,而在怎么让每一根模拟 tick 只被处理一次。上面这段 EA 骨架用了一个客户端全局变量 ReplayTick 当闸门:OnInit 里把它注册成临时全局变量,OnDeinit 里删掉,避免残留。 OnTick 里先用 SymbolInfoTick 抓当前 tick 的明细——time、time_msc、bid、ask、last、volume 全打进日志,ctd 这个静态计数器从 0 开始累加,跑一轮就能看到真实接收节奏(比如 EURUSD 在伦敦盘早期 1 秒可能来 3~5 个 tick,亚盘可能 1 秒不到 1 个)。 处理完你的标记逻辑后,最后一行 GlobalVariableSet(ReplayTick,0) 是把闸门复位。配合外部回放控制器写 1 来放行下一跳,就能在 MT5 里逐 tick 步进验证策略,而不用被实时流推着走。外汇与贵金属杠杆高、滑点跳空频繁,回放结论只代表历史数据下的概率表现,实盘可能偏离。
class="macro">#define def_GlobalVariableTick "ReplayTick" class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">int OnInit() { GlobalVariableTemp(def_GlobalVariableTick); Print("Variavel Criada: ", def_GlobalVariableTick); class="kw">return INIT_SUCCEEDED; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnDeinit(class="kw">const class="type">int reason) { GlobalVariableDel(def_GlobalVariableTick); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnTick() { class="type">MqlTick mTick; class="kw">static class="type">int ctd=class="num">0; SymbolInfoTick(Symbol(),mTick); ctd++; Print(Symbol(), " Tick Recebido ", ctd, ": Time: ", mTick.time, " Time_msc: ", mTick.time_msc, " Bid: ", mTick.bid, " Ask: ", mTick.ask, " Last: ", mTick.last, " Volume: ", mTick.volume); class=class="str">"cmt">// . class=class="str">"cmt">// . class=class="str">"cmt">// 标记处理代码 class=class="str">"cmt">// . class=class="str">"cmt">// . class=class="str">"cmt">// 将全局变量的值改为零,以接收下一个刻度线 GlobalVariableSet(def_GlobalVariableTick, class="num">0); }