Открыть на GitHub

Стратегия Smart Trend Follower

Обзор

Smart Trend Follower — порт на StockSharp советника MetaTrader 5 Smart Trend Follower. Исходный робот чередует контртрендовое пересечение скользящих средних и трендовую модель с подтверждением стохастиком, а также наращивает позиции по принципу мартингейла. В версии StockSharp сохранены все торговые идеи, но реализованы средствами высокоуровневого API (подписки на свечи, связка индикаторов и рыночные заявки).

Торговые сигналы

Режим работы выбирается параметром SignalMode:

  1. CrossMa — повторяет контртрендовую схему. Если быстрая SMA пересекает медленную сверху вниз (текущее fast < slow, а на предыдущей свече fast > slow), стратегия открывает/усиливает длинную корзину. Обратное пересечение (fast > slow, ранее fast < slow) инициирует короткую корзину.
  2. Trend — трендовый вариант. Длинный сигнал появляется при fast > slow, бычьей свече и значении стохастика %K ≤ 30. Короткий — при fast < slow, медвежьей свече и %K ≥ 70.

Условия оцениваются только по закрытым свечам. Если во время сигнала остаётся позиция противоположного направления, она закрывается рыночным ордером до обработки новых сделок, что удерживает корзину в направлении актуального сигнала.

Масштабирование позиций

Алгоритм полностью повторяет мартингейл оригинала:

  • Первая сделка открывается объёмом InitialVolume лотов.
  • Каждое дополнительное усреднение умножает предыдущий объём на Multiplier (значения ≤ 1 отключают рост лота).
  • Новая заявка появляется только после смещения цены от лучшей цены входа (минимум для лонгов, максимум для шортов) минимум на LayerDistancePips пунктов.
  • Объёмы нормализуются по VolumeStep, VolumeMin и VolumeMax, если биржевой инструмент предоставляет эти ограничения.

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

Для каждой корзины стратегия рассчитывает усреднённую цену входа и поддерживает общие цели:

  • TakeProfitPips задаёт расстояние до цели фиксации прибыли. Лонги закрываются, когда максимум свечи достигает уровня, шорты — когда минимум касается своей цели. Ноль отключает тейк-профит.
  • StopLossPips аналогично определяет защитный стоп. Лонги закрываются, если минимум свечи пробивает уровень, шорты — если максимум свечи превышает его. Ноль отключает жесткий стоп.

Выходы совершаются рыночными ордерами после того, как закрывшаяся свеча подтвердит достижение уровня. Флаги _longExitRequested и _shortExitRequested предотвращают повторные заявки до прихода отчёта о сделке.

Параметры

Параметр Тип Значение по умолчанию Описание
SignalMode enum (CrossMa, Trend) CrossMa Выбор источника сигналов (контртрендовый или трендовый режим).
CandleType DataType таймфрейм 30 минут Основная свечная серия для расчётов и сигналов.
InitialVolume decimal 0.01 Стартовый объём (в лотах) первой сделки в корзине.
Multiplier decimal 2 Множитель объёма для каждого следующего усреднения.
LayerDistancePips decimal 200 Минимальный отступ цены от лучшей сделки перед новым ордером.
FastPeriod int 14 Период быстрой SMA.
SlowPeriod int 28 Период медленной SMA (должен быть больше FastPeriod).
StochasticKPeriod int 10 Базовый период стохастика %K.
StochasticDPeriod int 3 Период сглаживания линии %D.
StochasticSlowing int 3 Дополнительное сглаживание %K.
TakeProfitPips decimal 500 Расстояние до тейк-профита в пунктах; 0 отключает.
StopLossPips decimal 0 Расстояние до стоп-лосса в пунктах; 0 отключает.

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

  • Размер пункта рассчитывается по PriceStep и количеству знаков (Decimals), что соответствует понятию point в MetaTrader (например, 0.0001 для пятизначных котировок).
  • Для учёта сделок используются два списка PositionEntry, что имитирует тиковый учёт MT5; при обратных сделках позиции сокращаются по принципу FIFO.
  • Индикаторы подключаются через SubscribeCandles().BindEx(...); метод GetValue не используется, индикаторы не добавляются напрямую в Strategy.Indicators.
  • В методе OnStarted вызывается StartProtection(), чтобы активировать стандартные защитные механизмы StockSharp.
  • Поскольку StockSharp оперирует чистой позицией, перед открытием новой корзины противоположные позиции закрываются — это делает поведение предсказуемым и близким к оригинальному советнику.

Файлы

  • CS/SmartTrendFollowerStrategy.cs — реализация стратегии на C# с использованием высокоуровневого API 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>
/// Port of the "Smart Trend Follower" MetaTrader 5 expert advisor that combines moving average signals
/// with stochastic confirmation and a martingale-style layering engine.
/// </summary>
public class SmartTrendFollowerStrategy : Strategy
{
	private readonly StrategyParam<DataType> _candleType;
	private readonly StrategyParam<SignalModes> _signalMode;
	private readonly StrategyParam<decimal> _initialVolume;
	private readonly StrategyParam<decimal> _multiplier;
	private readonly StrategyParam<decimal> _layerDistancePips;
	private readonly StrategyParam<int> _fastPeriod;
	private readonly StrategyParam<int> _slowPeriod;
	private readonly StrategyParam<int> _stochasticKPeriod;
	private readonly StrategyParam<int> _stochasticDPeriod;
	private readonly StrategyParam<int> _stochasticSlowing;
	private readonly StrategyParam<decimal> _takeProfitPips;
	private readonly StrategyParam<decimal> _stopLossPips;

	private SimpleMovingAverage _fastSma;
	private SimpleMovingAverage _slowSma;
	private StochasticOscillator _stochastic;

	private readonly List<PositionEntry> _longEntries = new();
	private readonly List<PositionEntry> _shortEntries = new();

	private decimal? _prevFast;
	private decimal? _prevSlow;
	private decimal _pipSize;
	private bool _longExitRequested;
	private bool _shortExitRequested;

	/// <summary>
	/// Trading signal mode.
	/// </summary>
	public SignalModes SignalMode
	{
		get => _signalMode.Value;
		set => _signalMode.Value = value;
	}

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

	/// <summary>
	/// Initial order volume expressed in lots.
	/// </summary>
	public decimal InitialVolume
	{
		get => _initialVolume.Value;
		set => _initialVolume.Value = value;
	}

	/// <summary>
	/// Multiplier applied to the volume of every additional averaging order.
	/// </summary>
	public decimal Multiplier
	{
		get => _multiplier.Value;
		set => _multiplier.Value = value;
	}

	/// <summary>
	/// Distance in pips required before stacking another order in the same direction.
	/// </summary>
	public decimal LayerDistancePips
	{
		get => _layerDistancePips.Value;
		set => _layerDistancePips.Value = value;
	}

	/// <summary>
	/// Fast simple moving average period.
	/// </summary>
	public int FastPeriod
	{
		get => _fastPeriod.Value;
		set => _fastPeriod.Value = value;
	}

	/// <summary>
	/// Slow simple moving average period.
	/// </summary>
	public int SlowPeriod
	{
		get => _slowPeriod.Value;
		set => _slowPeriod.Value = value;
	}

	/// <summary>
	/// Stochastic oscillator %K length.
	/// </summary>
	public int StochasticKPeriod
	{
		get => _stochasticKPeriod.Value;
		set => _stochasticKPeriod.Value = value;
	}

	/// <summary>
	/// Stochastic oscillator %D smoothing length.
	/// </summary>
	public int StochasticDPeriod
	{
		get => _stochasticDPeriod.Value;
		set => _stochasticDPeriod.Value = value;
	}

	/// <summary>
	/// Additional smoothing applied to the %K line.
	/// </summary>
	public int StochasticSlowing
	{
		get => _stochasticSlowing.Value;
		set => _stochasticSlowing.Value = value;
	}

	/// <summary>
	/// Take-profit distance in pips relative to the average entry price.
	/// </summary>
	public decimal TakeProfitPips
	{
		get => _takeProfitPips.Value;
		set => _takeProfitPips.Value = value;
	}

	/// <summary>
	/// Stop-loss distance in pips relative to the average entry price.
	/// </summary>
	public decimal StopLossPips
	{
		get => _stopLossPips.Value;
		set => _stopLossPips.Value = value;
	}

	/// <summary>
	/// Initializes a new instance of <see cref="SmartTrendFollowerStrategy"/>.
	/// </summary>
	public SmartTrendFollowerStrategy()
	{
		_signalMode = Param(nameof(SignalMode), SignalModes.CrossMa)
		.SetDisplay("Signal Mode", "Trading logic selection", "Signals");

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(30).TimeFrame())
		.SetDisplay("Candle Type", "Primary timeframe", "General");

		_initialVolume = Param(nameof(InitialVolume), 1m)
		.SetGreaterThanZero()
		.SetDisplay("Initial Volume", "Starting order volume in lots", "Money Management");

		_multiplier = Param(nameof(Multiplier), 2m)
		.SetNotNegative()
		.SetDisplay("Volume Multiplier", "Martingale multiplier applied to additional entries", "Money Management");

		_layerDistancePips = Param(nameof(LayerDistancePips), 200m)
		.SetNotNegative()
		.SetDisplay("Layer Distance", "Pip distance before adding another order", "Money Management");

		_fastPeriod = Param(nameof(FastPeriod), 14)
		.SetGreaterThanZero()
		.SetDisplay("Fast MA", "Fast moving average period", "Indicators")
		;

		_slowPeriod = Param(nameof(SlowPeriod), 28)
		.SetGreaterThanZero()
		.SetDisplay("Slow MA", "Slow moving average period", "Indicators")
		;

		_stochasticKPeriod = Param(nameof(StochasticKPeriod), 10)
		.SetGreaterThanZero()
		.SetDisplay("Stochastic %K", "%K lookback length", "Indicators");

		_stochasticDPeriod = Param(nameof(StochasticDPeriod), 3)
		.SetGreaterThanZero()
		.SetDisplay("Stochastic %D", "%D smoothing length", "Indicators");

		_stochasticSlowing = Param(nameof(StochasticSlowing), 3)
		.SetGreaterThanZero()
		.SetDisplay("Stochastic Slowing", "Extra smoothing for %K", "Indicators");

		_takeProfitPips = Param(nameof(TakeProfitPips), 500m)
		.SetNotNegative()
		.SetDisplay("Take Profit", "Target distance in pips", "Risk Management");

		_stopLossPips = Param(nameof(StopLossPips), 0m)
		.SetNotNegative()
		.SetDisplay("Stop Loss", "Protective distance in pips", "Risk Management");
	}

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

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

		_fastSma = null;
		_slowSma = null;
		_stochastic = null;

		_longEntries.Clear();
		_shortEntries.Clear();

		_prevFast = null;
		_prevSlow = null;
		_pipSize = 0m;
		_longExitRequested = false;
		_shortExitRequested = false;
	}

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

		_fastSma = new SimpleMovingAverage { Length = Math.Max(1, FastPeriod) };
		_slowSma = new SimpleMovingAverage { Length = Math.Max(1, SlowPeriod) };
		_stochastic = new StochasticOscillator();
		_stochastic.K.Length = Math.Max(1, StochasticKPeriod);
		_stochastic.D.Length = Math.Max(1, StochasticDPeriod);

		var subscription = SubscribeCandles(CandleType);
		subscription
		.BindEx(_fastSma, _slowSma, _stochastic, ProcessCandle)
		.Start();

		_pipSize = CalculatePipSize();

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

		// protection managed manually via ManageExits
	}

	/// <inheritdoc />
	protected override void OnOwnTradeReceived(MyTrade trade)
	{
		base.OnOwnTradeReceived(trade);

		var price = trade.Trade.Price;
		var volume = trade.Trade.Volume;

		if (trade.Order.Side == Sides.Buy)
		{
			ReduceEntries(_shortEntries, ref volume);

			if (volume > 0m)
			{
				_longEntries.Add(new PositionEntry(price, volume));
			}
		}
		else if (trade.Order.Side == Sides.Sell)
		{
			ReduceEntries(_longEntries, ref volume);

			if (volume > 0m)
			{
				_shortEntries.Add(new PositionEntry(price, volume));
			}
		}

		if (GetTotalVolume(_longEntries) <= 0m)
		{
			_longEntries.Clear();
			_longExitRequested = false;
		}

		if (GetTotalVolume(_shortEntries) <= 0m)
		{
			_shortEntries.Clear();
			_shortExitRequested = false;
		}
	}

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

		var fast = fastValue.ToDecimal();
		var slow = slowValue.ToDecimal();

		ManageExits(candle);

		var signal = SignalDirections.None;

		if (SignalMode == SignalModes.CrossMa)
		{
			if (_prevFast.HasValue && _prevSlow.HasValue)
			{
				var crossBuy = fast < slow && _prevSlow.Value < _prevFast.Value;
				var crossSell = fast > slow && _prevSlow.Value > _prevFast.Value;

				if (crossBuy)
					signal = SignalDirections.Buy;
				else if (crossSell)
					signal = SignalDirections.Sell;
			}
		}
		else if (_stochastic?.IsFormed == true)
		{
			var kValue = stochasticValue.ToDecimal();
			var bullish = candle.ClosePrice > candle.OpenPrice;
			var bearish = candle.ClosePrice < candle.OpenPrice;

			if (fast > slow && bullish && kValue <= 30m)
				signal = SignalDirections.Buy;
			else if (fast < slow && bearish && kValue >= 70m)
				signal = SignalDirections.Sell;
		}

		if (signal != SignalDirections.None)
		{
			ProcessSignal(signal, candle.ClosePrice);
		}

		_prevFast = fast;
		_prevSlow = slow;
	}

	private void ProcessSignal(SignalDirections signal, decimal referencePrice)
	{
		switch (signal)
		{
			case SignalDirections.Buy:
			{
				var shortVolume = GetTotalVolume(_shortEntries);
				if (shortVolume > 0m)
				{
					if (!_shortExitRequested)
					{
						_shortExitRequested = true;
						BuyMarket(shortVolume);
					}
					return;
				}

				var longCount = _longEntries.Count;
				var requested = CalculateRequestedVolume(longCount);
				var volume = PrepareNextVolume(requested);
				if (volume <= 0m)
					return;

				if (longCount == 0)
				{
					BuyMarket(volume);
					return;
				}

				var lowest = GetExtremePrice(_longEntries, true);
				var threshold = lowest - LayerDistancePips * (_pipSize > 0m ? _pipSize : 1m);

				if (referencePrice <= threshold)
				{
					BuyMarket(volume);
				}

				break;
			}
			case SignalDirections.Sell:
			{
				var longVolume = GetTotalVolume(_longEntries);
				if (longVolume > 0m)
				{
					if (!_longExitRequested)
					{
						_longExitRequested = true;
						SellMarket(longVolume);
					}
					return;
				}

				var shortCount = _shortEntries.Count;
				var requested = CalculateRequestedVolume(shortCount);
				var volume = PrepareNextVolume(requested);
				if (volume <= 0m)
					return;

				if (shortCount == 0)
				{
					SellMarket(volume);
					return;
				}

				var highest = GetExtremePrice(_shortEntries, false);
				var threshold = highest + LayerDistancePips * (_pipSize > 0m ? _pipSize : 1m);

				if (referencePrice >= threshold)
				{
					SellMarket(volume);
				}

				break;
			}
		}
	}

	private void ManageExits(ICandleMessage candle)
	{
		var longVolume = GetTotalVolume(_longEntries);
		if (longVolume > 0m && !_longExitRequested)
		{
			var average = GetAveragePrice(_longEntries);
			var takeProfit = TakeProfitPips > 0m ? average + TakeProfitPips * (_pipSize > 0m ? _pipSize : 1m) : (decimal?)null;
			var stopLoss = StopLossPips > 0m ? average - StopLossPips * (_pipSize > 0m ? _pipSize : 1m) : (decimal?)null;

			if (takeProfit.HasValue && candle.HighPrice >= takeProfit.Value)
			{
				_longExitRequested = true;
				SellMarket(longVolume);
				return;
			}

			if (stopLoss.HasValue && candle.LowPrice <= stopLoss.Value)
			{
				_longExitRequested = true;
				SellMarket(longVolume);
				return;
			}
		}

		var shortVolume = GetTotalVolume(_shortEntries);
		if (shortVolume > 0m && !_shortExitRequested)
		{
			var average = GetAveragePrice(_shortEntries);
			var takeProfit = TakeProfitPips > 0m ? average - TakeProfitPips * (_pipSize > 0m ? _pipSize : 1m) : (decimal?)null;
			var stopLoss = StopLossPips > 0m ? average + StopLossPips * (_pipSize > 0m ? _pipSize : 1m) : (decimal?)null;

			if (takeProfit.HasValue && candle.LowPrice <= takeProfit.Value)
			{
				_shortExitRequested = true;
				BuyMarket(shortVolume);
				return;
			}

			if (stopLoss.HasValue && candle.HighPrice >= stopLoss.Value)
			{
				_shortExitRequested = true;
				BuyMarket(shortVolume);
			}
		}
	}

	private decimal CalculateRequestedVolume(int existingCount)
	{
		if (InitialVolume <= 0m)
			return 0m;

		var result = InitialVolume;

		if (existingCount > 0 && Multiplier > 0m)
		{
			result *= (decimal)Math.Pow((double)Math.Max(Multiplier, 1m), existingCount);
		}

		return result;
	}

	private decimal PrepareNextVolume(decimal requested)
	{
		if (requested <= 0m)
			return 0m;

		var security = Security;
		if (security == null)
			return requested;

		var step = security.VolumeStep ?? 0m;
		if (step > 0m)
		{
			requested = step * Math.Round(requested / step, MidpointRounding.AwayFromZero);
		}

		var min = security.MinVolume ?? 0m;
		if (min > 0m && requested < min)
			return 0m;

		var max = security.MaxVolume ?? decimal.MaxValue;
		if (requested > max)
		{
			requested = max;
		}

		return requested;
	}

	private void ReduceEntries(List<PositionEntry> entries, ref decimal volume)
	{
		var index = 0;
		while (volume > 0m && index < entries.Count)
		{
			var entry = entries[index];
			if (volume >= entry.Volume)
			{
				volume -= entry.Volume;
				entries.RemoveAt(index);
			}
			else
			{
				entry.Volume -= volume;
				volume = 0m;
				entries[index] = entry;
			}
		}
	}

	private static decimal GetTotalVolume(List<PositionEntry> entries)
	{
		var total = 0m;
		for (var i = 0; i < entries.Count; i++)
			total += entries[i].Volume;
		return total;
	}

	private static decimal GetAveragePrice(List<PositionEntry> entries)
	{
		var totalVolume = GetTotalVolume(entries);
		if (totalVolume <= 0m)
			return 0m;

		var weighted = 0m;
		for (var i = 0; i < entries.Count; i++)
			weighted += entries[i].Price * entries[i].Volume;

		return weighted / totalVolume;
	}

	private static decimal GetExtremePrice(List<PositionEntry> entries, bool forLong)
	{
		if (entries.Count == 0)
			return 0m;

		var extreme = entries[0].Price;
		for (var i = 1; i < entries.Count; i++)
		{
			var price = entries[i].Price;
			if (forLong)
			{
				if (price < extreme)
					extreme = price;
			}
			else if (price > extreme)
			{
				extreme = price;
			}
		}

		return extreme;
	}

	private decimal CalculatePipSize()
	{
		var security = Security;
		if (security == null)
			return 0m;

		var step = security.PriceStep ?? 0m;
		if (step <= 0m)
			return 0m;

		var decimals = security.Decimals;
		if (decimals == 3 || decimals == 5)
			return step * 10m;

		return step;
	}

	private enum SignalDirections
	{
		None,
		Buy,
		Sell
	}

	/// <summary>
	/// Signal selector for the strategy.
	/// </summary>
	public enum SignalModes
	{
		/// <summary>
		/// Use moving average crossovers in a contrarian fashion.
		/// </summary>
		CrossMa,

		/// <summary>
		/// Follow trend direction using moving averages with stochastic confirmation.
		/// </summary>
		Trend
	}

	private sealed class PositionEntry
	{
		public PositionEntry(decimal price, decimal volume)
		{
			Price = price;
			Volume = volume;
		}

		public decimal Price { get; }

		public decimal Volume { get; set; }
	}
}