Exodus Strategy
The EXODUS strategy combines a volume-weighted momentum oscillator (VWMO) with the Average Directional Index (ADX) to detect strong directional moves.
Details
- Entry Criteria
- Long:
VWMO > VwmoThresholdandADX > AdxThreshold. - Short:
VWMO < -VwmoThresholdandADX > AdxThreshold.
- Long:
- Exit Criteria
- Momentum crosses zero or an opposite signal appears.
- Indicators
- Average True Range
- Average Directional Index
- Simple Moving Average
- Parameters
VwmoMomentum,VwmoVolume,VwmoSmooth,VwmoThresholdAtrLength,AtrMultiplier,TpMultiplierAdxLength,AdxThresholdVolumeCandleType
VWMO formula and defaults: VwmoMomentum=10, VwmoVolume=20, VwmoSmooth=3, VwmoThreshold=25, AdxLength=14, AdxThreshold=25, AtrLength=14, AtrMultiplier=2, TpMultiplier=3, CandleType=1 minute. For each completed bar i, let d_i=Close_i−Close_(i−VwmoMomentum). Raw VWMO=100 × Σ(d_i × Volume_i) / Σ(|d_i| × Volume_i) over the last VwmoVolume bars (zero if denominator is zero); smooth it by an SMA over the last VwmoSmooth raw values. It is a normalized signed volume-weighted momentum (−100..100), not an EMA crossover. After the VWMO and true ADX are formed, a long signal requires VWMO > +VwmoThreshold && ADX > AdxThreshold; a short requires VWMO < −VwmoThreshold && ADX > AdxThreshold. An opposite signal reverses the whole existing position in one market order. Without an opposite signal, close a long as VWMO crosses from positive to nonpositive, or a short from negative to nonnegative. Initial protection uses a Wilder ATR (AtrLength), native absolute stop distance ATR × AtrMultiplier, and take-profit distance stop × TpMultiplier. Native market protection observes Level1 bid and ask changes between completed candles. Orders remain blocked while a prior order is active; future data are never used.
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-weighted directional momentum filtered by ADX.
/// Reverses on an opposite signal, exits on a momentum zero cross,
/// and protects filled positions by frozen ATR-based native distances.
/// </summary>
public class ExodusStrategy : Strategy
{
private readonly StrategyParam<int> _vwmoMomentum;
private readonly StrategyParam<int> _vwmoVolume;
private readonly StrategyParam<int> _vwmoSmooth;
private readonly StrategyParam<decimal> _vwmoThreshold;
private readonly StrategyParam<int> _adxLength;
private readonly StrategyParam<decimal> _adxThreshold;
private readonly StrategyParam<int> _atrLength;
private readonly StrategyParam<decimal> _atrMultiplier;
private readonly StrategyParam<decimal> _tpMultiplier;
private readonly StrategyParam<DataType> _candleType;
private readonly List<(decimal close, decimal volume)> _history = new();
private readonly Queue<decimal> _oscillator = new();
private decimal? _previousVwmo;
private decimal? _previousClose;
private decimal _atr;
private int _atrSamples;
private Order _pendingOrder;
private Unit _stopDistance;
private Unit _targetDistance;
private bool _protectionStarted;
private AverageDirectionalIndex _adx;
public int VwmoMomentum { get => _vwmoMomentum.Value; set => _vwmoMomentum.Value = value; }
public int VwmoVolume { get => _vwmoVolume.Value; set => _vwmoVolume.Value = value; }
public int VwmoSmooth { get => _vwmoSmooth.Value; set => _vwmoSmooth.Value = value; }
public decimal VwmoThreshold { get => _vwmoThreshold.Value; set => _vwmoThreshold.Value = value; }
public int AdxLength { get => _adxLength.Value; set => _adxLength.Value = value; }
public decimal AdxThreshold { get => _adxThreshold.Value; set => _adxThreshold.Value = value; }
public int AtrLength { get => _atrLength.Value; set => _atrLength.Value = value; }
public decimal AtrMultiplier { get => _atrMultiplier.Value; set => _atrMultiplier.Value = value; }
public decimal TpMultiplier { get => _tpMultiplier.Value; set => _tpMultiplier.Value = value; }
public DataType CandleType { get => _candleType.Value; set => _candleType.Value = value; }
public ExodusStrategy()
{
_vwmoMomentum = Param(nameof(VwmoMomentum), 10).SetGreaterThanZero().SetDisplay("VWMO Momentum", "Momentum difference lag, in candles.", "VWMO");
_vwmoVolume = Param(nameof(VwmoVolume), 20).SetGreaterThanZero().SetDisplay("VWMO Volume", "Window for volume-weighted momentum.", "VWMO");
_vwmoSmooth = Param(nameof(VwmoSmooth), 3).SetGreaterThanZero().SetDisplay("VWMO Smooth", "SMA period for normalized oscillator.", "VWMO");
_vwmoThreshold = Param(nameof(VwmoThreshold), 25m).SetNotNegative().SetDisplay("VWMO Threshold", "Absolute normalized oscillator entry threshold.", "VWMO");
_adxLength = Param(nameof(AdxLength), 14).SetGreaterThanZero().SetDisplay("ADX Length", "Average Directional Index period.", "Strength");
_adxThreshold = Param(nameof(AdxThreshold), 25m).SetNotNegative().SetDisplay("ADX Threshold", "Minimum trend strength for entry.", "Strength");
_atrLength = Param(nameof(AtrLength), 14).SetGreaterThanZero().SetDisplay("ATR Length", "Wilder ATR period for frozen protection.", "Protection");
_atrMultiplier = Param(nameof(AtrMultiplier), 2m).SetGreaterThanZero().SetDisplay("ATR Multiplier", "Stop-loss distance as an ATR multiple.", "Protection");
_tpMultiplier = Param(nameof(TpMultiplier), 3m).SetGreaterThanZero().SetDisplay("TP Multiplier", "Take-profit distance as an additional stop multiple.", "Protection");
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(1).TimeFrame()).SetDisplay("Candle Type", "Source candles.", "General");
}
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
=> [(Security, CandleType), (Security, DataType.Level1)];
protected override void OnReseted()
{
base.OnReseted();
_history.Clear();
_oscillator.Clear();
_previousVwmo = null;
_previousClose = null;
_atr = 0m;
_atrSamples = 0;
_pendingOrder = null;
_protectionStarted = false;
_stopDistance = null;
_targetDistance = null;
_adx = null;
}
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_adx = new AverageDirectionalIndex { Length = AdxLength };
_stopDistance = new Unit(0m, UnitTypes.Absolute);
_targetDistance = new Unit(0m, UnitTypes.Absolute);
OrderRegistering += order => _pendingOrder = order;
foreach (var field in new[] { Level1Fields.BestBidPrice, Level1Fields.BestAskPrice })
{
var quotes = new Subscription(DataType.Level1, Security);
quotes.MarketData.BuildField = field;
SubscribeLevel1(quotes).Bind(ObserveProtectionQuote).Start();
}
var subscription = SubscribeCandles(CandleType);
subscription.Bind(ProcessCandle).Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, _adx);
DrawOwnTrades(area);
}
}
private void ObserveProtectionQuote(Level1ChangeMessage quote)
{
// Native protection is evaluated on the quote stream between signal candles.
}
private void ProcessCandle(ICandleMessage candle)
{
if (candle.State != CandleStates.Finished)
return;
var adxResult = (AverageDirectionalIndexValue)_adx.Process(candle);
var trueRange = _previousClose is decimal previous
? Math.Max(candle.HighPrice - candle.LowPrice,
Math.Max(Math.Abs(candle.HighPrice - previous), Math.Abs(candle.LowPrice - previous)))
: candle.HighPrice - candle.LowPrice;
_atrSamples++;
var atrCount = Math.Min(_atrSamples, AtrLength);
_atr = (_atr * (atrCount - 1) + trueRange) / atrCount;
_previousClose = candle.ClosePrice;
_history.Add((candle.ClosePrice, candle.TotalVolume));
var needed = VwmoMomentum + VwmoVolume;
if (_history.Count > needed) _history.RemoveAt(0);
if (_history.Count < needed || !adxResult.IsFormed || adxResult.MovingAverage is not decimal strength)
return;
var numerator = 0m;
var denominator = 0m;
for (var i = _history.Count - VwmoVolume; i < _history.Count; i++)
{
var weightedDelta = (_history[i].close - _history[i - VwmoMomentum].close) * _history[i].volume;
numerator += weightedDelta;
denominator += Math.Abs(weightedDelta);
}
_oscillator.Enqueue(denominator > 0m ? numerator * 100m / denominator : 0m);
while (_oscillator.Count > VwmoSmooth) _oscillator.Dequeue();
if (_oscillator.Count < VwmoSmooth)
return;
var vwmo = _oscillator.Average();
var pendingActive = _pendingOrder is not null && _pendingOrder.State is not (OrderStates.Done or OrderStates.Failed);
if (!pendingActive)
{
var strong = strength > AdxThreshold;
var signalLong = strong && vwmo > VwmoThreshold;
var signalShort = strong && vwmo < -VwmoThreshold;
var zeroExit = Position > 0m && _previousVwmo is decimal longMomentum && longMomentum > 0m && vwmo <= 0m
|| Position < 0m && _previousVwmo is decimal shortMomentum && shortMomentum < 0m && vwmo >= 0m;
if (signalLong && Position <= 0m)
{
ConfigureProtection(_atr);
BuyMarket(Volume + Math.Abs(Position));
}
else if (signalShort && Position >= 0m)
{
ConfigureProtection(_atr);
SellMarket(Volume + Math.Abs(Position));
}
else if (zeroExit && Position != 0m)
{
if (Position > 0m) SellMarket(Position);
else BuyMarket(Math.Abs(Position));
}
}
_previousVwmo = vwmo;
}
private void ConfigureProtection(decimal atr)
{
if (atr <= 0m)
throw new InvalidOperationException("ATR must be positive before placing a protected trade.");
_stopDistance.Value = atr * AtrMultiplier;
_targetDistance.Value = _stopDistance.Value * TpMultiplier;
if (!_protectionStarted)
{
StartProtection(_targetDistance, _stopDistance, useMarketOrders: true, isLocalStop: true);
_protectionStarted = true;
}
}
}
import clr
clr.AddReference("StockSharp.Messages")
clr.AddReference("StockSharp.Algo")
clr.AddReference("StockSharp.Algo.Indicators")
clr.AddReference("StockSharp.Algo.Strategies")
clr.AddReference("StockSharp.BusinessEntities")
from System import Decimal, TimeSpan, Math
from StockSharp.Messages import DataType, CandleStates, OrderStates, Unit, UnitTypes, Level1Fields
from StockSharp.Algo.Indicators import AverageDirectionalIndex, CandleIndicatorValue
from StockSharp.Algo.Strategies import Strategy
from StockSharp.BusinessEntities import Subscription
class exodus_strategy(Strategy):
"""Normalized volume-weighted momentum with ADX filtering and native ATR protection."""
def __init__(self):
super(exodus_strategy, self).__init__()
self._vwmo_momentum = self.Param("VwmoMomentum", 10).SetGreaterThanZero() \
.SetDisplay("VWMO Momentum", "Lag for price momentum.", "VWMO")
self._vwmo_volume = self.Param("VwmoVolume", 20).SetGreaterThanZero() \
.SetDisplay("VWMO Volume", "Volume-weighted momentum window.", "VWMO")
self._vwmo_smooth = self.Param("VwmoSmooth", 3).SetGreaterThanZero() \
.SetDisplay("VWMO Smooth", "SMA smoothing period of normalized oscillator.", "VWMO")
self._vwmo_threshold = self.Param("VwmoThreshold", 25.0).SetNotNegative() \
.SetDisplay("VWMO Threshold", "Absolute normalized oscillator threshold.", "VWMO")
self._adx_length = self.Param("AdxLength", 14).SetGreaterThanZero() \
.SetDisplay("ADX Length", "Average Directional Index period.", "Strength")
self._adx_threshold = self.Param("AdxThreshold", 25.0).SetNotNegative() \
.SetDisplay("ADX Threshold", "Minimum trend strength.", "Strength")
self._atr_length = self.Param("AtrLength", 14).SetGreaterThanZero() \
.SetDisplay("ATR Length", "Wilder ATR period for entry protection.", "Protection")
self._atr_multiplier = self.Param("AtrMultiplier", 2.0).SetGreaterThanZero() \
.SetDisplay("ATR Multiplier", "Initial stop as a multiple of ATR.", "Protection")
self._tp_multiplier = self.Param("TpMultiplier", 3.0).SetGreaterThanZero() \
.SetDisplay("TP Multiplier", "Take-profit distance as a stop multiple.", "Protection")
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(1))) \
.SetDisplay("Candle Type", "Candle timeframe.", "General")
self._history = []
self._oscillator = []
self._previous_vwmo = None
self._previous_close = None
self._atr = 0.0
self._atr_samples = 0
self._pending_order = None
self._protection_started = False
self._stop_distance = None
self._target_distance = None
def GetWorkingSecurities(self):
return [(self.Security, self._candle_type.Value), (self.Security, DataType.Level1)]
def OnReseted(self):
super(exodus_strategy, self).OnReseted()
self._history = []
self._oscillator = []
self._previous_vwmo = None
self._previous_close = None
self._atr = 0.0
self._atr_samples = 0
self._pending_order = None
self._protection_started = False
self._stop_distance = None
self._target_distance = None
def OnStarted2(self, time):
super(exodus_strategy, self).OnStarted2(time)
self._adx = AverageDirectionalIndex()
self._adx.Length = int(self._adx_length.Value)
self._stop_distance = Unit(Decimal.Zero, UnitTypes.Absolute)
self._target_distance = Unit(Decimal.Zero, UnitTypes.Absolute)
self.OrderRegistering += self._on_order_registering
for field in (Level1Fields.BestBidPrice, Level1Fields.BestAskPrice):
quotes = Subscription(DataType.Level1, self.Security)
quotes.MarketData.BuildField = field
self.SubscribeLevel1(quotes).Bind(self._observe_protection_quote).Start()
subscription = self.SubscribeCandles(self._candle_type.Value)
subscription.Bind(self.on_candle).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, subscription)
self.DrawIndicator(area, self._adx)
self.DrawOwnTrades(area)
def _observe_protection_quote(self, quote):
# Native protection is evaluated on incoming bid/ask updates between bars.
pass
def _on_order_registering(self, order):
self._pending_order = order
def _configure_protection(self, atr):
if atr <= 0.0:
raise ValueError("A positive ATR is required for protected entries")
stop = atr * float(self._atr_multiplier.Value)
target = stop * float(self._tp_multiplier.Value)
self._stop_distance.Value = Decimal(stop)
self._target_distance.Value = Decimal(target)
if not self._protection_started:
self.StartProtection(self._target_distance, self._stop_distance,
useMarketOrders=True, isLocalStop=True)
self._protection_started = True
def on_candle(self, candle):
if candle.State != CandleStates.Finished:
return
input_value = CandleIndicatorValue(self._adx, candle)
input_value.IsFinal = True
value = self._adx.Process(input_value)
close = float(candle.ClosePrice)
high = float(candle.HighPrice)
low = float(candle.LowPrice)
volume = float(candle.TotalVolume)
true_range = (max(high - low, abs(high - self._previous_close),
abs(low - self._previous_close))
if self._previous_close is not None else high - low)
self._atr_samples += 1
atr_count = min(self._atr_samples, int(self._atr_length.Value))
self._atr = (self._atr * (atr_count - 1) + true_range) / atr_count
self._previous_close = close
momentum_period = int(self._vwmo_momentum.Value)
volume_period = int(self._vwmo_volume.Value)
smooth = int(self._vwmo_smooth.Value)
need = momentum_period + volume_period
self._history.append((close, volume))
if len(self._history) > need:
self._history.pop(0)
strength = value.MovingAverage
if len(self._history) < need or not value.IsFormed or strength is None:
return
numerator = 0.0
denominator = 0.0
for i in range(len(self._history) - volume_period, len(self._history)):
weighted_delta = (self._history[i][0] - self._history[i - momentum_period][0]) * self._history[i][1]
numerator += weighted_delta
denominator += abs(weighted_delta)
self._oscillator.append(100.0 * numerator / denominator if denominator > 0.0 else 0.0)
if len(self._oscillator) > smooth:
self._oscillator.pop(0)
if len(self._oscillator) < smooth:
return
oscillator = sum(self._oscillator) / smooth
active = self._pending_order is not None and self._pending_order.State not in (
OrderStates.Done, OrderStates.Failed)
if not active:
strong = float(strength) > float(self._adx_threshold.Value)
long_signal = strong and oscillator > float(self._vwmo_threshold.Value)
short_signal = strong and oscillator < -float(self._vwmo_threshold.Value)
position = self.Position
zero_exit = (position > 0 and self._previous_vwmo is not None
and self._previous_vwmo > 0 and oscillator <= 0) or (
position < 0 and self._previous_vwmo is not None
and self._previous_vwmo < 0 and oscillator >= 0)
if long_signal and position <= 0:
self._configure_protection(self._atr)
self.BuyMarket(self.Volume + Math.Abs(position))
elif short_signal and position >= 0:
self._configure_protection(self._atr)
self.SellMarket(self.Volume + Math.Abs(position))
elif zero_exit and position != 0:
if position > 0:
self.SellMarket(position)
else:
self.BuyMarket(Math.Abs(position))
self._previous_vwmo = oscillator
def CreateClone(self):
return exodus_strategy()