Ver en GitHub

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 = 9
    • ClusterDataLength = 50
    • RsiPeriod = 14
    • CandleType = 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;
		}
	}
}