Стратегия Percentage Crossover Channel
Обзор
Percentage Crossover Channel — это стратегия, перенесённая из MetaTrader 5 (советник Percentage_Crossover_Channel_EA). Она строит динамический канал вокруг выбранной цены и реагирует либо на касания границ, либо на пересечения средней линии. Реализация на StockSharp повторяет исходную логику, обрабатывая только завершённые свечи через высокоуровневый API.
Построение канала
Индикатор формирует канал согласно следующему алгоритму:
- Вычисляется базовая цена по выбранному режиму Applied Price (по умолчанию Close).
- На цену накладывается скользящая средняя длиной 1 для получения эталонного значения.
- По параметру Percent (например, 50 → ±0,5%) рассчитываются верхняя и нижняя границы для допустимого положения средней линии.
- Предыдущее значение средней линии «зажимается» в пределах новых границ, чтобы сформировать текущую среднюю.
- Верхняя и нижняя линии канала равны среднему значению, умноженному на коэффициенты ±percent.
Такая рекурсия позволяет каналу расширяться во время тренда и сужаться в фазах консолидации, что точно соответствует поведению индикатора в MQL5.
Торговая логика
Поддерживаются два режима сигналов:
- Касание границы (значение по умолчанию):
- Покупка, если минимум предпоследней свечи находился выше нижней границы, а последняя завершённая свеча касается или пробивает нижнюю линию.
- Продажа, если максимум предпоследней свечи был ниже верхней границы, а последняя свеча касается или пробивает верхнюю линию.
- Пересечение средней (TradeOnMiddleCross = true):
- Покупка при переходе цены через среднюю линию сверху вниз.
- Продажа при пересечении средней снизу вверх.
Флаг ReverseSignals меняет местами условия для длинных и коротких позиций. При появлении нового сигнала стратегия закрывает и разворачивает открытую позицию одной рыночной заявкой объёмом OrderVolume плюс абсолютное значение текущей позиции.
Управление рисками
Опциональные защитные уровни повторяют настройки стоп-лосса и тейк-профита из оригинального советника:
- StopLossPoints — расстояние в шагах цены, вычитаемое (для покупок) или добавляемое (для продаж) от оценочной цены входа.
- TakeProfitPoints — расстояние в шагах цены, добавляемое (для покупок) или вычитаемое (для продаж) от цены входа.
Нулевое значение отключает соответствующую защиту. Проверка срабатывания выполняется на каждой завершённой свече по экстремумам High/Low. Трейлинг-стоп не используется.
Параметры
| Параметр |
Описание |
CandleType |
Тип свечей, которые обрабатывает стратегия (по умолчанию таймфрейм 15 минут). |
Percent |
Ширина канала в процентах от цены (преобразуется в коэффициенты ±percent/100). |
PriceMode |
Режим базовой цены: 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 |
Переключение между касанием границ и пересечением средней линии. |
ReverseSignals |
Инверсия длинных и коротких сигналов. |
StopLossPoints |
Дистанция стоп-лосса в шагах цены инструмента. |
TakeProfitPoints |
Дистанция тейк-профита в шагах цены. |
OrderVolume |
Базовый объём рыночных заявок; при развороте добавляется величина текущей позиции. |
Особенности реализации
- Заявки формируются только после закрытия свечи, что соответствует логике MT5, где сделки открывались в начале следующего бара на основании данных предыдущего.
- Индикатор канала реализован в самой стратегии без хранения массивов исторических данных — используются только скалярные состояния.
- Защитные ордера моделируются вручную, чтобы воспроизвести обработку стопов и тейков из платформы MetaTrader.
- Для корректной работы стоп-уровней у инструмента должен быть задан
PriceStep; при его отсутствии стопы и тейки игнорируются.
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()