Box-Cox 变换·综合运用
📊

Box-Cox 变换·综合运用

(3/3)·从理论到落地,用七步流程把不规则报价转成正态可分析序列

案例拆解 第 3/3 篇
直接把回归、方差分析套在原始外汇报价上,结果常常偏得离谱。报价既不稳定也不服从正态分布,硬上参数化模型只是自我安慰。先变换再建模,才是对待不规则序列的务实做法。

Box-Cox 变换里的 Delta 边界与分位映射

在 MT5 里做价格序列的正态化预处理,CFullBoxCox 类先把输入数组 dat[] 的长度记进 Dlen,并给 Dat、Shift、BCDat、Cdf 四个内部数组按同一长度开空间。Cdf 存的是正态分位逆函数 ndtri 的输出,不是价格本身。 CalcPar 里分位点的算法值得盯一眼:首尾两个分位用 a=MathPow(0.5,1.0/Dlen) 算,中间第 i 个用 (i+0.6825)/(Dlen+0.365) 喂给 ndtri。全部 Cdf 元素平方和再开根得到 Scdf,这一步直接决定后续优化目标函数的尺度。 Delta 的上下界是硬算出来的:DeltaMin 固定为 1e-5 减序列最小值,DeltaMax 则是 (最大值-最小值)*200 减最小值。Lambda 初值给 1.0,Delta 初值取 (max-min)/2-min,随后丢进 Powell 法跑 Optimize(Par)。外汇与贵金属价格跳变频繁,这套边界在极端行情下可能把搜索空间拉得过大,实盘前建议用历史 tick 数据打印 Par[] 收敛轨迹。

MQL5 / C++
class="type">class="kw">double DeltaMin;                      class=class="str">"cmt">// Delta minimum value
class="type">class="kw">double DeltaMax;                      class=class="str">"cmt">// Delta maximum value
class="type">class="kw">double Par[class="num">2];                        class=class="str">"cmt">// parameters array
class="kw">public:
  class="type">void   CFullBoxCox(class="type">void)       { }
  class="type">void   CalcPar(class="type">class="kw">double &dat[]);
  class="type">class="kw">double GetPar(class="type">int n)           { class="kw">return(Par[n]); }
class="kw">private:
  class="type">class="kw">double ndtri(class="type">class="kw">double y0);       class=class="str">"cmt">// the function opposite to the normal distribution function
  class="kw">virtual class="type">class="kw">double func(const class="type">class="kw">double &p[]);
  };
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//| CalcPar                                                            |
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void CFullBoxCox::CalcPar(class="type">class="kw">double &dat[])
  {
  class="type">int i;
  class="type">class="kw">double a,max,min;

  Dlen=ArraySize(dat);
  ArrayResize(Dat,Dlen);
  ArrayResize(Shift,Dlen);
  ArrayResize(BCDat,Dlen);
  ArrayResize(Cdf,Dlen);
class=class="str">"cmt">//--- copy the input data array
  ArrayCopy(Dat,dat);

  Scdf=class="num">0;
  a=MathPow(class="num">0.5,class="num">1.0/Dlen);
  Cdf[Dlen-class="num">1]=ndtri(a); Scdf+=Cdf[Dlen-class="num">1]*Cdf[Dlen-class="num">1];
  Cdf[class="num">0]=ndtri(class="num">1.0-a); Scdf+=Cdf[class="num">0]*Cdf[class="num">0];
  a=Dlen+class="num">0.365;
  for(i=class="num">1;i<(Dlen-class="num">1);i++)
    {
    class=class="str">"cmt">//--- calculation of the distribution cumulative function Quantile
    Cdf[i]=ndtri((i+class="num">0.6825)/a);
    class=class="str">"cmt">//--- calculation of the sum of Quantile^class="num">2
    Scdf+=Cdf[i]*Cdf[i];
    }
class=class="str">"cmt">//--- square root of the sum of Quantile^class="num">2
  Scdf=MathSqrt(Scdf);

  min=dat[class="num">0]; max=min;
  for(i=class="num">0;i<Dlen;i++)
    {
class=class="str">"cmt">//--- copy the input data
    a=dat[i]; Dat[i]=a;
    if(min>a)min=a;
    if(max<a)max=a;
    }

class=class="str">"cmt">//--- Delta minimum value
  DeltaMin=class="num">1e-5-min;
class=class="str">"cmt">//--- Delta maximum value
  DeltaMax=(max-min)*class="num">200-min;
class=class="str">"cmt">//--- Lambda initial value
  Par[class="num">0]=class="num">1.0;
class=class="str">"cmt">//--- Delta initial value
  Par[class="num">1]=(max-min)/class="num">2-min;
class=class="str">"cmt">//--- optimization using Powell method
  Optimize(Par);
  }

「Box-Cox 变换里的参数边界与正态贴合度计算」

在 MT5 里做价格序列的 Box-Cox 变换时,Lambda 和 Delta 不能无限制搜索。上面这段 CFullBoxCox::func 给 Lambda 加了 ±5.0 的硬边界,超出后按 (lam-5.0)*400 这类惩罚项累加进返回值,相当于把越界的解往回拉。Delta 则依赖外部定义的 DeltaMax / DeltaMin 截断,越界同样乘 400 做惩罚。 几何均值 gm 先由 Dat[i]+del 取对数求和再除以 Dlen 做 MathExp 还原,gmpow 用 lam*MathPow(gm,lam-1) 作尺度因子。Lambda 非零时走幂变换分支,Lambda 为零走对数分支,两套都写出 BCDat[i] 并累加 mean。 变换完的 BCDat 直接 ArraySort,再和预设的 Cdf 数组做相关:ret = a/(Scdf*MathSqrt(b))。最终返回值是 k1+k2-ret,也就是惩罚减去正态相关系数。开 MT5 把 Dlen 设成你盯的 K 线数,调 Lambda 初值从 -5 到 5 扫一遍,能直观看到 ret 在哪一截最高。 外汇与贵金属波动有跳空和异方差,Box-Cox 只是把分布往正态拉,不保证后续信号胜率,实盘前请用历史数据验证。

MQL5 / C++
class="type">class="kw">double CFullBoxCox::func(const class="type">class="kw">double &p[])
  {
  class="type">int i;
  class="type">class="kw">double a,b,c,lam,del,k1,k2,gm,gmpow,mean,ret;
  
  lam=p[class="num">0]; del=p[class="num">1]; k1=class="num">0; k2=class="num">0;
  if (lam>class="num">5.0){k1=(lam-class="num">5.0)*class="num">400; lam=class="num">5.0;}                    class=class="str">"cmt">// Lambda >  class="num">5.0
  else if(lam<-class="num">5.0){k1=-(lam+class="num">5.0)*class="num">400; lam=-class="num">5.0;}            class=class="str">"cmt">// Lambda < -class="num">5.0
  if (del>DeltaMax){k2=(del-DeltaMax)*class="num">400; del=DeltaMax;}    class=class="str">"cmt">// Delta > DeltaMax
  else if(del<DeltaMin){k2=(DeltaMin-del)*class="num">400; del=DeltaMin; class=class="str">"cmt">// Delta < DeltaMin
  
  gm=class="num">0;
  for(i=class="num">0;i<Dlen;i++)
    {
    Shift[i]=Dat[i]+del;
    gm+=MathLog(Shift[i]);
    }
class=class="str">"cmt">//--- geometric mean
  gm=MathExp(gm/Dlen);
  gmpow=lam*MathPow(gm,lam-class="num">1);
  mean=class="num">0;
class=class="str">"cmt">//--- Lambda != class="num">0.0
   if(lam!=class="num">0)                                       
     {
     for(i=class="num">0;i<Dlen;i++)
       {
       a=(MathPow(Shift[i],lam)-class="num">1.0)/gmpow;
       class=class="str">"cmt">//--- transformed data(Box-Cox)
       BCDat[i]=a;
       class=class="str">"cmt">//--- average value
       mean+=a;
       }
     }
  class=class="str">"cmt">//--- Lambda == class="num">0.0
  else                                              
     {
     for(i=class="num">0;i<Dlen;i++)
       {
       a=gm*MathLog(Shift[i]);
       class=class="str">"cmt">//--- transformed data(Box-Cox)
       BCDat[i]=a;
class=class="str">"cmt">//--- average value
       mean+=a;
       }
     }
  mean=mean/Dlen;
  class=class="str">"cmt">//--- sorting of the transformed data array
  ArraySort(BCDat);
  a=class="num">0; b=class="num">0;
  for(i=class="num">0;i<Dlen;i++)
    {
    c=(BCDat[i]-mean);
    a+=Cdf[i]*c;
    b+=c*c;
    }
class=class="str">"cmt">//--- correlation coefficient
  ret=a/(Scdf*MathSqrt(b));
  class="kw">return(k1+k2-ret);
  }

◍ 误差函数底层的常量与多项式系数

在 MT5 里手写误差函数(erf)或相关概率计算时,核心是一组静态常量与分段有理逼近系数。下面这段声明直接给出了 sqrt(2π) 的近似值 2.50662827463100050242,以及 P0/Q0、P1/Q1、P2/Q2 三组多项式系数,分别对应不同自变量区间的逼近公式。 这些系数不是拍脑袋来的:P0 有 5 项、Q0 有 8 项,P1/P2 各 9 项、Q1/Q2 各 8 项,精度跑到双精度尾段。比如 P0[0] 是 -59.9633501014107895267,Q0[3] 是 -225.462687854119370527,数量级跨度很大,复制时漏一位就会让尾部概率算偏。

[CODE] 里的声明可以直接塞进你的 .mq5 脚本头部,配合分段判断x大小来选 P0Q0 / P1Q1 / P2Q2 做除法,就能在 EA 里脱离标准库自己算正态累积概率。外汇与贵金属波动大,这类自算模块仅用于策略研究,实盘高杠杆风险自负。
MQL5 / C++
class="kw">static class="type">class="kw">double s2pi =class="num">2.50662827463100050242E0; class=class="str">"cmt">// sqrt(2pi)
class="kw">static class="type">class="kw">double P0[class="num">5]={-class="num">5.99633501014107895267E1, class="num">9.80010754185999661536E1,
                     -class="num">5.66762857469070293439E1, class="num">1.39312609387279679503E1,
                     -class="num">1.23916583867381258016E0};
class="kw">static class="type">class="kw">double Q0[class="num">8]={ class="num">1.95448858338141759834E0, class="num">4.67627912898881538453E0,
                      class="num">8.63602421390890590575E1, -class="num">2.25462687854119370527E2,
                      class="num">2.00260212380060660359E2, -class="num">8.20372256168333339912E1,
                      class="num">1.59056225126211695515E1, -class="num">1.18331621121330003142E0};
class="kw">static class="type">class="kw">double P1[class="num">9]={ class="num">4.05544892305962419923E0, class="num">3.15251094599893866154E1,
                      class="num">5.71628192246421288162E1, class="num">4.40805073893200834700E1,
                      class="num">1.46849561928858024014E1, class="num">2.18663306850790267539E0,
                      -class="num">1.40256079171354495875E-1,-class="num">3.50424626827848203418E-2,
                      -class="num">8.57456785154685413611E-4};
class="kw">static class="type">class="kw">double Q1[class="num">8]={ class="num">1.57799883256466749731E1, class="num">4.53907635128879210584E1,
                      class="num">4.13172038254672030440E1, class="num">1.50425385692907503408E1,
                      class="num">2.50464946208309415979E0, -class="num">1.42182922854787788574E-1,
                      -class="num">3.80806407691578277194E-2,-class="num">9.33259480895457427372E-4};
class="kw">static class="type">class="kw">double P2[class="num">9]={ class="num">3.23774891776946035970E0, class="num">6.91522889068984211695E0,
                      class="num">3.93881025292474443415E0, class="num">1.33303460815807542389E0,
                      class="num">2.01485389549179081538E-1, class="num">1.23716634817820021358E-2,
                      class="num">3.01581553508235416007E-4, class="num">2.65806974686737550832E-6,
                      class="num">6.23974539184983293730E-9};
class="kw">static class="type">class="kw">double Q2[class="num">8]={ class="num">6.02427039364742014255E0, class="num">3.67983563856160859403E0,

逆正态累积分布的分段逼近实现

这段 ndtr 反函数(分位函数)的核心,是把输入概率 y0 按阈值 0.13533528323661269189(即 exp(-2))一分为二:大于该值走中心多项式逼近,小于则走尾部分支的对数变换。 当 y 落在 (exp(-2), 1-exp(-2)) 区间时,代码先做 y-=0.5 的对称偏移,再用 P0/Q0 两组系数(最高 7 阶)做有理逼近,最后乘 s2pi 返回。该区间覆盖了约 73% 的常用置信带,日常 1σ~2σ 转换直接走这里。 尾部分支先算 x=sqrt(-2*log(y)),再按 x<8.0 切到 P1/Q1 或 P2/Q2 两组 8 阶系数做修正;x<8 对应 y>1.266e-14(exp(-32)),再往下概率 MT5 双精度已难稳定表达。 下面这段是原文尾部分支与返回逻辑的直接抽取,逐行对应上述分支: // y0 越界直接报错返回极值 if(y0<=0.0){Print("Function ndtri() error!"); return(-DBL_MAX);} if(y0>=1.0){Print("Function ndtri() error!"); return(DBL_MAX);} // code=1 标记原分支,y>1-exp(-2) 时翻转并 code=0 code=1; y=y0; if(y>(1.0-0.13533528323661269189)){y=1.0-y; code=0;} // 中心区:偏移后有理逼近 if(y>0.13533528323661269189){ y=y-0.5; y2=y*y; a=P0[0]; for(i=1;i<5;i++)a=a*y2+P0[i]; b=y2+Q0[0]; for(i=1;i<8;i++)b=b*y2+Q0[i]; x=y+y*(y2*a/b); x=x*s2pi; return(x); } // 尾部:对数初值 + 分段修正 x=MathSqrt(-2.0*MathLog(y)); x0=x-MathLog(x)/x; z=1.0/x; if(x<8.0){ a=P1[0]; for(i=1;i<9;i++)a=a*z+P1[i]; b=z+Q1[0]; for(i=1;i<8;i++)b=b*z+Q1[i]; x1=z*a/b; } else { a=P2[0]; for(i=1;i<9;i++)a=a*z+P2[i]; b=z+Q2[0]; for(i=1;i<8;i++)b=b*z+Q2[i]; x1=z*a/b; } x=x0-x1; if(code!=0)x=-x; return(x); 在 MT5 里接 CFullBoxCox 类调用该分位函数,可把 Box-Cox 变换后的统计量反查回正态分位;外汇与贵金属价格序列做这类变换前须明确——杠杆市场高波动可能让尾部分支溢出,实盘验证务必用小样本先跑。

MQL5 / C++
if(y0<=class="num">0.0){Print("Function ndtri() error!"); class="kw">return(-DBL_MAX);}
if(y0>=class="num">1.0){Print("Function ndtri() error!"); class="kw">return(DBL_MAX);}
code=class="num">1; y=y0;
if(y>(class="num">1.0-class="num">0.13533528323661269189)){y=class="num">1.0-y; code=class="num">0;}
if(y>class="num">0.13533528323661269189){
  y=y-class="num">0.5; y2=y*y;
  a=P0[class="num">0]; for(i=class="num">1;i<class="num">5;i++)a=a*y2+P0[i];
  b=y2+Q0[class="num">0]; for(i=class="num">1;i<class="num">8;i++)b=b*y2+Q0[i];
  x=y+y*(y2*a/b); x=x*s2pi; class="kw">return(x);
}
x=MathSqrt(-class="num">2.0*MathLog(y));
x0=x-MathLog(x)/x; z=class="num">1.0/x;
if(x<class="num">8.0){ a=P1[class="num">0]; for(i=class="num">1;i<class="num">9;i++)a=a*z+P1[i];
  b=z+Q1[class="num">0]; for(i=class="num">1;i<class="num">8;i++)b=b*z+Q1[i]; x1=z*a/b; }
else { a=P2[class="num">0]; for(i=class="num">1;i<class="num">9;i++)a=a*z+P2[i];
  b=z+Q2[class="num">0]; for(i=class="num">1;i<class="num">8;i++)b=b*z+Q2[i]; x1=z*a/b; }
x=x0-x1; if(code!=class="num">0)x=-x; class="kw">return(x);

「把 EURUSD 报价跑成 Box-Cox 变换数组」

这段脚本干的事很直接:从本地 CSV 读入 1200 根 EURUSD M1 收盘价,用 Box-Cox 把原始序列压成近似正态的 bcdat[],方便后续做均值回归或波动率建模。外汇与贵金属属高风险品种,变换只是预处理,不预示方向。 OnStart 里先调 Bc.CalcPar(dat) 自动搜 lambda 和 delta,再打印迭代次数与参数值;例如某次跑下来可能输出 lambda=0.3521、delta=0.0008。之后对每个点做 (shift[i]^lambda - 1)/gmpow,lambda 为 0 时退化为 gm*log(shift[i])。 readCSV 用 FILE_CSV|FILE_ANSI 打开,初始数组 512、每次快满就加 128,读完裁到实际长度 n-1。路径写死 Dataset2\\EURUSD_M1_1200.txt,开 MT5 前先把文件丢进 MQL5\\Files 对应目录,否则 FileOpen 返回 INVALID_HANDLE 直接报错退出。

MQL5 / C++
class="type">void OnStart()
  {
   class="type">int i,n;
   class="type">class="kw">double dat[],shift[],bcdat[],lambda,delta,gm,gmpow;
   class="type">class="kw">string fname;
class=class="str">"cmt">//--- input file name
   fname="Dataset2\\EURUSD_M1_1200.txt";
class=class="str">"cmt">//--- reading the data
   if(readCSV(fname,dat)<class="num">0){Print("Error."); class="kw">return;}
class=class="str">"cmt">//--- data size
   n=ArraySize(dat);
class=class="str">"cmt">//--- shifted input data array
   ArrayResize(shift,n);
class=class="str">"cmt">//--- transformed data array
   ArrayResize(bcdat,n);
class=class="str">"cmt">//--- lambda and delta parameters optimization
   Bc.CalcPar(dat);
   lambda=Bc.GetPar(class="num">0);
   delta=Bc.GetPar(class="num">1);
   PrintFormat("Iterations= %i,   lambda= %.4f,   delta= %.4f",
               Bc.GetIter(),lambda,delta);
   gm=class="num">0;
   for(i=class="num">0;i<n;i++)
     {
      shift[i]=dat[i]+delta;
      gm+=MathLog(shift[i]);
     }
class=class="str">"cmt">//--- geometric mean
   gm=MathExp(gm/n);
   gmpow=lambda*MathPow(gm,lambda-class="num">1);
   if(lambda!=class="num">0){for(i=class="num">0;i<n;i++)bcdat[i]=(MathPow(shift[i],lambda)-class="num">1.0)/gmpow;}
   else         {for(i=class="num">0;i<n;i++)bcdat[i]=gm*MathLog(shift[i]);}
class=class="str">"cmt">//--- dat[]   <-- input data
class=class="str">"cmt">//--- shift[] <-- input data with the shift
class=class="str">"cmt">//--- bcdat[] <-- transformed data
  }
class="type">int readCSV(class="type">class="kw">string fnam,class="type">class="kw">double &dat[])
  {
   class="type">int n,asize,fhand;
   fhand=FileOpen(fnam,FILE_READ|FILE_CSV|FILE_ANSI);
   if(fhand==INVALID_HANDLE)
     {
      Print("FileOpen Error!");
      class="kw">return(-class="num">1);
     }
   asize=class="num">512;
   ArrayResize(dat,asize);
   n=class="num">0;
   while(FileIsEnding(fhand)!=true)
     {
      dat[n++]=FileReadNumber(fhand);
      if(n+class="num">128>asize)
        {
         asize+=class="num">128;
         ArrayResize(dat,asize);
        }
     }
   FileClose(fhand);
   ArrayResize(dat,n-class="num">1);
   class="kw">return(class="num">0);
  }

◍ 预测场景下的参数反搜逻辑

Box-Cox 变换在多数人认知里是「把序列弄成正态」的预处理手段,但在 MT5 做时间序列预测时,它可以反过来用:以预测误差最小化为目标去反搜变换参数与模型参数。 具体回路是:先给定变换参数初值与预测模型,对输入做 Box-Cox 正变换,按当前参数出预测,再做反向变换还原,用原始未变换序列算误差,然后改参数重跑,直到误差下不去为止。这一步里,变换目标已经不是正态性,而是「哪种分布规律配这套预测法误差最低」。 实测中该分布往往不必须是正态,取决于你选的是线性回归还是其他外推法。另一个硬约束是:Box-Cox 只吃正值非零序列,碰到含零或负的行情序列必须先整体移位再变换,这算它唯一的明显短板。 即便有移位麻烦,它在同类变换里依旧是最通用且省心的选项,外汇与贵金属回测中高频出现零值缺口时,移位量设 1e-6 通常够用,但这类品种杠杆高、跳空多,实盘验证前务必小资金试错。

把工具请下神坛

Box-Cox 变换附带的 91.48 KB ZIP 里是可直接在 MT5 跑的 MQL5 实现,但别把它当正态化的银弹。作者在原讨论里自己承认,重复套用变换效果会衰减,且可能把原始序列特征洗得面目全非,外汇与贵金属行情本身高波动、高杠杆,盲信「变换后就能用经典统计」容易过度拟合。 读者真正该做的,是下载那包代码,在 EURUSD 的 H1 上对比变换前后 Q-Q 图偏离度,只在首次变换显著提升近似正态时才纳入过滤逻辑。工具是箱子里的扳手,不是占卜的水晶球。

让小布替你跑这套变换
小布盯盘已内置常见品种的分布诊断与变换建议,打开对应页面就能看到当前报价偏离正态的程度以及是否值得做 Box-Cox 预处理,你只管看结论。

常见问题

标准 Box-Cox 要求序列严格为正,实操中会对报价做平移偏移,例如减去序列最小值再加一个极小常数,之后再拟合最优 lambda。
倾向如此。lambda 接近 1 时变换近似恒等映射,说明原序列分布已较接近正态,强行变换反而引入噪声。
需用逆 Box-Cox 公式回推,并注意还原后可能不再保持无偏,点估计和区间都应回算后再用于决策。
可以。小布盯盘在品种页给出正态性检验与偏度指标,当尾部厚重且 lambda 明显偏离 1 时才会提示变换价值,避免无谓处理。
建模只是提高对历史结构的认知概率,不承诺方向。外汇贵金属杠杆高、跳空频繁,任何统计结论都只能作为参考而非依据。