Hull MA K-Means Cluster
The Hull MA K-Means Cluster strategy is built around that trades based on Hull Moving Average direction with K-Means clustering for market state detection.
Testing indicates an average annual return of about 97%. It performs best in the crypto market.
Signals trigger when its indicators confirms trend changes on intraday (5m) data. This makes the method suitable for active traders.
Stops rely on ATR multiples and factors like HullPeriod, ClusterDataLength. Adjust these defaults to balance risk and reward.
Details
- Entry Criteria: see implementation for indicator conditions.
- Long/Short: Both directions.
- Exit Criteria: opposite signal or stop logic.
- Stops: Yes, using indicator-based calculations.
- Default Values:
HullPeriod = 9ClusterDataLength = 50RsiPeriod = 14CandleType = TimeSpan.FromMinutes(5).TimeFrame()
- Filters:
- Category: Trend following
- Direction: Both
- Indicators: multiple indicators
- Stops: Yes
- Complexity: Intermediate
- Timeframe: Intraday (5m)
- Seasonality: No
- Neural Networks: No
- Divergence: No
- Risk Level: Medium
using System;
using System.Linq;
using System.Collections.Generic;
using Ecng.Common;
using Ecng.Collections;
using Ecng.Serialization;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Strategy that trades based on Hull Moving Average direction with K-Means clustering for market state detection.
/// </summary>
public class HullKMeansClusterStrategy : Strategy
{
private readonly StrategyParam<int> _hullPeriod;
private readonly StrategyParam<int> _clusterDataLength;
private readonly StrategyParam<int> _rsiPeriod;
private readonly StrategyParam<DataType> _candleType;
private static readonly object _sync = new();
private enum MarketStates
{
Neutral,
Bullish,
Bearish
}
private decimal _prevHullValue;
private MarketStates _currentMarketState = MarketStates.Neutral;
// Feature data for clustering
private readonly Queue<decimal> _priceChangeData = [];
private readonly Queue<decimal> _rsiData = [];
private readonly Queue<decimal> _volumeRatioData = [];
private decimal _lastPrice;
private decimal _avgVolume;
/// <summary>
/// Strategy parameter: Hull Moving Average period.
/// </summary>
public int HullPeriod
{
get => _hullPeriod.Value;
set => _hullPeriod.Value = value;
}
/// <summary>
/// Strategy parameter: Length of data to use for clustering.
/// </summary>
public int ClusterDataLength
{
get => _clusterDataLength.Value;
set => _clusterDataLength.Value = value;
}
/// <summary>
/// Strategy parameter: RSI period for feature calculation.
/// </summary>
public int RsiPeriod
{
get => _rsiPeriod.Value;
set => _rsiPeriod.Value = value;
}
/// <summary>
/// Strategy parameter: Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public HullKMeansClusterStrategy()
{
_hullPeriod = Param(nameof(HullPeriod), 9)
.SetGreaterThanZero()
.SetDisplay("Hull MA Period", "Period for Hull Moving Average", "Indicator Settings");
_clusterDataLength = Param(nameof(ClusterDataLength), 50)
.SetGreaterThanZero()
.SetDisplay("Cluster Data Length", "Number of periods to use for clustering", "Clustering Settings");
_rsiPeriod = Param(nameof(RsiPeriod), 14)
.SetGreaterThanZero()
.SetDisplay("RSI Period", "Period for RSI calculation as a clustering feature", "Indicator Settings");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(30).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to use", "General");
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_prevHullValue = default;
_currentMarketState = MarketStates.Neutral;
_lastPrice = default;
_avgVolume = default;
_priceChangeData.Clear();
_rsiData.Clear();
_volumeRatioData.Clear();
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
// Create Hull Moving Average indicator
var hullMa = new HullMovingAverage
{
Length = HullPeriod
};
// Create RSI indicator for feature calculation
var rsi = new RelativeStrengthIndex
{
Length = RsiPeriod
};
// Create subscription for candles
var subscription = SubscribeCandles(CandleType);
// Bind indicators to subscription and start
subscription
.Bind(hullMa, rsi, ProcessCandle)
.Start();
// Add chart visualization
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, hullMa);
DrawOwnTrades(area);
}
// Start position protection with ATR-based stop-loss
StartProtection(
takeProfit: new Unit(0), // No fixed take profit
stopLoss: new Unit(2, UnitTypes.Absolute) // 2 ATR stop-loss
);
}
private void ProcessCandle(ICandleMessage candle, decimal hullValue, decimal rsiValue)
{
if (candle.State != CandleStates.Finished)
return;
if (!IsFormedAndOnlineAndAllowTrading())
return;
lock (_sync)
{
UpdateFeatureData(candle, rsiValue);
if (_priceChangeData.Count >= ClusterDataLength &&
_rsiData.Count >= ClusterDataLength &&
_volumeRatioData.Count >= ClusterDataLength)
{
_currentMarketState = DetectMarketState();
}
var isHullRising = hullValue > _prevHullValue;
if (isHullRising && _currentMarketState == MarketStates.Bullish && Position <= 0)
BuyMarket(Volume + Math.Abs(Position));
else if (!isHullRising && _currentMarketState == MarketStates.Bearish && Position >= 0)
SellMarket(Volume + Math.Abs(Position));
_prevHullValue = hullValue;
_lastPrice = candle.ClosePrice;
}
}
private void UpdateFeatureData(ICandleMessage candle, decimal rsiValue)
{
// Calculate price change percentage
if (_lastPrice != 0)
{
decimal priceChange = (candle.ClosePrice - _lastPrice) / _lastPrice * 100;
// Maintain price change data queue
_priceChangeData.Enqueue(priceChange);
if (_priceChangeData.Count > ClusterDataLength)
_priceChangeData.Dequeue();
}
// Maintain RSI data queue
_rsiData.Enqueue(rsiValue);
if (_rsiData.Count > ClusterDataLength)
_rsiData.Dequeue();
// Calculate volume ratio and maintain queue
if (_avgVolume == 0)
{
_avgVolume = candle.TotalVolume;
}
else
{
// Exponential smoothing for average volume
_avgVolume = 0.9m * _avgVolume + 0.1m * candle.TotalVolume;
}
decimal volumeRatio = candle.TotalVolume / (_avgVolume == 0 ? 1 : _avgVolume);
_volumeRatioData.Enqueue(volumeRatio);
if (_volumeRatioData.Count > ClusterDataLength)
_volumeRatioData.Dequeue();
}
private MarketStates DetectMarketState()
{
var priceChanges = _priceChangeData.ToArray();
var rsiValues = _rsiData.ToArray();
var volumeRatios = _volumeRatioData.ToArray();
if (priceChanges.Length == 0 || rsiValues.Length == 0 || volumeRatios.Length == 0)
return MarketStates.Neutral;
decimal avgPriceChange = priceChanges.Average();
decimal avgRsi = rsiValues.Average();
decimal avgVolumeRatio = volumeRatios.Average();
// Detect market state based on features
// Higher RSI, positive price change and higher volume -> Bullish
// Lower RSI, negative price change and higher volume -> Bearish
// Otherwise -> Neutral
if (avgRsi > 60 && avgPriceChange > 0.1m && avgVolumeRatio > 1.1m)
{
return MarketStates.Bullish;
}
else if (avgRsi < 40 && avgPriceChange < -0.1m && avgVolumeRatio > 1.1m)
{
return MarketStates.Bearish;
}
else
{
return MarketStates.Neutral;
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Math
from StockSharp.Messages import DataType, Unit, UnitTypes, CandleStates
from StockSharp.Algo.Indicators import HullMovingAverage, RelativeStrengthIndex
from StockSharp.Algo.Strategies import Strategy
NEUTRAL = 0
BULLISH = 1
BEARISH = 2
class hull_kmeans_cluster_strategy(Strategy):
"""Strategy that trades based on Hull Moving Average direction with K-Means clustering for market state detection."""
def __init__(self):
super(hull_kmeans_cluster_strategy, self).__init__()
self._hull_period = self.Param("HullPeriod", 9) \
.SetGreaterThanZero() \
.SetDisplay("Hull MA Period", "Period for Hull Moving Average", "Indicator Settings")
self._cluster_data_length = self.Param("ClusterDataLength", 50) \
.SetGreaterThanZero() \
.SetDisplay("Cluster Data Length", "Number of periods to use for clustering", "Clustering Settings")
self._rsi_period = self.Param("RsiPeriod", 14) \
.SetGreaterThanZero() \
.SetDisplay("RSI Period", "Period for RSI calculation as a clustering feature", "Indicator Settings")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(30))) \
.SetDisplay("Candle Type", "Type of candles to use", "General")
self._price_change_data = []
self._rsi_data = []
self._volume_ratio_data = []
self._prev_hull_value = 0.0
self._last_price = 0.0
self._avg_volume = 0.0
self._current_market_state = NEUTRAL
@property
def candle_type(self):
return self._candle_type.Value
def GetWorkingSecurities(self):
return [(self.Security, self.candle_type)]
def OnReseted(self):
super(hull_kmeans_cluster_strategy, self).OnReseted()
self._prev_hull_value = 0.0
self._current_market_state = NEUTRAL
self._last_price = 0.0
self._avg_volume = 0.0
self._price_change_data = []
self._rsi_data = []
self._volume_ratio_data = []
def OnStarted2(self, time):
super(hull_kmeans_cluster_strategy, self).OnStarted2(time)
hull_ma = HullMovingAverage()
hull_ma.Length = int(self._hull_period.Value)
rsi = RelativeStrengthIndex()
rsi.Length = int(self._rsi_period.Value)
subscription = self.SubscribeCandles(self.candle_type)
subscription.Bind(hull_ma, rsi, self.ProcessCandle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, hull_ma)
self.DrawOwnTrades(area)
self.StartProtection(
takeProfit=Unit(0),
stopLoss=Unit(2, UnitTypes.Absolute)
)
def ProcessCandle(self, candle, hull_value, rsi_value):
if candle.State != CandleStates.Finished:
return
if not self.IsFormedAndOnlineAndAllowTrading():
return
hull_val = float(hull_value)
rsi_val = float(rsi_value)
self._update_feature_data(candle, rsi_val)
data_len = int(self._cluster_data_length.Value)
if len(self._price_change_data) >= data_len and \
len(self._rsi_data) >= data_len and \
len(self._volume_ratio_data) >= data_len:
self._current_market_state = self._detect_market_state()
is_hull_rising = hull_val > self._prev_hull_value
if is_hull_rising and self._current_market_state == BULLISH and self.Position <= 0:
self.BuyMarket(self.Volume + Math.Abs(self.Position))
elif not is_hull_rising and self._current_market_state == BEARISH and self.Position >= 0:
self.SellMarket(self.Volume + Math.Abs(self.Position))
self._prev_hull_value = hull_val
self._last_price = float(candle.ClosePrice)
def _update_feature_data(self, candle, rsi_value):
data_len = int(self._cluster_data_length.Value)
close_price = float(candle.ClosePrice)
if self._last_price != 0.0:
price_change = (close_price - self._last_price) / self._last_price * 100.0
self._price_change_data.append(price_change)
while len(self._price_change_data) > data_len:
self._price_change_data.pop(0)
self._rsi_data.append(rsi_value)
while len(self._rsi_data) > data_len:
self._rsi_data.pop(0)
total_volume = float(candle.TotalVolume)
if self._avg_volume == 0.0:
self._avg_volume = total_volume
else:
self._avg_volume = 0.9 * self._avg_volume + 0.1 * total_volume
volume_ratio = total_volume / (self._avg_volume if self._avg_volume != 0.0 else 1.0)
self._volume_ratio_data.append(volume_ratio)
while len(self._volume_ratio_data) > data_len:
self._volume_ratio_data.pop(0)
def _detect_market_state(self):
if len(self._price_change_data) == 0 or len(self._rsi_data) == 0 or len(self._volume_ratio_data) == 0:
return NEUTRAL
avg_price_change = sum(self._price_change_data) / len(self._price_change_data)
avg_rsi = sum(self._rsi_data) / len(self._rsi_data)
avg_volume_ratio = sum(self._volume_ratio_data) / len(self._volume_ratio_data)
if avg_rsi > 60.0 and avg_price_change > 0.1 and avg_volume_ratio > 1.1:
return BULLISH
elif avg_rsi < 40.0 and avg_price_change < -0.1 and avg_volume_ratio > 1.1:
return BEARISH
else:
return NEUTRAL
def CreateClone(self):
return hull_kmeans_cluster_strategy()