IC Markets

Unsupervised Learning for S/R: Agglomerative Hierarchical Clustering (AHC) Levels

Share, develop, and backtest custom MQL4/MQL5 Expert Advisors, Python data-scraping scripts, trading bots, and automated market alert systems.
Post Reply
FTtrader
Posts: 151
Joined: Mon Aug 03, 2026 2:43 pm

Unsupervised Learning for S/R: Agglomerative Hierarchical Clustering (AHC) Levels

Post by FTtrader »

Fellow scalpers,

We all know the traditional methods for plotting S/R levels—looking left, finding double tops, drawing discretionary lines, or relying on arbitrary round numbers. But when you are running high-frequency or sub-M5 scalping systems, discretion is your enemy. You need mathematically quantifiable liquidity zones.

Lately, I’ve been stripping away standard pivot/fractal logic and applying Unsupervised Machine Learning—specifically Agglomerative Hierarchical Clustering (AHC)—directly in MQL4 to identify institutional accumulation/distribution zones.

Here is a breakdown of the logic, the MQL4 source code, and how to weaponize it for scalping.

The Quantitative Logic:

Why AHC?Agglomerative clustering is a "bottom-up" approach. Instead of guessing where levels are, we let the price data speak for itself:Initialization: We extract the last $N$ swing highs and lows (local extrema) and treat every single price point as its own independent cluster.

Distance Metric: We use 1D Euclidean distance (simple absolute price difference).Centroid Linkage: The algorithm finds the two closest price points and merges them into a single cluster. The new cluster's price is the weighted average (centroid) of the merged points.Termination: This looping merge process continues until no two clusters are closer than a defined Threshold (e.g., 3 pips).

The MQL4 Implementation (Expert Level)

I’ve written a lightweight, non-blocking MQL4 indicator. It runs a 1D AHC algorithm over the recent history of fractals.Note: Since AHC has an algorithmic complexity of $O(n^3)$ in its naive state, I restrict the dataset to the last X swing points. For MT4, $N < 500$ processes in sub-milliseconds, meaning it won't freeze your terminal tick thread.

Code: Select all

//+------------------------------------------------------------------+
//|                                              AHC_Levels_Pro.mq4  |
//|                                            forex-scalping.com    |
//+------------------------------------------------------------------+
#property copyright "Open Source for forex-scalping.com"
#property indicator_chart_window

input int    LookbackBars = 1000;         // Bars to scan for extrema
input int    SwingPeriod = 5;             // Bars for local high/low (Fractal logic)
input double ClusterThresholdPips = 4.0;  // Merge threshold in Pips
input int    MinClusterWeight = 2;        // Minimum touches to plot a line
input color  LevelColor = clrDodgerBlue;

struct Cluster {
   double price;
   int weight;
};

Cluster clusters[];

//+------------------------------------------------------------------+
int OnInit() {
   ObjectsDeleteAll(0, "AHC_Level_");
   return(INIT_SUCCEEDED);
}

void OnDeinit(const int reason) {
   ObjectsDeleteAll(0, "AHC_Level_");
}

//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[]) 
{
   // Only recalculate on a new bar to save CPU
   if (prev_calculated == rates_total) return rates_total;

   ObjectsDeleteAll(0, "AHC_Level_");
   ArrayResize(clusters, 0);
   
   // 1. Gather Data Points (Local Extrema)
   int count = 0;
   for(int i = SwingPeriod; i < LookbackBars && i < rates_total - SwingPeriod; i++) {
      bool isHigh = true;
      bool isLow = true;
      
      for(int j = 1; j <= SwingPeriod; j++) {
         if(high[i] <= high[i+j] || high[i] <= high[i-j]) isHigh = false;
         if(low[i] >= low[i+j] || low[i] >= low[i-j]) isLow = false;
      }
      
      if(isHigh) {
         ArrayResize(clusters, count + 1);
         clusters[count].price = high[i];
         clusters[count].weight = 1;
         count++;
      }
      if(isLow) {
         ArrayResize(clusters, count + 1);
         clusters[count].price = low[i];
         clusters[count].weight = 1;
         count++;
      }
   }

   // 2. Agglomerative Hierarchical Clustering (Centroid Linkage)
   double thresholdPts = ClusterThresholdPips * 10 * Point; // Adjust for 5-digit brokers
   if(StringFind(Symbol(), "JPY") >= 0) thresholdPts = ClusterThresholdPips * Point; // JPY fix (simplistic)

   bool merged = true;
   while(merged && count > 1) {
      merged = false;
      double min_dist = 999999.0;
      int merge_i = -1, merge_j = -1;

      // Find closest pair
      for(int i = 0; i < count; i++) {
         if(clusters[i].weight == 0) continue;
         
         for(int j = i + 1; j < count; j++) {
            if(clusters[j].weight == 0) continue;
            
            double dist = MathAbs(clusters[i].price - clusters[j].price);
            if(dist < min_dist) {
               min_dist = dist;
               merge_i = i;
               merge_j = j;
            }
         }
      }

      // Merge if within threshold
      if(min_dist <= thresholdPts && merge_i != -1) {
         // Calculate new weighted centroid
         double total_weight = clusters[merge_i].weight + clusters[merge_j].weight;
         clusters[merge_i].price = ((clusters[merge_i].price * clusters[merge_i].weight) + 
                                   (clusters[merge_j].price * clusters[merge_j].weight)) / total_weight;
         
         clusters[merge_i].weight += clusters[merge_j].weight;
         clusters[merge_j].weight = 0; // Mark j as dead
         merged = true;
      }
   }

   // 3. Render the S/R Clusters
   int drawn = 0;
   for(int i = 0; i < count; i++) {
      if(clusters[i].weight >= MinClusterWeight) {
         string objName = "AHC_Level_" + IntegerToString(drawn);
         ObjectCreate(0, objName, OBJ_HLINE, 0, 0, clusters[i].price);
         ObjectSetInteger(0, objName, OBJPROP_COLOR, LevelColor);
         
         // Thicker line for higher clustered weights
         int width = clusters[i].weight > 4 ? 2 : 1; 
         ObjectSetInteger(0, objName, OBJPROP_WIDTH, width);
         
         // Optional: Add text label with cluster weight
         ObjectSetString(0, objName, OBJPROP_TEXT, " W:" + IntegerToString(clusters[i].weight));
         drawn++;
      }
   }
   
   return rates_total;
}
//+------------------------------------------------------------------+
How to weaponize this for Scalping
Threshold Tuning: The ClusterThresholdPips parameter is the secret sauce. On the EURUSD M1/M5, a 3.0 to 4.5 pip threshold clusters the noise into highly reactive institutional levels. If you trade higher volatility pairs (like GBPJPY or Gold), you must scale this up (e.g., 8-12 pips) to account for structural market depth.

The "Weight" Variable: The script outputs a weight (W:X) on each line. A weight of 2 means a standard double top/bottom. A weight of 5+ indicates a massive liquidity shelf.

Execution Strategy: Do not place blind limit orders. Use these nodes as your areas of interest. When price approaches a heavy cluster (Weight 4+), switch to your order flow/tick chart and look for:

Deceleration (Delta divergence).

A micro-break of structure (mBOS) on the 1-minute chart.

Enter with a tight stop just outside the cluster's algorithmic threshold.

Open for Discussion
I intentionally kept the linkage method as Centroid Linkage because it's fast in C/MQL4. Has anyone here experimented with Ward's Method or Complete Linkage for S/R modeling via external Python scripts/ZMQ? I'm curious if minimizing the variance of the clusters yields sharper bounce zones than simply pulling the weighted mean.

Let me know if you run this through the strategy tester or throw it on a live chart. Happy hunting.
FTtrader
Posts: 151
Joined: Mon Aug 03, 2026 2:43 pm

Re: Unsupervised Learning for S/R: Agglomerative Hierarchical Clustering (AHC) Levels

Post by FTtrader »

Here is the complete MT5/MQL5 translation of the Agglomerative Hierarchical Clustering script.

To migrate this from MQL4 to MQL5 smoothly, three key architectural differences had to be addressed:

Timeseries Indexing: MQL5 arrays (like high[] and low[]) are indexed strictly from oldest to newest by default (index 0 is the oldest bar in history). MT4 does the exact opposite. I added ArraySetAsSeries() at the top of OnCalculate to force MT5 to read the data backwards, allowing the MT4 fractal looping math to work perfectly without restructuring the algorithmic complexity.

Environment Variables: Point and Symbol() are swapped to their native MQL5 equivalents (_Point and _Symbol).

Buffer Management: I explicitly declared #property indicator_buffers 0 and #property indicator_plots 0 so the strict MQL5 compiler doesn't throw warnings looking for standard indicator buffers (since we are drawing raw objects directly to the chart).

MT5 Source Code

Code: Select all

//+------------------------------------------------------------------+
//|                                              AHC_Levels_Pro.mq5  |
//|                                            forex-scalping.com    |
//+------------------------------------------------------------------+
#property copyright "Open Source for forex-scalping.com"
#property indicator_chart_window
#property indicator_buffers 0
#property indicator_plots 0

input int    LookbackBars = 1000;         // Bars to scan for extrema
input int    SwingPeriod = 5;             // Bars for local high/low (Fractal logic)
input double ClusterThresholdPips = 4.0;  // Merge threshold in Pips
input int    MinClusterWeight = 2;        // Minimum touches to plot a line
input color  LevelColor = clrDodgerBlue;

struct Cluster {
   double price;
   int weight;
};

Cluster clusters[];

//+------------------------------------------------------------------+
//| Custom indicator initialization function                         |
//+------------------------------------------------------------------+
int OnInit() {
   ObjectsDeleteAll(0, "AHC_Level_");
   return(INIT_SUCCEEDED);
}

//+------------------------------------------------------------------+
//| Custom indicator deinitialization function                       |
//+------------------------------------------------------------------+
void OnDeinit(const int reason) {
   ObjectsDeleteAll(0, "AHC_Level_");
}

//+------------------------------------------------------------------+
//| Custom indicator iteration function                              |
//+------------------------------------------------------------------+
int OnCalculate(const int rates_total,
                const int prev_calculated,
                const datetime &time[],
                const double &open[],
                const double &high[],
                const double &low[],
                const double &close[],
                const long &tick_volume[],
                const long &volume[],
                const int &spread[]) 
{
   // 1. Force MT5 to read arrays from Right-to-Left (Current Bar = 0)
   ArraySetAsSeries(high, true);
   ArraySetAsSeries(low, true);

   // Only recalculate on a new bar to save terminal CPU cycles
   if (prev_calculated == rates_total) return(rates_total);

   ObjectsDeleteAll(0, "AHC_Level_");
   ArrayResize(clusters, 0);
   
   // 2. Gather Data Points (Local Extrema)
   int count = 0;
   for(int i = SwingPeriod; i < LookbackBars && i < rates_total - SwingPeriod; i++) {
      bool isHigh = true;
      bool isLow = true;
      
      for(int j = 1; j <= SwingPeriod; j++) {
         if(high[i] <= high[i+j] || high[i] <= high[i-j]) isHigh = false;
         if(low[i] >= low[i+j] || low[i] >= low[i-j]) isLow = false;
      }
      
      if(isHigh) {
         ArrayResize(clusters, count + 1);
         clusters[count].price = high[i];
         clusters[count].weight = 1;
         count++;
      }
      if(isLow) {
         ArrayResize(clusters, count + 1);
         clusters[count].price = low[i];
         clusters[count].weight = 1;
         count++;
      }
   }

   // 3. Agglomerative Hierarchical Clustering (Centroid Linkage)
   double thresholdPts = ClusterThresholdPips * 10 * _Point; // Adjust for 5-digit brokers
   if(StringFind(_Symbol, "JPY") >= 0) thresholdPts = ClusterThresholdPips * _Point; // JPY fix (simplistic)

   bool merged = true;
   while(merged && count > 1) {
      merged = false;
      double min_dist = 999999.0;
      int merge_i = -1, merge_j = -1;

      // Find closest pair
      for(int i = 0; i < count; i++) {
         if(clusters[i].weight == 0) continue;
         
         for(int j = i + 1; j < count; j++) {
            if(clusters[j].weight == 0) continue;
            
            double dist = MathAbs(clusters[i].price - clusters[j].price);
            if(dist < min_dist) {
               min_dist = dist;
               merge_i = i;
               merge_j = j;
            }
         }
      }

      // Merge if within threshold
      if(min_dist <= thresholdPts && merge_i != -1) {
         // Calculate new weighted centroid
         double total_weight = clusters[merge_i].weight + clusters[merge_j].weight;
         clusters[merge_i].price = ((clusters[merge_i].price * clusters[merge_i].weight) + 
                                   (clusters[merge_j].price * clusters[merge_j].weight)) / total_weight;
         
         clusters[merge_i].weight += clusters[merge_j].weight;
         clusters[merge_j].weight = 0; // Mark j as dead
         merged = true;
      }
   }

   // 4. Render the S/R Clusters
   int drawn = 0;
   for(int i = 0; i < count; i++) {
      if(clusters[i].weight >= MinClusterWeight) {
         string objName = "AHC_Level_" + IntegerToString(drawn);
         ObjectCreate(0, objName, OBJ_HLINE, 0, 0, clusters[i].price);
         ObjectSetInteger(0, objName, OBJPROP_COLOR, LevelColor);
         
         // Thicker line for higher clustered weights
         int width = clusters[i].weight > 4 ? 2 : 1; 
         ObjectSetInteger(0, objName, OBJPROP_WIDTH, width);
         
         // Add text label with cluster weight
         ObjectSetString(0, objName, OBJPROP_TEXT, " W:" + IntegerToString(clusters[i].weight));
         
         // MT5 Specific: Ensure object text isn't hidden behind candles
         ObjectSetInteger(0, objName, OBJPROP_BACK, false);
         ObjectSetInteger(0, objName, OBJPROP_HIDDEN, true);
         ObjectSetInteger(0, objName, OBJPROP_SELECTABLE, false);
         
         drawn++;
      }
   }
   
   return(rates_total);
}
//+------------------------------------------------------------------+
FTtrader
Posts: 151
Joined: Mon Aug 03, 2026 2:43 pm

Re: Unsupervised Learning for S/R: Agglomerative Hierarchical Clustering (AHC) Levels

Post by FTtrader »

Here are the complete translations for both cTrader (C# / cAlgo API) and TradingView (Pine Script v5).

Translating Unsupervised Machine Learning into these environments requires navigating their distinct data handling architectures.For cTrader: We use List<T> to rapidly handle the matrix shrinking, relying on Symbol.PipSize to natively handle the clustering thresholds.For Pine Script: Because Pine Script operates on a strict bar-by-bar execution cycle, running a heavy $O(N^3)$ clustering loop on every single historical bar will trigger a script timeout error. The solution is wrapping the logic inside an if barstate.islast condition, forcing the clustering to only process the current state of the chart and draw backward.1. cTrader Source Code (C# / cAlgo)To install this on cTrader, open the Automate tab, click "New Indicator", paste the code, and click "Build".

Code: Select all

using System;
using System.Collections.Generic;
using cAlgo.API;
using cAlgo.API.Indicators;

namespace cAlgo
{
    [Indicator(IsOverlay = true, TimeZone = TimeZones.UTC, AccessRights = AccessRights.None)]
    public class AHC_Levels_Pro : Indicator
    {
        [Parameter("Lookback Bars", DefaultValue = 1000)]
        public int LookbackBars { get; set; }

        [Parameter("Swing Period", DefaultValue = 5)]
        public int SwingPeriod { get; set; }

        [Parameter("Cluster Threshold (Pips)", DefaultValue = 4.0)]
        public double ClusterThresholdPips { get; set; }

        [Parameter("Minimum Cluster Weight", DefaultValue = 2)]
        public int MinClusterWeight { get; set; }

        [Parameter("Level Color", DefaultValue = "DodgerBlue")]
        public Color LevelColor { get; set; }
        
        private class Cluster
        {
            public double Price { get; set; }
            public int Weight { get; set; }
        }

        // Keep track of drawn objects for clean refresh cycles
        private List<string> _drawnObjects = new List<string>();

        protected override void Initialize()
        {
            // Initialization
        }

        public override void Calculate(int index)
        {
            // Only execute the heavy clustering on the latest bar to save CPU
            if (!IsLastBar) return;
            
            // Clean up previous drawing iterations
            foreach (var objName in _drawnObjects)
            {
                Chart.RemoveObject(objName);
            }
            _drawnObjects.Clear();

            List<Cluster> clusters = new List<Cluster>();
            
            // 1. Gather Data Points (Local Extrema)
            int startIndex = Math.Max(0, index - LookbackBars);
            
            for (int i = startIndex + SwingPeriod; i <= index - SwingPeriod; i++)
            {
                bool isHigh = true;
                bool isLow = true;

                for (int j = 1; j <= SwingPeriod; j++)
                {
                    if (Bars.HighPrices[i] <= Bars.HighPrices[i + j] || Bars.HighPrices[i] <= Bars.HighPrices[i - j]) isHigh = false;
                    if (Bars.LowPrices[i] >= Bars.LowPrices[i + j] || Bars.LowPrices[i] >= Bars.LowPrices[i - j]) isLow = false;
                }

                if (isHigh) clusters.Add(new Cluster { Price = Bars.HighPrices[i], Weight = 1 });
                if (isLow) clusters.Add(new Cluster { Price = Bars.LowPrices[i], Weight = 1 });
            }

            double thresholdPts = ClusterThresholdPips * Symbol.PipSize;

            // 2. Agglomerative Hierarchical Clustering
            bool merged = true;
            while (merged && clusters.Count > 1)
            {
                merged = false;
                double min_dist = double.MaxValue;
                int merge_i = -1;
                int merge_j = -1;

                for (int i = 0; i < clusters.Count; i++)
                {
                    for (int j = i + 1; j < clusters.Count; j++)
                    {
                        double dist = Math.Abs(clusters[i].Price - clusters[j].Price);
                        if (dist < min_dist)
                        {
                            min_dist = dist;
                            merge_i = i;
                            merge_j = j;
                        }
                    }
                }

                // Merge if within threshold
                if (min_dist <= thresholdPts && merge_i != -1)
                {
                    double totalWeight = clusters[merge_i].Weight + clusters[merge_j].Weight;
                    
                    // Calculate weighted centroid
                    clusters[merge_i].Price = ((clusters[merge_i].Price * clusters[merge_i].Weight) +
                                              (clusters[merge_j].Price * clusters[merge_j].Weight)) / totalWeight;

                    clusters[merge_i].Weight += clusters[merge_j].Weight;
                    
                    // Remove the secondary cluster node entirely to speed up the loop execution
                    clusters.RemoveAt(merge_j); 
                    merged = true;
                }
            }

            // 3. Render the Zones
            int drawn = 0;
            for (int i = 0; i < clusters.Count; i++)
            {
                if (clusters[i].Weight >= MinClusterWeight)
                {
                    string objName = "AHC_Level_" + drawn;
                    string txtName = "AHC_Level_txt_" + drawn;
                    
                    int width = clusters[i].Weight > 4 ? 2 : 1;
                    
                    Chart.DrawHorizontalLine(objName, clusters[i].Price, LevelColor, width, LineStyle.Solid);
                    Chart.DrawText(txtName, " W:" + clusters[i].Weight, index + 5, clusters[i].Price, LevelColor);
                    
                    _drawnObjects.Add(objName);
                    _drawnObjects.Add(txtName);
                    
                    drawn++;
                }
            }
        }
    }
}
FTtrader
Posts: 151
Joined: Mon Aug 03, 2026 2:43 pm

Re: Unsupervised Learning for S/R: Agglomerative Hierarchical Clustering (AHC) Levels

Post by FTtrader »

TradingView Source Code (Pine Script v5)
To install this on TradingView, open the Pine Editor at the bottom of your chart, paste the code, and click "Add to chart".

Note: In Pine Script, we simulate the Struct functionality by utilizing tandem parallel arrays for Prices and Weights.

Code: Select all

//@version=5
indicator("AHC Levels Pro", overlay=true, max_lines_count=500, max_labels_count=500)

lookbackBars = input.int(1000, title="Lookback Bars", maxval=4000)
swingPeriod = input.int(5, title="Swing Period")
thresholdPips = input.float(4.0, title="Cluster Threshold (Pips)")
minWeight = input.int(2, title="Minimum Cluster Weight")
levelColor = input.color(color.blue, title="Level Color")

// Memory arrays to store drawing IDs for real-time deletion
var line[] levelLines = array.new_line()
var label[] levelLabels = array.new_label()

if barstate.islast
    // 0. Clean up previous objects on a new tick
    if array.size(levelLines) > 0
        for i = 0 to array.size(levelLines) - 1
            line.delete(array.get(levelLines, i))
        array.clear(levelLines)
        
    if array.size(levelLabels) > 0
        for i = 0 to array.size(levelLabels) - 1
            label.delete(array.get(levelLabels, i))
        array.clear(levelLabels)

    // Data arrays serving as our Cluster struct
    clusterPrices = array.new_float(0)
    clusterWeights = array.new_int(0)
    
    // 1. Gather Data Points (Local Extrema)
    startIdx = math.min(lookbackBars, bar_index - swingPeriod)
    
    for i = swingPeriod to startIdx
        isHigh = true
        isLow = true
        
        // Loop historically backward/forward from reference point i
        for j = 1 to swingPeriod
            if high[i] <= high[i+j] or high[i] <= high[i-j]
                isHigh := false
            if low[i] >= low[i+j] or low[i] >= low[i-j]
                isLow := false
                
        if isHigh
            array.push(clusterPrices, high[i])
            array.push(clusterWeights, 1)
        if isLow
            array.push(clusterPrices, low[i])
            array.push(clusterWeights, 1)

    // 2. Agglomerative Hierarchical Clustering
    // Adapting Pip logic for FX pairs vs standard asset point values
    thresholdPts = thresholdPips * syminfo.mintick * (syminfo.type == "forex" ? 10 : 1)
    
    merged = true
    while merged and array.size(clusterPrices) > 1
        merged := false
        min_dist = 999999.0
        merge_i = -1
        merge_j = -1
        
        size = array.size(clusterPrices)
        if size > 1
            for i = 0 to size - 2
                for j = i + 1 to size - 1
                    dist = math.abs(array.get(clusterPrices, i) - array.get(clusterPrices, j))
                    if dist < min_dist
                        min_dist := dist
                        merge_i := i
                        merge_j := j
                        
        if min_dist <= thresholdPts and merge_i != -1
            w_i = array.get(clusterWeights, merge_i)
            w_j = array.get(clusterWeights, merge_j)
            p_i = array.get(clusterPrices, merge_i)
            p_j = array.get(clusterPrices, merge_j)
            
            // Generate Centroid Point
            new_w = w_i + w_j
            new_p = ((p_i * w_i) + (p_j * w_j)) / new_w
            
            array.set(clusterPrices, merge_i, new_p)
            array.set(clusterWeights, merge_i, new_w)
            
            // Remove the merged target to radically shrink loop execution time
            array.remove(clusterPrices, merge_j)
            array.remove(clusterWeights, merge_j)
            merged := true

    // 3. Render the S/R Clusters
    size2 = array.size(clusterPrices)
    if size2 > 0
        for i = 0 to size2 - 1
            w = array.get(clusterWeights, i)
            if w >= minWeight
                p = array.get(clusterPrices, i)
                lineWidth = w > 4 ? 2 : 1
                
                ln = line.new(bar_index - lookbackBars, p, bar_index + 10, p, color=levelColor, width=lineWidth)
                array.push(levelLines, ln)
                
                lbl = label.new(bar_index + 10, p, text="W:" + str.tostring(w), style=label.style_label_left, color=color.new(color.white, 100), textcolor=levelColor, size=size.small)
                array.push(levelLabels, lbl)

I hope that it will be usefull for you :-)
Take a care, bye bye.
Post Reply