开发回放系统 市场模拟(第 22 部分):外汇(III)(基础篇)
◍ 用历史点差给外汇回测注入真实摩擦
在 MT5 里做外汇回测,默认点差往往被压成常数,这会让剥头皮与高频策略的绩效虚高。把真实历史点差喂进测试器,是逼近实盘的第一步。 MetaTrader 5 的回放系统支持在本地以历史分笔数据重演市场,点差不再由 broker 模拟参数硬给,而是从当时买卖报价差里实时取。对 XAUUSD 这类高波动贵金属,伦敦午休与美盘开盘的点差能差出 3~5 倍,忽略这段分布会严重低估滑点成本。 下面这段脚本演示如何在 EA 的 OnTick 里读取当前点差并做简单记录,方便你回测时观察点差峰值出现在哪些时段: 外汇与贵金属杠杆品种波动剧烈,点差扩张叠加跳空可能引发远超本金的损失,回测仅反映历史概率而非未来保证。
class="type">void OnTick() { class=class="str">"cmt">// 获取当前品种点差(以点数计) class="type">class="kw">double spread = SymbolInfoInteger(_Symbol, SYMBOL_SPREAD); class=class="str">"cmt">// 将点数点差转为价格单位 class="type">class="kw">double spread_price = spread * _Point; class=class="str">"cmt">// 打印到专家日志,回测后可导出分析 Print("Spread points: ", spread, " price: ", spread_price); }
为什么外汇回放总在隐藏柱线时失真
回放系统在前一篇已能稳定跑股票,但附件实测里外汇(及一切以 BID 为基准价的资产)在隐藏模拟柱线后图形会错乱。这不是偶发:当你关掉回放/模拟器生成的柱线、再把视图拖到别的位置,旧位与新位之间失去柱线衔接,挂上去的指标全部算错。 根子在报价结构。股票用最后成交价,外汇及同类市场用出价(BID)当基准,显示逻辑不同。此前做股票回放时踩过同类的坑,但那套处理办法不能直接套到 BID 基准上,所以系统目前在外汇隐藏柱线模式下仍无法正常刷新。 本文先啃下这个:让隐藏模拟柱线时,拖动图表位置也能保住指标正确性。搞清这一点,才能把股票那套回放覆盖逻辑平推到外汇和贵金属——后者杠杆高、跳空频繁,验证时请先在策略测试器用极小资金或纯可视化跑,别直接上实盘。
「出价绘图下的跳价转柱线校正」
原快速定位系统读取跳价后转 1 分钟柱线的逻辑有个硬伤:创建柱线函数要求有交易量,而出价(bid)模式下成交量始终为 0,导致子例程根本不会被调用。问题不在代码算错,而在于用出价表达价格值时没法套用原来的转换路径。 我们只得把转换调用从文件读取函数里抽出来,改在 CustomRatesUpdate 之前执行,并把转换起点作为参数传进去。这样做能避免给读取函数塞无关变量,同时直接拿到检测工作范围所需的上下文。 转换函数本身要重写:保留原先最后成交价转 1 分钟柱线的主体,外面包一层从调用方指定起点到文件末尾最后一条跳价的循环。若系统判定当前是出价绘图,就把循环里取的 last 值替换为 bid,收盘价就能正确生成柱线。 跳价交易量还得单独处理。实测规律是——最后成交价绘图时计数器从头归零,出价绘图时则从 1 开始计数,否则柱线 volume 字段会失真。外汇与贵金属市场跳价稀疏、点差跳动频繁,这类细节不防住,回放可能直接跑偏,属典型高风险验证项,建议开 MT5 用真实 tick 文件单步跟一遍。
<span class="keyword">class="kw">inline</span> <span class="keyword">class="type">bool</span> ReadAllsTicks(<span class="keyword">class="kw">const</span> <span class="keyword">class="type">bool</span> ToReplay) { <span class="preprocessor">class="macro">#define </span>def_LIMIT(<span class="macro">INT_MAX</span> - <span class="number">class="num">2</span>) <span class="preprocessor">class="macro">#define </span>def_Ticks m_Ticks.Info[m_Ticks.nTicks] <span class="keyword">class="type">class="kw">string</span> szInfo; <span class="predefines">class="type">MqlRates</span> rate; <span class="functions">Print</span>(<span class="class="type">class="kw">string">"Loading ticks for replay. Please wait..."</span>); <span class="functions">ArrayResize</span>(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); m_Ticks.ModePlot = PRICE_FOREX; <span class="keyword">while</span> ((!<span class="functions">FileIsEnding</span>(m_File)) && (m_Ticks.nTicks < def_LIMIT) && (!<span class="predefines">_StopFlag</span>)) { <span class="functions">ArrayResize</span>(m_Ticks.Info, m_Ticks.nTicks + <span class="number">class="num">1</span>, def_MaxSizeArray); szInfo = <span class="functions">FileReadString</span>(m_File) + <span class="class="type">class="kw">string">" "</span> + <span class="functions">FileReadString</span>(m_File); def_Ticks.time = <span class="functions">StringToTime</span>(<span class="functions">StringSubstr</span>(szInfo, <span class="number">class="num">0</span>, <span class="number">class="num">19</span>)); def_Ticks.time_msc = (def_Ticks.time * <span class="number">class="num">1000</span>) + (<span class="keyword">class="type">int</span>)<span class="functions">StringToInteger</span>(<span class="functions">StringSubstr</span>(szInfo, <span class="number">class="num">20</span>, <span class="number">class="num">3</span>)); def_Ticks.bid = <span class="functions">StringToDouble</span>(<span class="functions">FileReadString</span>(m_File)); def_Ticks.ask = <span class="functions">StringToDouble</span>(<span class="functions">FileReadString</span>(m_File)); def_Ticks.last = <span class="functions">StringToDouble</span>(<span class="functions">FileReadString</span>(m_File)); def_Ticks.volume_real = <span class="functions">StringToDouble</span>(<span class="functions">FileReadString</span>(m_File)); def_Ticks.flags = (<span class="keyword">class="type">uchar</span>)<span class="functions">StringToInteger</span>(<span class="functions">FileReadString</span>(m_File)); m_Ticks.ModePlot = (def_Ticks.volume_real > <span class="number">class="num">0.0</span> ? PRICE_EXCHANGE : m_Ticks.ModePlot); <span class="keyword">if</span> (def_Ticks.volume_real > <span class="number">class="num">0.0</span>) { <span class="functions">ArrayResize</span>(m_Ticks.Rate, (m_Ticks.nRate > <span class="number">class="num">0</span> ? m_Ticks.nRate + <span class="number">class="num">2</span> : def_BarsDiary), def_BarsDiary); m_Ticks.nRate += (BuiderBar1Min(rate, def_Ticks) ? <span class="number">class="num">1</span> : <span class="number">class="num">0</span>);
◍ 回放前如何把 tick 文件读进内存
做历史 tick 回放,第一步是把 CSV 里的逐笔数据搬进结构体数组。下面这段 ReadAllsTicks 给出了一种直接按列解析的实现:时间取前 19 字符转 datetime,毫秒段再单独拼到 time_msc,bid/ask/last/volume 依次用 StringToDouble 读,flags 用 StringToInteger 强转 uchar。 def_LIMIT 被宏定义为 INT_MAX - 2,也就是约 21.4 亿条的上限;一旦 nTicks 撞到这个数,函数会打印 Too much data 并 return false,实盘或复盘前要先确认 tick 文件行数远低于此,否则直接中断。 ModePlot 的切换逻辑值得留意:只要某一笔的 volume_real 大于 0,就把绘图模式从 PRICE_FOREX 改成 PRICE_EXCHANGE。这意味着你的 csv 若带成交量和 last 价,回放时会自动按交易所品种渲染,外汇无成交量的品种则保持外汇样式。 循环里每次都 ArrayResize(m_Ticks.Info, nTicks+1, def_MaxSizeArray),预留块大小由 def_MaxSizeArray 控制。若这个预留值设得太小,大文件下频繁重分配会拖慢加载;开 MT5 跑之前,建议先拿几万行小文件验证时间解析和毫秒拼接是否正确。
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; Print("Loading ticks for replay. Please wait..."); ArrayResize(m_Ticks.Info, def_MaxSizeArray, def_MaxSizeArray); m_Ticks.ModePlot = PRICE_FOREX; 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 = (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)); def_Ticks.ModePlot = (def_Ticks.volume_real > class="num">0.0 ? PRICE_EXCHANGE : m_Ticks.ModePlot); 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 }
回放模式下的K线重建与品种属性切换
这段逻辑干两件事:非回放时把已缓存的 MqlRates 推给自定义品种,回放时按交易所或外汇规则设计算模式。 MemNRates = (m_Ticks.nRate < 0 ? 0 : m_Ticks.nRate); 先把有效 bar 数算出来,负数当成 0 处理,避免 ArrayResize 拿到负值崩掉。 若 ToReplay 为 false:ArrayResize(RatesLocal, m_Ticks.nRate - MemNRates) 只截取新增部分,CustomRatesUpdate 推给 def_SymbolReplay,dtRet 取最后一根 Rate 的时间,随后 nRate 回退到 MemNRates(为 0 则置 -1),释放局部数组。 若 ToReplay 为 true:用 CustomSymbolSetInteger 切 SYMBOL_TRADE_CALC_MODE——PRICE_EXCHANGE 走 SYMBOL_CALC_MODE_EXCH_STOCKS 且图表显 LAST,否则走 SYMBOL_CALC_MODE_FOREX 且显 BID。外汇/贵金属自定义回放须留意点差与杠杆的高风险。 BuiderBar1Min 按 macroRemoveSec(tick.time) 对齐到分钟:跨分钟则重置量价并开新 bar,否则更新 close/high/low,real_volume 累加 volume_real,tick_volume 仅 last>0 时 +1。 LoadTicks 入口重复了 MemNRates、MemNTicks、dtRet、RatesLocal[] 的声明,说明它是独立封装的载入函数,调用前需保证 CSV 已 Open 且 ReadAllsTicks 成功,否则直接 return 0。
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); 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="kw">inline class="type">bool BuiderBar1Min(class="type">MqlRates &rate, class="kw">const class="type">MqlTick &tick) { if (rate.time != macroRemoveSec(tick.time)) { rate.real_volume = class="num">0; rate.tick_volume = class="num">0; rate.time = macroRemoveSec(tick.time); rate.open = rate.low = rate.high = rate.close = tick.last; class="kw">return true; } rate.close = tick.last; 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) tick.volume_real; rate.tick_volume += (tick.last > class="num">0 ? class="num">1 : class="num">0); class="kw">return class="kw">false; } 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[];