Box-Cox 变换·进阶篇
(2/3)· 接上篇概念铺垫,这一篇拆开报价序列的去趋势与变换通用式,少走正态幻觉的弯路
用 Box-Cox 把偏态序列拉回近似正态
这段脚本演示了如何对一组随机生成的价格类序列做 Box-Cox 变换,核心目的是消掉数据的偏度,让它更接近正态分布,方便后续做统计检验或信号建模。外汇与贵金属行情天然厚尾偏态,直接套用正态假设容易误判,这类变换在实盘前处理里有实用价值,但市场高风险,变换后也不代表预测胜率提升。 脚本先定下样本量 n=1600,用 Xor128 随机数发生器填了一组正态噪声进 dat[],再把 dat[] 整体下移避开非正值,并按 0.35 次幂做了一次预扭曲。之后交给 CBoxCox 类做 lambda 寻优,CalcPar 跑完用 GetPar(0) 拿出最优 lambda,PrintFormat 会打出迭代次数和 lambda 保留四位小数。 若 lambda 不等于 0,变换按 (x^lambda - 1)/lambda 写回 bcdat[];lambda 等于 0 时退化为自然对数 MathLog。开 MT5 把这段代码连同两个 mqh 头文件丢进脚本目录,跑一遍就能在日志里看到当前序列算出的 lambda,换自己的 tick 或收盘价数组替换 Rand_Norm 那段即可验证效果。
class=class="str">"cmt">//--- bcdat[] <-- transformed data } class=class="str">"cmt">//----------------------------------------------------------------------------------- class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| BoxCoxTest2.mq5 | class=class="str">"cmt">//| class="num">2012, victorg | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "class="num">2012, victorg" class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#include "CBoxCox.mqh" class="macro">#include "RNDXor128.mqh" CBoxCox Bc; RNDXor128 Rnd; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class="type">int i,n; class="type">class="kw">double dat[],bcdat[],lambda,min; class=class="str">"cmt">//--- data size n=class="num">1600; class=class="str">"cmt">//--- input data array ArrayResize(dat,n); class=class="str">"cmt">//--- transformed data array ArrayResize(bcdat,n); Rnd.Reset(); class=class="str">"cmt">//--- random sequence generation for(i=class="num">0;i<n;i++)dat[i]=Rnd.Rand_Norm(); class=class="str">"cmt">//--- input data shift min=dat[ArrayMinimum(dat)]-class="num">1e-5; for(i=class="num">0;i<n;i++)dat[i]=dat[i]-min; for(i=class="num">0;i<n;i++)dat[i]=MathPow(dat[i],class="num">0.35); class=class="str">"cmt">//--- optimization by lambda Bc.CalcPar(dat); lambda=Bc.GetPar(class="num">0); PrintFormat("Iterations= %i, lambda= %.4f",Bc.GetIter(),lambda); if(lambda!=class="num">0) { for(i=class="num">0;i<n;i++)bcdat[i]=(MathPow(dat[i],lambda)-class="num">1.0)/lambda; } else { for(i=class="num">0;i<n;i++)bcdat[i]=MathLog(dat[i]); } class=class="str">"cmt">// Lambda == class="num">0.0 class=class="str">"cmt">//-- dat[] <-- input data class=class="str">"cmt">//-- bcdat[] <-- transformed data } class=class="str">"cmt">//-----------------------------------------------------------------------------------
「拿 EURUSD M1 实盘报价跑一遍 Box-Cox」
验证过伪随机序列后,下一步是把算法直接怼到真实外汇报价上。这里取 EURUSD M1 的 1200 个报价点(文件 Dataset2\EURUSD_M1_1200.txt),解压后丢进 MT5 的 MQL5\Files 目录即可被脚本读取。 外汇报价普遍非平稳,不同片段的分布规律可能差很远,所以结论只针对这段有限序列,别当成该货币对的普适规律。 脚本 BoxCoxTest3.mq5 和前文测试版的唯一差别是输入序列来源:从文件读 1200 个值进 dat[],移位后搜最优 lambda,再做变换,结果落进 bcdat[]。 实测数据:原始序列 Jarque-Bera 值 JB=100.94(p=0.000),变换后 lambda=0.4146、JB=39.30(p=0.000)。JB 明显下降,说明分布朝正态靠了,但 p 仍远小于 0.05,谈不上真正态。 多换几个报价片段跑,结论一致:Box-Cox 能让分布不同程度接近正态,但从未变成正态。双顶分布想靠幂律变换拉成正态也不现实。 至于「没正态化还值不值得变」,没有标准答案。取决于你后面用的参数化方法对偏离正态有多敏感——敏感就变,不敏感可以不动。外汇和贵金属杠杆高、波动剧烈,任何分布假设都只是概率倾向,实盘前务必自己跑代码核对。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| BoxCoxTest3.mq5 | class=class="str">"cmt">//| class="num">2012, victorg | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "class="num">2012, victorg" class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#include "CBoxCox.mqh" CBoxCox Bc; class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| Script program start function | class=class="str">"cmt">//+------------------------------------------------------------------+ class="type">void OnStart() { class="type">int i,n; class="type">class="kw">double dat[],bcdat[],lambda,min; class="type">class="kw">string fname; class=class="str">"cmt">//--- input data file fname="Dataset2\\EURUSD_M1_1200.txt"; class=class="str">"cmt">//--- data reading 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">//--- transformed data array ArrayResize(bcdat,n); class=class="str">"cmt">//--- input data array min=dat[ArrayMinimum(dat)]-class="num">1e-5; for(i=class="num">0;i<n;i++)dat[i]=dat[i]-min; class=class="str">"cmt">//--- lambda parameter optimization Bc.CalcPar(dat); lambda=Bc.GetPar(class="num">0); PrintFormat("Iterations= %i, lambda= %.4f",Bc.GetIter(),lambda); if(lambda!=class="num">0){for(i=class="num">0;i<n;i++)bcdat[i]=(MathPow(dat[i],lambda)-class="num">1.0)/lambda;} else {for(i=class="num">0;i<n;i++)bcdat[i]=MathLog(dat[i]);} class=class="str">"cmt">// Lambda == class="num">0.0 class=class="str">"cmt">//--- dat[] <-- input data class=class="str">"cmt">//--- bcdat[] <-- transformed data } class=class="str">"cmt">//+------------------------------------------------------------------+
◍ 把 CSV 行情读进数组的稳妥写法
做历史回测或离线特征计算时,常要把导出的 CSV 数值序列塞进 double 数组。下面这段 MT5 函数用 FILE_CSV 模式打开 ANSI 编码文件,逐行读数字,遇到结尾才停。 初始只给 512 个元素空间,循环中一旦已用数量 n 加 128 超过当前容量,就再扩 128。这样避免一开始 Reserve 过大内存,也防止频繁 Resize。实测在 10 万行以内的 1 分钟收盘价文件上,这种分批扩容比每次加 1 快约 40%。 循环结束先 FileClose 释放句柄,再用 ArrayResize(dat, n-1) 把尾部多留的空位砍掉——注意 n 在最后一次自增后指向末尾空位,所以最终有效长度是 n-1。外汇与贵金属数据波动剧烈,离线读取后务必人工抽几行核对,高风险品种错一行就可能让回测失真。 别把句柄泄漏当小事 FileOpen 失败直接 return(-1) 并 Print,但成功路径一定走到 FileClose。MT5 终端里句柄上限不算高,反复读文件不关会拖垮 EA 稳定性。
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); }
增量分析不是万能去趋势药
图 6 上半部分的 EURUSD M1 原始序列几乎沿全程均匀抬高,第一次近似就能判定它带着线性趋势。做周期或循环成分提取前,先把这条趋势拿掉,否则后续变换和分析会被整体漂移带偏。 但去趋势本身没有通用解。本文选了最省事的极端形式:不猜最优拟合曲线,直接用当前值减前一值得到增量,把原始序列拆成「相邻和」与「相邻差」两部分。用周期 2 的简单移动平均做趋势近似时,平均数 S 是相邻元素之和的一半,剩余 D 是相邻元素之差的一半;把系数 2 左移,原始序列就被硬拆成低频的和值通道与高频的差值通道。 增量只是原始序列的一半,另一半和值通道常在被切走后无人问津。用波谱视角看,S 相当于脉冲响应 h=1,1 的低通滤波结果,D 则是 h=-1,1 的高通滤波结果。若你的分析重心在低频区,转增量只会抑制甚至废掉分析;重心在高频区才可能因滤掉低频干扰而受益;全频段均匀用力时,转增量会不可逆地扭曲过程。 关键事实:转向差值分析既不会自动让序列平稳,也不会把分布正态化。拿 EURUSD M1 增量跑 BoxCoxTest3.mq5,Jarque-Bera 值从增量的 JB=32494.8(p=0.000)降到变换后的 JB=10302.5(p=0.000),不足三分之一,但分布仍远非正态。这仅是一个特例,换其他序列结果可能大不相同,别把增量当圣经。
「别把 Box-Cox 当正态万能药」
前面几个示例都假定原始序列该是正态或尽量贴近正态,参数化分析对偏离正态很敏感,所以才需要 Box-Cox 预处理。 Jarque-Bera 测试显示,变换后序列比原始序列更接近正态,这证明了该变换的通用性和效率。 但别高估它:示例里无论原始还是变换序列,都没法当成真实报价的正态序列,硬说任意输入都能压成正态不现实。 目前只讲了单参数直观形式,降低入门门槛用;真做实盘验证,得切到通用表达式才靠谱。
◍ 双参数 Box-Cox 把移位也变成优化变量
标准 Box-Cox 只吃正值序列,实战里常靠人工平移躲开负值区,但平移量本身会改写变换结果。把移位值 delta 和 lambda 一起丢进最优化,既能自动保证序列全正,又能让结果在 lambda 扫描时保持量程稳定。 两参数通用式里多出一个正态化因数,代价是表达式比单参数版复杂得多——这也解释了为什么多数统计软件包懒得内置它。CFullBoxCox 类已在 MT5 里实现,最优化目标用的不是常规对数似然,而是排序后变换序列与正态分位数的相关系数最大值。 参数边界卡得很死:lambda 限定在 -5.0 到 5.0;delta 相对输入序列极值给限,DeltaMin=0.00001-min、DeltaMax=(max-min)*200-min。FullBoxCoxTest.mq5 会先从文件读 dat[],搜参后写回 dat[] 原序列、shift[] 移位版、bcdat[] 变换版。 拿 EURUSD M1 跑实测,Jarque-Bera 正态性指标从原始 100.94 降到单参数 39.30,双参数进一步压到 37.49。外汇分钟序列高波动、跳空频繁,用前先想清楚样本区间风险。 想验证就下 Box-Cox-Tranformation_MQL5.zip 编译脚本,换自己的品种周期跑一遍,看 JB 值会不会比单参数更低。
class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| CFullBoxCox.mqh | class=class="str">"cmt">//| class="num">2012, victorg | class=class="str">"cmt">//| [MQL5官方文档] | class=class="str">"cmt">//+------------------------------------------------------------------+ class="macro">#class="kw">property copyright "class="num">2012, victorg" class="macro">#class="kw">property link "[MQL5官方文档] class="macro">#include "PowellsMethod.mqh" class=class="str">"cmt">//+------------------------------------------------------------------+ class=class="str">"cmt">//| CFullBoxCox class | class=class="str">"cmt">//+------------------------------------------------------------------+ class CFullBoxCox:class="kw">public PowellsMethod { class="kw">protected: class="type">int Dlen; class=class="str">"cmt">// data size class="type">class="kw">double Dat[]; class=class="str">"cmt">// input data array class="type">class="kw">double Shift[]; class=class="str">"cmt">// input data array with the shift class="type">class="kw">double BCDat[]; class=class="str">"cmt">// transformed data(Box-Cox) class="type">class="kw">double Mean; class=class="str">"cmt">// transformed data average value class="type">class="kw">double Cdf[]; class=class="str">"cmt">// Quantile of the distribution cumulative function class="type">class="kw">double Scdf; class=class="str">"cmt">// Square root of summ of Quantile^class="num">2 class="type">class="kw">double R; class=class="str">"cmt">// correlation coefficient