开发回放系统 市场模拟(第 17 部分):跳价和更多跳价(I)·进阶篇
(2/3)· 为什么真实跳价远比你以为的复杂,又该如何塞进回放类而不崩
◍ 回放引擎怎么把tick塞进内存
做历史 tick 回放,第一步是把文件里的逐笔数据读进自定义结构数组。下面这段逻辑用宏把上限和当前元素访问封装起来,避免循环里反复写长下标。 #define def_LIMIT (INT_MAX - 2) 把单文件 tick 数上限卡在 2147483645,防止数组下标溢出;#define def_Ticks m_Ticks.Info[m_Ticks.nTicks] 则是把「当前正在填的那一笔」做成短别名。 循环条件是 !FileIsEnding(m_File) 且 nTicks < def_LIMIT 且没收到 _StopFlag。每读一笔先用 ArrayResize 把 Info 数组扩 1,再用 FileReadString 连读两串拼成 szInfo,前 19 字符转 time、第 20–22 字符转毫秒偏移填进 def_Ticks。 若 nTicks 触到 def_LIMIT,会 Print 报错并 return false,说明该 tick 文件体量已超出回放器设计容量,此时只能换分段时间或降采样,外汇与贵金属 tick 重放本身属高风险验证,结论仅代表程序中止可能。
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_LIMIT } class="kw">inline class="type">bool ReadAllsTicks(class="kw">const class="type">bool ToReplay) { class="macro">#define def_LIMIT(INT_MAX - class="num">2) class="macro">#define def_Ticks m_Ticks.Info[m_Ticks.nTicks] class="type">class="kw">string szInfo; class="type">MqlRates rate; Print("Loading replay ticks. Please wait..."); ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < def_LIMIT) && (!_StopFlag)) { ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray); szInfo = FileReadString(m_File) + " " + FileReadString(m_File); def_Ticks.time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19)); def_Ticks.time_msc = (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3));
从 tick 文件重建分钟 K 的读取循环
把历史 tick 数据从文件读回内存时,逐行解析是最容易踩坑的一段。下面这段代码演示了如何按字段顺序读取 bid、ask、last、volume_real 与 flags,并在成交量大于 0 时才参与聚合。
def_Ticks.bid = StringToDouble(FileReadString(m_File)); class=class="str">"cmt">// 读一行字符串并转为 class="type">class="kw">double,赋给 bid def_Ticks.ask = StringToDouble(FileReadString(m_File)); class=class="str">"cmt">// 同上,读 ask 报价 def_Ticks.last = StringToDouble(FileReadString(m_File)); class=class="str">"cmt">// 读 last 成交价 def_Ticks.volume_real = StringToDouble(FileReadString(m_File)); class=class="str">"cmt">// 读真实成交量 def_Ticks.flags = (class="type">uchar)StringToInteger(FileReadString(m_File)); class=class="str">"cmt">// 读整数字符串并强转为 class="type">uchar 标记 if (def_Ticks.volume_real > class="num">0.0) class=class="str">"cmt">// 仅当成交量大于 class="num">0 才处理 { ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); class=class="str">"cmt">// 动态扩容量价数组,步长 class="num">2 或初始 def_BarsDiary m_Ticks.nRate += (BuiderBar1Min(rate, def_Ticks) ? class="num">1 : class="num">0); class=class="str">"cmt">// 调分钟柱构建函数,成功则计数 +class="num">1 m_Ticks.Rate[m_Ticks.nRate] = rate; class=class="str">"cmt">// 存入构建好的 rate 结构 } m_Ticks.nTicks++; class=class="str">"cmt">// 无论成交量如何,tick 总数 +class="num">1 } FileClose(m_File); class=class="str">"cmt">// 关闭文件句柄 if (m_Ticks.nTicks == def_LIMIT) class=class="str">"cmt">// 达到预设 tick 上限 { Print("Too much data in the tick file.\nCannot class="kw">continue..."); class=class="str">"cmt">// 打印溢出提示 class="kw">return class="kw">false; class=class="str">"cmt">// 终止读取返回失败 }
ArrayResize 的第三个参数 def_BarsDiary 是预留增量,若你的 tick 文件单日量很大,把这个值调小会频繁触发重分配,MT5 回测时可能拖慢 10%–30% 的装载速度。
def_LIMIT 是防呆上限,建议你在 EA 输入参数里暴露出来;EURUSD 这类品种一天 tick 数可能破百万,不限制直接读容易让脚本卡死在 FileReadString 循环里。
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)); 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; }
「给重放行情补一根零量开盘棒」
在自建回放引擎里,首根历史 tick 之前往往没有可绘制的 K 线,直接推送后续 rate 会让指标在图表最左端出现断点。上面这段从 C_ConfigService 里摘出的 FirstBarNULL,就是专门用来补一根‘空棒’的。 它先声明 MqlRates rate[1],然后顺着 m_Ticks.Info 往后扫,只要某 tick 的 volume_real 等于 0,就把该 tick 的 last 价持续赋给 rate[0].close。循环结束时,open/high/low/close 全部压成同一个价,tick_volume 与 real_volume 都置 0,time 设为首 tick 时间减 60 秒。 最后用 CustomRatesUpdate(def_SymbolReplay, rate) 把这根零量棒写进自定义品种,并把 m_ReplayCount 归零。你在 MT5 里跑重放脚本时,若发现副图指标开头少一根或错位,优先检查这里有没有被调用——外汇与贵金属重放属高仿真但非实盘环境,信号仅作概率参考。
class="kw">return (!_StopFlag); class="macro">#undef def_Ticks class="macro">#undef def_LIMIT } class C_ConfigService : class="kw">protected C_FileTicks { class="kw">protected: class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">class="kw">datetime m_dtPrevLoading; class="type">int m_ReplayCount; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">void FirstBarNULL(class="type">void) { class="type">MqlRates rate[class="num">1]; for(class="type">int c0 = class="num">0; m_Ticks.Info[c0].volume_real == class="num">0; c0++) rate[class="num">0].close = m_Ticks.Info[c0].last; rate[class="num">0].open = rate[class="num">0].high = rate[class="num">0].low = rate[class="num">0].close; rate[class="num">0].tick_volume = class="num">0; rate[class="num">0].real_volume = class="num">0; rate[class="num">0].time = m_Ticks.Info[class="num">0].time - class="num">60; CustomRatesUpdate(def_SymbolReplay, rate); m_ReplayCount = class="num">0; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//... The rest of the class... }
◍ 改 C_Replay 时碰到的报价注入坑
改 C_Replay 类最开始只是小修小补,后来却绕进了报价注入的逻辑里。为了不让回放刚开始时资产在画出第一根 1 分钟柱前就骤变,我们被迫加了一段临时代码——禁用它,图表开头就会出现一次明显跳空,这跳空源于后面才会讲到的另一处事实。 关键改动在原始建柱函数里。表面看新函数和旧函数差不多,但系统找收盘价的方式变了:BID/ASK 会干扰数据链,不能像没报价影响时直接取数。新办法是看一次跳价是否带交易量,带量才把它的值当收盘价用。 第二个新调用最怪,核心是 CustomTicksAdd。文档写明:该函数只对接市场观察里已打开的自定义品种;一次传超过 256 个跳价时,前大半直接写库,最后 128 个发往市场观察再由终端存库。MqlTick 里 time 与 time_msc 的处理规则是——time_msc 非零就反推 time,time_msc 为零且 time 非零就乘 1000 补毫秒,都零则用服务器当前毫秒时间填。 很多人建模失败,是因为从文件读真实跳价直接塞进 MqlTick,却没满足「time_msc 必须自含 time 推导值」的隐含条件。上面那段代码就是强制造出第一个条件:填好自零时毫秒值,同时让毫秒字段里带上 time 应有的数值,MT5 才算得对时间。 这么一来 CustomTicksAdd 不光把跳价送进市场观察,顺带让出价、要价、最后价三条线出现在正在拼的图表上——以前没这功能。但系统还没完工,得拿真实跳价数据跑一遍回放来验证这阶段。 [CODE] 里的 AdjustPositionToReplay 干的就是定位回放位置:先读全局变量拿偏移,m_ReplayCount 从 0 起跳过 volume_real 为 0 的跳价;若滑块位置换算后没动就 return;否则按滑块比例从总跳价里算出 iPos,再把对应跳价时间去掉秒级误差写进 Rate[0].time,作为当前柱起点。
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) 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);
回放游标越界时的数据截断与等待
当回放游标 iPos 小于已重放计数 m_ReplayCount,说明发生了倒退或重置。此时先调用 CustomRatesDelete 把重放品种从当前 Rate[0].time 到 LONG_MAX 的自定义行情整段清掉,避免旧柱干扰后续重建。 若首帧且之前无加载记录,直接 FirstBarNULL() 把首根置空;否则把 Rate[0].time 减 60 秒,反向递减 m_ReplayCount,直到时间追上 tick 信息里对应的秒级边界,再补回 1 次计数。 反向越界(iPos 大于 m_ReplayCount)时逻辑相反。若处于图形构建态 bViewBuider,就置 isWait 为真并通过 GlobalVariableSet 写全局变量通知外部;否则向前推 m_ReplayCount 直到时间对齐,再统计 nCount 定位到 iPos 对应的秒级行情下标。外汇与贵金属回放测试属高风险验证,结果仅代表历史数据重演概率,不预示实盘表现。
if (iPos < m_ReplayCount) { CustomRatesDelete(def_SymbolReplay, Rate[class="num">0].time, 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; GlobalVariableSet(def_GlobalVariableReplay, Info.u_Value.df_Value); }else { for(; Rate[class="num">0].time > m_Ticks.Info[m_ReplayCount].time; m_ReplayCount++); for (nCount = class="num">0; m_Ticks.Rate[nCount].time < macroRemoveSec(m_Ticks.Info[iPos].time); nCount++);