将概率论应用于缺口交易·进阶篇
(2/3)·当价格被假设为零漂移随机漫游,盈利是否真无可能?本篇拆解检测偏差与风险定量的实战路径
用 tick 中点捕捉价格缺口的底层结构
这套逻辑把每个 tick 的 bid 与 ask 取平均,得到一个近似成交中点 p,用前后两个中点之差判断缺口是否达到阈值 gmin。外汇与贵金属点差跳动频繁,用中点而非单一报价,能削弱点差噪声带来的伪缺口,但高频tick下仍属高风险观测。
| 核心状态由一个 is0 标志控制:首 tick 只记录 tick0,不下判断;从第二个 tick 起才计算 p0(上一 tick 中点)与 p(当前中点),当 | p-p0 | ≥gmin 时调用 gaps.add 登记一个新缺口。 |
|---|
SGap 结构里 brkn() 判定缺口是否被价格回穿:若原方向向上(p0>p1)且后续 p3≤p1,或反向跌破后涨回 p1,均视为已破。change() 在破位前持续刷新 p2(极值延伸点)与 p3(最新点),Q() 则返回 (p2-p0)/(p2-p1),量化价格相对缺口原点的推进比例,Q=0 直接返回避免除零。 CGaps 用动态数组 gs 存全部缺口、go 存未破缺口索引;add() 里若 go 有 -1 空位就复用,否则扩容(步长 ND)。卸载时 gs2f() 把每个缺口的幅度、Q值、破位标记(1/0)、点差写入公共目录文本文件,方便用 Python 或 Excel 做后续分布统计。
class="type">bool is0=class="kw">false; class="type">void OnTick() { class="type">MqlTick tick; if (!SymbolInfoTick(_Symbol, tick)) class="kw">return; if(is0) { class="type">class="kw">double p=(tick.bid+tick.ask)*class="num">0.5, p0=(tick0.bid+tick0.ask)*class="num">0.5; gaps.change(p); if(MathAbs(p-p0)>=gmin) gaps.add(p0,p,tick.ask-tick.bid); } else is0=true; tick0=tick; } class="type">int OnInit() { gaps.init(); class="kw">return(INIT_SUCCEEDED); } class="type">void OnDeinit(class="kw">const class="type">int reason) { gaps.gs2f(fname); } class="type">void SGap :: set(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd) { p1=p_1; p0=p2=p3=p; s=sprd; } class="type">bool SGap :: brkn() { class="kw">return ((p0>p1)&&(p3<=p1))||((p0<p1)&&(p3>=p1)); } class="type">void SGap :: change(class="type">class="kw">double p) { if(brkn()) class="kw">return; if((p0>p1&&p>p2) || (p0<p1&&p<p2)) p2=p; p3=p; } class="type">class="kw">double SGap :: gap() { class="kw">return MathAbs(p0-p1); } class="type">class="kw">double SGap :: Q() { class="type">class="kw">double q=p2-p1; if(q==class="num">0.0) class="kw">return class="num">0.0; class="kw">return (p2-p0)/q; } class="type">void CGaps :: init() { ngs=ngo=class="num">0; } class="type">void CGaps :: add(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd) { ++ngs; if(ArraySize(gs)<ngs) ArrayResize(gs,ngs,ND); gs[ngs-class="num">1].set(p_1,p,sprd); class="type">int i=class="num">0; for(; i<ngo; ++i) if(go[i]<class="num">0) class="kw">break; if(i==ngo) { ++ngo; if(ArraySize(go)<ngo) ArrayResize(go,ngo,ND); } go[i]=ngs-class="num">1; } class="type">void CGaps :: change(class="type">class="kw">double p) { for(class="type">int i=class="num">0; i<ngo; ++i) { if(go[i]<class="num">0) class="kw">continue; gs[go[i]].change(p); if(gs[go[i]].brkn()) go[i]=-class="num">1; } } class="type">void CGaps :: gs2f(class="type">class="kw">string fn) { class="type">int f=FileOpen(fn, FILE_WRITE|FILE_COMMON|FILE_ANSI|FILE_TXT), c; for(class="type">int i=class="num">0;i<ngs;++i) { if (gs[i].brkn()) c=class="num">1; else c=class="num">0; FileWriteString(f,(class="type">class="kw">string)gs[i].gap()+" "+(class="type">class="kw">string)gs[i].Q()+" "+(class="type">class="kw">string)c+" "+(class="type">class="kw">string)gs[i].s); if(i==ngs-class="num">1) class="kw">break; FileWriteString(f,"\n"); }
◍ 缺口采集的会话时段切割逻辑
这段脚本把「跳空缺口」的定义绑在自定义交易时段上,而不是整根 K 线。核心靠 nsession() 做时间取模:先减去 mbias 分钟乖离,再对 speriod(时段期限秒数)取余,若余数大于 slength(时段长度秒数)就返回 -1,表示该时刻不在统计窗口内。
| 实际跑起来,OnTick 里用 tick0 缓存上一会话中价,当新会话编号 ns 大于 ns0 且 | p-p0 | >=gmin 时才 add 缺口。gmin 按品种设:USDJPY 给 0.1、EURUSD 给 0.001,否则噪声缺口会灌满文件。 |
|---|
OnDeinit 调用 gs2f 把结构数组写进 gaps\stat.txt,所以EA停止时才落盘——中途断线数据会丢。外汇与贵金属跳空受消息面驱动,滑点扩大属高风险,参数需按品种手动标定。
class="macro">#define ND class="num">100 input class="type">class="kw">double gmin=class="num">0.001; class=class="str">"cmt">// 最小缺口大小: USDJPY - class="num">0.1, EURUSD - class="num">0.001 input class="type">uint mperiod=class="num">1; class=class="str">"cmt">// 时段期限(分钟) input class="type">uint mlength=class="num">1; class=class="str">"cmt">// 时段长度(分钟) input class="type">uint mbias=class="num">0; class=class="str">"cmt">// 第一个时段乖离(分钟) input class="type">class="kw">string fname="gaps\\stat.txt"; class=class="str">"cmt">// 统计文件名 class="kw">struct SGap { class="type">class="kw">double p0; class="type">class="kw">double p1; class="type">class="kw">double p2; class="type">class="kw">double p3; class="type">class="kw">double s; class="type">void set(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd); class="type">bool brkn(); class="type">void change(class="type">class="kw">double p); class="type">class="kw">double gap(); class="type">class="kw">double Q(); }; class CGaps { SGap gs[]; class="type">int ngs; class="type">int go[]; class="type">int ngo; class="kw">public: class="type">void init(); class="type">void add(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd); class="type">bool change(class="type">class="kw">double p); class="type">void gs2f(class="type">class="kw">string fn); }; CGaps gaps; class="type">MqlTick tick0; class="type">int ns0=-class="num">1; class="type">class="kw">ulong sbias=mbias*class="num">60, speriod=mperiod*class="num">60, slength=mlength*class="num">60; class="type">void OnTick() { class="type">MqlTick tick; if (!SymbolInfoTick(_Symbol, tick)) class="kw">return; class="type">class="kw">double p=(tick.bid+tick.ask)*class="num">0.5; gaps.change(p); class="type">int ns=nsession(tick.time); if(ns>=class="num">0) { class="type">class="kw">double p0=(tick0.bid+tick0.ask)*class="num">0.5; if(ns0>=class="num">0&&ns>ns0&&MathAbs(p-p0)>=gmin) gaps.add(p0,p,tick.ask-tick.bid); ns0=ns; tick0=tick; } } class="type">int OnInit() { if(speriod==class="num">0||slength==class="num">0||speriod<slength||speriod<=sbias) { Print("wrong session format"); class="kw">return(INIT_FAILED); } gaps.init(); class="kw">return(INIT_SUCCEEDED); } class="type">void OnDeinit(class="kw">const class="type">int reason) { gaps.gs2f(fname); } class="type">int nsession(class="type">class="kw">datetime t) { class="type">class="kw">ulong t0=(class="type">class="kw">ulong)t; if(t0<sbias) class="kw">return -class="num">1; t0-=sbias; if(t0%speriod>slength) class="kw">return -class="num">1; class="kw">return (class="type">int)(t0/speriod); }
「缺口对象的内部状态机怎么写」
这段结构把价格缺口抽象成 SGap 对象,再用 CGaps 容器管理多个活跃缺口。SGap::set 初始化时把基准价 p1、当前价 p 与点差 sprd 记录下来,p0/p2/p3 全部锚在 p,意味着新缺口刚生成时极值与实时价重合。 SGap::brkn 用一句布尔表达式判定缺口是否已被回填:向上缺口要求 p0>p1 且最新 p3 跌破 p1;向下缺口则相反。只要触发,后续 change 直接 return,缺口退出跟踪。 change 里的逻辑值得细看:只有当你价 p 继续沿原方向拉大极值(p0>p1 且 p>p2,或反之)才更新 p2,否则 p2 冻结。p3 每次都刷新为最新价,所以 Q() 返回的 (p2-p0)/(p2-p1) 实质是「极值相对基准的穿透进度」,q==0 时直接返回 0.0 避免除零。 CGaps::add 用 go 数组做空闲槽复用,ngo 只增不减但 go[i]<0 的槽会被下次 add 覆盖,ArrayResize 的第三参 ND 控制内存步长。change 遍历所有活跃槽,任一缺口 brkn 就置 go[i]=-1 并标记 chngd,调用方据此可能判断行情已吞掉某档缺口。 gs2f 把全部缺口的 gap、Q、brkn 标志与点差写进公共目录文本文件,brkn 写成 1/0 方便外部统计。外汇与贵金属跳空伴随高杠杆风险,这套状态机仅用于历史缺口分布刻画,实盘信号须自行验证。
class="type">void SGap :: set(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd) { p1=p_1; p0=p2=p3=p; s=sprd; } class="type">bool SGap :: brkn() { class="kw">return ((p0>p1)&&(p3<=p1))||((p0<p1)&&(p3>=p1)); } class="type">void SGap :: change(class="type">class="kw">double p) { if(brkn()) class="kw">return; if((p0>p1&&p>p2) || (p0<p1&&p<p2)) p2=p; p3=p; } class="type">class="kw">double SGap :: gap() { class="kw">return MathAbs(p0-p1); } class="type">class="kw">double SGap :: Q() { class="type">class="kw">double q=p2-p1; if(q==class="num">0.0) class="kw">return class="num">0.0; class="kw">return (p2-p0)/q; } class="type">void CGaps :: init() { ngs=ngo=class="num">0; } class="type">void CGaps :: add(class="type">class="kw">double p_1,class="type">class="kw">double p,class="type">class="kw">double sprd) { ++ngs; if(ArraySize(gs)<ngs) ArrayResize(gs,ngs,ND); gs[ngs-class="num">1].set(p_1,p,sprd); class="type">int i=class="num">0; for(; i<ngo; ++i) if(go[i]<class="num">0) class="kw">break; if(i==ngo) { ++ngo; if(ArraySize(go)<ngo) ArrayResize(go,ngo,ND); } go[i]=ngs-class="num">1; } class="type">bool CGaps :: change(class="type">class="kw">double p) { class="type">bool chngd=class="kw">false; for(class="type">int i=class="num">0; i<ngo; ++i) { if(go[i]<class="num">0) class="kw">continue; gs[go[i]].change(p); if(gs[go[i]].brkn()) {go[i]=-class="num">1; chngd=true;} } class="kw">return chngd; } class="type">void CGaps :: gs2f(class="type">class="kw">string fn) { class="type">int f=FileOpen(fn, FILE_WRITE|FILE_COMMON|FILE_ANSI|FILE_TXT), c; for(class="type">int i=class="num">0;i<ngs;++i) { if (gs[i].brkn()) c=class="num">1; else c=class="num">0; FileWriteString(f,(class="type">class="kw">string)gs[i].gap()+" "+(class="type">class="kw">string)gs[i].Q()+" "+(class="type">class="kw">string)c+" "+(class="type">class="kw">string)gs[i].s); if(i==ngs-class="num">1) class="kw">break; FileWriteString(f,"\n"); } FileClose(f); }
用缺口结构反推每笔风险
USDJPY 分钟柱间的跳空版本最值得继续做。注意这里说的缺口不是平均价,而是区分 bid 和 ask:上行缺口看 ask,下行缺口看 bid,回补也按同一侧价格判定。 我们把收集时段缺口的统计模块改成了 EA(gaps_ses_test.mq5),核心改动是缺口结构体 SGap 直接挂上这笔交易需要的量化和平仓信息,再补两个函数:pp2v() 算每笔手数,trade() 存手数与持仓的对应。
- 年跑完测试后,用结果去定 2018 年的风险值。思路是:系统跑到指定笔数内平均盈利低于底线、或回撤破上限就停,重新优化;风险值要调到‘仍盈利的概率够高、且随机波动造成的回撤不超线’。这比拍脑袋给 0.5%–3%、或 Vince 的优化 f(常给过高风险)更稳。
- 年样本质量一般,所以需求设得很中性:26 笔(na=26)内不亏(G0=0.0)、回撤不超 10%(D0=0.9)。为拿到非零风险,显著水平只能放到 dlt=0.17——正常最好不超 0.1,放到这么大本身就说明结果差。最终脚本给的风险 r=0.014。
这种参数下 EA 不该上真实账户,USDJPY 普通缺口又少又小,实证品种做普通缺口基本无效;更值得转去看时段间价格变化的泛缺口。外汇与贵金属杠杆高,按此风险值实盘可能迅速失效,须用小资金验证。
class=class="str">"cmt">// gaps_ses_test.mq5 class="macro">#define ND class="num">100 input class="type">uint mperiod=class="num">1; class=class="str">"cmt">// 时段期限(分钟) input class="type">uint mlength=class="num">1; class=class="str">"cmt">// 时段长度(分钟) input class="type">uint mbias=class="num">0; class=class="str">"cmt">// 第一个时段乖离(分钟) input class="type">class="kw">double g0=class="num">0.1; class=class="str">"cmt">// 最小缺口大小: USDJPY - class="num">0.1, EURUSD - class="num">0.001 input class="type">class="kw">double q0=class="num">0.4; class=class="str">"cmt">// q0=sl/(sl+tp) input class="type">class="kw">double r=class="num">0.01; class=class="str">"cmt">// 交易风险 input class="type">class="kw">double s=class="num">0.02; class=class="str">"cmt">// 近似点差 input class="type">class="kw">string fname="gaps\\stat.txt"; class=class="str">"cmt">// 统计文件名 class="kw">struct SGap { class="type">class="kw">double p0; class="type">class="kw">double p1; class="type">class="kw">double p2; class="type">class="kw">double v; class="type">int brkn(); class="type">bool up(); class="type">void change(class="type">class="kw">double p); class="type">class="kw">double gap(); class="type">class="kw">double Q(); class="type">class="kw">double a(); }; class CGaps { SGap gs[]; class="type">int ngs; class="type">int go[]; class="type">int ngo; class="kw">public: class="type">void init(); class="type">void add(class="type">class="kw">double p_1,class="type">class="kw">double p); class="type">bool change(class="type">class="kw">double pbid,class="type">class="kw">double pask); class="type">class="kw">double v(); class="type">void gs2f(class="type">class="kw">string fn); }; CGaps gaps; class="type">MqlTick tick0; class="type">int ns0=-class="num">1; class="type">class="kw">ulong sbias=mbias*class="num">60, speriod=mperiod*class="num">60, slength=mlength*class="num">60;
◍ 把跳空映射成仓位变动的落地代码
下面这段 EA 骨架把前几节说的‘跳空捕获—净值敞口换算’接到了 MT5 实盘事件循环里。核心在 OnTick:每次报价先判定是否跨会话、是否出现超过阈值 g0 的 bid/ask 跳空,命中就写进 gaps 并触发 trade()。 dv 取 SYMBOL_VOLUME_STEP,保证后续手数加减都是合约最小步长的整数倍;nsession() 用 sbias/speriod/slength 三个量把时间切成交易会话,返回 -1 代表不在统计窗口内。 pp2v() 是敞口转Volume的关键:用 OrderCalcProfit 反推价格区间 psl~pen 对应的盈亏 dc,再以账户权益 c0 和系数 r 算出目标仓位 vt = -dir*r*dv*c0/dc。外汇与贵金属杠杆高,dc 受点差与杠杆影响大,实盘前务必在策略测试器用真实点差回测一遍。 trade() 里先读当前持仓 vr(卖仓记负),再算 vi=(vt-vr)/dv 的差量手数;vi 为 0 直接 return,不为 0 才发 TRADE_ACTION_DEAL。复制下面代码到 MT5 的 EA 模板,把 g0、sbias、speriod、slength、r、fname 补成你自己的参数就能跑。
class="type">class="kw">double dv=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_STEP); class="type">void OnTick() { class="type">MqlTick tick; if (!SymbolInfoTick(_Symbol, tick)) class="kw">return; class="type">bool chngd=gaps.change(tick.bid,tick.ask); class="type">int ns=nsession(tick.time); if(ns>=class="num">0) { if(ns0>=class="num">0&&ns>ns0) { if(tick0.ask-tick.ask>=g0) {gaps.add(tick0.ask,tick.ask); chngd=true;} else if(tick.bid-tick0.bid>=g0) {gaps.add(tick0.bid,tick.bid); chngd=true;} } ns0=ns; tick0=tick; } if(chngd) trade(gaps.v()); } class="type">int OnInit() { gaps.init(); class="kw">return(INIT_SUCCEEDED); } class="type">void OnDeinit(class="kw">const class="type">int reason) { gaps.gs2f(fname); } class="type">int nsession(class="type">class="kw">datetime t) { class="type">class="kw">ulong t0=(class="type">class="kw">ulong)t; if(t0<sbias) class="kw">return -class="num">1; t0-=sbias; if(t0%speriod>slength) class="kw">return -class="num">1; class="kw">return (class="type">int)(t0/speriod); } class="type">class="kw">double pp2v(class="type">class="kw">double psl, class="type">class="kw">double pen) { if(psl==pen) class="kw">return class="num">0.0; class="type">class="kw">double dc, dir=class="num">1.0; class="type">class="kw">double c0=AccountInfoDouble(ACCOUNT_EQUITY); class="type">bool ner=true; if (psl<pen) ner=OrderCalcProfit(ORDER_TYPE_BUY,_Symbol,dv,pen+s,psl,dc); else {ner=OrderCalcProfit(ORDER_TYPE_SELL,_Symbol,dv,pen,psl+s,dc); dir=-class="num">1.0;} if(!ner) class="kw">return class="num">0.0; class="kw">return -dir*r*dv*c0/dc; } class="type">void trade(class="type">class="kw">double vt) { class="type">class="kw">double v0=SymbolInfoDouble(_Symbol,SYMBOL_VOLUME_MIN); if(-v0<vt<v0) vt=v0*MathRound(vt/v0); class="type">class="kw">double vr=class="num">0.0; if(PositionSelect(_Symbol)) { vr=PositionGetDouble(POSITION_VOLUME); if(PositionGetInteger(POSITION_TYPE)==POSITION_TYPE_SELL) vr=-vr; } class="type">int vi=(class="type">int)((vt-vr)/dv); if(vi==class="num">0) class="kw">return; class="type">MqlTradeRequest request={class="num">0}; class="type">MqlTradeResult result={class="num">0}; request.action=TRADE_ACTION_DEAL; request.symbol=_Symbol; if(vi>class="num">0) { request.volume=vi*dv; request.type=ORDER_TYPE_BUY; }