Открыть на GitHub

Стратегия «Fractals at Close Prices»

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

Эта стратегия представляет собой порт советника MetaTrader 5 «Fractals at Close prices» автора Владимира Карпутова на платформу StockSharp. Она анализирует последовательность из пяти подряд закрытий и строит фракталы Билла Вильямса именно по ценам закрытия, без использования максимумов и минимумов. Для определения направления тренда сравниваются два последних бычьих и два последних медвежьих фрактала. Если новый бычий фрактал появился выше предыдущего, открывается длинная позиция. Если новый медвежий фрактал сформировался ниже предыдущего, открывается короткая позиция. Перед входом противоположная позиция закрывается, поэтому стратегия всегда находится только в одном направлении.

Сделки разрешены лишь в заданном часовом диапазоне. Если текущий час выходит за рамки интервала, все позиции закрываются немедленно — так же поступает оригинальный эксперт. Фильтр времени поддерживает внутридневную торговлю (start < end), переход через полночь (start > end) и круглосуточный режим (start == end).

Логика индикатора

  • Каждая завершённая свеча добавляется в скользящее окно из пяти последних закрытий.
  • Как только окно заполнено, анализируется средняя цена (две свечи назад):
    • Бычий фрактал фиксируется, если средняя цена строго выше двух более старых закрытий и не ниже двух более новых.
    • Медвежий фрактал фиксируется, если средняя цена строго ниже двух более старых закрытий и не выше двух более новых.
  • Сохраняются последние и предпоследние значения бычьих и медвежьих фракталов для последующего сравнения.
  • Если новый бычий фрактал выше предыдущего, определяется восходящий тренд. Если новый медвежий фрактал ниже предыдущего, считается, что тренд нисходящий.

Торговые правила

  1. Лонг
    • Закрыть все активные короткие позиции по рынку.
    • Если длинной позиции нет, купить OrderVolume по рынку на закрытии, подтвердившем бычью последовательность фракталов.
  2. Шорт
    • Закрыть все активные длинные позиции по рынку.
    • Если короткой позиции нет, продать OrderVolume по рынку при подтверждении медвежьей последовательности фракталов.
  3. Контроль сессии
    • Перед обработкой сигналов проверяется, попадает ли candle.OpenTime.Hour в разрешённый диапазон. Если нет, вызывается CloseAllPositions, и текущая свеча игнорируется.

Управление рисками

  • Стоп-лосс и тейк-профит задаются в пунктах. Реализована логика MT5: значение шага цены умножается на десять, если инструмент имеет 3 или 5 знаков после запятой, и полученный «пункт» домножается на выбранные расстояния.
  • При входе уровни стоп-лосса и тейк-профита сохраняются во внутренних переменных. Поскольку StockSharp не сопровождает защитные ордера MT5 автоматически, стратегия отслеживает завершённые свечи и закрывает позицию по рынку, когда диапазон цены касается соответствующего уровня.
  • Трейлинг-стоп повторяет правила советника: новый стоп равен close ± TrailingStop, когда прибыль превышает TrailingStop + TrailingStep. Стоп смещается только если прирост относительно предыдущего уровня не меньше TrailingStep.
  • По окончании торгового окна все позиции закрываются независимо от состояния трейлинга, полностью копируя поведение оригинала.

Параметры

Имя Описание Значение по умолчанию
OrderVolume Объём каждой рыночной сделки. 0.1
StartHour Час (0–23), начиная с которого разрешена торговля. При равенстве с EndHour торговля ведётся весь день. 10
EndHour Час (0–23), после которого сигналы не отрабатываются. 22
StopLossPips Расстояние стоп-лосса в пунктах. Значение 0 отключает стоп. 30
TakeProfitPips Расстояние тейк-профита в пунктах. Значение 0 отключает тейк. 50
TrailingStopPips Базовое расстояние трейлинг-стопа в пунктах. Значение 0 отключает трейлинг. 15
TrailingStepPips Дополнительный профит в пунктах, необходимый для переноса трейлинг-стопа. 5
CandleType Тип свечей, на которые подписывается стратегия. По умолчанию — часовые свечи. 1 hour TimeFrame

Особенности реализации

  • Используется высокоуровневый API SubscribeCandles; индикаторы вручную не регистрируются, что соответствует требованиям репозитория.
  • Защитные выходы (стоп, тейк, трейлинг) исполняются рыночными ордерами после закрытия свечи, поскольку StockSharp не управляет ими автоматически, как MT5.
  • Фильтр торговых часов, поиск фракталов и логика трейлинга повторяют структуру советника, включая принудительное закрытие позиций вне торгового окна.
  • Масштабирование пунктов полностью совпадает с MT5 благодаря умножению шага цены на десять для инструментов с 3 или 5 знаками.

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

  1. Привяжите стратегию к нужному инструменту и задайте подходящий OrderVolume.
  2. Выберите тип свечей, соответствующий таймфрейму, на котором запускался советник в MetaTrader 5.
  3. Настройте временное окно торговли под сессию брокера или желаемые часы работы.
  4. Подберите расстояния в пунктах под волатильность инструмента. Большие TrailingStepPips замедляют перенос стопа, меньшие заставляют его следовать за ценой плотнее.
  5. Отслеживайте сообщения в логах и визуализацию сделок на графике, чтобы контролировать работу стратегии.
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 MT5 "Fractals at Close prices" expert advisor.
/// Detects bullish and bearish fractal sequences built on close prices and trades trend reversals.
/// Includes configurable trading hours and manual risk management with trailing stops.
/// </summary>
public class FractalsAtClosePricesStrategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _startHour;
	private readonly StrategyParam<int> _endHour;
	private readonly StrategyParam<int> _stopLossPips;
	private readonly StrategyParam<int> _takeProfitPips;
	private readonly StrategyParam<int> _trailingStopPips;
	private readonly StrategyParam<int> _trailingStepPips;
	private readonly StrategyParam<DataType> _candleType;

	private readonly List<decimal> _closeWindow = new(6);

	private decimal? _lastUpperFractal;
	private decimal? _previousUpperFractal;
	private decimal? _lastLowerFractal;
	private decimal? _previousLowerFractal;

	private decimal _pipValue;
	private decimal _stopLossDistance;
	private decimal _takeProfitDistance;
	private decimal _trailingStopDistance;
	private decimal _trailingStepDistance;

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

	/// <summary>
	/// Trading volume used for every market order.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set => _orderVolume.Value = value;
	}

	/// <summary>
	/// Hour when the strategy can start opening positions.
	/// </summary>
	public int StartHour
	{
		get => _startHour.Value;
		set => _startHour.Value = value;
	}

	/// <summary>
	/// Hour when the strategy stops opening positions.
	/// </summary>
	public int EndHour
	{
		get => _endHour.Value;
		set => _endHour.Value = value;
	}

	/// <summary>
	/// Stop-loss size expressed in pips.
	/// </summary>
	public int StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Take-profit size expressed in pips.
	/// </summary>
	public int TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Trailing stop distance expressed in pips.
	/// </summary>
	public int TrailingStopPips
	{
		get => _trailingStopPips.Value;
		set => _trailingStopPips.Value = value;
	}

	/// <summary>
	/// Minimum price improvement required before moving the trailing stop.
	/// </summary>
	public int TrailingStepPips
	{
		get => _trailingStepPips.Value;
		set => _trailingStepPips.Value = value;
	}

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

	/// <summary>
	/// Initializes <see cref="FractalsAtClosePricesStrategy"/> parameters.
	/// </summary>
	public FractalsAtClosePricesStrategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 0.1m)
		.SetGreaterThanZero()
		.SetDisplay("Order Volume", "Volume used for entries", "General")
		;

		_startHour = Param(nameof(StartHour), 0)
		.SetRange(0, 23)
		.SetDisplay("Start Hour", "Hour when trading can start (0-23)", "Trading Hours");

		_endHour = Param(nameof(EndHour), 0)
		.SetRange(0, 23)
		.SetDisplay("End Hour", "Hour when trading stops (0-23)", "Trading Hours");

		_stopLossPips = Param(nameof(StopLossPips), 200)
		.SetRange(0, 1000)
		.SetDisplay("Stop Loss (pips)", "Stop-loss distance in pips", "Risk Management")
		;

		_takeProfitPips = Param(nameof(TakeProfitPips), 400)
		.SetRange(0, 1000)
		.SetDisplay("Take Profit (pips)", "Take-profit distance in pips", "Risk Management")
		;

		_trailingStopPips = Param(nameof(TrailingStopPips), 15)
		.SetRange(0, 1000)
		.SetDisplay("Trailing Stop (pips)", "Base distance for the trailing stop", "Risk Management")
		;

		_trailingStepPips = Param(nameof(TrailingStepPips), 5)
		.SetRange(0, 1000)
		.SetDisplay("Trailing Step (pips)", "Additional move required before trailing", "Risk Management")
		;

		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
		.SetDisplay("Candle Type", "Type of candles processed by the strategy", "General");
	}

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

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

		_closeWindow.Clear();
		_lastUpperFractal = null;
		_previousUpperFractal = null;
		_lastLowerFractal = null;
		_previousLowerFractal = null;

		_pipValue = 0m;
		_stopLossDistance = 0m;
		_takeProfitDistance = 0m;
		_trailingStopDistance = 0m;
		_trailingStepDistance = 0m;

		_entryPrice = null;
		ResetRiskLevels();
	}

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

		var priceStep = Security?.PriceStep ?? 1m;
		var decimals = Security?.Decimals ?? 0;

		_pipValue = priceStep;
		if (decimals == 3 || decimals == 5)
		{
			// MT5 version multiplies point value by 10 when the symbol uses 3 or 5 decimals.
			_pipValue *= 10m;
		}

		_stopLossDistance = StopLossPips == 0 ? 0m : StopLossPips * _pipValue;
		_takeProfitDistance = TakeProfitPips == 0 ? 0m : TakeProfitPips * _pipValue;
		_trailingStopDistance = TrailingStopPips == 0 ? 0m : TrailingStopPips * _pipValue;
		_trailingStepDistance = TrailingStepPips == 0 ? 0m : TrailingStepPips * _pipValue;

		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;
		}

		UpdateFractals(candle);

		if (!IsWithinTradingHours(candle.OpenTime))
		{
			CloseAllPositions();
			return;
		}

		ApplyRiskManagement(candle);

		// no bound indicators, skip IsFormedAndOnlineAndAllowTrading()

		ExecuteEntries(candle);
	}

	private void UpdateFractals(ICandleMessage candle)
	{
		// Maintain a rolling window of the five most recent closes.
		_closeWindow.Add(candle.ClosePrice);
		while (_closeWindow.Count > 5)
			_closeWindow.RemoveAt(0);

		if (_closeWindow.Count < 5)
		{
			return;
		}

		var window = _closeWindow;
		var center = window[2];

		var isUpper = center > window[0]
		&& center > window[1]
		&& center >= window[3]
		&& center >= window[4];

		if (isUpper)
		{
			_previousUpperFractal = _lastUpperFractal;
			_lastUpperFractal = center;
		}

		var isLower = center < window[0]
		&& center < window[1]
		&& center <= window[3]
		&& center <= window[4];

		if (isLower)
		{
			_previousLowerFractal = _lastLowerFractal;
			_lastLowerFractal = center;
		}
	}

	private bool IsWithinTradingHours(DateTimeOffset time)
	{
		var hour = time.Hour;

		if (StartHour == EndHour)
		{
			// Trade the entire day when start and end hours are equal.
			return true;
		}

		if (StartHour < EndHour)
		{
			return hour >= StartHour && hour < EndHour;
		}

		return hour >= StartHour || hour < EndHour;
	}

	private void ApplyRiskManagement(ICandleMessage candle)
	{
		if (Position > 0)
		{
			if (_longStop is decimal stop && candle.LowPrice <= stop)
			{
				// Close the long position if the stop-loss level is breached.
				SellMarket(Position);
				ResetRiskLevels();
				return;
			}

			if (_longTake is decimal take && candle.HighPrice >= take)
			{
				// Close the long position when the take-profit level is hit.
				SellMarket(Position);
				ResetRiskLevels();
				return;
			}

			UpdateLongTrailingStop(candle);
		}
		else if (Position < 0)
		{
			if (_shortStop is decimal stop && candle.HighPrice >= stop)
			{
				// Cover the short position if the stop-loss level is breached.
				BuyMarket(Math.Abs(Position));
				ResetRiskLevels();
				return;
			}

			if (_shortTake is decimal take && candle.LowPrice <= take)
			{
				// Cover the short position when the take-profit level is hit.
				BuyMarket(Math.Abs(Position));
				ResetRiskLevels();
				return;
			}

			UpdateShortTrailingStop(candle);
		}
	}

	private void UpdateLongTrailingStop(ICandleMessage candle)
	{
		if (_trailingStopDistance <= 0m || _entryPrice is not decimal entry)
		{
			return;
		}

		var profitDistance = candle.ClosePrice - entry;
		if (profitDistance <= _trailingStopDistance + _trailingStepDistance)
		{
			return;
		}

		var targetStop = candle.ClosePrice - _trailingStopDistance;
		if (_longStop is decimal currentStop && currentStop >= candle.ClosePrice - (_trailingStopDistance + _trailingStepDistance))
		{
			// Skip updates until price improved by the trailing step.
			return;
		}

		_longStop = targetStop;
	}

	private void UpdateShortTrailingStop(ICandleMessage candle)
	{
		if (_trailingStopDistance <= 0m || _entryPrice is not decimal entry)
		{
			return;
		}

		var profitDistance = entry - candle.ClosePrice;
		if (profitDistance <= _trailingStopDistance + _trailingStepDistance)
		{
			return;
		}

		var targetStop = candle.ClosePrice + _trailingStopDistance;
		if (_shortStop is decimal currentStop && currentStop <= candle.ClosePrice + (_trailingStopDistance + _trailingStepDistance))
		{
			// Skip updates until price improved by the trailing step.
			return;
		}

		_shortStop = targetStop;
	}

	private void ExecuteEntries(ICandleMessage candle)
	{
		// Only trade when flat to avoid too frequent reversals.
		if (Position != 0)
			return;

		var bullishTrend = _lastLowerFractal is decimal lastLow
		&& _previousLowerFractal is decimal prevLow
		&& prevLow < lastLow;

		if (bullishTrend && OrderVolume > 0m)
		{
			BuyMarket(OrderVolume);
			_entryPrice = candle.ClosePrice;
			_longStop = _stopLossDistance > 0m ? candle.ClosePrice - _stopLossDistance : null;
			_longTake = _takeProfitDistance > 0m ? candle.ClosePrice + _takeProfitDistance : null;
			_shortStop = null;
			_shortTake = null;
			return;
		}

		var bearishTrend = _lastUpperFractal is decimal lastUp
		&& _previousUpperFractal is decimal prevUp
		&& prevUp > lastUp;

		if (bearishTrend && OrderVolume > 0m)
		{
			SellMarket(OrderVolume);
			_entryPrice = candle.ClosePrice;
			_shortStop = _stopLossDistance > 0m ? candle.ClosePrice + _stopLossDistance : null;
			_shortTake = _takeProfitDistance > 0m ? candle.ClosePrice - _takeProfitDistance : null;
			_longStop = null;
			_longTake = null;
		}
	}

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

		ResetRiskLevels();
	}

	private void CloseLongPosition()
	{
		if (Position > 0)
		{
			SellMarket(Position);
			ResetRiskLevels();
		}
	}

	private void CloseShortPosition()
	{
		if (Position < 0)
		{
			BuyMarket(Math.Abs(Position));
			ResetRiskLevels();
		}
	}

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