Открыть на GitHub

Стратегия Surfing 3.0

Обзор

Данная стратегия на C# является точной адаптацией эксперта MetaTrader 4 Surfing 3.0. Она повторяет пробойную логику, основанную на экспоненциальных скользящих средних (EMA), построенных по максимумам и минимумам свечей. Когда предыдущая свеча закрывается внутри канала, а последняя закрытая свеча пробивает границу, система открывает сделку в направлении пробоя. Перенос выполнен на высокоуровневом API StockSharp с использованием подписки на свечи и встроенных индикаторов, без ручного копирования буферов.

Алгоритм работает исключительно с закрытыми свечами выбранной агрегации. Он хранит только минимальный набор значений, чтобы воспроизвести обращения iMA и iClose из оригинала. Решения принимаются один раз на каждую закрытую свечу, что полностью соответствует стилю "по закрытию бара" в версии MQL.

Индикаторы

  • EMA по максимумам / EMA по минимумам – две экспоненциальные скользящие, вычисляемые по High и Low. Они образуют динамический коридор, относительно которого определяется пробой для входа в покупки или продажи.
  • Индекс относительной силы (RSI) – фильтр тренда. Для длинных позиций RSI должен быть выше LongRsiThreshold, для коротких – ниже ShortRsiThreshold.

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

  1. Подписаться на свечи типа CandleType и на каждой закрытой свече обновлять значения EMA и RSI.
  2. Сохранять значения закрытия и EMA предыдущей свечи – это аналоги PriceClose_2, PriceHigh_2 и PriceLow_2 в исходном коде.
  3. Если последняя закрытая свеча (PriceClose_1) пробивает вверх EMA по максимумам, а предыдущая свеча закрылась ниже либо на границе, и RSI подтверждает сигнал:
    • Закрыть открытую короткую позицию (если она есть).
    • Открыть рыночную покупку объёмом OrderVolume.
    • Рассчитать стоп-лосс и тейк-профит в пунктах инструмента.
  4. Если последняя закрытая свеча пробивает вниз EMA по минимумам, а предыдущая свеча закрылась выше либо на границе, и RSI находится ниже порога для шорта:
    • Закрыть открытую длинную позицию.
    • Открыть рыночную продажу объёмом OrderVolume.
    • Установить защитные уровни с теми же точными отступами в пунктах.
  5. Одновременно может существовать только одна чистая позиция. Сигналы разворота сначала закрывают текущую позицию и только потом открывают противоположную.
  6. Вне торгового окна [TradeStartHour, TradeEndHour) новые сделки не открываются. Как только наступает TradeEndHour, стратегия закрывает все позиции и сбрасывает внутреннюю историю, что повторяет логику closeAllPos() из MQL.

Риск-менеджмент

  • Стоп-лосс / тейк-профит – задаются в пунктах инструмента и переводятся в абсолютные цены с учётом шага цены. Значение 0 отключает соответствующий уровень.
  • Выход по окончании сессии – при наступлении TradeEndHour все позиции закрываются по рынку, после чего очищаются контрольные уровни. Это не позволяет переносить сделки через ночь, как и в оригинальном эксперте со свойствами startHour и endHour.

Параметры

Имя Описание Значение по умолчанию
OrderVolume Объём каждой рыночной сделки. 1
TakeProfitPoints Дистанция тейк-профита в пунктах. 80
StopLossPoints Дистанция стоп-лосса в пунктах. 50
MaPeriod Период EMA, рассчитываемых по максимумам и минимумам. 50
RsiPeriod Период индикатора RSI. 10
LongRsiThreshold Минимальное значение RSI для разрешения покупок. 40
ShortRsiThreshold Максимальное значение RSI для разрешения продаж. 65
TradeStartHour Час (время биржи), начиная с которого разрешены новые сделки. 8
TradeEndHour Час (исключительно), после которого сделки закрываются и новые не открываются. 18
CandleType Тип свечей для расчётов (по умолчанию – 15-минутные свечи). 15m

Примечания

  • Сигналы оцениваются только по закрытым свечам, внутрибараные колебания игнорируются, как и в MetaTrader.
  • История EMA очищается после завершения торгового окна, чтобы не смешивать данные разных дней.
  • Python-версия умышленно не создаётся согласно требованиям проекта.
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;

using StockSharp.Algo;
using StockSharp.Algo.Candles;

namespace StockSharp.Samples.Strategies;

/// <summary>
/// Strategy that reproduces the Surfing 3.0 expert advisor logic from MetaTrader.
/// </summary>
public class Surfing30Strategy : Strategy
{
	private readonly StrategyParam<decimal> _orderVolume;
	private readonly StrategyParam<int> _takeProfitPoints;
	private readonly StrategyParam<int> _stopLossPoints;
	private readonly StrategyParam<int> _maPeriod;
	private readonly StrategyParam<int> _rsiPeriod;
	private readonly StrategyParam<decimal> _longRsiThreshold;
	private readonly StrategyParam<decimal> _shortRsiThreshold;
	private readonly StrategyParam<int> _tradeStartHour;
	private readonly StrategyParam<int> _tradeEndHour;
	private readonly StrategyParam<DataType> _candleType;

	private RelativeStrengthIndex _rsi = null!;

	private decimal? _previousClose;
	private decimal? _previousHighEma;
	private decimal? _previousLowEma;

	private decimal? _stopLossPrice;
	private decimal? _takeProfitPrice;

	/// <summary>
	/// Initialize <see cref="Surfing30Strategy"/>.
	/// </summary>
	public Surfing30Strategy()
	{
		_orderVolume = Param(nameof(OrderVolume), 1m)
			.SetGreaterThanZero()
			.SetDisplay("Order Volume", "Volume applied to every trade.", "Trading")
			
			.SetOptimize(0.1m, 5m, 0.1m);

		_takeProfitPoints = Param(nameof(TakeProfitPoints), 80)
			.SetNotNegative()
			.SetDisplay("Take Profit Points", "Distance to the take profit in instrument points.", "Risk Management")
			
			.SetOptimize(10, 200, 10);

		_stopLossPoints = Param(nameof(StopLossPoints), 50)
			.SetNotNegative()
			.SetDisplay("Stop Loss Points", "Distance to the stop loss in instrument points.", "Risk Management")
			
			.SetOptimize(10, 150, 10);

		_maPeriod = Param(nameof(MaPeriod), 50)
			.SetRange(1, 1000)
			.SetDisplay("EMA Period", "Length of the exponential moving averages calculated over highs and lows.", "Indicators")
			
			.SetOptimize(10, 120, 5);

		_rsiPeriod = Param(nameof(RsiPeriod), 10)
			.SetRange(1, 1000)
			.SetDisplay("RSI Period", "Length of the RSI filter.", "Indicators")
			
			.SetOptimize(5, 30, 1);

		_longRsiThreshold = Param(nameof(LongRsiThreshold), 30m)
			.SetDisplay("Long RSI Threshold", "Minimum RSI value required for long entries.", "Filters")
			
			.SetOptimize(20m, 60m, 5m);

		_shortRsiThreshold = Param(nameof(ShortRsiThreshold), 70m)
			.SetDisplay("Short RSI Threshold", "Maximum RSI value allowed for short entries.", "Filters")
			
			.SetOptimize(40m, 80m, 5m);

		_tradeStartHour = Param(nameof(TradeStartHour), 0)
			.SetDisplay("Trade Start Hour", "Hour of the day when new trades may start.", "Sessions")
			;

		_tradeEndHour = Param(nameof(TradeEndHour), 23)
			.SetDisplay("Trade End Hour", "Hour of the day when all positions are closed.", "Sessions")
			;

		_candleType = Param(nameof(CandleType), TimeSpan.FromMinutes(15).TimeFrame())
			.SetDisplay("Candle Type", "Aggregation used for calculations.", "Data");
	}

	/// <summary>
	/// Volume used for every trade.
	/// </summary>
	public decimal OrderVolume
	{
		get => _orderVolume.Value;
		set
		{
			_orderVolume.Value = value;
			Volume = value;
		}
	}

	/// <summary>
	/// Distance to the take profit in instrument points.
	/// </summary>
	public int TakeProfitPoints
	{
		get => _takeProfitPoints.Value;
		set => _takeProfitPoints.Value = value;
	}

	/// <summary>
	/// Distance to the stop loss in instrument points.
	/// </summary>
	public int StopLossPoints
	{
		get => _stopLossPoints.Value;
		set => _stopLossPoints.Value = value;
	}

	/// <summary>
	/// Length of the exponential moving averages calculated over candle highs and lows.
	/// </summary>
	public int MaPeriod
	{
		get => _maPeriod.Value;
		set => _maPeriod.Value = value;
	}

	/// <summary>
	/// Length of the RSI filter.
	/// </summary>
	public int RsiPeriod
	{
		get => _rsiPeriod.Value;
		set => _rsiPeriod.Value = value;
	}

	/// <summary>
	/// Minimum RSI value required for long entries.
	/// </summary>
	public decimal LongRsiThreshold
	{
		get => _longRsiThreshold.Value;
		set => _longRsiThreshold.Value = value;
	}

	/// <summary>
	/// Maximum RSI value allowed for short entries.
	/// </summary>
	public decimal ShortRsiThreshold
	{
		get => _shortRsiThreshold.Value;
		set => _shortRsiThreshold.Value = value;
	}

	/// <summary>
	/// Hour of the day when new trades may start.
	/// </summary>
	public int TradeStartHour
	{
		get => _tradeStartHour.Value;
		set => _tradeStartHour.Value = value;
	}

	/// <summary>
	/// Hour of the day when all positions are closed.
	/// </summary>
	public int TradeEndHour
	{
		get => _tradeEndHour.Value;
		set => _tradeEndHour.Value = value;
	}

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

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

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

		_previousClose = null;
		_previousHighEma = null;
		_previousLowEma = null;
		_stopLossPrice = null;
		_takeProfitPrice = null;
	}

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

		Volume = OrderVolume;

		var sma = new SimpleMovingAverage { Length = MaPeriod };
		_rsi = new RelativeStrengthIndex { Length = RsiPeriod };

		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(sma, _rsi, ProcessCandle)
			.Start();
	}

	private void ProcessCandle(ICandleMessage candle, decimal smaValue, decimal rsiValue)
	{
		if (candle.State != CandleStates.Finished)
			return;

		var currentClose = candle.ClosePrice;

		if (ManageActivePosition(candle))
		{
			UpdateHistory(currentClose, smaValue, smaValue);
			return;
		}

		if (_previousClose is null || _previousHighEma is null)
		{
			UpdateHistory(currentClose, smaValue, smaValue);
			return;
		}

		var previousClose = _previousClose.Value;
		var previousSma = _previousHighEma.Value;

		var buySignal = previousClose <= previousSma && currentClose > smaValue && rsiValue > LongRsiThreshold;
		var sellSignal = previousClose >= previousSma && currentClose < smaValue && rsiValue < ShortRsiThreshold;

		if (buySignal && Position <= 0)
		{
			if (Position < 0)
			{
				CloseCurrentPosition();
				ResetTargets();
			}

			BuyMarket(OrderVolume);
			SetTargets(currentClose, true);
		}
		else if (sellSignal && Position >= 0)
		{
			if (Position > 0)
			{
				CloseCurrentPosition();
				ResetTargets();
			}

			SellMarket(OrderVolume);
			SetTargets(currentClose, false);
		}

		UpdateHistory(currentClose, smaValue, smaValue);
	}

	private bool ManageActivePosition(ICandleMessage candle)
	{
		if (Position > 0)
		{
			if (_stopLossPrice is not null && candle.LowPrice <= _stopLossPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}

			if (_takeProfitPrice is not null && candle.HighPrice >= _takeProfitPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}
		}
		else if (Position < 0)
		{
			if (_stopLossPrice is not null && candle.HighPrice >= _stopLossPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}

			if (_takeProfitPrice is not null && candle.LowPrice <= _takeProfitPrice)
			{
				CloseCurrentPosition();
				ResetTargets();
				return true;
			}
		}

		return false;
	}

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

	private void SetTargets(decimal entryPrice, bool isLong)
	{
		var priceStep = Security?.PriceStep ?? 0m;
		if (priceStep <= 0m)
			priceStep = 1m;

		if (isLong)
		{
			_stopLossPrice = StopLossPoints > 0 ? entryPrice - StopLossPoints * priceStep : null;
			_takeProfitPrice = TakeProfitPoints > 0 ? entryPrice + TakeProfitPoints * priceStep : null;
		}
		else
		{
			_stopLossPrice = StopLossPoints > 0 ? entryPrice + StopLossPoints * priceStep : null;
			_takeProfitPrice = TakeProfitPoints > 0 ? entryPrice - TakeProfitPoints * priceStep : null;
		}
	}

	private void ResetTargets()
	{
		_stopLossPrice = null;
		_takeProfitPrice = null;
	}

	private void UpdateHistory(decimal currentClose, decimal currentHighEma, decimal currentLowEma)
	{
		_previousClose = currentClose;
		_previousHighEma = currentHighEma;
		_previousLowEma = currentLowEma;
	}

	private void ResetHistory()
	{
		_previousClose = null;
		_previousHighEma = null;
		_previousLowEma = null;
	}

	private bool IsWithinTradeHours(DateTimeOffset time)
	{
		var startHour = TradeStartHour;
		var endHour = TradeEndHour;

		if (endHour <= startHour)
			return time.Hour >= startHour || time.Hour < endHour;

		return time.Hour >= startHour && time.Hour < endHour;
	}
}