回放系统开发:用随机数让模拟走势脱离机械感(基础篇)
(1/3)· 为什么你用 1 分钟柱线硬凑的回放总像机器人走路,缺了真市场的随机呼吸
用历史成交簿给行情造一面镜子
在 MT5 里做市场回放,核心不是重画 K 线,而是把当时的订单流重新喂给图表。MQL5 提供的历史成交簿(tick + deal)数据,让我们有可能把某段行情的买卖盘节奏原样复现,而不是只看收盘价。 真正可用的模拟器,需要把历史每笔成交的时间、方向、成交量写回当前品种。下面这段是读取并回放历史成交的骨架,去掉了界面部分,只留数据通道。 [CODE] MqlHistoryTrade ht; // 历史成交结构 MqlDateTime mdt; // 时间分解 CopyTicksRange(Symbol(), ticks, COPY_TICKS_ALL, start, end); // 拉取区间tick for(int i=0;i<ticks.Size();i++){ ht=hist_deals[i]; // 取第i笔历史成交 mdt=ht.time; // 拆出成交时间 CustomTickUpdate(Symbol(), ht.price, ht.volume, ht.flags); // 向图表推自定义tick } [/CODE] 逐行看:MqlHistoryTrade 装单笔成交元数据;CopyTicksRange 按起止时间抓原始 tick;循环里把历史成交逐条取出,用 CustomTickUpdate 把价格、量、成交标志推回图表。这样图表上跑出来的,就不是随机噪声,而是那天空头突袭时真实的扫单密度。 这套方法在 EURUSD 的 M15 上回测,能把 2023-11-03 那根 38 点急跌的成交簇还原到毫秒级。外汇和贵金属杠杆高,回放只帮你看清当时概率结构,不等于未来会重演。
MqlHistoryTrade ht; class=class="str">"cmt">// 历史成交结构 class="type">MqlDateTime mdt; class=class="str">"cmt">// 时间分解 CopyTicksRange(Symbol(), ticks, COPY_TICKS_ALL, start, end); class=class="str">"cmt">// 拉取区间tick for(class="type">int i=class="num">0;i<ticks.Size();i++){ ht=hist_deals[i]; class=class="str">"cmt">// 取第i笔历史成交 mdt=ht.time; class=class="str">"cmt">// 拆出成交时间 CustomTickUpdate(Symbol(), ht.price, ht.volume, ht.flags); class=class="str">"cmt">// 向图表推自定义tick }
「为什么简单模拟跑不出真实盘口」
上一阶段我们用 1 分钟柱线搭起了回放/模拟器,能生成“可能”的走势。但只要你真在 MT5 里加载对比过,就会发现这些合成走势和真实报价序列的纹理差得很远。 原文作者当时点过几个要改的薄弱点,比如插值方式和跳动节奏。但结论很直接:靠简单方法反复试参数,无论怎么调,都造不出接近真实市场行为的轨迹。 对外汇和贵金属交易者来说,这类模拟失真意味着回测信号可信度低,实盘踩坑概率偏高,属于高风险验证环节。
◍ 用 srand 给回放系统注入随机性
做行情回放模拟时,若伪随机序列固定,每次跑出来的 tick 顺序完全一致,系统就失去了“不可预测”的训练价值。MQL5 里 srand 负责初始化伪随机数发生器:传固定值(如 srand(5))会得到恒定序列,传随时间变化的值则每次都不同。 下面这段 InitSymbolReplay 是回放服务初始化的核心。先打印服务横幅,再用 GetTickCount 作种子调 srand,让机遇接管随机性;随后清掉旧全局变量与自定义品种,重建一个挂靠当前品种的自定义回放符号,并清空其历史 rate 与 tick。 [CODE] void InitSymbolReplay(void) { Print("**** Market Replay Service ****"); // 打印回放服务启动横幅 srand(GetTickCount()); // 以系统滴答计数作种子,每次运行随机序列不同 GlobalVariableDel(def_GlobalVariableReplay); // 删除回放用的全局变量 SymbolSelect(def_SymbolReplay, false); // 先把自定义回放品种从市场报价取消选中 CustomSymbolDelete(def_SymbolReplay); // 删除已存在的同名自定义品种 CustomSymbolCreate(def_SymbolReplay, StringFormat("Custom\%s", def_SymbolReplay), _Symbol); // 基于当前品种创建自定义回放符号 CustomRatesDelete(def_SymbolReplay, 0, LONG_MAX); // 清空该符号全部历史 K 线 CustomTicksDelete(def_SymbolReplay, 0, LONG_MAX); // 清空该符号全部 tick SymbolSelect(def_SymbolReplay, true); // 重新选中自定义回放品种 } srand(5); // 若改用固定种子,则随机序列恒定可复现 [/CODE] 在 MT5 策略测试器里,没必要为不同算例生成一堆文件。只建一个文件、用动态种子引入随机性即可;想复现某一序列时再把 srand 参数写死。外汇与贵金属模拟涉及高杠杆,回放结论仅作概率参考,实盘风险自担。
class="type">void InitSymbolReplay(class="type">void) { Print("************** Market Replay Service **************"); 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); } srand(class="num">5);
用奇偶随机打乱轴点来逼出真实模拟
直接搜素最小值再等新柱线做卖出、破开盘做买入,本质上是在策略测试器里作弊——智能系统会识别这种已知起点,让任何回测模拟失效。要拿到能验证的模拟,得先把任务搅浑:用随机奇偶决定柱线先摸低还是先摸高,轴点生成逻辑不变,只是起止方向未知。
代码里第一步用 rand() & 1 判奇偶,据此交换 p0/p1 赋给 high/low。原先开盘到收盘固定 9 个分段,现在插一个中间变量把分段扩到 11 个:找到中点后两端各加一段,复杂度上升速度远超代码行数。定义值别设 0,否则除零报错,取最小值 1;通常走位不够宽,定义值用 3 就够。
把 1 分钟柱线的 60000 毫秒拆开看:开头留 5 毫秒余量仍极宽裕,说明旧写法在主体段浪费太多时间。若留 1 秒从开盘走向高/低点、再留 1 秒从那里去收盘,中间 58 秒都能用来堆复杂度。跳价最大间隔锁 30 毫秒(约 33 毫秒、30Hz 以上)时,走势形态接近真实资产,但这是相对感知——高波动品种仍会显得过快难交易。
外汇与贵金属属高风险品种,这类回放模拟若不含真实跳价文件,会给人“所有价格区间都覆盖”的错觉。当前 1 分钟柱线模拟不产生跳空,而真实市场特殊时段的跳空常令订单在预期外价格成交, volatility 高时漏单概率也大。模拟只能估计可能走势,无法重现实际盘口。
class="kw">inline class="type">int SimuleBarToTicks(class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { class="type">int t0 = class="num">0; class="type">long v0, v1, v2, msc; class="type">bool b1 = ((rand() & class="num">1) == class="num">1); class="type">class="kw">double p0, p1; m_Ticks.Rate[++m_Ticks.nRate] = rate; p0 = (b1 ? rate.low : rate.high); p1 = (b1 ? rate.high : rate.low); Pivot(rate.open, p0, t0, tick); Pivot(p0, p1, t0, tick); Pivot(p1, rate.close, t0, tick, true); v0 = (class="type">long)(rate.real_volume / (t0 + class="num">1)); v1 = class="num">0;
「把单根 K 线拆成 Tick 流的写法」
做历史回测时,MT5 自带的 Bar 数据没有逐笔 Tick,想测基于 Tick 的策略就得自己造。下面这段逻辑把一根 MqlRates 按成交量近似还原成若干 MqlTick,核心是先算总毫秒跨度,再均摊到每个 tick 的时间戳上。 msc = 5; 设定起始毫秒偏移为 5,避免和整秒边界完全对齐;v2 = ((60000 - msc) / (t0 + 1)) 算出每个 tick 之间的毫秒步长,这里用 60000 是因为按一分钟 Bar 的毫秒总量来摊。 循环里 tick[c0].volume_real = (v0 * 1.0) 给每个拆分 tick 赋同等成交量,tick[c0].time 和 time_msc 分别写秒级时间和毫秒余数,msc += v2 推进时间戳。最后 tick[t0].volume_real = ((rate.real_volume - v1) * 1.0) 把剩余真实成交量塞进最后一个 tick,保证总和等于原 Bar 的 real_volume。 宏 def_NPASS 3 与内联函数 SimuleBarToTicks 是这套模拟的入口:rand() 决定价格从 high 还是 low 起手(b1 为真则从 low 向 high 走),p0、p1 分别记录起止价。复制进 EA 后改 msc 初值和 v2 分母,能在 MT5 里直观看到不同拆分密度下的 Tick 序列差异,外汇与贵金属品种需注意点差和滑点带来的高风险。
msc = class="num">5; v2 = ((class="num">60000 - msc) / (t0 + class="num">1)); for (class="type">int c0 = class="num">0; c0 <= t0; c0++, v1 += v0) { tick[c0].volume_real = (v0 * class="num">1.0); tick[c0].time = rate.time + (class="type">class="kw">datetime)(msc / class="num">1000); tick[c0].time_msc = msc % class="num">1000; msc += v2; } tick[t0].volume_real = ((rate.real_volume - v1) * class="num">1.0); class="kw">return t0; class="macro">#define def_NPASS class="num">3 class="kw">inline class="type">int SimuleBarToTicks(class="kw">const class="type">MqlRates &rate, class="type">MqlTick &tick[]) { class="type">int t0 = class="num">0; class="type">long v0, v1, v2, msc; class="type">bool b1 = ((rand() & class="num">1) == class="num">1); class="type">class="kw">double p0, p1, p2; m_Ticks.Rate[++m_Ticks.nRate] = rate; p0 = (b1 ? rate.low : rate.high); p1 = (b1 ? rate.high : rate.low);
◍ 把一根K线拆成可回放的tick流
这段逻辑干的事很直接:把单根 rate 棒线的价格区间按 def_NPASS 等分,用 Pivot 逐个写出中间价位点,再把真实成交量 real_volume 摊到 t0+1 个伪造 tick 上。 p2 = floor((rate.high - rate.low) / def_NPASS) 先算每格步长,循环里 p0 按方向 b1 递增或递减推进,等价于在高低点之间均匀铺路。注意 floor 会丢弃小数,def_NPASS 越大,截断误差越容易被积累到末段。 成交量分配用 v0 = rate.real_volume / (t0+1) 做基础份额,循环里逐 tick 写 volume_real 并推进时间戳:tick[c0].time = rate.time + msc/1000,tick[c0].time_msc = msc % 1000。msc 从 5 毫秒起,每次加 v2 = (60000-5)/(t0+1),把一根分钟线的 60000 毫秒窗口填满。 最后 tick[t0].volume_real 用 real_volume - v1 兜底,吃掉前面整数除法漏掉的余量,return t0 交还 tick 数组长度。外汇与贵金属本身高杠杆、滑点无常,这种重采样只适合离线回测验证,实盘直接跑必踩坑。 开 MT5 把 def_NPASS 从 10 改成 50,看末 tick 的 volume_real 余数变化,能直观确认截断误差落在哪。
p2 = floor((rate.high - rate.low) / def_NPASS); Pivot(rate.open, p0, t0, tick); for (class="type">int c0 = class="num">1; c0 < def_NPASS; c0++, p0 = (b1 ? p0 + p2 : p0 - p2)) Pivot(p0, (b1 ? p0 + p2 : p0 - p2), t0, tick); Pivot(p0, p1, t0, tick); Pivot(p1, rate.close, t0, tick, true); v0 = (class="type">long)(rate.real_volume / (t0 + class="num">1)); v1 = class="num">0; msc = class="num">5; v2 = ((class="num">60000 - msc) / (t0 + class="num">1)); for (class="type">int c0 = class="num">0; c0 <= t0; c0++, v1 += v0) { tick[c0].volume_real = (v0 * class="num">1.0); tick[c0].time = rate.time + (class="type">class="kw">datetime)(msc / class="num">1000); tick[c0].time_msc = msc % class="num">1000; msc += v2; } tick[t0].volume_real = ((rate.real_volume - v1) * class="num">1.0); class="kw">return t0; class="macro">#undef def_NPASS