Percentage Crossover Channel Strategy
Overview
The Percentage Crossover Channel strategy originates from the MetaTrader 5 expert advisor Percentage_Crossover_Channel_EA. It relies on a custom channel constructed around a fast moving average and reacts to either band touches or middle line crossovers. This StockSharp implementation follows the same logic while using the high-level API to process completed candles.
Channel construction
The underlying indicator builds a dynamic channel around the selected price (close by default):
- Compute the base price using the configured Applied Price mode.
- Apply a 1-period simple moving average to obtain the short-term reference price.
- Calculate two bounds using the Percent parameter (e.g., 50 → ±0.5%).
- Clamp the previous middle line inside the new bounds to obtain the current middle value.
- The upper and lower bands are the clamped middle value multiplied by the ±percent factors.
This recursion allows the channel to lag during strong trends while keeping a tight envelope when price consolidates.
Trading logic
Two different signal modes are available:
- Band touch mode (default):
- Long entry when the previous candle’s low was above the lower band and the last completed candle touches or pierces it.
- Short entry when the previous candle’s high was below the upper band and the last completed candle touches or pierces it.
- Middle crossover mode (TradeOnMiddleCross = true):
- Long entry when price crosses the middle line from above to below.
- Short entry when price crosses the middle line from below to above.
The ReverseSignals flag swaps long and short rules. The strategy always closes and reverses existing positions by sending a single market order whose volume equals the configured OrderVolume plus the absolute value of the current position.
Risk management
Optional protective levels emulate the original MT5 stop-loss and take-profit settings:
- StopLossPoints – distance in price steps subtracted (long) or added (short) from the estimated entry price.
- TakeProfitPoints – distance in price steps added (long) or subtracted (short) from the entry price.
If either parameter is zero the corresponding protection is disabled. Stops are evaluated on each finished candle by comparing candle highs and lows against the stored levels. No trailing logic is applied.
Parameters
| Parameter |
Description |
CandleType |
Candle data type to subscribe to (15-minute time frame by default). |
Percent |
Channel width in percent of price (converted to ±percent/100 factors). |
PriceMode |
Applied price for the channel. Options: Close, Open, High, Low, Median (H+L)/2, Typical (H+L+C)/3, Weighted (H+L+2C)/4, Average (O+H+L+C)/4. |
TradeOnMiddleCross |
Switch between band touch logic and middle line crossover logic. |
ReverseSignals |
Invert long and short conditions. |
StopLossPoints |
Protective stop distance expressed in security price steps. |
TakeProfitPoints |
Profit target distance expressed in security price steps. |
OrderVolume |
Base volume for market entries. The strategy adds the absolute open position to reverse in one transaction. |
Implementation notes
- Orders are issued only after candles finish, which mirrors the MT5 expert that acted at the beginning of the next bar using the previous bar’s data.
- The channel indicator is recreated inside the strategy without storing historical collections, relying on scalar state variables.
- Protective stops and targets are checked manually to replicate the platform-specific order handling from MT5.
- Ensure the selected security exposes a valid
PriceStep; otherwise stop-loss and take-profit distances will be ignored.
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>
/// Percentage Crossover Channel strategy converted from MetaTrader 5.
/// </summary>
public class PercentageCrossoverChannelStrategy : Strategy
{
public enum PercentageChannelPriceModes
{
Close,
Open,
High,
Low,
Median,
Typical,
Weighted,
Average
}
private readonly StrategyParam<DataType> _candleType;
private readonly StrategyParam<decimal> _percent;
private readonly StrategyParam<PercentageChannelPriceModes> _priceMode;
private readonly StrategyParam<bool> _tradeOnMiddleCross;
private readonly StrategyParam<bool> _reverseSignals;
private readonly StrategyParam<int> _stopLossPoints;
private readonly StrategyParam<int> _takeProfitPoints;
private readonly StrategyParam<decimal> _orderVolume;
// Cached indicator values for the previous two finished candles.
private decimal? _prevUpper;
private decimal? _prevMiddle;
private decimal? _prevLower;
private decimal? _prevPrevUpper;
private decimal? _prevPrevMiddle;
private decimal? _prevPrevLower;
// Stored price data for signal evaluation.
private decimal? _prevClose;
private decimal? _prevHigh;
private decimal? _prevLow;
private decimal? _prevPrevClose;
private decimal? _prevPrevHigh;
private decimal? _prevPrevLow;
// Internal state of the channel middle line recursion.
private decimal _lastMiddle;
private bool _hasIndicatorState;
// Protective levels that mimic MT5 stop loss and take profit requests.
private decimal? _stopPrice;
private decimal? _takePrice;
private decimal _entryPrice;
public PercentageCrossoverChannelStrategy()
{
_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(5).TimeFrame())
.SetDisplay("Candle Type", "Timeframe for processing", "General");
_percent = Param(nameof(Percent), 1m)
.SetDisplay("Percent", "Channel width percent", "Channel")
.SetGreaterThanZero();
_priceMode = Param(nameof(PriceMode), PercentageChannelPriceModes.Close)
.SetDisplay("Applied Price", "Price source for channel calculations", "Channel");
_tradeOnMiddleCross = Param(nameof(TradeOnMiddleCross), false)
.SetDisplay("Trade Middle Cross", "Use middle line crossovers instead of band touches", "Signals");
_reverseSignals = Param(nameof(ReverseSignals), false)
.SetDisplay("Reverse Signals", "Invert long and short logic", "Signals");
_stopLossPoints = Param(nameof(StopLossPoints), 0)
.SetDisplay("Stop Loss (points)", "Protective stop distance in points", "Risk")
.SetNotNegative();
_takeProfitPoints = Param(nameof(TakeProfitPoints), 0)
.SetDisplay("Take Profit (points)", "Target profit distance in points", "Risk")
.SetNotNegative();
_orderVolume = Param(nameof(OrderVolume), 1m)
.SetDisplay("Order Volume", "Base volume for market entries", "Trading")
.SetGreaterThanZero();
}
public DataType CandleType
{
get => _candleType.Value;
set => _candleType.Value = value;
}
public decimal Percent
{
get => _percent.Value;
set => _percent.Value = value;
}
public PercentageChannelPriceModes PriceMode
{
get => _priceMode.Value;
set => _priceMode.Value = value;
}
public bool TradeOnMiddleCross
{
get => _tradeOnMiddleCross.Value;
set => _tradeOnMiddleCross.Value = value;
}
public bool ReverseSignals
{
get => _reverseSignals.Value;
set => _reverseSignals.Value = value;
}
public int StopLossPoints
{
get => _stopLossPoints.Value;
set => _stopLossPoints.Value = value;
}
public int TakeProfitPoints
{
get => _takeProfitPoints.Value;
set => _takeProfitPoints.Value = value;
}
public decimal OrderVolume
{
get => _orderVolume.Value;
set => _orderVolume.Value = value;
}
/// <inheritdoc />
public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
{
return [(Security, CandleType)];
}
/// <inheritdoc />
protected override void OnReseted()
{
base.OnReseted();
_prevUpper = null;
_prevMiddle = null;
_prevLower = null;
_prevPrevUpper = null;
_prevPrevMiddle = null;
_prevPrevLower = null;
_prevClose = null;
_prevHigh = null;
_prevLow = null;
_prevPrevClose = null;
_prevPrevHigh = null;
_prevPrevLow = null;
_lastMiddle = 0m;
_hasIndicatorState = false;
ResetProtection();
}
/// <inheritdoc />
protected override void OnStarted2(DateTime time)
{
base.OnStarted2(time);
Volume = OrderVolume;
// Subscribe to candle updates that will drive the high level logic.
var subscription = SubscribeCandles(CandleType);
subscription
.Bind(ProcessCandle)
.Start();
var area = CreateChartArea();
if (area != null)
{
DrawCandles(area, subscription);
DrawOwnTrades(area);
}
}
private void ProcessCandle(ICandleMessage candle)
{
// Work only with completed candles to stay consistent with the MT5 implementation.
if (candle.State != CandleStates.Finished)
return;
var exitTriggered = CheckProtection(candle);
if (!exitTriggered)
TryEnterPositions(candle);
UpdateChannelState(candle);
}
private void TryEnterPositions(ICandleMessage candle)
{
// Wait until the channel has valid values for two completed candles.
if (!_prevLower.HasValue || !_prevPrevLower.HasValue)
return;
if (!_prevClose.HasValue || !_prevPrevClose.HasValue || !_prevHigh.HasValue || !_prevPrevHigh.HasValue || !_prevLow.HasValue || !_prevPrevLow.HasValue)
return;
var openLong = false;
var openShort = false;
if (TradeOnMiddleCross)
{
// Evaluate crossovers of the price and the middle channel line.
var crossDown = _prevPrevClose.Value > _prevPrevMiddle.Value && _prevClose.Value < _prevMiddle.Value;
var crossUp = _prevPrevClose.Value < _prevPrevMiddle.Value && _prevClose.Value > _prevMiddle.Value;
if (!ReverseSignals)
{
if (crossDown)
openLong = true;
if (crossUp)
openShort = true;
}
else
{
if (crossDown)
openShort = true;
if (crossUp)
openLong = true;
}
}
else
{
// Default mode trades touches of the outer channel boundaries.
var touchLower = _prevPrevLow.Value > _prevPrevLower.Value && _prevLow.Value <= _prevLower.Value;
var touchUpper = _prevPrevHigh.Value < _prevPrevUpper.Value && _prevHigh.Value >= _prevUpper.Value;
if (!ReverseSignals)
{
if (touchLower)
openLong = true;
if (touchUpper)
openShort = true;
}
else
{
if (touchLower)
openShort = true;
if (touchUpper)
openLong = true;
}
}
if (openLong)
{
EnterLong(candle);
}
else if (openShort)
{
EnterShort(candle);
}
}
private void EnterLong(ICandleMessage candle)
{
// Combine base order volume with the size required to flatten shorts.
var volume = OrderVolume + (Position < 0 ? Math.Abs(Position) : 0m);
if (volume <= 0m)
return;
BuyMarket(volume);
_entryPrice = candle.OpenPrice;
_stopPrice = CalculateStopPrice(Sides.Buy, _entryPrice);
_takePrice = CalculateTakePrice(Sides.Buy, _entryPrice);
}
private void EnterShort(ICandleMessage candle)
{
// Combine base order volume with the size required to flatten longs.
var volume = OrderVolume + (Position > 0 ? Position : 0m);
if (volume <= 0m)
return;
SellMarket(volume);
_entryPrice = candle.OpenPrice;
_stopPrice = CalculateStopPrice(Sides.Sell, _entryPrice);
_takePrice = CalculateTakePrice(Sides.Sell, _entryPrice);
}
private bool CheckProtection(ICandleMessage candle)
{
// Emulate MT5 protective stop and take profit that were attached to market orders.
if (Position > 0)
{
if (_stopPrice.HasValue && candle.LowPrice <= _stopPrice.Value)
{
SellMarket(Math.Abs(Position));
ResetProtection();
return true;
}
if (_takePrice.HasValue && candle.HighPrice >= _takePrice.Value)
{
SellMarket(Math.Abs(Position));
ResetProtection();
return true;
}
}
else if (Position < 0)
{
if (_stopPrice.HasValue && candle.HighPrice >= _stopPrice.Value)
{
BuyMarket(Math.Abs(Position));
ResetProtection();
return true;
}
if (_takePrice.HasValue && candle.LowPrice <= _takePrice.Value)
{
BuyMarket(Math.Abs(Position));
ResetProtection();
return true;
}
}
else
{
ResetProtection();
}
return false;
}
private void UpdateChannelState(ICandleMessage candle)
{
// Recreate the Percentage Crossover Channel middle line recursion.
var percent = Percent <= 0m ? 0.001m : Percent;
var plusFactor = 1m + percent / 100m;
var minusFactor = 1m - percent / 100m;
var price = GetAppliedPrice(candle);
decimal currentMiddle;
if (!_hasIndicatorState)
{
currentMiddle = price;
_hasIndicatorState = true;
}
else
{
var lowerBound = price * minusFactor;
var upperBound = price * plusFactor;
var previousMiddle = _lastMiddle;
currentMiddle = previousMiddle;
if (lowerBound > previousMiddle)
currentMiddle = lowerBound;
else if (upperBound < previousMiddle)
currentMiddle = upperBound;
}
var currentUpper = currentMiddle * plusFactor;
var currentLower = currentMiddle * minusFactor;
if (_prevUpper.HasValue)
{
_prevPrevUpper = _prevUpper;
_prevPrevMiddle = _prevMiddle;
_prevPrevLower = _prevLower;
_prevPrevClose = _prevClose;
_prevPrevHigh = _prevHigh;
_prevPrevLow = _prevLow;
}
_prevUpper = currentUpper;
_prevMiddle = currentMiddle;
_prevLower = currentLower;
_prevClose = candle.ClosePrice;
_prevHigh = candle.HighPrice;
_prevLow = candle.LowPrice;
_lastMiddle = currentMiddle;
}
private decimal GetAppliedPrice(ICandleMessage candle)
{
// Convert the selected price mode into a candle value.
return PriceMode switch
{
PercentageChannelPriceModes.Open => candle.OpenPrice,
PercentageChannelPriceModes.High => candle.HighPrice,
PercentageChannelPriceModes.Low => candle.LowPrice,
PercentageChannelPriceModes.Median => (candle.HighPrice + candle.LowPrice) / 2m,
PercentageChannelPriceModes.Typical => (candle.HighPrice + candle.LowPrice + candle.ClosePrice) / 3m,
PercentageChannelPriceModes.Weighted => (candle.HighPrice + candle.LowPrice + (2m * candle.ClosePrice)) / 4m,
PercentageChannelPriceModes.Average => (candle.OpenPrice + candle.HighPrice + candle.LowPrice + candle.ClosePrice) / 4m,
_ => candle.ClosePrice,
};
}
private decimal? CalculateStopPrice(Sides side, decimal entryPrice)
{
if (StopLossPoints <= 0)
return null;
var step = Security?.PriceStep ?? 0m;
if (step <= 0m)
return null;
var offset = StopLossPoints * step;
return side == Sides.Buy ? entryPrice - offset : entryPrice + offset;
}
private decimal? CalculateTakePrice(Sides side, decimal entryPrice)
{
if (TakeProfitPoints <= 0)
return null;
var step = Security?.PriceStep ?? 0m;
if (step <= 0m)
return null;
var offset = TakeProfitPoints * step;
return side == Sides.Buy ? entryPrice + offset : entryPrice - offset;
}
private void ResetProtection()
{
_stopPrice = null;
_takePrice = null;
_entryPrice = 0m;
}
}
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
from StockSharp.Algo.Strategies import Strategy
from datatype_extensions import *
class percentage_crossover_channel_strategy(Strategy):
def __init__(self):
super(percentage_crossover_channel_strategy, self).__init__()
self._candle_type = self.Param("CandleType", DataType.TimeFrame(TimeSpan.FromMinutes(5))).SetDisplay("Candle Type", "Timeframe", "General")
self._percent = self.Param("Percent", 1.0).SetGreaterThanZero().SetDisplay("Percent", "Channel width percent", "Channel")
self._sl_points = self.Param("StopLossPoints", 0).SetNotNegative().SetDisplay("Stop Loss (points)", "Protective stop distance", "Risk")
self._tp_points = self.Param("TakeProfitPoints", 0).SetNotNegative().SetDisplay("Take Profit (points)", "Target profit distance", "Risk")
@property
def CandleType(self): return self._candle_type.Value
@CandleType.setter
def CandleType(self, value): self._candle_type.Value = value
def OnReseted(self):
super(percentage_crossover_channel_strategy, self).OnReseted()
self._last_middle = 0
self._has_state = False
self._prev_upper = None
self._prev_lower = None
self._prev_close = None
self._prev_high = None
self._prev_low = None
self._prev_prev_upper = None
self._prev_prev_lower = None
self._prev_prev_close = None
self._prev_prev_high = None
self._prev_prev_low = None
self._stop_price = None
self._take_price = None
self._entry_price = 0
def OnStarted2(self, time):
super(percentage_crossover_channel_strategy, self).OnStarted2(time)
self._last_middle = 0
self._has_state = False
self._prev_upper = None
self._prev_lower = None
self._prev_close = None
self._prev_high = None
self._prev_low = None
self._prev_prev_upper = None
self._prev_prev_lower = None
self._prev_prev_close = None
self._prev_prev_high = None
self._prev_prev_low = None
self._stop_price = None
self._take_price = None
self._entry_price = 0
sub = self.SubscribeCandles(self.CandleType)
sub.Bind(self.OnProcess).Start()
area = self.CreateChartArea()
if area is not None:
self.DrawCandles(area, sub)
self.DrawOwnTrades(area)
def _get_step(self):
if self.Security is not None and self.Security.PriceStep is not None and self.Security.PriceStep > 0:
return float(self.Security.PriceStep)
return 0.01
def OnProcess(self, candle):
if candle.State != CandleStates.Finished:
return
exit_triggered = self._check_protection(candle)
if not exit_triggered:
self._try_enter(candle)
self._update_channel(candle)
def _try_enter(self, candle):
if self._prev_lower is None or self._prev_prev_lower is None:
return
if self._prev_close is None or self._prev_prev_close is None:
return
touch_lower = self._prev_prev_low > self._prev_prev_lower and self._prev_low <= self._prev_lower
touch_upper = self._prev_prev_high < self._prev_prev_upper and self._prev_high >= self._prev_upper
if touch_lower:
self._enter_long(candle)
elif touch_upper:
self._enter_short(candle)
def _enter_long(self, candle):
volume = 1 + (Math.Abs(self.Position) if self.Position < 0 else 0)
self.BuyMarket(volume)
self._entry_price = float(candle.OpenPrice)
step = self._get_step()
self._stop_price = self._entry_price - self._sl_points.Value * step if self._sl_points.Value > 0 and step > 0 else None
self._take_price = self._entry_price + self._tp_points.Value * step if self._tp_points.Value > 0 and step > 0 else None
def _enter_short(self, candle):
volume = 1 + (self.Position if self.Position > 0 else 0)
self.SellMarket(volume)
self._entry_price = float(candle.OpenPrice)
step = self._get_step()
self._stop_price = self._entry_price + self._sl_points.Value * step if self._sl_points.Value > 0 and step > 0 else None
self._take_price = self._entry_price - self._tp_points.Value * step if self._tp_points.Value > 0 and step > 0 else None
def _check_protection(self, candle):
if self.Position > 0:
if self._stop_price is not None and candle.LowPrice <= self._stop_price:
self.SellMarket(Math.Abs(self.Position))
self._reset_protection()
return True
if self._take_price is not None and candle.HighPrice >= self._take_price:
self.SellMarket(Math.Abs(self.Position))
self._reset_protection()
return True
elif self.Position < 0:
if self._stop_price is not None and candle.HighPrice >= self._stop_price:
self.BuyMarket(Math.Abs(self.Position))
self._reset_protection()
return True
if self._take_price is not None and candle.LowPrice <= self._take_price:
self.BuyMarket(Math.Abs(self.Position))
self._reset_protection()
return True
else:
self._reset_protection()
return False
def _reset_protection(self):
self._stop_price = None
self._take_price = None
self._entry_price = 0
def _update_channel(self, candle):
pct = self._percent.Value if self._percent.Value > 0 else 0.001
plus_factor = 1.0 + pct / 100.0
minus_factor = 1.0 - pct / 100.0
price = float(candle.ClosePrice)
if not self._has_state:
current_middle = price
self._has_state = True
else:
lower_bound = price * minus_factor
upper_bound = price * plus_factor
current_middle = self._last_middle
if lower_bound > current_middle:
current_middle = lower_bound
elif upper_bound < current_middle:
current_middle = upper_bound
current_upper = current_middle * plus_factor
current_lower = current_middle * minus_factor
if self._prev_upper is not None:
self._prev_prev_upper = self._prev_upper
self._prev_prev_lower = self._prev_lower
self._prev_prev_close = self._prev_close
self._prev_prev_high = self._prev_high
self._prev_prev_low = self._prev_low
self._prev_upper = current_upper
self._prev_lower = current_lower
self._prev_close = float(candle.ClosePrice)
self._prev_high = float(candle.HighPrice)
self._prev_low = float(candle.LowPrice)
self._last_middle = current_middle
def CreateClone(self):
return percentage_crossover_channel_strategy()