开发回放系统 市场模拟(第 20 部分):外汇(I)·进阶篇
(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 数量是否符合预期。
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 用这段构造逻辑验证符号重建是否干净。
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,观察图表是否按毫秒级间隔重绘——能跑通说明你的回放内核接线没问题。
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 分布偏离单机回放,高风险。
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),循环直接跳过,就能进下一步。 插入柱线时只需告诉系统收盘价,其余交给模拟函数。外汇与证券两种模式此处写法一致,关键就一行按显示类型生成收盘价。注意,这阶段只是回放文件里的数据,还没到模拟。 两个遗留问题里,定时器更紧迫:某些品种可能几小时无成交(停牌、拍卖或其它),原定时器假设连续跳动,会卡死回放。下面这段是调整回放位置的函数骨架,能看清跳过前导零量跳价和按滑块定位的逻辑。 外汇与贵金属回放属高风险验证环境,参数没调好可能画出畸形柱,仅建议在策略测试器隔离品种上跑。
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 被逐根拼出来;外汇与贵金属杠杆高,回测参数偏差可能被放大,验证时先用小周期样本。
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);