Volume Exhaustion Strategy
Sharp spikes in volume often signal the end of a move as traders rush to exit or enter positions. This strategy measures current volume against an average to spot exhaustion. When combined with candle direction and a moving average filter it can pinpoint reversal entries.
Testing indicates an average annual return of about 133%. It performs best in the crypto market.
Each candle updates the average volume. If the new bar's volume exceeds this average by a set multiplier and the candle closes in the direction opposite the prevailing trend, the system enters a trade. A stop based on ATR protects the position.
The trade is typically exited via the stop-loss as the strategy anticipates a swift reversal following the volume burst.
Details
- Entry Criteria: Volume spike above average with candle opposite the trend.
- Long/Short: Both.
- Exit Criteria: Stop-loss.
- Stops: Yes, ATR based.
- Default Values:
VolumePeriod= 20VolumeMultiplier= 2.0MAPeriod= 20AtrMultiplier= 2 ATR The trend is the direction of the moving average from the previous candle. The stop starts AtrMultiplier ATRs (period 14) from the entry close and trails each later close by that distance; it is checked on candle closes.CandleType= 5 minute
- Filters:
- Category: Reversal
- Direction: Both
- Indicators: Volume, MA, ATR
- Stops: Yes
- Complexity: Intermediate
- Timeframe: Intraday
- Seasonality: No
- Neural networks: No
- Divergence: No
- Risk level: Medium
using System;
using System.Collections.Generic;
using System.Linq;
using Ecng.Common;
using StockSharp.Algo.Indicators;
using StockSharp.Algo.Strategies;
using StockSharp.BusinessEntities;
using StockSharp.Messages;
namespace StockSharp.Samples.Strategies;
/// <summary>
/// Volume Exhaustion strategy.
/// A volume spike is a candle whose volume exceeds VolumeMultiplier times the average of the previous VolumePeriod candles.
/// While flat, a bullish spike against a falling moving average buys and a bearish spike against a rising one sells.
/// The only exit is a stop that trails the close by AtrMultiplier ATRs.
/// </summary>
public class VolumeExhaustionStrategy : Strategy
{
/// <summary>
/// Period of the ATR that sizes the stop.
/// </summary>
public const int AtrPeriod = 14;
private readonly StrategyParam<int> _volumePeriod;
private readonly StrategyParam<decimal> _volumeMultiplier;
private readonly StrategyParam<int> _maPeriod;
private readonly StrategyParam<decimal> _atrMultiplier;
private readonly StrategyParam<DataType> _candleType;
private readonly List<decimal> _volumes = [];
private decimal? _prevMa;
private decimal _stopPrice;
/// <summary>
/// Number of previous candles the volume is averaged over.
/// </summary>
public int VolumePeriod
{
get => _volumePeriod.Value;
set => _volumePeriod.Value = value;
}
/// <summary>
/// How many times the average volume a spike must exceed.
/// </summary>
public decimal VolumeMultiplier
{
get => _volumeMultiplier.Value;
set => _volumeMultiplier.Value = value;
}
/// <summary>
/// Period of the moving average that defines the trend.
/// </summary>
public int MAPeriod
{
get => _maPeriod.Value;
set => _maPeriod.Value = value;
}
/// <summary>
/// Distance of the trailing stop in ATR multiples.
/// </summary>
public decimal AtrMultiplier
{
get => _atrMultiplier.Value;
set => _atrMultiplier.Value = value;
}
/// <summary>
/// Candle type.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Constructor.
/// </summary>
public VolumeExhaustionStrategy()
{
_volumePeriod = Param(nameof(VolumePeriod), 20)
.SetGreaterThanZero()
.SetDisplay("Volume Period", "Previous candles the volume is averaged over", "Indicators");
_volumeMultiplier = Param(nameof(VolumeMultiplier), 2m)
.SetGreaterThanZero()
.SetDisplay("Volume Multiplier", "How many times the average volume a spike must exceed", "Indicators");
_maPeriod = Param(nameof(MAPeriod), 20)
.SetGreaterThanZero()
.SetDisplay("MA Period", "Moving average that defines the trend", "Indicators");
_atrMultiplier = Param(nameof(AtrMultiplier), 2m)
.SetGreaterThanZero()
.SetDisplay("ATR Multiplier", "Distance of the trailing stop in ATR multiples", "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();
_volumes.Clear();
_prevMa = null;
_stopPrice = default;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_volumes.Clear();
_prevMa = null;
_stopPrice = default;
var sma = new SimpleMovingAverage { Length = MAPeriod };
var atr = new AverageTrueRange { Length = AtrPeriod };
var subscription = SubscribeCandles(CandleType);
subscription
.BindEx(sma, atr, ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, sma);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, IIndicatorValue smaValue, IIndicatorValue atrValue)
{
if (candle.State != CandleStates.Finished)
return;
// The spike is measured against the candles before this one.
var average = _volumes.Count == VolumePeriod ? _volumes.Average() : (decimal?)null;
_volumes.Add(candle.TotalVolume);
if (_volumes.Count > VolumePeriod)
_volumes.RemoveAt(0);
if (!smaValue.IsFormed || !atrValue.IsFormed)
return;
var ma = smaValue.GetValue<decimal>();
var prevMa = _prevMa;
_prevMa = ma;
if (average is not decimal avgVolume || prevMa is not decimal lastMa || !IsFormedAndOnlineAndAllowTrading())
return;
var close = candle.ClosePrice;
var distance = AtrMultiplier * atrValue.GetValue<decimal>();
if (Position > 0)
{
if (close <= _stopPrice)
SellMarket(Position);
else
_stopPrice = Math.Max(_stopPrice, close - distance);
return;
}
if (Position < 0)
{
if (close >= _stopPrice)
BuyMarket(-Position);
else
_stopPrice = Math.Min(_stopPrice, close + distance);
return;
}
var spike = candle.TotalVolume > avgVolume * VolumeMultiplier;
if (!spike)
return;
if (close > candle.OpenPrice && ma < lastMa)
{
BuyMarket(Volume);
_stopPrice = close - distance;
}
else if (close < candle.OpenPrice && ma > lastMa)
{
SellMarket(Volume);
_stopPrice = close + distance;
}
}
}
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, Decimal
from StockSharp.Messages import DataType, CandleStates
from StockSharp.Algo.Indicators import SimpleMovingAverage, AverageTrueRange
from StockSharp.Algo.Strategies import Strategy
# Period of the ATR that sizes the stop.
ATR_PERIOD = 14
class volume_exhaustion_strategy(Strategy):
"""
Volume Exhaustion strategy.
A volume spike is a candle whose volume exceeds VolumeMultiplier times the average of the previous VolumePeriod candles.
While flat, a bullish spike against a falling moving average buys and a bearish spike against a rising one sells.
The only exit is a stop that trails the close by AtrMultiplier ATRs.
"""
def __init__(self):
super(volume_exhaustion_strategy, self).__init__()
self._volume_period = self.Param("VolumePeriod", 20).SetGreaterThanZero().SetDisplay("Volume Period", "Previous candles the volume is averaged over", "Indicators")
self._volume_multiplier = self.Param("VolumeMultiplier", 2.0).SetGreaterThanZero().SetDisplay("Volume Multiplier", "How many times the average volume a spike must exceed", "Indicators")
self._ma_period = self.Param("MAPeriod", 20).SetGreaterThanZero().SetDisplay("MA Period", "Moving average that defines the trend", "Indicators")
self._atr_multiplier = self.Param("AtrMultiplier", 2.0).SetGreaterThanZero().SetDisplay("ATR Multiplier", "Distance of the trailing stop in ATR multiples", "Risk")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))).SetDisplay("Candle Type", "Type of candles to use", "General")
self._volumes = []
self._prev_ma = None
self._stop_price = Decimal(0)
@property
def candle_type(self):
return self._candle_type.Value
def OnReseted(self):
super(volume_exhaustion_strategy, self).OnReseted()
self._volumes = []
self._prev_ma = None
self._stop_price = Decimal(0)
def OnStarted2(self, time):
super(volume_exhaustion_strategy, self).OnStarted2(time)
self._volumes = []
self._prev_ma = None
self._stop_price = Decimal(0)
sma = SimpleMovingAverage()
sma.Length = self._ma_period.Value
atr = AverageTrueRange()
atr.Length = ATR_PERIOD
subscription = self.SubscribeCandles(self.candle_type)
subscription.BindEx(sma, atr, self._process_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, sma)
self.DrawOwnTrades(area)
def _process_candle(self, candle, sma_value, atr_value):
if candle.State != CandleStates.Finished:
return
# The spike is measured against the candles before this one.
period = self._volume_period.Value
average = None
if len(self._volumes) == period:
total = Decimal(0)
for volume in self._volumes:
total += volume
average = total / Decimal(period)
self._volumes.append(candle.TotalVolume)
if len(self._volumes) > period:
self._volumes.pop(0)
if not sma_value.IsFormed or not atr_value.IsFormed:
return
ma = sma_value.GetValue[Decimal](None)
prev_ma = self._prev_ma
self._prev_ma = ma
if average is None or prev_ma is None or not self.IsFormedAndOnlineAndAllowTrading():
return
close = candle.ClosePrice
distance = Decimal(self._atr_multiplier.Value) * atr_value.GetValue[Decimal](None)
if self.Position > 0:
if close <= self._stop_price:
self.SellMarket(self.Position)
else:
self._stop_price = Math.Max(self._stop_price, close - distance)
return
if self.Position < 0:
if close >= self._stop_price:
self.BuyMarket(-self.Position)
else:
self._stop_price = Math.Min(self._stop_price, close + distance)
return
spike = candle.TotalVolume > average * Decimal(self._volume_multiplier.Value)
if not spike:
return
if close > candle.OpenPrice and ma < prev_ma:
self.BuyMarket(self.Volume)
self._stop_price = close - distance
elif close < candle.OpenPrice and ma > prev_ma:
self.SellMarket(self.Volume)
self._stop_price = close + distance
def CreateClone(self):
return volume_exhaustion_strategy()