MQL5 酷宝书:利用自定义品种进行交易策略压力测试·综合运用
(3/3)· 从品种类封装到压力测试落地,手把手把经纪商权限握在自己手里
「用日志确认历史tick是否真的进来了」
在 MT5 策略测试器里跑一段加载校验,最直观的办法是让 EA 把关键节点打印到日志。下面这段输出来自 EURUSD H1 周期的一次 TestLoad 调用:程序先报出本地缓存里最后一根K线的时间停在 2019.08.02 20:56,说明历史序列已就位;紧接着一行显示 Copied ticks number: 354400,即本次从服务器复制到的 tick 总量为 354400 笔。 这两个数能直接帮你判断「数据缺口」——若最后 rate time 远早于你设定的回测起点,或 copied ticks 明显少于同周期正常量级,就得怀疑品种数据没下全。外汇与贵金属杠杆高、滑点跳空频繁,拿残缺 tick 做回测,信号会偏得离谱。 开 MT5 后随便挂个 EA,在 OnTick 或独立函数里加 Print 把最后时间和 tick 计数打出来,跑一次就知道你的终端到底装了多少真数据。
EQ class="num">0 class="num">15:class="num">52:class="num">50.170 TestLoad(EURUSD,H1) The last rate time: class="num">2019.08.class="num">02 class="num">20:class="num">56 DJ class="num">0 class="num">15:class="num">52:class="num">50.351 TestLoad(EURUSD,H1) Copied ticks number: class="num">354400
去哪搞到可用的 Tick 历史
外汇是 OTC 市场,没有统一基准报价,也就不存在官方留存的即时报价存档。多数货币期货在芝加哥商品交易所成交,报价能当某种基准,但历史数据不免费,往往要注册、找销售经理谈才可能拿到测试期。 经纪商之间因竞争,报价差异通常不大,可他们基本不存 Tick 存档也不给下载。Dukascopy Bank 是少数免费源:简单注册就能下历史数据,含即时报价。其文件每行 5 列——时间、要价、出价、采购量、抛售量。 手动下只能选一天,要若干年历史就得耗大量精力。Quant Data Manager 这类辅助程序能批量从 Dukascopy 拉存档,图例里“数据”选项卡可勾选品种。 CiCustomSymbol::LoadTicks() 已适配上述程序的 csv 格式,意味着你下完文件就能直接喂给自定义品种做回测。外汇与贵金属杠杆高、滑点跳空频繁,用 Tick 级数据验证前先确认本地时区与 Dukascopy 的 UTC 偏移一致,否则会错周期。
◍ 给策略灌点差毒药看它扛不扛得住
回测只跑历史报价算不上真考验,压测的思路是人为造更恶劣的环境,逼出策略在极端条件下的稳定性边界。外汇和贵金属本身高杠杆高波动,点差一变就可能吞掉短线系统的全部利润,压测前先认清这类品种的风险本质。 点差因子直接决定交易成本。瞄准短线的策略对点差极度敏感,某些情形下点差占潜在回报比能超 100%。在 MT5 里可用 CiCustomSymbol::ChangeSpread() 造一个和基准点差不同的自定义品种来复现这种压力。 改点差有三条路:一是在 _spread_size 填正数设固定点差(测试器里 SYMBOL_SPREAD_FLOAT 恒为 true,点差仍按浮动逻辑走);二是用 _spread_markup 给浮动点差加标记;三是用 ENUM_SPREAD_BASE 选参考价——选 bid 则 ask=bid+点差,选 ask 则 bid=ask-点差,选 average 则 bid=均价-点差/2。该方法不写品种属性,而是在每个 tick 到来时改点差并存进即时报价库。 用 EURUSD 五位小数真实浮动点差(12-17 点)做对照,把点差拉到 25 点、50 点后:交易数从 172 降到 156、145,净盈利从 4018.27 降到 3877.58、3574.10 美元,最大净值回撤反而从 11.79% 降到 9.65%、8.29%,单笔盈利维持在 23-25 美元。点差越大单量越少、回撤越低,获利能力略升,这是短线策略典型的「少做少错」现象。 停止价位和冻结价位也能压。部分经纪商在重大数据前把止损最小距离提到 10 倍点差、收盘前提到 25 倍,可用 SetProperty 配 SYMBOL_TRADE_STOPS_LEVEL 和 SYMBOL_TRADE_FREEZE_LEVEL,但测试器当前版本不能动态改品种属性,只能预配置。保证金需求(SYMBOL_MARGIN_INITIAL 等)则可编程单独设,杠杆一变就能试出策略爆仓临界。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Change the initial spread | class=class="str">"cmt">//| Input parameters: | class=class="str">"cmt">//| class="num">1) _spread_size - the new fixed value of the spread, pips. | class=class="str">"cmt">//| If the value > class="num">0 then the spread value is fixed. | class=class="str">"cmt">//| class="num">2) _spread_markup - a markup for the floating value of the | class=class="str">"cmt">//| spread, pips. The value is added to the current spread if | class=class="str">"cmt">//| _spread_size=class="num">0. | class=class="str">"cmt">//| class="num">3) _spread_base - a type of the price to which a markup is | class=class="str">"cmt">//| added in case of the floating value. | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">bool CiCustomSymbol::ChangeSpread(class="kw">const class="type">uint _spread_size,class="kw">const class="type">uint _spread_markup=class="num">0, class="kw">const ENUM_SPREAD_BASE _spread_base=SPREAD_BASE_BID) { if(_spread_size==class="num">0) if(_spread_markup==class="num">0) { ::PrintFormat(__FUNCTION__+": neither the spread size nor the spread markup are set!", m_name,::GetLastError()); class="kw">return class="kw">false; } class="type">int symbol_digs=(class="type">int)this.GetProperty(SYMBOL_DIGITS); ::ZeroMemory(m_tick); class=class="str">"cmt">//--- copy ticks class="type">int tick_idx=class="num">0; class="type">uint tick_cnt=class="num">0; class="type">class="kw">ulong from=class="num">1;
「逐日回放自定义品种 tick 并重建点差」
做自定义品种回测时,MT5 不会替你把真实 tick 按天切片,得自己用 CopyTicksRange 按日边界拉数据。下面这段逻辑的核心,是把回放区间拆成以日为单位的小段,每天单独取 COPY_TICKS_INFO 类型的 tick 数组,再逐笔算买卖价与点差。 代码里先把起始时刻的时分许秒清零,用 StructToTime 得到当日 0 点 datetime;结束时刻直接取回放截止的毫秒换算值。循环里 t1 为当日 0 点毫秒、t2 为次日 0 点前 1 毫秒,这样一天最多拉满 86400 秒内的 tick。 点差处理分固定和浮动两种:固定时用 curr_point*_spread_size 直接算点差点数;浮动时取 ask-bid 再加可选加点 curr_point*_spread_markup。外汇与贵金属自带高杠杆与跳空风险,回放出的点差仅反映历史样本,实盘可能瞬间扩大数倍。 这段可直接塞进自定义品种类的回放方法里验证:把 m_name 换成你生成的离线品种名,跑完打印某天的 ticks_copied,若连续多日为 0 说明那段没写入 tick,倾向是数据源断档而非代码错。
class="type">class="kw">double curr_point=this.GetProperty(SYMBOL_POINT); class="type">int ticks_copied=class="num">0; class="type">MqlDateTime t1_time; TimeToStruct((class="type">int)(m_from_msc/class="num">1e3),t1_time); t1_time.hour=t1_time.min=t1_time.sec=class="num">0; class="type">class="kw">datetime start_datetime,stop_datetime; start_datetime=::StructToTime(t1_time); stop_datetime=(class="type">int)(m_to_msc/class="num">1e3); do { class="type">MqlTick custom_symbol_ticks[]; class="type">class="kw">ulong t1,t2; t1=(class="type">class="kw">ulong)class="num">1e3*start_datetime; t2=(class="type">class="kw">ulong)class="num">1e3*(start_datetime+PeriodSeconds(PERIOD_D1))-class="num">1; ::ResetLastError(); ticks_copied=::CopyTicksRange(m_name,custom_symbol_ticks,COPY_TICKS_INFO,t1,t2); if(ticks_copied<class="num">0) { ::PrintFormat(__FUNCTION__+": failed to copy ticks for a %s symbol! Error code: %d", m_name,::GetLastError()); class="kw">return class="kw">false; } class=class="str">"cmt">//--- there are some ticks for the current day else if(ticks_copied>class="num">0) { for(class="type">int t_idx=class="num">0; t_idx<ticks_copied; t_idx++) { class="type">MqlTick curr_tick=custom_symbol_ticks[t_idx]; class="type">class="kw">double curr_bid_pr=::NormalizeDouble(curr_tick.bid,symbol_digs); class="type">class="kw">double curr_ask_pr=::NormalizeDouble(curr_tick.ask,symbol_digs); class="type">class="kw">double curr_spread_pnt=class="num">0.; class=class="str">"cmt">//--- if the spread is fixed if(_spread_size>class="num">0) { if(_spread_size>class="num">0) curr_spread_pnt=curr_point*_spread_size; } class=class="str">"cmt">//--- if the spread is floating else { class="type">class="kw">double spread_markup_pnt=class="num">0.; if(_spread_markup>class="num">0) spread_markup_pnt=curr_point*_spread_markup; curr_spread_pnt=curr_ask_pr-curr_bid_pr+spread_markup_pnt; } class="kw">switch(_spread_base)
用基准价重建买卖盘口与 tick 标记
自定义品种回放里,盘口价格不一定齐整。下面这段按三种基准模式把 bid / ask 补全,再打上变动标记,最后一次性替换进行情池。 SPREAD_BASE_BID 表示以点差加在 bid 上得到 ask;SPREAD_BASE_ASK 则反过来从 ask 减点差推 bid。两种都只用一次 NormalizeDouble,按 symbol_digs 截断小数位,避免浮点尾巴污染后续比较。 SPREAD_BASE_AVERAGE 最绕:先取 bid 与 ask 均值,再向两侧各摊 half-spread。这里连续三次 NormalizeDouble,均值那行除以 2. 而非 2,强制走浮点除法,否则整数除法会直接丢精度。 价格算完写回 curr_tick 后,用 flags 记录哪一侧动了:bid 变置 TICK_FLAG_BID,ask 变置 TICK_FLAG_ASK;若两边都没变,则强制双标,防止静默 tick 被行情订阅方误判为断流。 替换不是一次就完。代码用 ATTEMTS 次重试调 TicksReplace,只有返回值等于 ticks_copied 才 break——这意味着自定义品种在高频回放时可能存在写冲突,重试机制能兜住部分丢失。开 MT5 把 ATTEMTS 调到 1 对比一下,能直观看到 tick 丢失数量变化。 外汇与贵金属自定义符号同样适用此逻辑,但点差跳变频繁,重写盘口时须留意滑点放大风险。
{
case SPREAD_BASE_BID:
{
curr_ask_pr=::NormalizeDouble(curr_bid_pr+curr_spread_pnt,symbol_digs);
class="kw">break;
}
case SPREAD_BASE_ASK:
{
curr_bid_pr=::NormalizeDouble(curr_ask_pr-curr_spread_pnt,symbol_digs);
class="kw">break;
}
case SPREAD_BASE_AVERAGE:
{
class="type">class="kw">double curr_avg_pr=::NormalizeDouble((curr_bid_pr+curr_ask_pr)/class="num">2.,symbol_digs);
curr_bid_pr=::NormalizeDouble(curr_avg_pr-curr_spread_pnt/class="num">2.,symbol_digs);
curr_ask_pr=::NormalizeDouble(curr_bid_pr+curr_spread_pnt,symbol_digs);
class="kw">break;
}
}
class=class="str">"cmt">//--- new ticks
curr_tick.bid=curr_bid_pr;
curr_tick.ask=curr_ask_pr;
class=class="str">"cmt">//--- flags
curr_tick.flags=class="num">0;
if(m_tick.bid!=curr_tick.bid)
curr_tick.flags|=TICK_FLAG_BID;
if(m_tick.ask!=curr_tick.ask)
curr_tick.flags|=TICK_FLAG_ASK;
if(curr_tick.flags==class="num">0)
curr_tick.flags=TICK_FLAG_BID|TICK_FLAG_ASK;
custom_symbol_ticks[t_idx]=curr_tick;
m_tick=curr_tick;
}
class=class="str">"cmt">//--- replace ticks
class="type">int ticks_replaced=class="num">0;
for(class="type">int att=class="num">0; att<ATTEMTS; att++)
{
ticks_replaced=this.TicksReplace(custom_symbol_ticks);
if(ticks_replaced==ticks_copied)
class="kw">break;◍ 逐日刷新 tick 后的替换计数与价差基准
上面这段收尾逻辑跑在日循环里:每天把 start_datetime 往后推一个 PERIOD_D1 的秒数,只要没超过 stop_datetime 且脚本没被终止,就继续往下刷。刷完一轮会用 PrintFormat 把累计替换的 tick 数打出来,变量 tick_cnt 在每次替换成功后累加 ticks_replaced。 实际在 EURUSD 的 M1 周期上跑过一次,终端日志显示 2019.08.30 12:49:59.678 输出了「Replaced ticks number: 354400」——也就是约 35.4 万笔 tick 被成功重写。这个数字直接反映你覆盖的历史样本量,想验证重放精度,先盯这个数够不够大。 紧接着定义了一个 ENUM_SPREAD_BASE 枚举,给后面算价差留了三种基准:0 用 bid、1 用 ask、2 用两者平均。回测里改 spread 时,选哪个基准会直接影响成交价推算,外汇和贵金属杠杆高、点值敏感,基准选错可能让回测结果偏离实盘概率较大。
::Sleep(PAUSE); } if(ticks_replaced!=ticks_copied) { ::Print(__FUNCTION__+": failed to replace the refreshed ticks!"); class="kw">return class="kw">false; } tick_cnt+=ticks_replaced; } class=class="str">"cmt">//--- next datetimes start_datetime=start_datetime+::PeriodSeconds(PERIOD_D1); } class="kw">while(start_datetime<=stop_datetime && !::IsStopped()); ::PrintFormat("\nReplaced ticks number: %I32u",tick_cnt); class=class="str">"cmt">//--- class="kw">return true; } class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Spread calculation base | class=class="str">"cmt">//+------------------------------------------------------------------+ enum ENUM_SPREAD_BASE { SPREAD_BASE_BID=class="num">0, class=class="str">"cmt">// bid price SPREAD_BASE_ASK=class="num">1, class=class="str">"cmt">// ask price SPREAD_BASE_AVERAGE=class="num">2, class=class="str">"cmt">// average price }; class=class="str">"cmt">//+------------------------------------------------------------------+ class="num">2019.08.class="num">30 class="num">12:class="num">49:class="num">59.678 TestChangeSpread(EURUSD,M1) Replaced ticks number: class="num">354400
「自定义品种压测的落地余味」
前面几节把自定义品种的参数配置、市场深度模拟和 tick 数据回放都拆开了,这一节只补一句实在的:压测不是跑通就完事,你得用自己策略真正依赖的那组参数去建品种。 示例包里 EURUSD1_tick.zip 有 3087.31 KB 的逐笔报价,Custom.zip 仅 10.37 KB 却带了脚本源码和品种类,直接拖进 MT5 的 Files 目录就能复现文里的测试环境。 外汇和贵金属自带高杠杆与跳空风险,自定义品种再逼真也只是近似市况,实盘前建议先用策略测试器跑至少三种点差档位。 真要验证市场深度那一项有没有用,开 MT5 建个自定义品种,把 Level 2 数据喂进去,比读十遍文档来得快。