迷你行情模拟器或手动策略测试器(基础篇)
◍ 用 MT5 自带工具手测策略
MT5 终端里藏着一个常被忽略的手动回放功能,官方叫「测试者」里的迷你行情模拟器,能让你脱离自动 EA,纯手动按历史 tick 一步步重演行情。 对价格行为交易者来说,这比直接看静态图表更有用:你可以暂停、回退、在某一根 K 线重新挂单,验证自己当时的进出场直觉是否站得住脚。 这套工具在 2017-11-29 的公开帖里就被交易者 Dmitriy Zabudskiy 点出,该帖累计浏览 9595 次、互动 21 条,说明手动验证的需求一直都在。 外汇与贵金属杠杆高、滑点大,手动测试结果只代表历史路径下的可能性,不能外推为稳定盈利依据;开 MT5 按 F4 进测试者切到「手动」模式即可上手。
新手入市前的两条岔路
刚接触外汇和贵金属的人,动机高度一致——都想从市场里拿走利润,但各自设定的门槛、资金条件和时间窗口天差地别。这里先把典型心态拆开看,因为后续你选哪种验证方式,直接由这条路决定。 一类人奔着“一次全部”来:轻信那种“极少资金甚至免费拿到无瑕策略”的诱导,以为快进快出就能暴富。这类玩法在外汇/贵金属高杠杆环境下,本金蒸发的速度往往和账面浮盈一样快,风险极高。 另一类是“教育、教育再教育”型,拒绝童话,肯花时间啃行情规律和交易系统。可真上实盘才发现,利润常低于教科书预期——不是理论错,是滑点、流动性、情绪成本在教科书里被省掉了。 走到这一步,前一类多数永久退出,后一类继续补理论和实盘策略。本文面向等不及、想在模拟环境先跑策略的人:有人做短线但只有周末闭市后的夜间有空,有人做中长线但不愿耗一整年磨模拟盘。 有人会想,拿历史图表回测不就完了?实践中常掉坑:回测华丽,实盘拉胯。原因各异,但结论倾向明确——尽量在接近真实的系统里学。行情模拟器(市面可购)够用,而我自己在 MetaTrader 5 里写的“迷你行情模拟器”指标,功能比完整终端少,只做策略理论验证。
「模拟器面板能干什么」
这套模拟器自带独立控制面板,同时复用 MetaTrader 5 终端本身的按钮,相当于在 MT5 里嵌了一个可交互的行情沙盘。它只支持两个方向的订单:买入与卖出;在下单前可设止损、止盈和交易量,订单挂出后还能改价,停止价位直接用鼠标拖。 建模速度只有七档,可归为三组:第一组叫“珠宝”,基于分钟帧分笔报价重建,和策略测试器逻辑一致;第二组用现成分钟数据、不生成 tick,速度快但精度掉一截;第三组最猛,无视周期统一每秒一根 K 线。 面板实时给出利润、点数、交易量,覆盖当前与历史订单,以及从仿真启动以来的累计成交。MT5 全部标准图形对象和周期都可用,切周期用终端面板按钮就行。外汇和贵金属杠杆高,模拟结论只代表历史环境,实盘可能完全走样。
◍ 分笔报价的生成逻辑与代码拆解
在 MT5 策略测试器里复现真实盘口,核心是把一根蜡烛拆成若干分笔报价。原算法被改造成两个函数协作:主函数负责按成交量与价位关系派发基础 tick,辅助函数补足细节,整套机制被交易者戏称为“珠宝建模”。 主函数 func_tick_generation 接收蜡烛结构与 tick 数组两个引用参数。若开盘、收盘、最高、最低四价全相等,直接将 tick_volume 置 1,避免后续除法出现除零错误——这是脏数据下的保护分支。 当 tick_volume 小于 4 时,按 1~3 个 tick 的几种形态直接填充数组:1 个就取收盘价;2 个取开收;3 个则依据阴阳与高低点回滚路径插中间价。例如收在低位且开不等于高,中间 tick 取最高价,模拟冲高回落。 超过 3 个 tick 时,蜡烛按点数切成上、中、下三段(看涨与看跌的切法不同,代码里用背景色区分了两套公式)。算出每段占全长比例后,交给辅助函数 Tick Small 逐步推演,最终单根蜡烛输出的分笔报价不超过 11 个。 外汇与贵金属品种点值小、跳空频繁,用这套生成器做回测可能更贴近真实滑点,但仍是概率性近似,实盘高风险依旧存在。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Tick Generation 函数 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void func_tick_generation( class="type">MqlRates &rates, class=class="str">"cmt">// 蜡烛数据 class="type">class="kw">double &tick[] class=class="str">"cmt">// 分笔报价数组 ) { if(rates.open==rates.close && rates.high==rates.low && rates.open==rates.high){rates.tick_volume=class="num">1;} if(rates.tick_volume<class="num">4)class=class="str">"cmt">// 少于四个分笔报价 { ArrayResize(tick,class="type">int(rates.tick_volume)); class=class="str">"cmt">// 调整分笔报价数组大小 if(rates.tick_volume==class="num">1)tick[class="num">0]=rates.close; class=class="str">"cmt">// 一个分笔报价 if(rates.tick_volume==class="num">2) class=class="str">"cmt">// 两个分笔报价 { tick[class="num">0]=rates.open; tick[class="num">1]=rates.close; } if(rates.tick_volume==class="num">3) class=class="str">"cmt">// 三个分笔报价 { tick[class="num">0]=rates.open; tick[class="num">2]=rates.close; if(rates.open==rates.close) class=class="str">"cmt">// 朝着一个方向前进, 并回到了开盘价 { if(rates.high==rates.open)tick[class="num">1]=rates.low; if(rates.low==rates.open)tick[class="num">1]=rates.high; } if(rates.close==rates.low && rates.open!=rates.high)tick[class="num">1]=rates.high; class=class="str">"cmt">// 朝着一个方向前进, 回滚并突破开盘价 if(rates.close==rates.high && rates.open!=rates.low)tick[class="num">1]=rates.low; if(rates.open==rates.high && rates.close!=rates.low)tick[class="num">1]=rates.low; class=class="str">"cmt">// 朝着一个方向前进, 回滚但未突破开盘价 if(rates.open==rates.low && rates.close!=rates.high)tick[class="num">1]=rates.high; if((rates.open==rates.low && rates.close==rates.high) || (rates.open==rates.high && rates.close==rates.low)) { tick[class="num">1]=NormalizeDouble((((rates.high-rates.low)/class="num">2)+rates.low),_Digits); class=class="str">"cmt">// 在一个方向的若干点数 } } } if(rates.tick_volume>class="num">3) class=class="str">"cmt">// 超过三个分笔报价 { class=class="str">"cmt">// 按照点数计算蜡烛的大小 class="type">int candle_up=class="num">0; class="type">int candle_down=class="num">0; class="type">int candle_centre=class="num">0; if(rates.open>rates.close) { candle_up=class="type">int(MathRound((rates.high-rates.open)/_Point)); candle_down=class="type">int(MathRound((rates.close-rates.low)/_Point)); } if(rates.open<=rates.close) { candle_up=class="type">int(MathRound((rates.high-rates.close)/_Point)); candle_down=class="type">int(MathRound((rates.open-rates.low)/_Point)); } candle_centre=class="type">int(MathRound((rates.high-rates.low)/_Point)); class="type">int candle_all=candle_up+candle_down+candle_centre; class=class="str">"cmt">// 走势的总长度 class="type">int point_max=class="type">int(MathRound(class="type">class="kw">double(candle_all)/class="num">2)); class=class="str">"cmt">// 分笔报价的最大可能数量 class="type">class="kw">double share_up=class="type">class="kw">double(candle_up)/class="type">class="kw">double(candle_all); class="type">class="kw">double share_down=class="type">class="kw">double(candle_down)/class="type">class="kw">double(candle_all); class="type">class="kw">double share_centre=class="type">class="kw">double(candle_centre)/class="type">class="kw">double(candle_all); class=class="str">"cmt">// 计算每个部分的参考点数量
把单根K线拆成11档分笔报价
这段逻辑干的事,是把一根蜡烛的 open/high/low/close 按成交量压缩成最多 11 个 tick 点,塞进 tick[] 数组。先按 tick_volume 限幅:量小于 10 就取实际量,否则封顶 10,再受 point_max 约束;随后用 share_up / share_down / share_centre 三个比例切出上、下、中三段的点数分配。 若对应方向有 candle 但算出来点数为 0,会强制补 1 点并从 centre 扣 1,避免某段塌掉。ArrayResize(tick,11) 先把容器开好,p 从 0 起,tick[0] 直接写开盘价。 open>close 判为下行:先铺 high 段(route=1)、再 low 段(route=-1)、最后 close 段;open<=close 则反过来,先 low 后 high 再 close。每段交给 func_tick_small 增量写数组,写完按实际用到的最大索引裁掉多余空间。 func_tick_small 里,point==1 时只补一个终点;point>1 才做波浪式插值:wave_v=(point+1)/2,step_v 按 candle 距离整除后向上取整,循环里一正一反交替加减 _Point 模拟毛刺,末尾若归一化价格不等于 end 就再补一脚终点。直接把这段代码贴进 MT5 脚本,改 rates 结构来源即可在「专家」日志里打印 tick[] 验证还原度。 外汇与贵金属杠杆高、滑点随机,这种合成 tick 仅用于回测近似,实盘分笔流可能偏离。
class="type">char point=class="num">0; if(rates.tick_volume<class="num">10)point=class="type">char(rates.tick_volume); else point=class="num">10; if(point>point_max)point=class="type">char(point_max); class="type">char point_up=class="type">char(MathRound(point*share_up)); class="type">char point_down=class="type">char(MathRound(point*share_down)); class="type">char point_centre=class="type">char(MathRound(point*share_centre)); class=class="str">"cmt">// 检查所选范围的参考点 if(candle_up>class="num">0 && point_up==class="num">0) {point_up=class="num">1;point_centre=point_centre-class="num">1;} if(candle_down>class="num">0 && point_down==class="num">0) {point_down=class="num">1;point_centre=point_centre-class="num">1;} class=class="str">"cmt">// 调整输出数组的大小 ArrayResize(tick,class="num">11); class="type">char p=class="num">0; class=class="str">"cmt">// 分笔报价数组的索引 (tick[]) tick[p]=rates.open; class=class="str">"cmt">// 第一个分笔报价等于开盘价 if(rates.open>rates.close) class=class="str">"cmt">// 下行趋势 { func_tick_small(rates.high,class="num">1,candle_up,point_up,tick,p); func_tick_small(rates.low,-class="num">1,candle_centre,point_centre,tick,p); func_tick_small(rates.close,class="num">1,candle_down,point_down,tick,p); ArrayResize(tick,p+class="num">1); } if(rates.open<=rates.close) class=class="str">"cmt">// 上行或十字星 { func_tick_small(rates.low,-class="num">1,candle_down,point_down,tick,p); func_tick_small(rates.high,class="num">1,candle_centre,point_centre,tick,p); func_tick_small(rates.close,-class="num">1,candle_up,point_up,tick,p); ArrayResize(tick,p+class="num">1); } } } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Tick Small 函数 | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void func_tick_small( class="type">class="kw">double end, class=class="str">"cmt">// 走势结束 class="type">char route, class=class="str">"cmt">// 走势方向 class="type">int candle, class=class="str">"cmt">// 走势距离 class="type">char point, class=class="str">"cmt">// 点数 class="type">class="kw">double &tick[], class=class="str">"cmt">// 分笔报价数组 class="type">char&i class=class="str">"cmt">// 数组的当前索引 ) { if(point==class="num">1) { i++; if(i>class="num">10)i=class="num">10; class=class="str">"cmt">// 调整 tick[i]=end; } if(point>class="num">1) { class="type">class="kw">double wave_v=(point+class="num">1)/class="num">2; class="type">class="kw">double step_v=(candle-class="num">1)/MathFloor(wave_v)+class="num">1; step_v=MathFloor(step_v); for(class="type">char p_v=i+class="num">1,i_v=i; p_v<i_v+point;) { i++; if(route==class="num">1)tick[i]=tick[i-class="num">1]+(step_v*_Point); if(route==-class="num">1)tick[i]=tick[i-class="num">1]-(step_v*_Point); p_v++; if(p_v<i_v+point) { i++; if(route==class="num">1)tick[i]=tick[i-class="num">1]-_Point; if(route==-class="num">1) tick[i]=tick[i-class="num">1]+_Point; } p_v++; } if(NormalizeDouble(tick[i],_Digits)!=NormalizeDouble(end,_Digits)) { i++; if(i>class="num">10)i=class="num">10; class=class="str">"cmt">// 调整 tick[i]=end; } } }
「周期切换下的数据搬运与全局变量通信」
系统代码里函数来源杂乱,根源在于程序既要接用户操作,也要跟终端外壳打交道。为了减少指标窗口里的控件对象,切换周期的机制直接借用了终端外壳,但代价是指标在切周期时会被整体重新初始化,局部和全局作用域的变量全部清零。 每次切换周期,程序会把 M1 和当前所选周期这两组数据的数组复制出来,后续在面板上选模拟速度、质量(精修或快速法)再跑图表模型。测试中发现,若不在重初始化前拷贝,切换后历史缓冲直接为空,模型无法接续。 控制面板的下单、删单走的是 COrder 类,图表构建过程中的订单管理也由它负责。周期变化导致指标重启这件事不可避免,所以跨重初始化的通信靠客户终端全局变量撑着——它比普通全局变量能多活 4 周,且重启不丢,唯一别扭点是只支持 double 类型。 比起每次新建文件做读写,用终端全局变量做桥接明显更轻。外汇与贵金属行情高波动、高杠杆,这类重初始化逻辑若写错,实盘可能瞬间重复发单,务必在 MT5 策略测试器里切周期跑一遍验证。