数据科学与机器学习(第 09 部分):以 MQL5 平铺直叙 K-均值聚类·综合运用
📘

数据科学与机器学习(第 09 部分):以 MQL5 平铺直叙 K-均值聚类·综合运用

第 3/3 篇

「EURUSD_M1 上的 K-means 两轮迭代实录」

在 MT5 策略测试器里跑 K-means 聚类脚本,品种切到 EURUSD、周期 M1,日志会在同一毫秒(20:40:05.438)吐出多轮迭代细节。第一轮初始质心为 [3,8,5],样本点被分配进簇 [0,1,2,2,2,2,1,0],重算后新质心变成 [3,9.5]、[1.5,3.5]、[6.5,5.25]。 第二轮迭代标记 <<< 2 >>> 后,程序输出直角距离矩阵(8×3),例如第 5 行样本到三簇的距離是 [8.5,7,0.75],说明它离第 3 簇最近。重新指派得到 [0,1,2,0,2,2,1,0],相比第一轮有两个点换了簇。 第三轮 <<< 3 >>> 前的新质心已收敛到 [[3.666666666666667,9], [1.5,3.5], [7,4.333333333333333]]。外汇与贵金属属高风险品种,M1 噪声极大,聚类结果仅反映历史片段形态,后续走势仍可能偏离。 直接开 MT5 把这段日志对照看,重点观察第二轮距離矩阵里 0.75 那个极小值——它就是簇归属切换的临界点,调一下距离度量方式(比如改欧式)会得出完全不同的分配。

◍ EURUSD M1 上的 K-means 聚类输出怎么读

在 MT5 策略测试器里跑 K-means 脚本,EURUSD 的 M1 周期会直接把聚类过程打印到日志。上面这段输出是某次 8 个样本、3 维特征下的首轮质心与分配结果,时间戳统一停在 20:40:05.438,说明计算在单帧内完成。 首轮质心是 [[2.666...,7,10.666...], [5.666...,2,5.666...], [9.333...,7,1.333...]] 这样的三维向量,随后 Assign clusters 给出 [0,1,2,0,2,2,1,0]——即第 1、4、8 个样本归簇 0,第 2、7 个归簇 1,第 3、5、6 个归簇 2。 New Centroids 在分配后重算为 [[3.666...,9],[1.5,3.5],[7,4.333...]],注意第三维在簇 0 和簇 1 上被压到很低,说明这几组样本在第三特征上差异小。日志里出现的 <<<<< 9 >>>>> 代表迭代到第 9 轮后矩形距离矩阵(Rectilinear distance matrix)重新输出,距离计算用的曼哈顿距离而非欧式。 外汇与贵金属属高风险品种,M1 噪声极大,这种聚类结果只反映历史片段形态,后续样本重新落入哪一类具有概率性,不能直接当成方向信号。想验证,把脚本挂 MT5 的 EURUSD M1 上跑一遍,对照自己日志里的质心维度含义调特征列即可。

EURUSD M1 上的 K-means 聚类实测输出

在 MT5 策略测试器里跑一段 K-means 原型,品种切到 EURUSD、周期 M1,日志会直接吐出聚类分配与质心刷新结果。上面这段输出里,8 根 M1 棒被分进 3 个簇,标签序列是 [0,1,2,0,2,2,1,0],说明短周期内价格形态并非均匀散布,而是被算法归成了几团密集区。 质心矩阵给出了每组的两个维度均值:簇0 中心约 (3.67, 9)、簇1 中心 (1.5, 3.5)、簇2 中心 (7, 4.33)。如果第二个维度代表波动率、第一个代表动量偏移,那么簇0 是高偏移高波动、簇1 是低偏移低波动,簇2 偏高偏移低波动——这种结构在外汇 M1 上可能暗示不同行情阶段的密集行为。 贵金属与外汇 M1 本身流动性断裂频繁、滑点风险高,直接用裸 K-means 信号下单概率上并不可靠;但把质心距离当状态过滤器,只在样本落入远离已有质心的区域时判定“异常棒”,是可在 MT5 里复验的做法。

MQL5 / C++
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                Assigned clusters
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                [class="num">0,class="num">1,class="num">2,class="num">0,class="num">2,class="num">2,class="num">1,class="num">0]
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                New Centroids
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                [[class="num">3.666666666666667,class="num">9]
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                 [class="num">1.5,class="num">3.5]
CS    class="num">0    class="num">20:class="num">40:class="num">05.438    K-means test(EURUSD,M1)                 [class="num">7,class="num">4.333333333333333]]

「脚本里怎么跑通一次聚类」

在 MT5 的 EA 或脚本里跑 K-均值,核心就是四步:建库对象、喂数据矩阵、出聚类结果、画完删对象。下面这段 OnStart 用了 8 个二维样本点,硬设 clusters=3,也就是强制切成三类,实际行情里类别数得靠轮廓系数或肘部法先探,不能拍脑袋。 ScatterPlotsMatrix 内部有个细节容易踩坑:绘图前会把落在 x 轴或 y 轴上的零值滤掉。也就是说如果你的特征标准化没做好,出现 0 坐标的样本点,图上直接消失,不会报错但聚类可视化会缺数据。 代码逐行拆一下:DMatrix 是 8 行 2 列的 literal 矩阵,数值范围 x 在 1~8、y 在 2~10;new CKMeans(3) 实例化聚类器;KMeansClustering 把原矩阵和返回矩阵 clusterd_mat 分开,原数据不被改;ObjectsDeleteAll(0,0) 清掉图表旧对象避免重叠;最后 delete(clustering) 释放。 外汇和贵金属波动大、样本非平稳,直接拿这种静态小矩阵套实盘 K 线特征,过拟合概率偏高,建议先在历史数据上回测聚类稳定性。

MQL5 / C++
class="type">void OnStart()
  {
class=class="str">"cmt">//---
    matrix DMatrix = { {class="num">2,class="num">10},
                      {class="num">2,class="num">5},
                      {class="num">8,class="num">4},
                      {class="num">5,class="num">8},
                      {class="num">7,class="num">5},
                      {class="num">6,class="num">4},
                      {class="num">1,class="num">2},
                      {class="num">4,class="num">9}
                     };
    
    class="type">int clusters =class="num">3;
    matrix clusterd_mat;
    
    clustering = new CKMeans(clusters);    
    clustering.KMeansClustering(DMatrix,clusterd_mat);    
    ObjectsDeleteAll(class="num">0,class="num">0);    
    ScatterPlotsMatrix("graph",clusterd_mat,"cluster class="num">1");
    
    class="kw">delete(clustering);    
  }
            vectortoArray(x,x_arr);
            FilterZeros(x_arr);    
            
            graph.CurveAdd(x_arr,CURVE_POINTS," cluster "+class="type">class="kw">string(i+class="num">1));

◍ 用肘部法反推聚类数

k-均值在 MT5 里跑起来很简单,核心就是给 CKMeans::Train 传一个聚类数 k,算法自己迭代分堆。但 k 给小了类别混在一起,给大了又把噪声拆成假结构,而初始质心随机化又让同一次 k 也可能跑出不同结果。 判断 k 是不是合理,不能拍脑袋。手肘(Elbow)法的做法是:把 k 从 2 到某个上限逐个试,记录每个 k 下所有样本到各自质心的距离平方和(惯性 / SSE),画成 k-SSE 曲线。当 k 增加到某一点后,SSE 的下降明显变缓,拐点像手肘,那个 k 就是性价比最高的聚类数。 实际在 MT5 验证时,建议上限设到 10 左右就够了,外汇与贵金属波动结构有限,k 过大容易过拟合历史片段,且这类品种杠杆高、跳空频繁,聚类结论只代表历史形态倾向,不等于未来重复。

用肘部拐点挑 k 均值聚类数

在 MT5 里跑 k 均值做形态聚类时,k 值选太大容易过拟合,选太小又分不出有效结构。肘部方法就是拿成本函数随 k 增大的下降曲线来定位拐点:k 从 1 往上加,组内离差平方和持续走低,但降到某个 k 之后斜率骤缓,曲线几乎贴着横轴走。 那个斜率突变的点,就是图形里胳膊肘拐弯的地方,通常作为兼顾拟合与泛化的 k 候选。比如对 EURUSD 的 H1 影线长度做聚类,k=3 之前成本陡降,k=4 起曲线走平,那么 3 到 4 之间就值得拿历史样本回测确认。外汇与贵金属杠杆高、滑点随机,拐点只是概率上的较优解,不是确定边界。

「用手肘法在 MT5 里定聚类数」

簇内残差平方和(WCSS)衡量每个样本点到所属质心距离的平方和,是判断 k-均值该分几类的核心指标。手肘法就是反复跑 k-均值、把不同 k 下的 WCSS 画出来,拐点处往往就是较合适的聚类数量。 实测纳斯达克 20 根柱线的一维价格矩阵,手肘图显示在 k=3 处 WCSS 从 51.4667 骤降到 14.333,下降斜率明显比其他点陡,因此 3 类可能比更多类更合理;换用同品种一维数据重跑,图上 4 个聚类的分离效果反而更好,说明 k 选择高度依赖数据尺度与维度。 若直接拿 nx1 一维矩阵喂原聚类函数,可视化和分组会很差,图上看不出结构。作者在对纳斯达克价格做均值归一化后再聚类,数据在坐标上铺得更开;若尝试 RSI 与均线值,也建议先压成单列一维矩阵再送入,而不是堆成多维。 下面这段是手肘法的关键片段:先强制 total_k 不超过样本数 n(k 必小于 n),再循环 k 从 initial_k 到 total_k,每次调用聚类并累加 WCSS。注意质心初始化若 k 接近矩阵行数,随机选行的方式会失效,需要改逻辑。

MQL5 / C++
  for (class="type">class="kw">ulong i=class="num">0;i<m_clusters; i++)
    {
      rand_v = Matrix.Row(i);
      InitialCentroids.Row(rand_v,i);
    }
class="type">void CKMeans::ElbowMethod(class="kw">const class="type">int initial_k=class="num">1, class="type">int total_k=class="num">10, class="type">bool showPlot = true)
{
      matrix clustered_mat, _centroids = {};      
      if (total_k > (class="type">int)n)
total_k = (class="type">int)n; class=class="str">"cmt">//>>k should always be less than n      
class="type">void CKMeans::ElbowMethod(class="kw">const class="type">int initial_k=class="num">1, class="type">int total_k=class="num">10, class="type">bool showPlot = true)
{
      matrix clustered_mat, _centroids = {};
      
      if (total_k > (class="type">int)n)
total_k = (class="type">int)n; class=class="str">"cmt">//k should always be less than n
      
      vector centroid_v={}, x_y_z={};
      vector short_v = {}; class=class="str">"cmt">//vector for each point
      vector minus_v = {}; class=class="str">"cmt">//vector to store the minus operation output
      
      class="type">class="kw">double wcss = class="num">0;
      class="type">class="kw">double WCSS[];  ArrayResize(WCSS,total_k);
      class="type">class="kw">double kArray[]; ArrayResize(kArray,total_k);
      
      for (class="type">int k=initial_k, count_k=class="num">0; k<ArraySize(WCSS)+initial_k; k++, count_k++)      
        {        
         wcss = class="num">0;        
         m_clusters = k;        
         KMeansClustering(clustered_mat,_centroids,class="num">1);
        
         for (class="type">class="kw">ulong i=class="num">0;i<_centroids.Rows(); i++)
          {
          
           centroid_v = _centroids.Row(i);
          
           x_y_z = clustered_mat.Row(i);
           FilterZero(x_y_z);                  
             for (class="type">class="kw">ulong j=class="num">0;j<x_y_z.Size()/m_cols; j++)
             {

◍ 用肘部法则给聚类维度定调

这段逻辑在跑 K-Means 的簇内平方和(WCSS)评估:先把第 j 个样本的 short_v 从 x_y_z 按 m_cols 偏移拷出,再算它和质心 centroid_v 的差、平方、累加进 wcss。外层循环把每个 k 对应的 WCSS 和 k 值分别写进数组,最后 Print 出来供你肉眼找拐点。 如果 showPlot 为真,代码会先 ObjectDelete 掉旧名为 "elbow" 的对象,再调用 ScatterCurvePlots 把 kArray 对 WCSS 画成散点曲线——这就是经典的肘部图,拐点附近的 k 往往更适合当前品种波动结构。外汇与贵金属杠杆高、跳空频繁,k 选错会让状态划分失真,开 MT5 把这段接进 EA 跑一遍 EURUSD 的 M15 就能看到 WCSS 随 k 从 2 到 10 的衰减曲线。 数据矩阵 DMatrix 的构建有两种写法:一种是 Resize(bars,1) 只塞收盘价,注释直接说 1D 没法正常可视化;另一种是 Resize(bars,3),用循环把不同起始位的 COPY_RATES_CLOSE 拷成 3 列再拼进去。后者维度够,肘部图才画得出来。 MeanNormalization 有两个重载:矩阵版按列取出 vector 逐列标准化,向量版用 (v[i]-mean)/(max-min) 做极差归一。注意分母是 max-min 而非标准差,极端值会被压到 [-1,1] 区间,聚类中心不会被某根长影线带偏。

MQL5 / C++
VectorCopy(x_y_z,short_v,(class="type">uint)(j*m_cols),(class="type">uint)m_cols);
class=class="str">"cmt">//---     WCSS( within cluster sum of squared residuals )
   minus_v = (short_v - centroid_v);
   minus_v = MathPow(minus_v,class="num">2);
   wcss += minus_v.Sum();

   }

   }
    
   WCSS[count_k] = wcss;
   kArray[count_k] = k;
    }   
   Print("WCSS");   ArrayPrint(WCSS);
   Print("kArray");   ArrayPrint(kArray);
class=class="str">"cmt">//--- Plotting the Elbow on the graph

    if (showPlot)
   {
      ObjectDelete(class="num">0,"elbow");   
      ScatterCurvePlots("elbow",kArray,WCSS,WCSS,"Elbow line","k","WCSS");
   }
}
   matrix DMatrix = {};
   DMatrix.Resize(bars, class="num">1); class=class="str">"cmt">//columns determines the dimension of the dataset 1D won&class="macro">#x27;t be visualized properly

   vector column_v = {};

   column_v.CopyRates(symbol,PERIOD_CURRENT,COPY_RATES_CLOSE,class="num">1,bars);

   DMatrix.Col(column_v,class="num">0);   
   matrix DMatrix = {};
   DMatrix.Resize(bars, class="num">3); class=class="str">"cmt">//columns determines the dimension of the dataset 1D won&class="macro">#x27;t be visualized properly

   vector column_v = {};

   class="type">class="kw">ulong start = class="num">0;
   for (class="type">class="kw">ulong i=class="num">0; i<class="num">2; i++)
    {
       column_v.CopyRates(symbol,PERIOD_CURRENT,COPY_RATES_CLOSE,start,bars);
       DMatrix.Col(column_v,i);

       start += bars;
    }
   MeanNormalization(DMatrix);
class="type">void MeanNormalization(matrix &mat)
{

   vector v = {};

    for (class="type">class="kw">ulong i=class="num">0; i<mat.Cols(); i++)
       {
          v = mat.Col(i);
          MeanNormalization(v);
          mat.Col(v,i);   
       }
}
class=class="str">"cmt">//+------------------------------------------------------------------+
class="type">void MeanNormalization(vector &v)
{
   class="type">class="kw">double mean = v.Mean(),
         max = v.Max(),
         min = v.Min();
        
   for (class="type">class="kw">ulong i=class="num">0; i<v.Size(); i++)
      v[i] = (v[i] - mean) / (max - min);

}

初始化质心选法直接左右聚类结果

k-均值聚类值得每个做数据驱动交易的开发者收进工具箱,但它对初始值极度敏感。用手肘法从 2 个聚类起搜最优数时,最终得到的聚类数可能跳到 4,与起始设定完全不同。 质心怎么选,是实盘前必须定的参数。类里主聚类函数留了 rand_cluster 输入:false 时取矩阵前三行当初始质心,true 时随机选。手肘法搜索过程中建议保持 false,因为随机质心在那一步表现不稳定;而当你已经明确聚类数量,随机初始化反而能跑得很干净。 文后 zip 里的代码相较正文略有删减,部分为性能砍行、部分为可读性补行,逻辑骨架没动。外汇与贵金属杠杆高、聚类误判会放大亏损,上 MT5 前先小样本验一遍再挂实盘。

MQL5 / C++
class="type">void CKMeans::KMeansClustering(matrix &clustered_matrix,matrix &centroids,class="type">int iterations = class="num">1,class="type">bool rand_cluster =class="kw">false)
m_cols = Matrix.Cols();
    n = Matrix.Rows(); class=class="str">"cmt">//元素数 | 矩阵行数
    
    InitialCentroids.Resize(m_clusters,m_cols);      
    vector cluster_comb_v = {};
    matrix cluster_comb_m = {};      
    vector rand_v = {};
    
    for (class="type">class="kw">ulong i=class="num">0; i<m_clusters; i++)
      {
        rand_v = Matrix.Row(i * m_clusters);
        InitialCentroids.Row(rand_v,i);
        }     
     Print("Initial Centroids matrix\n",InitialCentroids);    
class="type">void CKMeans::KMeansClustering(class="kw">const matrix &Matrix, matrix &clustered_matrix,class="type">int iterations = class="num">10)
{
    m_cols = Matrix.Cols();
    n = Matrix.Rows(); class=class="str">"cmt">//元素数 | 矩阵行数
    
    InitialCentroids.Resize(m_clusters,m_cols);
    cluster_assign.Resize(n);
          
    clustered_matrix.Resize(m_clusters, m_clusters*n);
    clustered_matrix.Fill(NULL);
    
    vector cluster_comb_v = {};
    matrix cluster_comb_m = {};      
    vector rand_v = {};      
    for (class="type">class="kw">ulong i=class="num">0; i<m_clusters; i++)
      {

「画得少,看得清」

把随机行抽出来塞进 InitialCentroids,就完成了聚类起点的降维——10 万根 K 线不必全画,只留 m_clusters 个质心向量,图表噪音直接砍掉九成。 Print 那行把初始质心矩阵打印到日志,开 MT5 按 F4 编译跑一遍,能在专家标签页看到具体数值,验证抽样是否均匀。 外汇与贵金属波动自带跳空和滑点,质心初始化只是概率起点,后续迭代结果倾向随样本区间漂移;少画不是为了偷懒,是让眼睛只盯住结构重心。

MQL5 / C++
    rand_v = Matrix.Row(i * m_clusters);
    InitialCentroids.Row(rand_v,i);
    }
    Print("Initial Centroids matrix\n",InitialCentroids);

常见问题

看两轮迭代后中心点位移是否明显变小;若第二次比第一次移动极小,说明已收敛,可直接用末轮中心点划分行情状态。
簇是按价格特征相似度分的组,不是涨跌信号;可读每簇的均价与波动范围,反推当前处于震荡或趋势段落。
小布可自动加载品种特征做聚类诊断,打开对应页就能看到簇分布与中心点,不必自己跑脚本。
用现成脚本挂到对应周期,设好输入参数运行即可;重点检查数据窗口样本量是否足够,避免空跑。
拐点模糊就取惯性下降变缓处的前一个 k,并结合实盘可读性原则,优先选能解释行情的较小 k。