Открыть на GitHub

Стратегия Percentage Crossover Channel

Обзор

Percentage Crossover Channel — это стратегия, перенесённая из MetaTrader 5 (советник Percentage_Crossover_Channel_EA). Она строит динамический канал вокруг выбранной цены и реагирует либо на касания границ, либо на пересечения средней линии. Реализация на StockSharp повторяет исходную логику, обрабатывая только завершённые свечи через высокоуровневый API.

Построение канала

Индикатор формирует канал согласно следующему алгоритму:

  1. Вычисляется базовая цена по выбранному режиму Applied Price (по умолчанию Close).
  2. На цену накладывается скользящая средняя длиной 1 для получения эталонного значения.
  3. По параметру Percent (например, 50 → ±0,5%) рассчитываются верхняя и нижняя границы для допустимого положения средней линии.
  4. Предыдущее значение средней линии «зажимается» в пределах новых границ, чтобы сформировать текущую среднюю.
  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;
	}
}