Mean Reversion Donchian Strategy
Overview
This strategy is a port of the MetaTrader expert advisor MeanReversion.mq5. It trades a simple mean-reversion pattern: whenever price prints a fresh low within the selected lookback window the strategy opens a long position, targeting the midpoint of the recent range. When a new high appears the strategy mirrors the logic on the short side. Position size is calculated from the risk percentage and the stop distance, closely replicating the lot calculation that the original EA performs.
Trading Logic
- Build a Donchian Channel using the configured candle type and lookback period. The upper band marks the highest high, and the lower band the lowest low over the window. The midpoint
(upper + lower) / 2 acts as the mean reversion target.
- If the current finished candle makes a new low (
Low <= LowerBand) and no position is open, the strategy buys at market. The protective stop is reflected around the entry price so that the midpoint becomes the profit target, matching the MetaTrader computation sl = 2 * Ask - tp.
- If the candle makes a new high (
High >= UpperBand) and no position is open, the strategy sells at market with a symmetric stop above price. The midpoint again acts as the take-profit level.
- The stop-loss and take-profit are monitored on every finished candle. A breakout beyond the stop closes the position immediately, while touching the midpoint exits the trade at the intended target. The internal state resets automatically whenever the position is flat.
Position Sizing
- Risk per trade equals
Portfolio.CurrentValue * (RiskPercent / 100). If portfolio data is not available the strategy falls back to the minimal tradable volume.
- Contract risk is measured as
|EntryPrice - StopPrice|. The raw volume is RiskAmount / perUnitRisk and is normalized to the instrument volume step. Minimum and maximum exchange constraints are respected. When the normalized volume is smaller than the minimal tradable size, the minimum is used instead.
Parameters
| Name |
Description |
Default |
CandleType |
Candle type and timeframe used for building the Donchian channel. |
15-minute time frame |
LookbackPeriod |
Number of candles used to compute the highest high and lowest low. |
200 |
RiskPercent |
Percentage of portfolio equity risked per trade. |
1% |
All parameters support optimization through the built-in optimizer.
Additional Notes
- The strategy only trades one position at a time, replicating the
PositionsTotal()>0 guard from the MQL version.
- Stop-loss and take-profit prices are maintained internally instead of sending separate orders, which keeps the logic close to the original Expert Advisor while remaining compatible with the high-level API.
- When portfolio equity or instrument volume information is missing the strategy still trades using the smallest possible volume to keep behaviour deterministic.
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>
/// Port of the MetaTrader strategy MeanReversion.mq5.
/// Buys when price sets a fresh lookback low and targets the mid-point of the recent range,
/// or sells at a new high aiming for the same reversion level.
/// Position size is determined from the percentage risk and the stop distance.
/// </summary>
public class MeanReversionDonchianStrategy : Strategy
{
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<int> _lookbackPeriod;
private readonly StrategyParam<decimal> _riskPercent;
private DonchianChannels _donchian = null!;
private decimal? _stopPrice;
private decimal? _takeProfitPrice;
private Sides? _activeSide;
/// <summary>
/// Candle type and timeframe used for the Donchian channel calculation.
/// </summary>
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
/// <summary>
/// Amount of candles included in the high/low range.
/// </summary>
public int LookbackPeriod
{
get => _lookbackPeriod.Value;
set => _lookbackPeriod.Value = value;
}
/// <summary>
/// Percent of portfolio equity risked per trade.
/// </summary>
public decimal RiskPercent
{
get => _riskPercent.Value;
set => _riskPercent.Value = value;
}
/// <summary>
/// Initializes a new instance of the <see cref="MeanReversionDonchianStrategy"/>.
/// </summary>
public MeanReversionDonchianStrategy()
{
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
.SetDisplay("Candle Type", "Type of candles to analyze", "General");
_lookbackPeriod = Param(nameof(LookbackPeriod), 200)
.SetDisplay("Lookback", "Number of candles used for range detection", "Signals")
.SetRange(20, 500)
;
_riskPercent = Param(nameof(RiskPercent), 1m)
.SetDisplay("Risk %", "Percentage of equity risked per entry", "Money Management")
.SetRange(0.25m, 5m)
;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_stopPrice = null;
_takeProfitPrice = null;
_activeSide = null;
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
_donchian = new DonchianChannels { Length = LookbackPeriod };
var subscription = SubscribeCandles(CandleType);
subscription
.BindEx(_donchian, ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawIndicator(area, _donchian);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle, IIndicatorValue donchianValue)
{
if (candle.State != CandleStates.Finished)
return;
// indicators bound via BindEx
ManageOpenPosition(candle);
if (Position != 0)
return;
if (donchianValue is not IDonchianChannelsValue channel)
return;
if (channel.UpperBand is not decimal upperBand || channel.LowerBand is not decimal lowerBand || channel.Middle is not decimal midBand)
return;
GenerateSignals(candle, lowerBand, upperBand, midBand);
}
private void ManageOpenPosition(ICandleMessage candle)
{
if (Position > 0 && _activeSide == Sides.Buy)
{
if (_stopPrice.HasValue && candle.LowPrice <= _stopPrice.Value)
{
SellMarket(Position);
ResetPositionState();
return;
}
if (_takeProfitPrice.HasValue && candle.HighPrice >= _takeProfitPrice.Value)
{
SellMarket(Position);
ResetPositionState();
}
}
else if (Position < 0 && _activeSide == Sides.Sell)
{
if (_stopPrice.HasValue && candle.HighPrice >= _stopPrice.Value)
{
BuyMarket(-Position);
ResetPositionState();
return;
}
if (_takeProfitPrice.HasValue && candle.LowPrice <= _takeProfitPrice.Value)
{
BuyMarket(-Position);
ResetPositionState();
}
}
if (Position == 0 && _activeSide != null)
{
ResetPositionState();
}
}
private void GenerateSignals(ICandleMessage candle, decimal lowerBand, decimal upperBand, decimal midBand)
{
var closePrice = candle.ClosePrice;
if (candle.LowPrice <= lowerBand)
{
var stopPrice = 2m * closePrice - midBand;
var volume = CalculateRiskAdjustedVolume(closePrice, stopPrice);
if (volume > 0m && stopPrice < closePrice)
{
BuyMarket(volume);
_stopPrice = stopPrice;
_takeProfitPrice = midBand;
_activeSide = Sides.Buy;
}
}
else if (candle.HighPrice >= upperBand)
{
var stopPrice = 2m * closePrice - midBand;
var volume = CalculateRiskAdjustedVolume(closePrice, stopPrice);
if (volume > 0m && stopPrice > closePrice)
{
SellMarket(volume);
_stopPrice = stopPrice;
_takeProfitPrice = midBand;
_activeSide = Sides.Sell;
}
}
}
private decimal CalculateRiskAdjustedVolume(decimal entryPrice, decimal stopPrice)
{
var perUnitRisk = Math.Abs(entryPrice - stopPrice);
if (perUnitRisk <= 0m)
return 0m;
var portfolioValue = Portfolio?.CurrentValue ?? 0m;
var riskBudget = portfolioValue > 0m ? portfolioValue * (RiskPercent / 100m) : 0m;
if (riskBudget <= 0m)
{
return GetMinimalVolume();
}
var rawVolume = riskBudget / perUnitRisk;
var normalized = NormalizeVolume(rawVolume);
var minimal = GetMinimalVolume();
if (normalized < minimal)
normalized = minimal;
return normalized;
}
private decimal NormalizeVolume(decimal volume)
{
if (volume <= 0m)
return 0m;
var step = Security?.VolumeStep ?? 0m;
if (step <= 0m)
return volume;
var normalized = Math.Floor(volume / step) * step;
var max = Security?.MaxVolume ?? 0m;
if (max > 0m && normalized > max)
normalized = max;
return normalized;
}
private decimal GetMinimalVolume()
{
var min = Security?.MinVolume ?? 0m;
if (min > 0m)
return min;
var step = Security?.VolumeStep ?? 0m;
if (step > 0m)
return step;
return Volume > 0m ? Volume : 1m;
}
private void ResetPositionState()
{
_stopPrice = null;
_takeProfitPrice = null;
_activeSide = 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, Math
from StockSharp.Messages import DataType, CandleStates, Sides
from StockSharp.Algo.Indicators import DonchianChannels
from StockSharp.Algo.Strategies import Strategy
class mean_reversion_donchian_strategy(Strategy):
"""Buys at Donchian low, sells at Donchian high, targeting the midpoint."""
def __init__(self):
super(mean_reversion_donchian_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(15))) \
.SetDisplay("Candle Type", "Type of candles to analyze", "General")
self._lookback_period = self.Param("LookbackPeriod", 200) \
.SetDisplay("Lookback", "Number of candles used for range detection", "Signals")
self._risk_percent = self.Param("RiskPercent", 1.0) \
.SetDisplay("Risk %", "Percentage of equity risked per entry", "Money Management")
self._stop_price = None
self._take_profit_price = None
self._active_side = None
@property
def CandleType(self):
return self._candle_type.Value
@CandleType.setter
def CandleType(self, value):
self._candle_type.Value = value
@property
def LookbackPeriod(self):
return self._lookback_period.Value
@property
def RiskPercent(self):
return self._risk_percent.Value
def OnReseted(self):
super(mean_reversion_donchian_strategy, self).OnReseted()
self._stop_price = None
self._take_profit_price = None
self._active_side = None
def OnStarted2(self, time):
super(mean_reversion_donchian_strategy, self).OnStarted2(time)
donchian = DonchianChannels()
donchian.Length = self.LookbackPeriod
subscription = self.SubscribeCandles(self.CandleType)
subscription.BindEx(donchian, self._process_candle).Start()
def _process_candle(self, candle, donchian_value):
if candle.State != CandleStates.Finished:
return
self._manage_open_position(candle)
if self.Position != 0:
return
upper = donchian_value.UpperBand
lower = donchian_value.LowerBand
middle = donchian_value.Middle
if upper is None or lower is None or middle is None:
return
up = float(upper)
lo = float(lower)
mid = float(middle)
close = float(candle.ClosePrice)
if float(candle.LowPrice) <= lo:
stop_p = 2.0 * close - mid
if stop_p < close:
self.BuyMarket()
self._stop_price = stop_p
self._take_profit_price = mid
self._active_side = Sides.Buy
elif float(candle.HighPrice) >= up:
stop_p = 2.0 * close - mid
if stop_p > close:
self.SellMarket()
self._stop_price = stop_p
self._take_profit_price = mid
self._active_side = Sides.Sell
def _manage_open_position(self, candle):
if self.Position > 0 and self._active_side == Sides.Buy:
if self._stop_price is not None and float(candle.LowPrice) <= self._stop_price:
self.SellMarket(self.Position)
self._reset_state()
return
if self._take_profit_price is not None and float(candle.HighPrice) >= self._take_profit_price:
self.SellMarket(self.Position)
self._reset_state()
elif self.Position < 0 and self._active_side == Sides.Sell:
if self._stop_price is not None and float(candle.HighPrice) >= self._stop_price:
self.BuyMarket(abs(self.Position))
self._reset_state()
return
if self._take_profit_price is not None and float(candle.LowPrice) <= self._take_profit_price:
self.BuyMarket(abs(self.Position))
self._reset_state()
if self.Position == 0 and self._active_side is not None:
self._reset_state()
def _reset_state(self):
self._stop_price = None
self._take_profit_price = None
self._active_side = None
def CreateClone(self):
return mean_reversion_donchian_strategy()