Открыть на GitHub

Стратегия повторных входов по Chandel Exit

Эта стратегия является переносом советника MetaTrader «Exp_ChandelExitSign_ReOpen» на высокоуровневый API StockSharp. Она торгует пробои по полосам Chandelier Exit и автоматически наращивает позицию, если тренд продолжается. Сигналы рассчитываются на настраиваемом таймфрейме, а управление риском реализовано через ATR-стоп и необязательный тейк-профит.

Основная идея — использовать Chandelier Exit одновременно как трендовый фильтр и динамический барьер. Когда нижняя полоса пересекает верхнюю, фиксируется бычий импульс; обратное пересечение даёт медвежий сигнал. Логика симметрична для покупок и продаж, причём каждый тип сигнала можно включить или отключить параметрами. После открытия позиции цена должна пройти заданное количество шагов цены (PriceStepPoints), прежде чем разрешено добавление. Добор копирует поведение оригинального советника и ограничивается значением MaxAdditions, чтобы объём не рос бесконтрольно.

Логика работы

  • Расчёт сигналов
    • RangePeriod баров (со сдвигом Shift) формируют максимум и минимум, по которым строятся полосы Chandelier Exit.
    • AtrPeriod и AtrMultiplier задают волатильностный буфер, смещающий полосы от цены.
    • SignalBar (по умолчанию 1) задерживает исполнение, чтобы работать по последней завершённой свече и повторять MT5-поведение.
  • Входы
    • Покупка: срабатывает, когда нижняя полоса пересекает верхнюю (IsUpSignal) и EnableBuyEntries = true. При наличии короткой позиции робот сначала пытается закрыть её, если EnableSellExits = true.
    • Продажа: противоположный сигнал (IsDownSignal) при EnableSellEntries = true. Длинная позиция закрывается только если EnableBuyExits = true.
  • Выходы
    • Лонг закрывается по медвежьему сигналу при EnableBuyExits = true, либо по защитному стопу/тейку.
    • Шорт закрывается по бычьему сигналу при EnableSellExits = true, либо по защитным уровням.
    • Когда включены и входы, и выходы, стратегия дополнительно просматривает более старые значения индикатора, чтобы гарантировать наличие сигнала закрытия даже если текущая свеча дала только вход.
  • Повторные входы
    • После каждого открытия запоминается цена сделки. Когда цена проходит в прибыль не менее PriceStepPoints * PriceStep, отправляется дополнительная заявка объёмом Volume — но не более MaxAdditions раз.
    • Каждый добор пересчитывает уровни стопа и тейка от последней цены, удерживая защиту рядом с актуальной позицией.
  • Риск-менеджмент
    • StopLossPoints и TakeProfitPoints задают расстояние в шагах цены от последней сделки. Нули отключают соответствующий уровень.
    • Защитные условия проверяются на каждой завершённой свече. Если цена внутри бара достигает стопа или тейка, позиция закрывается рыночной заявкой.

Параметры по умолчанию

Параметр Значение Назначение
CandleType TimeSpan.FromHours(4).TimeFrame() Таймфрейм, на котором строится индикатор.
RangePeriod 15 Длина окна для максимумов и минимумов.
Shift 1 Количество свежих баров, пропускаемых перед расчётом диапазона.
AtrPeriod 14 Период ATR для волатильностного буфера.
AtrMultiplier 4 Множитель ATR.
SignalBar 1 На сколько завершённых баров назад брать сигнал.
PriceStepPoints 300 Минимальное движение в шагах цены перед добавлением к позиции.
MaxAdditions 10 Максимальное число доборов после первого входа.
StopLossPoints 1000 Размер стоп-лосса в шагах цены.
TakeProfitPoints 2000 Размер тейк-профита в шагах цены.
EnableBuyEntries / EnableSellEntries true Разрешить открытие лонгов/шортов по сигналам.
EnableBuyExits / EnableSellExits true Разрешить закрытие лонгов/шортов по противоположным сигналам.

Практические рекомендации

  • Базовый объём сделки задаётся параметром Volume. Доборы используют тот же объём, поэтому для контроля риска корректируйте Volume или MaxAdditions.
  • Порог PriceStepPoints выражен в шагах цены. Убедитесь, что у инструмента корректно настроен PriceStep, иначе расстояния будут искажены.
  • Значение SignalBar = 1 повторяет оригинального бота и защищает от исполнения на той же свече, которая генерирует сигнал. Установка 0 включает работу на последнем закрытом баре.
  • Стратегия рассчитана на инструменты, где разрешены обе стороны торговли. При необходимости можно отключить лонговые или шортовые сигналы параметрами.
  • Если графическая панель доступна, методы DrawCandles, DrawIndicator и DrawOwnTrades автоматически отрисуют свечи, индикатор и сделки для наглядного контроля.

Пример сценария

  1. Появляется бычий сигнал: нижняя полоса Chandelier Exit пересекает верхнюю.
  2. При отсутствии позиции и включённых покупках стратегия отправляет рыночную заявку Volume. Стоп и тейк рассчитываются от цены входа.
  3. Если цена проходит вперёд как минимум PriceStepPoints * PriceStep, добавляется ещё один лот (до лимита MaxAdditions).
  4. Лонг закрывается при обратном сигнале, достижении стопа или тейка. Для шорта последовательность зеркальная.

Документация сохраняет логику MT5-советника, но использует соглашения StockSharp: параметры стратегии, подписку на свечи высокого уровня и явное управление позицией.

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>
/// Strategy converted from the ChandelExitSign expert advisor with re-entry logic.
/// </summary>
public class ChandelExitReopenStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<int> _rangePeriod;
	private readonly StrategyParam<int> _shift;
	private readonly StrategyParam<int> _atrPeriod;
	private readonly StrategyParam<decimal> _atrMultiplier;
	private readonly StrategyParam<int> _signalBar;
	private readonly StrategyParam<decimal> _priceStepPoints;
	private readonly StrategyParam<int> _maxAdditions;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<bool> _enableBuyEntries;
	private readonly StrategyParam<bool> _enableSellEntries;
	private readonly StrategyParam<bool> _enableBuyExits;
	private readonly StrategyParam<bool> _enableSellExits;

	private readonly List<CandleInfo> _history = new();
	private readonly List<SignalInfo> _signals = new();

	private decimal? _previousUp;
	private decimal? _previousDown;
	private int _direction;

	private int _longAdditions;
	private int _shortAdditions;
	private decimal? _longEntryPrice;
	private decimal? _shortEntryPrice;
	private decimal? _longStopPrice;
	private decimal? _shortStopPrice;
	private decimal? _longTakePrice;
	private decimal? _shortTakePrice;
	private DateTimeOffset? _lastLongAdditionTime;
	private DateTimeOffset? _lastShortAdditionTime;

	/// <summary>
	/// Initializes a new instance of <see cref="ChandelExitReopenStrategy"/>.
	/// </summary>
	public ChandelExitReopenStrategy()
	{
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Type of candles used for signals", "General");

		_rangePeriod = Param(nameof(RangePeriod), 15)
			.SetDisplay("Range Period", "Lookback for highest high and lowest low", "Indicator")
			.SetGreaterThanZero()
			;

		_shift = Param(nameof(Shift), 1)
			.SetDisplay("Shift", "Bars to skip from the most recent data", "Indicator")
			.SetNotNegative()
			;

		_atrPeriod = Param(nameof(AtrPeriod), 14)
			.SetDisplay("ATR Period", "ATR length for volatility filter", "Indicator")
			.SetGreaterThanZero()
			;

		_atrMultiplier = Param(nameof(AtrMultiplier), 4m)
			.SetDisplay("ATR Multiplier", "Multiplier applied to ATR", "Indicator")
			.SetGreaterThanZero()
			;

		_signalBar = Param(nameof(SignalBar), 1)
			.SetDisplay("Signal Bar", "How many bars back to read signals", "Trading")
			.SetNotNegative();

		_priceStepPoints = Param(nameof(PriceStepPoints), 1000m)
			.SetDisplay("Re-entry Distance", "Minimum favorable move in price steps before adding", "Position Management")
			.SetNotNegative()
			;

		_maxAdditions = Param(nameof(MaxAdditions), 1)
			.SetDisplay("Max Additions", "Maximum number of re-entries after the initial position", "Position Management")
			.SetNotNegative();

		_stopLossPoints = Param(nameof(StopLossPoints), 1000)
			.SetDisplay("Stop Loss Points", "Stop-loss distance in price steps", "Risk Management")
			.SetNotNegative();

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 2000)
			.SetDisplay("Take Profit Points", "Take-profit distance in price steps", "Risk Management")
			.SetNotNegative();

		_enableBuyEntries = Param(nameof(EnableBuyEntries), true)
			.SetDisplay("Enable Long Entries", "Allow opening long positions on up signals", "Trading");

		_enableSellEntries = Param(nameof(EnableSellEntries), true)
			.SetDisplay("Enable Short Entries", "Allow opening short positions on down signals", "Trading");

		_enableBuyExits = Param(nameof(EnableBuyExits), true)
			.SetDisplay("Enable Long Exits", "Allow closing long positions on down signals", "Trading");

		_enableSellExits = Param(nameof(EnableSellExits), true)
			.SetDisplay("Enable Short Exits", "Allow closing short positions on up signals", "Trading");
	}

	/// <summary>
	/// Candle type used for calculations.
	/// </summary>
	public DataType CandleType
	{
		get => _candleType.Value;
		set => _candleType.Value = value;
	}

	/// <summary>
	/// Range length for the Chandelier exit bands.
	/// </summary>
	public int RangePeriod
	{
		get => _rangePeriod.Value;
		set => _rangePeriod.Value = value;
	}

	/// <summary>
	/// Number of the most recent bars skipped before measuring the range.
	/// </summary>
	public int Shift
	{
		get => _shift.Value;
		set => _shift.Value = value;
	}

	/// <summary>
	/// ATR length used in the signal calculation.
	/// </summary>
	public int AtrPeriod
	{
		get => _atrPeriod.Value;
		set => _atrPeriod.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the ATR value.
	/// </summary>
	public decimal AtrMultiplier
	{
		get => _atrMultiplier.Value;
		set => _atrMultiplier.Value = value;
	}

	/// <summary>
	/// Offset of the signal bar relative to the latest finished candle.
	/// </summary>
	public int SignalBar
	{
		get => _signalBar.Value;
		set => _signalBar.Value = value;
	}

	/// <summary>
	/// Required move in price steps before another position add is allowed.
	/// </summary>
	public decimal PriceStepPoints
	{
		get => _priceStepPoints.Value;
		set => _priceStepPoints.Value = value;
	}

	/// <summary>
	/// Maximum number of additional entries after the first fill.
	/// </summary>
	public int MaxAdditions
	{
		get => _maxAdditions.Value;
		set => _maxAdditions.Value = value;
	}

	/// <summary>
	/// Stop-loss distance expressed in price steps.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Take-profit distance expressed in price steps.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Enables long entries generated by the up buffer.
	/// </summary>
	public bool EnableBuyEntries
	{
		get => _enableBuyEntries.Value;
		set => _enableBuyEntries.Value = value;
	}

	/// <summary>
	/// Enables short entries generated by the down buffer.
	/// </summary>
	public bool EnableSellEntries
	{
		get => _enableSellEntries.Value;
		set => _enableSellEntries.Value = value;
	}

	/// <summary>
	/// Enables long exits on down signals.
	/// </summary>
	public bool EnableBuyExits
	{
		get => _enableBuyExits.Value;
		set => _enableBuyExits.Value = value;
	}

	/// <summary>
	/// Enables short exits on up signals.
	/// </summary>
	public bool EnableSellExits
	{
		get => _enableSellExits.Value;
		set => _enableSellExits.Value = value;
	}

	/// <inheritdoc />
	public override IEnumerable<(Security sec, DataType dt)> GetWorkingSecurities()
	{
		return [(Security, CandleType)];
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();

		_history.Clear();
		_signals.Clear();

		_previousUp = null;
		_previousDown = null;
		_direction = 0;

		ResetLongState();
		ResetShortState();
	}

	/// <inheritdoc />
	protected override void OnStarted2(DateTime time)
	{
		base.OnStarted2(time);

		var atr = new AverageTrueRange { Length = AtrPeriod };
		var subscription = SubscribeCandles(CandleType);

		subscription
			.BindEx(atr, ProcessCandle)
			.Start();

		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawIndicator(area, atr);
			DrawOwnTrades(area);
		}
	}

	private void ProcessCandle(ICandleMessage candle, IIndicatorValue atrValue)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var atr = atrValue.IsFinal ? atrValue.ToDecimal() : 0m;
		var info = new CandleInfo(candle.OpenTime, candle.HighPrice, candle.LowPrice, candle.ClosePrice, atr);

		_history.Add(info);

		SignalInfo signal;
		if (atrValue.IsFinal)
		{
			signal = CalculateSignal(info);
		}
		else
		{
			signal = SignalInfo.Empty(info.Time);
		}

		_signals.Add(signal);
		TrimCache();

		if (!atrValue.IsFinal)
			return;

		if (_signals.Count <= SignalBar)
			return;

		var signals = _signals.ToArray();
		var targetIndex = signals.Length - 1 - SignalBar;
		if (targetIndex < 0)
			return;

		var targetSignal = signals[targetIndex];
		if (targetSignal is null)
			return;

		var buyOpen = targetSignal.IsUpSignal && EnableBuyEntries;
		var sellOpen = targetSignal.IsDownSignal && EnableSellEntries;
		var buyClose = targetSignal.IsDownSignal && EnableBuyExits;
		var sellClose = targetSignal.IsUpSignal && EnableSellExits;

		if (((EnableBuyEntries && EnableBuyExits) || (EnableSellEntries && EnableSellExits)) && !buyClose && !sellClose)
		{
			for (var idx = targetIndex - 1; idx >= 0; idx--)
			{
				var previousSignal = signals[idx];
				if (previousSignal is null)
					continue;

				if (!sellClose && EnableSellExits && previousSignal.IsUpSignal)
				{
					sellClose = true;
					break;
				}

				if (!buyClose && EnableBuyExits && previousSignal.IsDownSignal)
				{
					buyClose = true;
					break;
				}
			}
		}

		var step = Security.PriceStep ?? 1m;
		var priceStep = PriceStepPoints * step;

		var longClosed = false;
		var shortClosed = false;

		if (Position > 0m)
		{
			if (_longStopPrice is decimal sl && candle.LowPrice <= sl)
			{
				SellMarket();
				ResetLongState();
				longClosed = true;
				this.LogInfo($"Long stop triggered at {sl:0.########}");
			}
			else if (_longTakePrice is decimal tp && candle.HighPrice >= tp)
			{
				SellMarket();
				ResetLongState();
				longClosed = true;
				this.LogInfo($"Long take profit triggered at {tp:0.########}");
			}
		}

		if (Position < 0m)
		{
			if (_shortStopPrice is decimal sl && candle.HighPrice >= sl)
			{
				BuyMarket();
				ResetShortState();
				shortClosed = true;
				this.LogInfo($"Short stop triggered at {sl:0.########}");
			}
			else if (_shortTakePrice is decimal tp && candle.LowPrice <= tp)
			{
				BuyMarket();
				ResetShortState();
				shortClosed = true;
				this.LogInfo($"Short take profit triggered at {tp:0.########}");
			}
		}

		if (!longClosed && buyClose && Position > 0m)
		{
			SellMarket();
			ResetLongState();
			longClosed = true;
			this.LogInfo($"Long exit on down signal at {candle.ClosePrice:0.########}");
		}

		if (!shortClosed && sellClose && Position < 0m)
		{
			BuyMarket();
			ResetShortState();
			shortClosed = true;
			this.LogInfo($"Short exit on up signal at {candle.ClosePrice:0.########}");
		}

		if (!longClosed && Position > 0m && MaxAdditions > 0 && _longEntryPrice is decimal lastLongPrice && priceStep > 0m && _longAdditions < MaxAdditions)
		{
			if (candle.ClosePrice - lastLongPrice >= priceStep && _lastLongAdditionTime != candle.OpenTime)
			{
				if (Volume > 0m)
				{
					BuyMarket();
					_longAdditions++;
					_longEntryPrice = candle.ClosePrice;
					_lastLongAdditionTime = candle.OpenTime;
					UpdateLongProtection(candle.ClosePrice, step);
					this.LogInfo($"Added to long position at {candle.ClosePrice:0.########} (add #{_longAdditions})");
				}
			}
		}

		if (!shortClosed && Position < 0m && MaxAdditions > 0 && _shortEntryPrice is decimal lastShortPrice && priceStep > 0m && _shortAdditions < MaxAdditions)
		{
			if (lastShortPrice - candle.ClosePrice >= priceStep && _lastShortAdditionTime != candle.OpenTime)
			{
				if (Volume > 0m)
				{
					SellMarket();
					_shortAdditions++;
					_shortEntryPrice = candle.ClosePrice;
					_lastShortAdditionTime = candle.OpenTime;
					UpdateShortProtection(candle.ClosePrice, step);
					this.LogInfo($"Added to short position at {candle.ClosePrice:0.########} (add #{_shortAdditions})");
				}
			}
		}

		if (buyOpen && Position < 0m && !EnableSellExits)
		buyOpen = false;

		if (sellOpen && Position > 0m && !EnableBuyExits)
		sellOpen = false;

		if (buyOpen && Volume > 0m)
		{
			BuyMarket();
			ResetShortState();
			_longAdditions = 0;
			_longEntryPrice = candle.ClosePrice;
			_lastLongAdditionTime = candle.OpenTime;
			UpdateLongProtection(candle.ClosePrice, step);
			this.LogInfo($"Opened long position at {candle.ClosePrice:0.########}");
		}

		if (sellOpen && Volume > 0m)
		{
			SellMarket();
			ResetLongState();
			_shortAdditions = 0;
			_shortEntryPrice = candle.ClosePrice;
			_lastShortAdditionTime = candle.OpenTime;
			UpdateShortProtection(candle.ClosePrice, step);
			this.LogInfo($"Opened short position at {candle.ClosePrice:0.########}");
		}
	}

	private void TrimCache()
	{
		var maxItems = Math.Max(RangePeriod + Shift + 5, SignalBar + 5) + 50;
		if (_history.Count <= maxItems)
			return;

		var removeCount = _history.Count - maxItems;
		_history.RemoveRange(0, removeCount);
		_signals.RemoveRange(0, removeCount);
	}

	private SignalInfo CalculateSignal(CandleInfo current)
	{
		var history = _history.ToArray();
		var currentIndex = history.Length - 1;
		var range = RangePeriod;
		var shift = Shift;

		if (range <= 0 || currentIndex - shift < 0)
		return SignalInfo.Empty(current.Time);

		var windowEnd = currentIndex - shift;
		var windowStart = windowEnd - (range - 1);

		if (windowStart < 0 || windowEnd >= history.Length)
		return SignalInfo.Empty(current.Time);

		var highestHigh = decimal.MinValue;
		var lowestLow = decimal.MaxValue;

		for (var i = windowStart; i <= windowEnd; i++)
		{
			var item = history[i];
			if (item is null)
				continue;

			if (item.High > highestHigh)
			highestHigh = item.High;
			if (item.Low < lowestLow)
			lowestLow = item.Low;
		}

		if (highestHigh == decimal.MinValue || lowestLow == decimal.MaxValue)
			return SignalInfo.Empty(current.Time);

		var atr = current.Atr * AtrMultiplier;
		var upperBand = highestHigh - atr;
		var lowerBand = lowestLow + atr;

		decimal up;
		decimal down;

		if (_direction >= 0)
		{
			if (current.Close < upperBand)
			{
				_direction = -1;
				up = lowerBand;
				down = upperBand;
			}
			else
			{
				up = upperBand;
				down = lowerBand;
			}
		}
		else
		{
			if (current.Close > lowerBand)
			{
				_direction = 1;
				down = lowerBand;
				up = upperBand;
			}
			else
			{
				up = lowerBand;
				down = upperBand;
			}
		}

		var isUpSignal = false;
		var isDownSignal = false;

		if (_previousDown is decimal prevDn && _previousUp is decimal prevUp)
		{
			if (prevDn <= prevUp && down > up)
			isUpSignal = true;

			if (prevDn >= prevUp && down < up)
			isDownSignal = true;
		}

		_previousUp = up;
		_previousDown = down;

		return new SignalInfo(current.Time, isUpSignal, isDownSignal, up, down);
	}

	private void UpdateLongProtection(decimal entryPrice, decimal step)
	{
		_longStopPrice = StopLossPoints > 0 ? entryPrice - StopLossPoints * step : null;
		_longTakePrice = TakeProfitPoints > 0 ? entryPrice + TakeProfitPoints * step : null;
	}

	private void UpdateShortProtection(decimal entryPrice, decimal step)
	{
		_shortStopPrice = StopLossPoints > 0 ? entryPrice + StopLossPoints * step : null;
		_shortTakePrice = TakeProfitPoints > 0 ? entryPrice - TakeProfitPoints * step : null;
	}

	private void ResetLongState()
	{
		_longAdditions = 0;
		_longEntryPrice = null;
		_longStopPrice = null;
		_longTakePrice = null;
		_lastLongAdditionTime = null;
	}

	private void ResetShortState()
	{
		_shortAdditions = 0;
		_shortEntryPrice = null;
		_shortStopPrice = null;
		_shortTakePrice = null;
		_lastShortAdditionTime = null;
	}

	private sealed record CandleInfo(DateTimeOffset Time, decimal High, decimal Low, decimal Close, decimal Atr);

	private sealed record SignalInfo(DateTimeOffset Time, bool IsUpSignal, bool IsDownSignal, decimal Up, decimal Down)
	{
		public static SignalInfo Empty(DateTimeOffset time) => new(time, false, false, 0m, 0m);
	}
}