Crunchsters Normalised Trend Strategy
Strategy that normalizes returns and applies a Hull Moving Average to the cumulative normalized price. Enters long when the normalized price crosses above the HMA and short when it crosses below.
Testing indicates an average annual return of about 105%. It performs best in the crypto market.
Normalized returns allow price to be scaled by recent volatility. An ATR-based stop manages risk.
Details
- Entry Criteria:
- Long:
nPricecrosses aboveHMA - Short:
nPricecrosses belowHMA
- Long:
- Long/Short: Both
- Exit Criteria: Opposite crossover or ATR stop
- Stops: ATR-based using
StopMultiple - Default Values:
NormPeriod= 14HmaPeriod= 100HmaOffset= 0StopMultiple= 1mCandleType= TimeSpan.FromMinutes(5).TimeFrame()
- Filters:
- Category: Trend following
- Direction: Both
- Indicators: Hull Moving Average, Standard Deviation, ATR
- Stops: Yes
- Complexity: Intermediate
- Timeframe: Mid-term
- Seasonality: No
- Neural Networks: No
- Divergence: No
- Risk Level: Medium
using System;
using System.Collections.Generic;
using Ecng.Common;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Crunchster's Normalised Trend strategy.
/// Each close-to-close return is divided by the standard deviation of the last NormPeriod returns and the results are summed
/// into a normalised price. A cross of the normalised price above its Hull moving average (taken HmaOffset bars back) goes long
/// and a cross below goes short, reversing an opposite position. A stop StopMultiple ATRs (NormPeriod long) from the entry
/// closes a losing trade.
/// </summary>
public class CrunchstersNormalisedTrendStrategy : Strategy
{
private readonly StrategyParam<int> _normPeriod;
private readonly StrategyParam<int> _hmaPeriod;
private readonly StrategyParam<int> _hmaOffset;
private readonly StrategyParam<decimal> _stopMultiple;
private readonly StrategyParam<DataType> _candleType;
private readonly List<decimal> _hmaHistory = [];
private StandardDeviation _returnDeviation;
private HullMovingAverage _hma;
private decimal? _prevClose;
private decimal _normalizedPrice;
private decimal? _prevPrice;
private decimal? _prevHma;
private decimal? _stopPrice;
/// <summary>
/// Period of the return standard deviation.
/// </summary>
public int NormPeriod
{
get => _normPeriod.Value;
set => _normPeriod.Value = value;
}
/// <summary>
/// Hull moving average period.
/// </summary>
public int HmaPeriod
{
get => _hmaPeriod.Value;
set => _hmaPeriod.Value = value;
}
/// <summary>
/// Bars the Hull moving average is shifted back.
/// </summary>
public int HmaOffset
{
get => _hmaOffset.Value;
set => _hmaOffset.Value = value;
}
/// <summary>
/// ATR multiple for the stop.
/// </summary>
public decimal StopMultiple
{
get => _stopMultiple.Value;
set => _stopMultiple.Value = value;
}
/// <summary>
/// Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public CrunchstersNormalisedTrendStrategy()
{
_normPeriod = Param(nameof(NormPeriod), 14)
.SetRange(2, 10000)
.SetDisplay("Norm Period", "Period of the return standard deviation", "Indicators");
_hmaPeriod = Param(nameof(HmaPeriod), 100)
.SetGreaterThanZero()
.SetDisplay("HMA Period", "Hull moving average period", "Indicators");
_hmaOffset = Param(nameof(HmaOffset), 0)
.SetNotNegative()
.SetDisplay("HMA Offset", "Bars the Hull moving average is shifted back", "Indicators");
_stopMultiple = Param(nameof(StopMultiple), 1m)
.SetNotNegative()
.SetDisplay("Stop Multiple", "ATR multiple for the stop", "Risk");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).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();
ResetState();
_returnDeviation = null;
_hma = null;
}
private void ResetState()
{
_hmaHistory.Clear();
_prevClose = null;
_normalizedPrice = 0m;
_prevPrice = null;
_prevHma = null;
_stopPrice = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
ResetState();
var atr = new AverageTrueRange { Length = NormPeriod };
_returnDeviation = new StandardDeviation { Length = NormPeriod };
_hma = new HullMovingAverage { Length = HmaPeriod };
var subscription = SubscribeCandles(CandleType);
subscription
.BindEx(atr, ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, IIndicatorValue atrValue)
{
if (candle.State != CandleStates.Finished)
return;
var close = candle.ClosePrice;
var prevClose = _prevClose;
_prevClose = close;
if (prevClose is not decimal lastClose || lastClose == 0)
return;
var ret = (close - lastClose) / lastClose;
var deviationValue = _returnDeviation.Process(ret, candle.OpenTime, true);
if (!deviationValue.IsFormed)
return;
var deviation = deviationValue.ToDecimal();
if (deviation > 0)
_normalizedPrice += ret / deviation;
var price = _normalizedPrice;
var hmaValue = _hma.Process(price, candle.OpenTime, true);
if (!hmaValue.IsFormed)
return;
_hmaHistory.Add(hmaValue.ToDecimal());
if (_hmaHistory.Count > HmaOffset + 1)
_hmaHistory.RemoveAt(0);
if (_hmaHistory.Count <= HmaOffset)
return;
var hma = _hmaHistory[0];
var prevPrice = _prevPrice;
var prevHma = _prevHma;
_prevPrice = price;
_prevHma = hma;
if (!IsFormedAndOnlineAndAllowTrading())
return;
if (Position > 0 && _stopPrice is decimal longStop && candle.LowPrice <= longStop)
{
SellMarket(Position);
_stopPrice = null;
return;
}
if (Position < 0 && _stopPrice is decimal shortStop && candle.HighPrice >= shortStop)
{
BuyMarket(-Position);
_stopPrice = null;
return;
}
if (prevPrice is not decimal pp || prevHma is not decimal ph || !atrValue.IsFormed)
return;
var stopDistance = atrValue.ToDecimal() * StopMultiple;
if (pp <= ph && price > hma && Position <= 0)
{
BuyMarket(Volume + Math.Abs(Position));
_stopPrice = StopMultiple > 0 ? close - stopDistance : null;
}
else if (pp >= ph && price < hma && Position >= 0)
{
SellMarket(Volume + Math.Abs(Position));
_stopPrice = StopMultiple > 0 ? close + stopDistance : null;
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
from System import TimeSpan, Decimal
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Indicators import AverageTrueRange, StandardDeviation, HullMovingAverage
from StockSharp.Algo.Strategies import Strategy
from indicator_extensions import *
class crunchsters_normalised_trend_strategy(Strategy):
"""
Crunchster's Normalised Trend strategy.
Each close-to-close return is divided by the standard deviation of the last NormPeriod returns and the results are summed
into a normalised price. A cross of the normalised price above its Hull moving average (taken HmaOffset bars back) goes long
and a cross below goes short, reversing an opposite position. A stop StopMultiple ATRs (NormPeriod long) from the entry
closes a losing trade.
"""
def __init__(self):
super(crunchsters_normalised_trend_strategy, self).__init__()
self._norm_period = self.Param("NormPeriod", 14).SetRange(2, 10000).SetDisplay("Norm Period", "Period of the return standard deviation", "Indicators")
self._hma_period = self.Param("HmaPeriod", 100).SetGreaterThanZero().SetDisplay("HMA Period", "Hull moving average period", "Indicators")
self._hma_offset = self.Param("HmaOffset", 0).SetNotNegative().SetDisplay("HMA Offset", "Bars the Hull moving average is shifted back", "Indicators")
self._stop_multiple = self.Param("StopMultiple", 1.0).SetNotNegative().SetDisplay("Stop Multiple", "ATR multiple for the stop", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))).SetDisplay("Candle Type", "Type of candles to use", "General")
self._return_deviation = None
self._hma = None
self._reset_state()
@property
def candle_type(self):
return self._candle_type.Value
def _reset_state(self):
self._hma_history = []
self._prev_close = None
self._normalized_price = Decimal(0)
self._prev_price = None
self._prev_hma = None
self._stop_price = None
def OnReseted(self):
super(crunchsters_normalised_trend_strategy, self).OnReseted()
self._reset_state()
self._return_deviation = None
self._hma = None
def OnStarted2(self, time):
super(crunchsters_normalised_trend_strategy, self).OnStarted2(time)
self._reset_state()
atr = AverageTrueRange()
atr.Length = self._norm_period.Value
self._return_deviation = StandardDeviation()
self._return_deviation.Length = self._norm_period.Value
self._hma = HullMovingAverage()
self._hma.Length = self._hma_period.Value
subscription = self.SubscribeCandles(self.candle_type)
subscription.BindEx(atr, self._process_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawOwnTrades(area)
def _process_candle(self, candle, atr_value):
if candle.State != CandleStates.Finished:
return
close = candle.ClosePrice
last_close = self._prev_close
self._prev_close = close
if last_close is None or last_close == 0:
return
ret = (close - last_close) / last_close
deviation_value = process_value(self._return_deviation, ret, candle.OpenTime, True)
if not deviation_value.IsFormed:
return
deviation = deviation_value.GetValue[Decimal](None)
if deviation > 0:
self._normalized_price = self._normalized_price + ret / deviation
price = self._normalized_price
hma_value = process_value(self._hma, price, candle.OpenTime, True)
if not hma_value.IsFormed:
return
offset = self._hma_offset.Value
self._hma_history.append(hma_value.GetValue[Decimal](None))
if len(self._hma_history) > offset + 1:
self._hma_history.pop(0)
if len(self._hma_history) <= offset:
return
hma = self._hma_history[0]
prev_price = self._prev_price
prev_hma = self._prev_hma
self._prev_price = price
self._prev_hma = hma
if not self.IsFormedAndOnlineAndAllowTrading():
return
if self.Position > 0 and self._stop_price is not None and candle.LowPrice <= self._stop_price:
self.SellMarket(self.Position)
self._stop_price = None
return
if self.Position < 0 and self._stop_price is not None and candle.HighPrice >= self._stop_price:
self.BuyMarket(-self.Position)
self._stop_price = None
return
if prev_price is None or prev_hma is None or not atr_value.IsFormed:
return
stop_multiple = Decimal(self._stop_multiple.Value)
stop_distance = atr_value.GetValue[Decimal](None) * stop_multiple
if prev_price <= prev_hma and price > hma and self.Position <= 0:
self.BuyMarket(self.Volume + abs(self.Position))
self._stop_price = close - stop_distance if stop_multiple > 0 else None
elif prev_price >= prev_hma and price < hma and self.Position >= 0:
self.SellMarket(self.Volume + abs(self.Position))
self._stop_price = close + stop_distance if stop_multiple > 0 else None
def CreateClone(self):
return crunchsters_normalised_trend_strategy()