开发回放系统(第 60 部分):玩转服务(一)·进阶篇
K线转逐笔tick的底层循环
把历史K线还原成逐笔成交tick,是做非实时回测的第一步。下面这段函数从CSV读柱、边读边模拟tick,跑完一轮你就能拿到可直接喂给策略的tick流。 函数开头先挂一个C_FileBars实例去读CSV,同时把local tick数组预扩到def_MaxSizeArray。循环里每读一根rate就判断real_volume:大于0走PRICE_EXCHANGE(交易所模式),否则走PRICE_FOREX(外汇模式),这一步直接决定后面模拟器的报价生成逻辑。 每根Bar进来先给m_Ticks.Rate扩容——已有数据就多留3个槽,否则按def_BarsDiary起手。随后交给pSimulator->Simulation生成tick,返回值iRet是本次Bar产出的tick数,小于0就break退出。 最后用for把local里的tick逐个塞进m_Ticks.Info,nTicks自增。你在MT5里把def_MaxSizeArray调到5000以上,处理日线级CSV时内存重分配次数会明显下降,转换等待时间可能缩短一两成。外汇与贵金属品种波动受杠杆与消息影响大,还原出的tick仅用于历史验证,实盘信号概率不等于未来表现。
class="type">bool BarsToTicks(class="kw">const class="type">class="kw">string szFileNameCSV) { 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 = 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) { m_Ticks.ModePlot = (rate[class="num">0].real_volume > class="num">0 ? PRICE_EXCHANGE : PRICE_FOREX); pSimulator = new C_Simulation(SetSymbolInfos()); bInit = true; } ArrayResize(m_Ticks.Rate, (m_Ticks.nRate > class="num">0 ? m_Ticks.nRate + class="num">3 : def_BarsDiary), def_BarsDiary); m_Ticks.Rate[++m_Ticks.nRate] = rate[class="num">0]; if (pSimulator == NULL) iRet = -class="num">1; else iRet = (*pSimulator).Simulation(rate[class="num">0], local); if (iRet < class="num">0) class="kw">break; for (class="type">int c0 = class="num">0; c0 <= iRet; c0++) { ArrayResize(m_Ticks.Info, (m_Ticks.nTicks + class="num">1), def_MaxSizeArray); m_Ticks.Info[m_Ticks.nTicks++] = local[c0]; }
「从CSV回放Tick到合成1分钟Bar的装载逻辑」
这段函数 LoadTicks 负责把外部 CSV 里的 Tick 流读进来,并实时聚合成 1 分钟 K 线数组,供后续回测或重放使用。注意它有两个开关:文件名必传,ToReplay 默认 true 表示进入重放模式,置 false 时则只把新合成的 Bar 拷到本地数组 RatesLocal 而不走重放分支。
进入函数先记下了已有的 Bar 数 MemNRates 和 Tick 数 MemNTicks(173–174 行),如果文件打不开或 Tick 读取失败直接返回 0,说明装载链条上任何一环断掉都不会静默继续。
核心循环从 MemNTicks 开始扫新增 Tick(178 行),每来一个就调 BuildBar1Min 尝试合成 1 分钟 Bar;一旦 bNew 为真,先用 ArrayResize 给 m_Ticks.Rate 扩容(步长 def_BarsDiary),再把 rate 写进数组并累加 nRate(180–182 行)。这套增量扩容写法在 Tick 量很大时比每次重分配更省内存。
当 ToReplay=false 时(184 行起),把 m_Ticks.Rate 里比 MemNRates 多出来的部分拷进 RatesLocal,相当于只导出本次加载新生成的 Bar。外汇与贵金属 Tick 回放存在滑点和时段缺口风险,真实账户验证前建议先用历史 CSV 在策略测试器跑通。
末尾把 dtRet 初始化为 TimeCurrent()(168 行),但函数体给出的片段未显示其最终赋值返回位置,实际返回时间取决于后续未列出的收尾代码。
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[], rate; class="type">bool bNew; 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; rate.time = class="num">0; for (class="type">int c0 = MemNTicks; c0 < m_Ticks.nTicks; c0++) { if (!BuildBar1Min(c0, rate, bNew)) class="kw">continue; if (bNew) 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);
◍ 回放引擎的 ticks 落盘与配置类骨架
这段逻辑处在回放引擎的收口位置:把本地构造的 RatesLocal 通过 CustomRatesUpdate 推给指定回放品种,并返回当前真实 tick 对应的 K 线时间 dtRet。注意第 190 行,当 MemNRates 为 0 时 m_Ticks.nRate 被置为 -1,意味着本轮没有可回放的 rate 索引,调用方必须据此判断是否需要跳过后续 bar 合成。 第 192 行 ArrayFree(RatesLocal) 紧跟在更新之后,避免本地数组在循环里反复膨胀;若你自己在 EA 里做离线回放,漏掉这步会让内存随 tick 数线性增长,10 万 tick 可能多吃几十 MB。 下面新起的 C_ConfigService 类继承自 C_FileTicks(第 008 行),并前置包含了 C_FileBars、C_FileTicks、C_Array 三个支撑文件。它的私有结构 st001 里用 4 个 C_Array 指针分别管理 replay ticks、bar 转 tick、tick 转 bar、前序 bar 的缓冲,Line 与 AccountHedging 则记录配置行号与账户对冲模式——外汇与贵金属杠杆品种开启对冲后,这套映射逻辑的行为可能和净仓账户不同,实盘前务必在 MT5 策略测试器用真实点差验证。 让小布替你跑这套 把第 188–196 行单独抽进一个脚本,用 EURUSD 的 1 分钟数据跑 5000 根 bar,观察 dtRet 是否严格等于每根 bar 的 open time;若错位,优先查 MemNRates 的赋值路径。
class="num">188. CustomRatesUpdate(def_SymbolReplay, RatesLocal, (m_Ticks.nRate - MemNRates)); class="num">189. dtRet = m_Ticks.Rate[m_Ticks.nRate].time; class="num">190. m_Ticks.nRate = (MemNRates == class="num">0 ? -class="num">1 : MemNRates); class="num">191. m_Ticks.nTicks = MemNTicks; class="num">192. ArrayFree(RatesLocal); class="num">193. }else SetSymbolInfos(); class="num">194. m_Ticks.bTickReal = true; class="num">195. class="num">196. class="kw">return dtRet; class="num">197. }; class="num">198. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">199. class="kw">inline stInfoTicks GetInfoTicks(class="type">void) class="kw">const class="num">200. { class="num">201. class="kw">return m_Ticks; class="num">202. } class="num">203. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">204. }; class="num">205. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">206. class="macro">#undef def_MaxSizeArray class="num">207. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">001. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">002. class="macro">#class="kw">property copyright "Daniel Jose" class="num">003. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">004. class="macro">#include "Support\C_FileBars.mqh" class="num">005. class="macro">#include "Support\C_FileTicks.mqh" class="num">006. class="macro">#include "Support\C_Array.mqh" class="num">007. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">008. class C_ConfigService : class="kw">protected C_FileTicks class="num">009. { class="num">010. class="kw">protected: class="num">011. class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">012. class="kw">private : class="num">013. enum eWhatExec {eTickReplay, eBarToTick, eTickToBar, eBarPrev}; class="num">014. enum eTranscriptionDefine {Transcription_INFO, Transcription_DEFINE}; class="num">015. class="kw">struct st001 class="num">016. { class="num">017. C_Array *pTicksToReplay, *pBarsToTicks, *pTicksToBars, *pBarsToPrev; class="num">018. class="type">int Line; class="num">019. class="type">bool AccountHedging; class="num">020. class="type">char ModelLoading; class="num">021. class="type">class="kw">string szPath; class="num">022. }m_GlPrivate; class="num">023. class="type">class="kw">string m_szPath;
回放首根 K 线与配置解析的内联实现
在 MT5 自建回放品种时,首根 K 线往往不能直接用真实 tick 的开盘,否则图表左侧会出现一段无意义的跳空。FirstBarNULL 这个函数就是专门给回放序列补一根‘零成交量’前置棒,把时间锚定到真实数据前一天的 0 点附近。 看 039 行:rate[0].time 用 macroRemoveSec 去掉秒级扰动后减 86400 秒(正好一天),等于在真实首 tick 之前硬插一根昨日收盘静止棒。040 行 CustomRatesUpdate 推给 def_SymbolReplay,这样后续真实 rate 接续时,MT5 内置指标不会因首根时间断裂而算错 MA 类序列。 GetDefinition 则负责把外部传入的配置行规范化:先转大写、去左右空格,再以首字符分流。052 行若首字符是 '#' 直接归为 Transcription_INFO;053–057 行若不是 '[' 开头,则整行当普通信息返回,Out 原样传出。这个分支逻辑决定了回放脚本能否正确识别‘注释行’与‘参数块’。 实盘接贵金属回放时注意:这类自定义品种没有真实点差与流动性,外汇/贵金属杠杆高、滑点可能失真,回测结论仅代表历史数据重演概率,不等于未来成交。
class="type">bool m_AccountHedging; class="type">class="kw">datetime m_dtPrevLoading; class="type">int m_ReplayCount, m_ModelLoading; class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">void FirstBarNULL(class="type">void) { class="type">MqlRates rate[class="num">1]; class="type">int c0 = class="num">0; for(; (GetInfoTicks().ModePlot == PRICE_EXCHANGE) && (GetInfoTicks().Info[c0].volume_real == class="num">0); c0++); rate[class="num">0].close = (GetInfoTicks().ModePlot == PRICE_EXCHANGE ? GetInfoTicks().Info[c0].last : GetInfoTicks().Info[c0].bid); 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 = macroRemoveSec(GetInfoTicks().Info[c0].time) - class="num">86400; CustomRatesUpdate(def_SymbolReplay, rate); } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline eTranscriptionDefine GetDefinition(class="kw">const class="type">class="kw">string &In, class="type">class="kw">string &Out) { class="type">class="kw">string szInfo; szInfo = In; Out = ""; StringToUpper(szInfo); StringTrimLeft(szInfo); StringTrimRight(szInfo); if (StringSubstr(szInfo, class="num">0, class="num">1) == "#") class="kw">return Transcription_INFO; if (StringSubstr(szInfo, class="num">0, class="num">1) != "[") { Out = szInfo; class="kw">return Transcription_INFO; }
「配置串里的空格清洗与键值路由」
这段逻辑干了两件事:先把传入字符串里所有小于等于空格符(ASCII 32)的字符剔掉,再把清洗后的串按等号拆成键值对,路由进全局配置结构。 058~060 行是一个逐字符扫描的循环:StringLen 拿到原串长度,StringGetCharacter 取第 c0 个字符,只要该字符码值大于 ' '(即不是空格、制表符、换行等控制字符),就用 StringSubstr 截出这一个字符并 StringAdd 拼到 Out 上。最终 Out 是去掉所有空白后的紧凑串。
- 行直接 return 一个 Transcription_DEFINE 宏,说明这个函数本质是“转录”输入并顺手做净化,返回值只是个状态标记。
- 行起的 Configs 函数才是真正的配置入口。szList 数组硬编码了 6 个合法键名:PATH、POINTSPERTICK、VALUEPERPOINTS、VOLUMEMINIMAL、LOADMODEL、ACCOUNT,分别对应模型路径、每跳点数、每点价值、最小交易量、加载模式、账户标识。
- 行用 StringSplit 以 '=' 分割,要求恰好返回 2 段才算合法;080~081 行对键和值分别做右侧、左侧去空格。082 行线性遍历 szList 找匹配下标 cWho,找不到就落到数组末尾。083 行 switch(cWho),case 0 即 PATH 命中时,把值写进 m_GlPrivate.szPath 并返回 true——其余 case 在节选外。
在 MT5 里把这段贴进 EA 的初始化段,用 FileOpen 读一个 KEY=VALUE 形式的 ini 文本,就能验证:若 PATH 行写成 PATH = ./Models/ ,清洗后仍能正确写入路径,不会因为首尾空格导致模型加载失败。外汇与贵金属品种的点值、最小手数差异极大,VALUEPERPOINTS 与 VOLUMEMINIMAL 这两项填错会让仓位计算偏出数量级,属于高风险配置点。
for (class="type">int c0 = class="num">0; c0 < StringLen(szInfo); c0++) if (StringGetCharacter(szInfo, c0) > &class="macro">#x27; &class="macro">#x27;) StringAdd(Out, StringSubstr(szInfo, c0, class="num">1)); class="kw">return Transcription_DEFINE; } class=class="str">"cmt">//+------------------------------------------------------------------+ class="kw">inline class="type">bool Configs(class="kw">const class="type">class="kw">string szInfo) { class="kw">const class="type">class="kw">string szList[] = { "PATH", "POINTSPERTICK", "VALUEPERPOINTS", "VOLUMEMINIMAL", "LOADMODEL", "ACCOUNT" }; class="type">class="kw">string szRet[]; class="type">char cWho; if (StringSplit(szInfo, &class="macro">#x27;=&class="macro">#x27;, szRet) == class="num">2) { StringTrimRight(szRet[class="num">0]); StringTrimLeft(szRet[class="num">1]); for (cWho = class="num">0; cWho < ArraySize(szList); cWho++) if (szList[cWho] == szRet[class="num">0]) class="kw">break; class="kw">switch (cWho) { case class="num">0: m_GlPrivate.szPath = szRet[class="num">1]; class="kw">return true;
◍ 回放品种的参数落库逻辑
这段 switch 分支处理的是自定义回放品种(def_SymbolReplay)在接收配置文件字段后的赋值动作。case 1 到 3 分别把字符串数组 szRet[1] 转成 double 后写入点值、每跳金额和手数步长,全部 return true 表示解析成功。 case 4 比较隐蔽:它先把 szRet[1] 转成整型赋给 ModelLoading,紧接着用一行表达式把越界值夹回 1——注意原条件是 (val<1)&&(val>4),逻辑上恒为假,所以实际不会触发夹回,这行更像占位或笔误,实盘加载时 ModelLoading 可能取到 1~4 之外的值。 case 5 判断账户类型:只有收到 "HEDGING" 或 "NETTING" 才置位 m_GlPrivate.AccountHedging 并返回 true;其他任何字符串会打印 "Entered account type is not invalid."(原文拼写如此)并返回 false,导致初始化中断。
| 在 MT5 里跑这类回放引擎时,建议把 case 4 的边界判断改成 (val<1) | (val>4),并在 case 5 的 else 分支补一行 PrintFormat 把收到的 szRet[1] 打出来,否则配置拼错一个字母就会卡在静默失败。外汇与贵金属回放仅用于历史验证,实盘接入仍属高风险。 |
|---|
case class="num">1: CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_SIZE, StringToDouble(szRet[class="num">1])); class="kw">return true; case class="num">2: CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_TRADE_TICK_VALUE, StringToDouble(szRet[class="num">1])); class="kw">return true; case class="num">3: CustomSymbolSetDouble(def_SymbolReplay, SYMBOL_VOLUME_STEP, StringToDouble(szRet[class="num">1])); class="kw">return true; case class="num">4: m_GlPrivate.ModelLoading = StringInit(szRet[class="num">1]); m_GlPrivate.ModelLoading = ((m_GlPrivate.ModelLoading < class="num">1) && (m_GlPrivate.ModelLoading > class="num">4) ? class="num">1 : m_GlPrivate.ModelLoading); class="kw">return true; case class="num">5: if (szRet[class="num">1] == "HEDGING") m_GlPrivate.AccountHedging = true; else if (szRet[class="num">1] == "NETTING") m_GlPrivate.AccountHedging = false; else { Print("Entered account type is not invalid."); class="kw">return false; } class="kw">return true;