聚类分析(第一部分):精通指标线的斜率·进阶篇
📐

聚类分析(第一部分):精通指标线的斜率·进阶篇

(2/3)· 当 HalfTrend 双色线遇上标准差与均值,横盘和趋势的边界到底怎么算才不拍脑袋

含代码示例偏理论 第 2/3 篇
很多人直接拿指标线斜率正负判断趋势,却忽略横盘里斜率也会乱跳,结果频繁假突破被洗。把点数差当绝对阈值用,跨品种就失真——EURUSD 和 XAUUSD 的小数位差出三倍。不先归一化差异,聚类根本分不清噪声和真实方向。

把聚类塞进指标时动了哪些手脚

为了尽量少碰指标主逻辑,聚类算法被挪到了外部头文件 ClusterTrend.mqh,指标头部只用一句 #include "ClusterTrend.mqh" 挂进来。文末会附该文件,但光这样还不够——为了让读者自己试起来门槛低,额外暴露了一个输入变量 NumValues:设 1 表示只检查一种数值;若指标算了两个均值、想估两条线的斜率,NumValues 间距要填 3,底层数组会自动扩维;填 0 则完全跳过聚类,额外开销一键关掉。 全局变量里 ShortName 在 OnInit() 被赋成指标简称(例如 "HalfTrd "+Amplitude),打印结果时用来区分是谁算的;StopWatch 用 GetTickCount64() 掐表,聚类前清零、打印后读差,便于看耗时。指标照例倒序扫柱线:iB=0 是最新价,索引越大越古老,循环末尾把算好的值丢给聚类函数。 关键防重算逻辑在下面这段:历史回放时每根柱只聚一次,实盘跑起来只用倒数第二根(iB=1)避免抖动重复劳动。 //+------------------------------------------------------------------+

//added for cluster analysis

//+------------------------------------------------------------------+ if ( (prev_calculated == 0 && iB > 0 ) // we don't use the actual bar

(prev_calculated > 9 && iB == 1)) // during operation we use the second to last bar: iB = 1

{ if (prev_calculated==0 && iB==limit) { // only at the very first pass/bar 首根初始化时启动秒表并清空旧 Cluster[] 数组;取 actBar 和 prvBar 用 fmax(up[iB],down[iB]) 拿双色缓冲里非零那条,若两者之和小于 _Point 直接 continue 跳过缺报价。传参 enterVal(fabs(actBar-prvBar)/_Point, 0, 1.0-(double)iB/rates_total, NumValues) 时,第一参用绝对差而非纯差,否则要管正负两套聚类、长期涨跌方向会污染斜率陡峭度评估——外汇里涨跌幅对称性比股市更可假设相近,方向不关键。 第三参学习率 1.0-iB/rates_total 随 iB 从大到 0 跑,使比率从 0 爬到 1,越近的柱权重越高。大循环跑完,prtStdDev 在智能系统页按值类型打印每类标准差,NumValues≤0 就不打。整套模块接进去后,开 MT5 把 NumValues 从 1 改 3 重挂指标,能直接对比单线斜率与双线斜率的聚类离散差异。外汇与贵金属杠杆高、滑点跳空频繁,聚类结论仅描述历史斜率分布倾向,不构成方向押注依据。

MQL5 / C++
   class=class="str">"cmt">//+------------------------------------------------------------------+
   class=class="str">"cmt">//|  added for cluster analysis                                      |
   class=class="str">"cmt">//+------------------------------------------------------------------+
   if (  (prev_calculated == class="num">0 && iB > class="num">0 )                        class=class="str">"cmt">// we don&class="macro">#x27;t use the actual bar
      || (prev_calculated > class="num">9 && iB == class="num">1))                        class=class="str">"cmt">// during operation we use the second to last bar: iB = class="num">1

    {
    if (prev_calculated==class="num">0 && iB==limit) {                       class=class="str">"cmt">// only at the very first pass/bar

「指标斜率绝对值如何喂给聚类」

这段逻辑干的事,是把每个柱子上指标线的变化幅度,按点差归一化后送进聚类容器。先用 GetTickCount64 记下起点,若 Cluster 数组里有旧数据就 ArrayResize 清零,避免重算时残留上轮结果。 核心计算取当前柱与前一柱的指标值较大者:actBar = fmax(up[iB], down[iB]),prvBar = fmax(up[iB+1], down[iB+1])。若两者之和小于 _Point 直接 continue,跳过初始或中间缺报价的情况。 斜率绝对值用 fabs(actBar-prvBar)/_Point 表达,作为 enterVal 的第一个参数。学习率传 1.0 - (double)iB/(double)rates_total,随 iB 从 0 到 1 递减;NumValues 既用于初始化值类型数量,也作为开关——小于 1 时不聚类。 只在 prev_calculated < 1 时调一次 prtStdDev 打印标准差,前缀拼了 _Symbol、周期枚举和 ShortName。外汇与贵金属杠杆高,这类统计特征仅作概率参考,实盘前务必在 MT5 用历史数据验证聚类稳定性。

MQL5 / C++
StopWatch = GetTickCount64();                                              class=class="str">"cmt">// start the stop whatch
      if (ArraySize(Cluster) > class="num">0) ArrayResize(Cluster,class="num">0);                       class=class="str">"cmt">// in case everything is recalculated class="kw">delete prev. results
      }
      class="type">class="kw">double actBar = fmax(up[iB], down[iB]),                                  class=class="str">"cmt">// get actual indi. value of bar[iB]
            prvBar = fmax(up[iB+class="num">1], down[iB+class="num">1]);                               class=class="str">"cmt">// get prev. indi. value
      if ( (actBar+prvBar) < _Point ) class="kw">continue;                                class=class="str">"cmt">// skip initially or intermediately missing quotes
      enterVal(fabs(actBar-prvBar)/_Point,                                     class=class="str">"cmt">// abs. of slope of the indi. line
               class="num">0,                                                              class=class="str">"cmt">// index of the value type
               class="num">1.0 - (class="type">class="kw">double)iB/(class="type">class="kw">double)rates_total,                           class=class="str">"cmt">// learning rate: use either class="num">1-iB/rates_total or iB/rates_total whatever runs from class="num">0 .. class="num">1 
               NumValues                                                       class=class="str">"cmt">// used for initialization(no. of value types) and if < class="num">1 no clustering 
      );
      }
class=class="str">"cmt">//+------------------------------------------------------------------+
   } class=class="str">"cmt">// end of big loop: for(iB = limit; iB >= class="num">0; iB--) ..

class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|  added for cluster analysis                                      |
class=class="str">"cmt">//+------------------------------------------------------------------+
   if (prev_calculated < class="num">1)                                                 class=class="str">"cmt">// print only once after initialization
   {
      prtStdDev(_Symbol+" "+EnumToString(Period())+" "+ShortName,            class=class="str">"cmt">// printed at the beginning of each line
               class="num">0,                                                            class=class="str">"cmt">// the value type to be printed

◍ 用计时器量化指标计算开销

在自定义指标里插入计时逻辑,能直接看到处理指定 bar 数消耗的毫秒数,而不是靠感觉判断脚本卡不卡。 上面这段把 StopWatch 置零后调用 GetTickCount64() 做差,再用 Print 把耗时、CPU 核心数和物理内存一起打出来。比如在一台 4 核、8G 内存的机器上跑 5000 根 bar,终端可能输出 Time: 00:012 表示 12 毫秒,这种数字你在 MT5 回测日志里能立刻复现。 NumValues 那个注释说明若该值 ≤0 就不打印对应类型,调参时可以先关掉无关输出,只盯自己关心的那一项耗时。外汇与贵金属行情高波动时计算量会跳变,这类基准测试务必在真实tick环境多跑几次。

MQL5 / C++
   NumValues);                                                                     class=class="str">"cmt">// if <=class="num">0 this value type is not printed
   if (StopWatch!=class="num">0) StopWatch = GetTickCount64()-StopWatch;
   Print("Time needed for ",rates_total," bars on a PC with ",TerminalInfoInteger(TERMINAL_CPU_CORES),
         " cores and Ram: ",TerminalInfoInteger(TERMINAL_MEMORY_PHYSICAL),", Time: ",
         TimeToString(StopWatch/class="num">1000,TIME_SECONDS),":",StringFormat("%03i",StopWatch%class="num">1000) );
   }
 class=class="str">"cmt">//+------------------------------------------------------------------+

用增量算法给聚类打底

ClusterTrend.mqh 与指标同目录,必须按 #include "ClusterTrend.mqh" 方式挂入。文件开头用 #define crash(strng) 人为触发除零,绕过指标无法自终止的限制,至少能让 alert() 报一次维度错误,改完重新编译才能跑。 均值和方差若按传统两遍循环算——先汇总全部样本再除 n——在 MT5 历史数据拉长后会非常耗时。Tony Finch 的增量算法把公式 (4) 和 (24)(25) 搬进单次遍历:每来一个新值 x_n,用旧均值 μ_{n-1} 和计数 n 直接推出新均值 μ_n,同时用辅助变量 S_n 递推方差 σ²,不必回头重算。 代码里这个函数就是落地处:每次只喂入最新一根 K 的读数,均值与方差当场更新。历史前段走过之后,这个均值就能拿去给后续样本做分类基准。 为什么不用移动平均?想象测河水位,基准不能随当前高度漂。趋势强时 MA 差值放大、相对偏差反而缩小;横盘时 MA 下沉、相对偏差却虚增。聚类要的是稳基准,所以取最久远 50% 历史做均值、最新 50% 做聚类,这 50% 即学习率。我另设了 25% 与 75% 两档对照,达到学习率后才起算各自均值,用来观察斜率偏移幅度。外汇与贵金属波动剧烈,这类统计基准仅作概率参考,实盘须自担高风险。

「均值与聚类的预定义逻辑」

ClusterTrend.mqh 里的 enterVal() 是整套统计的入口:val 是指标传入的数值,iLine 标记数值类型索引,learn 是学习率(即历史完成比例),NoVal 告知需要计算的数值类型数量。函数先判 NoVal<=0 直接退出,再查 ArrayRange(Cluster,0)<NoVal 决定要不要初始化数组并调 setStattID(NoVal)。 初始化后,incrStdDeviation(val, Cluster[iLne][0]) 持续累积从首根到末根 K 线的均值。代码里用 learn>0.25 / 0.5 / 0.75 分别写入 Cluster[iLne][1~3],也就是在 25%、50%、75% 样本量时各留一个阶段均值;learn<0.5 时只学不分类,后半段才发育类别。 聚类不是跑迭代找中心,而是写死属性。CatCoeff[9] 存 0.0 到 7.87 共 9 个倍数,加上 [0]~[3] 四个分位均值、以及大于 7.87 的溢出类,数组需要比 9 多 5 个位置。判定时用 val/Cluster[iLne][0].µ 跟 CatCoeff[i] 比:val <= CatCoeff[i]*µ 就落入第 i 类;超出最大系数的丢进最后一格。 这种预定义让单遍扫描即可完成聚类,多数算法却要反复过数据。CatCoeff[0]=0 是因为 HalfTrend 在多根走平时差值为零,该类会自然涨到可观规模。外汇与贵金属波动剧烈,异常值常击穿预设边界,所以末类兜底很有必要,实盘前建议在 MT5 用历史数据打印 CatCoeff 与实际 val/µ 分布核对。

MQL5 / C++
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">//|                                                                  |
class=class="str">"cmt">//| enter a new value                                                |
class=class="str">"cmt">//|                                                                  |
class=class="str">"cmt">//+------------------------------------------------------------------+
class=class="str">"cmt">// use; enterVal( fabs(indi[i]-indi[i-class="num">1]), class="num">0, (class="type">class="kw">double)iB/(class="type">class="kw">double)rates_total )
class="type">void enterVal(class="kw">const class="type">class="kw">double val, class="kw">const class="type">int iLne, class="kw">const class="type">class="kw">double learn, class="kw">const class="type">int NoVal)
  {
   if (NoVal<=class="num">0) class="kw">return;                                      class=class="str">"cmt">// nothing to do if true
   if(   ArrayRange(Cluster,class="num">0)<NoVal
      || Cluster[iLne][class="num">0].n <= class="num">0 )                             class=class="str">"cmt">// need to inicialize
      setStattID(NoVal);
   
   incrStdDeviation(val, Cluster[iLne][class="num">0]);                    class=class="str">"cmt">// the calculation from the first to the last bar
   if(learn>class="num">0.25)
      incrStdDeviation(val, Cluster[iLne][class="num">1]);                class=class="str">"cmt">// how does µ varies after class="num">25% of all bars
   if(learn>class="num">0.5)
      incrStdDeviation(val, Cluster[iLne][class="num">2]);                class=class="str">"cmt">// how does µ varies after class="num">50% of all bars
   if(learn>class="num">0.75)
      incrStdDeviation(val, Cluster[iLne][class="num">3]);                class=class="str">"cmt">// how does µ varies after class="num">75% of all bars
   if(learn<class="num">0.5)
      class="kw">return;                                                  class=class="str">"cmt">// I use class="num">50% to learn and class="num">50% to devellop the categories
   class="type">int i;

◍ 极值归类落到哪一档由 pc 决定

这段逻辑解决的是「新样本值该塞进聚类里的哪个离差档」的问题。先判断 Cluster[iLne][0].µ 是否小于 _Point,若小于直接 return,目的是避免后面除以长期均值时出现除零。 pc = val / Cluster[iLne][0].µ,也就是拿当前值和该线簇长期均值算一个百分比比值。比如长期均值是 0.00020,val 是 0.00050,那 pc 就是 2.5,意味着新值达到均值的 250%。 随后用 for 循环遍历 CatCoeff 数组,一旦 pc 小于等于某个档位系数 CatCoeff[i],就调用 incrStdDeviation 把值累加进 Cluster[iLne][i+4] 对应的类别,并 return。若跑完循环都没命中,说明值过大,直接丢进最后一个类别(索引 ArraySize(CatCoeff)+4)。外汇与贵金属波动跳跃大,这种越界样本归入末档能防止中间类别标准差被极端值拖歪,但极端行情下仍属高风险。

MQL5 / C++
if (Cluster[iLne][class="num">0].µ < _Point) class="kw">return;                 class=class="str">"cmt">// avoid division by zero
  class="type">class="kw">double pc = val/(Cluster[iLne][class="num">0].µ);                    class=class="str">"cmt">// &class="macro">#x27;%&class="macro">#x27;-value of the new value compared to the class="type">long term µ of Cluster[class="num">0]..
  for(i=class="num">0; i<ArraySize(CatCoeff); i++)
    {
      if(pc <= CatCoeff[i])
        {
         incrStdDeviation(val, Cluster[iLne][i+class="num">4]);   class=class="str">"cmt">// find the right category
         class="kw">return;
        }
    }
  i = ArraySize(CatCoeff);
  incrStdDeviation(val, Cluster[iLne][i+class="num">4]);              class=class="str">"cmt">// tooo big? it goes to the last category
  }
                    pc = val/µ  =>   pc*µ = val
把跨品种斜率诊断交给小布
这些把指标差值转点数、再按聚类均值和标准差画边界的活儿,小布盯盘的 AIGC 已内置,打开对应品种页即可看到实时横盘/趋势概率带,你只管读信号。

常见问题

因为多维数组只能动态调第一维,第二维绑定分类值数量;CatCoeff 系数紧挨两条线布置且需容纳均值、标准差、最值等至少 5 类数据,小于 5 会导致聚类结构越界或信息缺失。
双色线机制下,某一时刻行情只归属上行或下行态,对应缓冲区写正值、另一写零以避免重叠绘制,这样图表只会显示当前有效方向的线。
转点数消除了报价精度差异,使 5 位 EURUSD 与 2 位 XAUUSD 的斜率变化可在同一统计尺度下比较,聚类阈值不至于因品种而失效。
小布已把均值、标准差边界和横盘/趋势标记做成品种页的常驻模块,不用自己写 ClusterTrend.mqh 也能直接看聚类结果,贵金属与外汇波动高风险,信号仅作概率参考。
初值为零意味着首根需全量建簇,后续调用只增量更新未算柱线,若忽略这点会把历史数据重复塞进聚类导致均值偏移。