Открыть на GitHub

Стратегия E-Skoch-Open (порт на StockSharp)

Общее описание

E-Skoch-Open — это перенос оригинального советника MetaTrader 5, основанного на простом ценовом паттерне из трёх свечей. В версии для StockSharp расчёты выполняются после закрытия каждой свечи: анализируется последовательность недавних закрытий и при появлении нужной конфигурации открывается позиция. Риски контролируются фиксированными уровнями стоп-лосса и тейк-профита, задаваемыми в «приведённых» пунктах (pip), а также глобальной целью по росту капитала. Объём позиций формируется по схеме мартингейла: убыточная сделка увеличивает размер следующей позиции в 1.6 раза, прибыльная — возвращает объём к исходному значению.

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

  1. Используется таймфрейм, указанный в параметре CandleType (по умолчанию 1 час).
  2. Стратегия ждёт появления минимум трёх завершённых свечей.
  3. Сигнал на покупку: если Close[n-3] > Close[n-2] и Close[n-1] < Close[n-2], а длинные сделки разрешены.
  4. Сигнал на продажу: если Close[n-3] > Close[n-2] и Close[n-2] < Close[n-1], а короткие сделки разрешены.
  5. При активном CloseOnOppositeSignal встречный сигнал сначала закрывает текущую позицию и до конца свечи новые сделки не открываются.
  6. При открытии позиции рассчитываются уровни стоп-лосса и тейк-профита от текущего закрытия. Если максимум/минимум последующих свечей достигает заданного уровня, позиция закрывается.
  7. Одновременно контролируется капитал портфеля. Как только рост относительно последнего момента без позиции превысит TargetProfitPercent, стратегия закрывает все сделки.
  8. После убыточной сделки объём следующей позиции умножается на 1.6, после прибыльной — сбрасывается к базовому значению. Объём корректируется под шаг, минимум и максимум инструмента (VolumeStep, VolumeMin, VolumeMax).

Параметры

Параметр Описание
CandleType Таймфрейм, по которому ищется паттерн (любые свечи, поддерживаемые StockSharp).
InitialOrderVolume Базовый объём первой сделки (по умолчанию 0.01).
StopLossPoints Дистанция стоп-лосса в приведённых пунктах: для инструментов с 5 или 3 знаками после запятой используется PriceStep * 10, иначе PriceStep.
TakeProfitPoints Дистанция тейк-профита, рассчитывается аналогично стоп-лоссу.
EnableBuySignals / EnableSellSignals Включение/выключение сделок в длинную или короткую сторону.
MaxBuyTrades / MaxSellTrades Максимальное число последовательных сделок в каждом направлении (-1 снимает ограничение). В порте по умолчанию используется одна чистая позиция на направление.
TargetProfitPercent Процент роста капитала, после которого закрываются все позиции (по умолчанию 1.2%).
CloseOnOppositeSignal При активации встречный сигнал немедленно закрывает позицию перед поиском новых входов.

Важные замечания по рискам

  • Уровни стопа и профита проверяются по экстремумам закрывшихся свечей. В реальном исполнении сервер MetaTrader может обрабатывать лимитные/стоп-заявки внутри бара, что даёт расхождения.
  • Множитель мартингейла 1.6 быстро увеличивает объёмы при серии убытков. Убедитесь, что ограничения инструмента (VolumeMax) и размер счёта позволяют выдержать максимальную позицию.
  • Триггер по росту капитала требует актуального значения Portfolio.CurrentValue от брокера или тестовой среды.

Рекомендации

  • Подберите CandleType, соответствующий исходной настройке советника.
  • Настройте StopLossPoints и TakeProfitPoints в соответствии с волатильностью инструмента — пересчёт в пункты выполняется автоматически.
  • При запрете хеджирования у брокера можно отключить одну из сторон торговли.
  • Перед длительными тестами оцените потенциальный рост объёма при использовании мартингейла и целевого профита по капиталу.
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 "E-Skoch-Open" MetaTrader strategy using StockSharp high level API.
/// The strategy reacts to a three-candle closing price pattern and applies
/// martingale position sizing together with equity based stops.
/// </summary>
public class ESkochOpenStrategy : Strategy
{
	private readonly StrategyParam<decimal> _martingaleMultiplier;

	private readonly StrategyParam<decimal> _stopLossPoints;
	private readonly StrategyParam<decimal> _takeProfitPoints;
	private readonly StrategyParam<bool> _enableBuySignals;
	private readonly StrategyParam<bool> _enableSellSignals;
	private readonly StrategyParam<decimal> _targetProfitPercent;
	private readonly StrategyParam<bool> _closeOnOppositeSignal;
	private readonly StrategyParam<int> _maxBuyTrades;
	private readonly StrategyParam<int> _maxSellTrades;
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<decimal> _initialOrderVolume;

	private decimal _pointValue;
	private decimal _currentVolume;
	private decimal _entryEquity;
	private decimal _baselineEquity;
	private bool _positionTracked;

	private decimal? _closeMinus1;
	private decimal? _closeMinus2;
	private decimal? _closeMinus3;

	private decimal? _longStop;
	private decimal? _longTake;
	private decimal? _shortStop;
	private decimal? _shortTake;

	private int _activeLongEntries;
	private int _activeShortEntries;
	private int _previousPatternSignal;

	/// <summary>
	/// Stop loss distance expressed in adjusted points (default: 130).
	/// </summary>
	public decimal StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Take profit distance expressed in adjusted points (default: 200).
	/// </summary>
	public decimal TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Enables long entries created by the pattern.
	/// </summary>
	public bool EnableBuySignals
	{
		get => _enableBuySignals.Value;
		set => _enableBuySignals.Value = value;
	}

	/// <summary>
	/// Enables short entries created by the pattern.
	/// </summary>
	public bool EnableSellSignals
	{
		get => _enableSellSignals.Value;
		set => _enableSellSignals.Value = value;
	}

	/// <summary>
	/// Equity percentage gain that triggers closing every open position.
	/// </summary>
	public decimal TargetProfitPercent
	{
		get => _targetProfitPercent.Value;
		set => _targetProfitPercent.Value = value;
	}

	/// <summary>
	/// When true, opposite trades immediately flatten the existing position.
	/// </summary>
	public bool CloseOnOppositeSignal
	{
		get => _closeOnOppositeSignal.Value;
		set => _closeOnOppositeSignal.Value = value;
	}

	/// <summary>
	/// Maximum number of consecutive long entries (-1 disables the limit).
	/// </summary>
	public int MaxBuyTrades
	{
		get => _maxBuyTrades.Value;
		set => _maxBuyTrades.Value = value;
	}

	/// <summary>
	/// Maximum number of consecutive short entries (-1 disables the limit).
	/// </summary>
	public int MaxSellTrades
	{
		get => _maxSellTrades.Value;
		set => _maxSellTrades.Value = value;
	}

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

	/// <summary>
	/// Base order volume used for the first trade in a sequence.
	/// </summary>
	public decimal InitialOrderVolume
	{
		get => _initialOrderVolume.Value;
		set => _initialOrderVolume.Value = value;
	}

	/// <summary>
	/// Volume multiplier after losses (martingale).
	/// </summary>
	public decimal MartingaleMultiplier
	{
		get => _martingaleMultiplier.Value;
		set => _martingaleMultiplier.Value = value;
	}

	/// <summary>
	/// Creates the strategy parameters with defaults similar to the MQL version.
	/// </summary>
	public ESkochOpenStrategy()
	{
		_martingaleMultiplier = Param(nameof(MartingaleMultiplier), 1.6m)
			.SetGreaterThanZero()
			.SetDisplay("Martingale Mult", "Volume multiplier after losses", "Risk");

		_stopLossPoints = Param(nameof(StopLossPoints), 130m)
		.SetDisplay("Stop Loss Points", "Loss distance measured in adjusted points", "Risk")
		.SetNotNegative();
		_takeProfitPoints = Param(nameof(TakeProfitPoints), 200m)
		.SetDisplay("Take Profit Points", "Profit distance measured in adjusted points", "Risk")
		.SetNotNegative();
		_enableBuySignals = Param(nameof(EnableBuySignals), true)
		.SetDisplay("Enable Buy", "Allow opening long positions", "Trading");
		_enableSellSignals = Param(nameof(EnableSellSignals), true)
		.SetDisplay("Enable Sell", "Allow opening short positions", "Trading");
		_targetProfitPercent = Param(nameof(TargetProfitPercent), 1.2m)
		.SetDisplay("Target Profit %", "Close all positions after reaching this equity growth", "Risk")
		.SetNotNegative();
		_closeOnOppositeSignal = Param(nameof(CloseOnOppositeSignal), false)
		.SetDisplay("Close On Opposite", "Close open positions when an opposite signal appears", "Trading");
		_maxBuyTrades = Param(nameof(MaxBuyTrades), 1)
		.SetDisplay("Max Long Trades", "Maximum concurrent long trades", "Risk");
		_maxSellTrades = Param(nameof(MaxSellTrades), 1)
		.SetDisplay("Max Short Trades", "Maximum concurrent short trades", "Risk");
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(1).TimeFrame())
		.SetDisplay("Candle Type", "Timeframe used for pattern recognition", "Data");
		_initialOrderVolume = Param(nameof(InitialOrderVolume), 0.01m)
		.SetDisplay("Initial Volume", "Volume of the first trade", "Trading")
		.SetGreaterThanZero();
	}

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

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

		_closeMinus1 = null;
		_closeMinus2 = null;
		_closeMinus3 = null;
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
		_activeLongEntries = 0;
		_activeShortEntries = 0;
		_positionTracked = false;
		_pointValue = 0m;
		_currentVolume = 0m;
		_entryEquity = 0m;
		_baselineEquity = 0m;
		_previousPatternSignal = 0;
	}

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

		Volume = InitialOrderVolume;
		_pointValue = CalculatePointValue();
		_currentVolume = NormalizeVolume(InitialOrderVolume);

		var equity = Portfolio?.CurrentValue ?? 0m;
		_baselineEquity = equity;
		_entryEquity = equity;
		_positionTracked = Position != 0;

		var subscription = SubscribeCandles(CandleType);
		subscription
		.Bind(ProcessCandle)
		.Start();

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

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


		CheckEquityTarget();

		if (CheckProtection(candle))
		{
			// Skip new entries if a protection exit already triggered on this bar.
			UpdateCloses(candle.ClosePrice);
			return;
		}

		// removed IsFormedAndOnlineAndAllowTrading check for backtesting

		if (_closeMinus1.HasValue && _closeMinus2.HasValue && _closeMinus3.HasValue)
		{
			var close1 = _closeMinus1.Value;
			var close2 = _closeMinus2.Value;
			var close3 = _closeMinus3.Value;

			var buySignal = close3 > close2 && close1 < close2;
			var sellSignal = close3 > close2 && close2 < close1;

			var patternSignal = buySignal ? 1 : sellSignal ? -1 : 0;

			if (buySignal && patternSignal != _previousPatternSignal)
			{
				HandleBuySignal(candle);
			}

			if (sellSignal && patternSignal != _previousPatternSignal)
			{
				HandleSellSignal(candle);
			}

			_previousPatternSignal = patternSignal;
		}
		else
		{
			_previousPatternSignal = 0;
		}

		UpdateCloses(candle.ClosePrice);
	}

	private void HandleBuySignal(ICandleMessage candle)
	{
		if (!EnableBuySignals)
		{
			return;
		}

		if (CloseOnOppositeSignal && Position < 0)
		{
			BuyMarket(Math.Abs(Position));
			return;
		}

		if (Position > 0)
		{
			return;
		}

		if (MaxBuyTrades != -1 && _activeLongEntries >= MaxBuyTrades)
		{
			return;
		}

		var volume = NormalizeVolume(_currentVolume);
		if (volume <= 0m)
		{
			return;
		}

		BuyMarket(volume);
		_activeLongEntries++;
		_positionTracked = true;
		_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		SetupProtection(true, candle.ClosePrice);
	}

	private void HandleSellSignal(ICandleMessage candle)
	{
		if (!EnableSellSignals)
		{
			return;
		}

		if (CloseOnOppositeSignal && Position > 0)
		{
			SellMarket(Math.Abs(Position));
			return;
		}

		if (Position < 0)
		{
			return;
		}

		if (MaxSellTrades != -1 && _activeShortEntries >= MaxSellTrades)
		{
			return;
		}

		var volume = NormalizeVolume(_currentVolume);
		if (volume <= 0m)
		{
			return;
		}

		SellMarket(volume);
		_activeShortEntries++;
		_positionTracked = true;
		_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		SetupProtection(false, candle.ClosePrice);
	}

	private bool CheckProtection(ICandleMessage candle)
	{
		if (Position > 0)
		{
			if (_longStop.HasValue && candle.LowPrice <= _longStop.Value)
			{
				SellMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}

			if (_longTake.HasValue && candle.HighPrice >= _longTake.Value)
			{
				SellMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}
		}
		else if (Position < 0)
		{
			if (_shortStop.HasValue && candle.HighPrice >= _shortStop.Value)
			{
				BuyMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}

			if (_shortTake.HasValue && candle.LowPrice <= _shortTake.Value)
			{
				BuyMarket(Math.Abs(Position));
				ResetProtection();
				return true;
			}
		}

		return false;
	}

	private void SetupProtection(bool isLong, decimal referencePrice)
	{
		var point = _pointValue;
		if (point <= 0m)
		{
			point = Security?.PriceStep ?? 0m;
		}

		if (isLong)
		{
			_longStop = StopLossPoints > 0m ? referencePrice - StopLossPoints * point : null;
			_longTake = TakeProfitPoints > 0m ? referencePrice + TakeProfitPoints * point : null;
			_shortStop = null;
			_shortTake = null;
		}
		else
		{
			_shortStop = StopLossPoints > 0m ? referencePrice + StopLossPoints * point : null;
			_shortTake = TakeProfitPoints > 0m ? referencePrice - TakeProfitPoints * point : null;
			_longStop = null;
			_longTake = null;
		}
	}

	private void ResetProtection()
	{
		_longStop = null;
		_longTake = null;
		_shortStop = null;
		_shortTake = null;
	}

	private void UpdateCloses(decimal close)
	{
		_closeMinus3 = _closeMinus2;
		_closeMinus2 = _closeMinus1;
		_closeMinus1 = close;
	}

	private void CheckEquityTarget()
	{
		if (TargetProfitPercent <= 0m)
		{
			return;
		}

		if (_baselineEquity <= 0m)
		{
			return;
		}

		var equity = Portfolio?.CurrentValue ?? 0m;
		var growthPercent = (equity - _baselineEquity) / _baselineEquity * 100m;

		if (growthPercent >= TargetProfitPercent)
		{
			CloseAllPositions();
		}
	}

	private void CloseAllPositions()
	{
		if (Position > 0)
		{
			SellMarket(Math.Abs(Position));
		}
		else if (Position < 0)
		{
			BuyMarket(Math.Abs(Position));
		}
	}

	/// <inheritdoc />
	protected override void OnPositionReceived(Position position)
	{
		base.OnPositionReceived(position);

		if (Position == 0)
		{
			if (_positionTracked)
			{
				var equity = Portfolio?.CurrentValue ?? _baselineEquity;
				if (equity >= _entryEquity)
				{
					_currentVolume = NormalizeVolume(InitialOrderVolume);
				}
				else
				{
					_currentVolume = NormalizeVolume(_currentVolume * MartingaleMultiplier);
				}

				_baselineEquity = equity;
				_positionTracked = false;
				ResetProtection();
				_activeLongEntries = 0;
				_activeShortEntries = 0;
			}
			else
			{
				_baselineEquity = Portfolio?.CurrentValue ?? _baselineEquity;
			}
		}
		else
		{
			_positionTracked = true;
			_entryEquity = Portfolio?.CurrentValue ?? _entryEquity;
		}
	}

	private decimal NormalizeVolume(decimal volume)
	{
		if (volume <= 0m)
		{
			return 0m;
		}

		var sec = Security;
		if (sec != null)
		{
			var step = sec.VolumeStep ?? 0m;
			if (step > 0m)
			{
				volume = Math.Floor(volume / step) * step;
			}

			var min = sec.MinVolume ?? 0m;
			if (min > 0m && volume < min)
			{
				volume = min;
			}

			var max = sec.MaxVolume ?? 0m;
			if (max > 0m && volume > max)
			{
				volume = max;
			}
		}

		return volume;
	}

	private decimal CalculatePointValue()
	{
		var step = Security?.PriceStep ?? 0m;
		if (step <= 0m)
		{
			return 0m;
		}

		var decimals = CountDecimals(step);
		return decimals == 3 || decimals == 5 ? step * 10m : step;
	}

	private static int CountDecimals(decimal value)
	{
		value = Math.Abs(value);
		var decimals = 0;

		while (value != Math.Truncate(value) && decimals < 10)
		{
			value *= 10m;
			decimals++;
		}

		return decimals;
	}
}