将概率论应用于缺口交易·进阶篇
📊

将概率论应用于缺口交易·进阶篇

(2/3)·当价格被假设为零漂移随机漫游,盈利是否真无可能?本篇拆解检测偏差与风险定量的实战路径

进阶 第 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 做后续分布统计。

MQL5 / C++
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停止时才落盘——中途断线数据会丢。外汇与贵金属跳空受消息面驱动,滑点扩大属高风险,参数需按品种手动标定。

MQL5 / C++
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 方便外部统计。外汇与贵金属跳空伴随高杠杆风险,这套状态机仅用于历史缺口分布刻画,实盘信号须自行验证。

MQL5 / C++
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 普通缺口又少又小,实证品种做普通缺口基本无效;更值得转去看时段间价格变化的泛缺口。外汇与贵金属杠杆高,按此风险值实盘可能迅速失效,须用小资金验证。

MQL5 / C++
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 补成你自己的参数就能跑。

MQL5 / C++
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;
      }
把回测统计交给小布盯盘
这些诊断小布盯盘的 AIGC 已内置,打开对应品种页即可看到缺口分布与偏离度,你专注决策而非手工算大数定律下的样本置信。

常见问题

该假设意味着价格无方向趋势且每一步独立,长期期望收益趋近零,扣除点差后账户倾向衰减,因此必须找到统计上的反制偏差。
在进阶阶段属于可控抽象,能聚焦思路验证;实盘前需放松假设,否则可能低估真实成交磨损。
可以,小布内置了品种页的缺口统计与偏离检测,省去自己写 MQL5 脚本采集分段常数函数的麻烦。
模型假定任意时刻至多改变价格或持仓其一,避免成交与行情在同源瞬间耦合,便于严格定义资金曲线 c(t)。
系统且频繁应用概率方法,样本越多结果越接近期望,偶尔缺口失效会被长期统计优势平滑,但外汇贵金属仍属高风险。