开发回放系统(第 63 部分):玩转服务(四)·进阶篇
(2/3)·最小分时报价数算不清、回放定时总漂移?这篇专治配置错配引发的隐性 bug
「从CSV回放tick时如何拦截异常成交量」
做历史 tick 回放时,最怕某根 1 分钟 bar 的 tick_volume 异常膨胀,直接把后续仿真节奏带偏。上面这段 LoadTicks 在 ToReplay 模式下,会用 MaxTickVolume 做一道硬闸门:一旦当前 Rate 的 tick_volume 超过阈值,就回退 nShift 并启动 C_Simulation 重新拆细 tick。
代码第 27 行是核心判断——m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume 才触发仿真分支。注意 nShift 先被 MemShift 复位(第 29 行),意味着这一批超量 tick 不会被原样写入,而是交给 pSimulator 按 nDigits 精度重造。
实盘接这套逻辑时,MaxTickVolume 建议先用品种近期平均分钟 tick 数 ×3 探一下。外汇与贵金属杠杆高、滑点随机,回测通过的参数上线仍可能失真,务必在 MT5 策略测试器用真实点差重跑。
别把正态当圣经
历史 tick 分布未必平稳,MaxTickVolume 写死容易在行情突变日误杀正常数据,最好做成随波动率滚动的阈值。
class="type">class="kw">datetime LoadTicks(const class="type">class="kw">string szFileNameCSV, const class="type">bool ToReplay, const class="type">int MaxTickVolume) { class="type">int MemNRates, MemNTicks, nDigits, nShift; class="type">class="kw">datetime dtRet = TimeCurrent(); class="type">MqlRates RatesLocal[], rate; class="type">MqlTick TicksLocal[]; class="type">bool bNew; MemNRates = (m_Ticks.nRate < class="num">0 ? class="num">0 : m_Ticks.nRate); nShift = MemNTicks = m_Ticks.nTicks; if (!Open(szFileNameCSV)) class="kw">return class="num">0; if (!ReadAllsTicks()) class="kw">return class="num">0; rate.time = class="num">0; nDigits = SetSymbolInfos(); m_Ticks.bTickReal = true; for (class="type">int c0 = MemNTicks, c1, MemShift = nShift; c0 < m_Ticks.nTicks; c0++, nShift++) { if (nShift != c0) m_Ticks.Info[nShift] = m_Ticks.Info[c0]; if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue; if (bNew) { if ((m_Ticks.nRate >= class="num">0) && (ToReplay)) if (m_Ticks.Rate[m_Ticks.nRate].tick_volume > MaxTickVolume) { nShift = MemShift; ArrayResize(TicksLocal, def_MaxSizeArray); C_Simulation *pSimulator = new C_Simulation(nDigits); if ((c1 = (*pSimulator).Simulation(m_Ticks.Rate[m_Ticks.nRate], TicksLocal, MaxTickVolume)) > class="num">0)
回放模式下数组与计数器的收尾处理
在逐笔 tick 写入缓冲之后,代码通过 nShift 累加控制 m_Ticks.Info 的实际位移,并用 ArrayCopy 把本地 tick 数组接在已有数据后面。若 c1 小于 0 说明本次拷贝异常,函数直接返回 0,避免脏数据进入回放序列。 非回放分支里,RatesLocal 被重设尺寸为 m_Ticks.nRate - MemNRates,再把 m_Ticks.Rate 中新增的部分拷进去,交给 CustomRatesUpdate 推到 def_SymbolReplay 这个自定义品种上。此时 dtRet 取最新一根 K 线时间,随后 nRate / nTicks 复位到进入函数前的记忆值,等于只把增量对外暴露一次。 宏定义里有一行值得注意:def_MaxSizeArray 设为 16777216,即 16 Mbytes,这是单数组内存上限的硬约束。在 MT5 里跑 tick 级回放时,若历史跨度大,m_Ticks.Info 可能逼近该值,提前用 ArrayFree 释放 TicksLocal、RatesLocal 能降低爆内存概率。 末段 else 分支很简短:ToReplay 为真时只把 m_Ticks.nTicks 设为 nShift,不做 CustomRatesUpdate。两种模式计数器的归属差异,是调试回放不同步时第一个要查的点。
nShift += ArrayCopy(m_Ticks.Info, TicksLocal, nShift, class="num">0, c1); class="kw">delete pSimulator; ArrayFree(TicksLocal); if (c1 < class="num">0) class="kw">return class="num">0; } MemShift = nShift; ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">2 : def_BarsDiary), def_BarsDiary); }; m_Ticks.Rate[(m_Ticks.nRate += (bNew ? class="num">1 : class="num">0))] = rate; } 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 m_Ticks.nTicks = nShift; class="kw">return dtRet; }; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "Daniel Jose" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#include "C_FileBars.mqh" class="macro">#include "C_Simulation.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#define macroRemoveSec(A) (A - (A % class="num">60)) class="macro">#define def_MaxSizeArray class="num">16777216 class=class="str">"cmt">// class="num">16 Mbytes class=class="str">"cmt">//+------------------------------------------------------------------+ class C_FileTicks {
◍ 用 tick 流拼出 1 分钟合成棒
做跨市场回测时,MT5 自带 1 分钟 K 线对交易所品种和外汇品种的处理逻辑不一样。下面这段受保护结构把两种报价源抽象进同一个 stInfoTicks,再用 BuildBar1Min 按 tick 逐个回填。 枚举 ePlotType 只分两类:PRICE_EXCHANGE 走 last 成交价,PRICE_FOREX 走 bid 报价。外汇端若 bid 为 0.0 或算出的 close 为 0.0,函数直接返回 false,避免脏棒写入。 关键在 039 行:bNew 由「当前 rate.time 是否等于去掉秒的 tick 时间」决定。若不同,新建一根棒,open/low/high/close 全初始化为 dClose,tick_volume 在外汇模式下记 1、交易所模式记 0;若相同,只更新 close。 实盘验证建议:挂一个 EA 把 m_Ticks.Info 的 bid 序列打进数组,观察欧美跳空时段 036 行的 false 返回频率——异常 tick 可能在 1 分钟内触发 3~5 次丢弃。外汇与贵金属属高风险品种,合成棒仅作分析参考,不预示方向。
class="kw">protected: enum ePlotType {PRICE_EXCHANGE, PRICE_FOREX}; class="kw">struct stInfoTicks { class="type">MqlTick Info[]; class="type">MqlRates Rate[]; class="type">int nTicks, nRate; class="type">bool bTickReal; ePlotType ModePlot; }; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">bool BuildBar1Min(const class="type">int iArg, class="type">MqlRates &rate, class="type">bool &bNew) { class="type">class="kw">double dClose = class="num">0; class="kw">switch (m_Ticks.ModePlot) { case PRICE_EXCHANGE: if (m_Ticks.Info[iArg].last == class="num">0.0) class="kw">return class="kw">false; dClose = m_Ticks.Info[iArg].last; break; case PRICE_FOREX: dClose = (m_Ticks.Info[iArg].bid > class="num">0.0 ? m_Ticks.Info[iArg].bid : dClose); if ((dClose == class="num">0.0) || (m_Ticks.Info[iArg].bid == class="num">0.0)) class="kw">return class="kw">false; break; } if (bNew = (rate.time != macroRemoveSec(m_Ticks.Info[iArg].time))) { rate.time = macroRemoveSec(m_Ticks.Info[iArg].time); rate.real_volume = class="num">0; rate.tick_volume = (m_Ticks.ModePlot == PRICE_FOREX ? class="num">1 : class="num">0); rate.open = rate.low = rate.high = rate.close = dClose; }else { rate.close = dClose;
「回放引擎里的高低价与成交量归集」
这段逻辑出现在逐笔 tick 回放类的内部处理中,负责把每一笔 tick 的 close 与当前 K 线高低点做比对并累加成交量。注意 048、049 行:若 tick 的 close 高于已知 high 则刷新 high,低于已知 low 则刷新 low,这意味着用 tick 收盘价动态扩展影线,而不是只用传统 OHLC 的首尾。
- 行把 real_volume 强转为 long 后累加,051 行则区分了真实 tick 标志:若 bTickReal 为真,tick_volume 只加 1(代表一笔真实成交事件),否则按整型 volume 累加。这种区分能避免回测时把聚合量当真实笔数用。
- 行起的 Open 方法暴露了文件约定:CSV 必须放在 Market Replay\Ticks\ 目录下,且前 7 个字段读出来须严格等于 "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>" 才认作合法 tick 文件,否则直接返回 false 并打印报错。
开 MT5 把一段真实 tick 导出成该格式 CSV,用这段 Open 逻辑加载,能验证你的复盘数据是否被引擎正确拒绝或接纳。外汇与贵金属品种 tick 稀疏时段可能出现 bTickReal 误标,实盘回放前建议先打印前 100 行 volume 分布。
rate.high = (rate.close > rate.high ? rate.close : rate.high); rate.low = (rate.close < rate.low ? rate.close : rate.low); rate.real_volume += (class="type">long) m_Ticks.Info[iArg].volume_real; rate.tick_volume += (m_Ticks.bTickReal ? class="num">1 : (class="type">int)m_Ticks.Info[iArg].volume); class="kw">return true; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">private : class="type">int m_File; stInfoTicks m_Ticks; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">bool Open(const class="type">class="kw">string szFileNameCSV) { class="type">class="kw">string szInfo = ""; if ((m_File = FileOpen("Market Replay\\Ticks\\" + szFileNameCSV + ".csv", FILE_CSV | FILE_READ | FILE_ANSI)) != INVALID_HANDLE) { for (class="type">int c0 = class="num">0; c0 < class="num">7; c0++) szInfo += FileReadString(m_File); if (szInfo == "<DATE><TIME><BID><ASK><LAST><VOLUME><FLAGS>") class="kw">return true; Print("File ", szFileNameCSV, ".csv not a traded tick file."); }else Print("Tick file ", szFileNameCSV,".csv not found..."); class="kw">return class="kw">false; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">bool ReadAllsTicks(class="type">void) { class="type">class="kw">string szInfo; Print("Loading replay ticks. Please wait...");
逐行拆解 tick 文件的流式读入
下面这段逻辑负责把导出的 tick 文本按行灌进内存数组,边读边扩容。循环里用 FileIsEnding 判尾、用 _StopFlag 响应终端停止,避免大文件卡死 MT5。 [CODE]ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); m_Ticks.ModePlot = PRICE_FOREX; while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < (INT_MAX - 2)) && (!_StopFlag)) { ArrayResize(m_Ticks.Info, m_Ticks.nTicks + 1, def_MaxSizeArray); szInfo = FileReadString(m_File) + " " + FileReadString(m_File); m_Ticks.Info[m_Ticks.nTicks].time = StringToTime(StringSubstr(szInfo, 0, 19)); m_Ticks.Info[m_Ticks.nTicks].time_msc = (m_Ticks.Info[m_Ticks.nTicks].time * 1000) + (int)StringToInteger(StringSubstr(szInfo, 20, 3)); m_Ticks.Info[m_Ticks.nTicks].bid = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].ask = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].last = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].volume_real = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].flags = (uchar)StringToInteger(FileReadString(m_File)); m_Ticks.ModePlot = (m_Ticks.Info[m_Ticks.nTicks].volume_real > 0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot); m_Ticks.nTicks++; } FileClose(m_File); if (m_Ticks.nTicks == (INT_MAX - 2)) { Print("Too much data in tick file.\nIt is not possible to continue..."); return false; } return (!_StopFlag);[/CODE] 第 81 行先按 def_MaxSizeArray 预分配一块内存,减少后续频繁重分配的 overhead;第 85 行每读一条就 ArrayResize 扩 1,步长仍是 def_MaxSizeArray,属于典型的“块分配+细增长”手法。 第 86–88 行把前两列拼成 szInfo,再用 StringSubstr 切出前 19 字符转成秒级 time,偏移 20 取 3 字符当毫秒拼到 time*1000 上,得到 time_msc。做高频回测时,毫秒字段直接决定成交排序,漏掉就会把同秒多 tick 当成无序。 第 94 行是个隐藏开关:只要某 tick 的 volume_real 大于 0,ModePlot 就从 PRICE_FOREX 切到 PRICE_EXCHANGE。这意味着你的数据里一旦出现真实成交量与 last 价,绘图模式会自动变,外汇无成交时维持外汇样式。 第 98–102 行设了硬上限 nTicks == INT_MAX-2(约 21.4 亿条),触顶就打印 Too much data 并 return false。实盘导几年 tick 很难撞这墙,但批量测多品种时得留心,否则静默断读。
ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); m_Ticks.ModePlot = PRICE_FOREX; class="kw">while ((!FileIsEnding(m_File)) && (m_Ticks.nTicks < (INT_MAX - class="num">2)) && (!_StopFlag)) { ArrayResize(m_Ticks.Info, m_Ticks.nTicks + class="num">1, def_MaxSizeArray); szInfo = FileReadString(m_File) + " " + FileReadString(m_File); m_Ticks.Info[m_Ticks.nTicks].time = StringToTime(StringSubstr(szInfo, class="num">0, class="num">19)); m_Ticks.Info[m_Ticks.nTicks].time_msc = (m_Ticks.Info[m_Ticks.nTicks].time * class="num">1000) + (class="type">int)StringToInteger(StringSubstr(szInfo, class="num">20, class="num">3)); m_Ticks.Info[m_Ticks.nTicks].bid = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].ask = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].last = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].volume_real = StringToDouble(FileReadString(m_File)); m_Ticks.Info[m_Ticks.nTicks].flags = (class="type">uchar)StringToInteger(FileReadString(m_File)); m_Ticks.ModePlot = (m_Ticks.Info[m_Ticks.nTicks].volume_real > class="num">0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot); m_Ticks.nTicks++; } FileClose(m_File); if (m_Ticks.nTicks == (INT_MAX - class="num">2)) { Print("Too much data in tick file.\nIt is not possible to class="kw">continue..."); class="kw">return class="kw">false; } class="kw">return (!_StopFlag);
◍ 回放品种的精度与计价分支
在自建回放品种时,小数位和计价模式必须按数据源类型切分。若行情来自交易所(PRICE_EXCHANGE),小数位设为 4、计价走 SYMBOL_CALC_MODE_EXCH_STOCKS、图表取 LAST 价;否则按外汇处理,小数位 5、计价走 SYMBOL_CALC_MODE_FOREX、图表取 BID 价。 这个分支直接决定 MT5 回测引擎怎么算保证金和盈亏。外汇与贵金属杠杆高、点值敏感,小数位设错会让历史回测的滑点和库存费出现系统性偏移,实盘复制前建议在策略测试器里先跑一遍样本。 构造函数里把 m_Ticks.Rate 预扩到 def_BarsDiary,nRate 置 -1、nTicks 清 0,相当于给日内重采样留好空槽。BarsToTicks 从 CSV 读柱、按 def_MaxSizeArray 扩 local 缓冲,循环里用 _StopFlag 做中断保护,转换大文件时不会卡死终端。
CustomSymbolSetInteger(def_SymbolReplay, SYMBOL_DIGITS, iRet = (m_Ticks.ModePlot == PRICE_EXCHANGE ? class="num">4 : class="num">5)); 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); class="kw">return iRet; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">public : class=class="str">"cmt">//+------------------------------------------------------------------+ C_FileTicks() { ArrayResize(m_Ticks.Rate, def_BarsDiary); m_Ticks.nRate = -class="num">1; m_Ticks.nTicks = class="num">0; m_Ticks.Rate[class="num">0].time = class="num">0; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool BarsToTicks(const class="type">class="kw">string szFileNameCSV, class="type">int MaxTickVolume) { C_FileBars *pFileBars; C_Simulation *pSimulator = NULL; class="type">int iMem = m_Ticks.nTicks, iRet = -class="num">1; class="type">MqlRates rate[class="num">1]; class="type">MqlTick local[]; class="type">bool bInit = class="kw">false; pFileBars = new C_FileBars(szFileNameCSV); ArrayResize(local, def_MaxSizeArray); Print("Converting bars to ticks. Please wait..."); class="kw">while ((*pFileBars).ReadBar(rate) && (!_StopFlag)) { if (!bInit) {