Открыть на GitHub

Стратегия Moving Averages

Обзор

Стратегия Moving Averages воспроизводит классического эксперта MetaTrader, который торгует по пересечениям цены с сдвинутой простой скользящей средней (SMA). Алгоритм обрабатывает только закрытые свечи, поэтому решения принимаются на основе полностью сформированных баров. Размер позиции рассчитывается по динамической модели риска, связанной с капиталом портфеля, и уменьшается при серии убыточных сделок, как и в исходном MQL-скрипте.

Логика торговли

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

Управление позицией

  • Медвежий сигнал закрывает длинную позицию.
  • Бычий сигнал закрывает короткую позицию.
  • Для сделок используются рыночные заявки по базовому инструменту стратегии.
  • Журнал сделок ведётся для расчёта средней цены входа, что позволяет корректно оценить финансовый результат при закрытии позиции.

Риск-менеджмент и размер позиции

  • Базовый объём заявки вычисляется как произведение стоимости портфеля на параметр MaximumRisk, делённое на текущую цену закрытия. Если данные о капитале недоступны, используется стандартный объём стратегии.
  • Параметр DecreaseFactor уменьшает рассчитанный объём при серии убыточных сделок. Степень сокращения пропорциональна длине серии, что повторяет адаптивное управление объёмом в версии для MetaTrader.
  • Итоговый объём никогда не становится отрицательным. Если уменьшение превышает базовое значение, сделка пропускается.

Параметры

Имя Описание Значение по умолчанию
MaximumRisk Доля капитала, которую стратегия рискует в сделке. 0.02
DecreaseFactor Делитель, уменьшающий объём после серии убытков. 3
MovingPeriod Период SMA для расчёта сигналов. 12
MovingShift Количество завершённых баров, на которые смещается SMA. 6
CandleType Тип свечей (таймфрейм), используемый в расчётах. Свечи 5 минут

Примечания

  • Сдвиг скользящей средней реализован через внутренний циклический буфер, поэтому стратегия использует значения SMA нескольких баров назад, как и параметр shift в MetaTrader.
  • Заявки отправляются только после того, как SMA и буфер сдвига полностью сформированы, что исключает преждевременные входы во время разогрева.
  • Журнальные сообщения фиксируют входы, выходы и результаты сделок, упрощая анализ и отладку стратегии.
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>
/// Moving average crossover strategy with risk-based position sizing and trade streak tracking.
/// </summary>
public class MovingAveragesStrategy : Strategy
{
	private readonly StrategyParam<decimal> _maximumRisk;
	private readonly StrategyParam<decimal> _decreaseFactor;
	private readonly StrategyParam<int> _movingPeriod;
	private readonly StrategyParam<int> _movingShift;
	private readonly StrategyParam<DataType> _candleType;

	private SimpleMovingAverage _sma = null!;
	private decimal[] _shiftBuffer = Array.Empty<decimal>();
	private int _shiftIndex;
	private int _shiftFillCount;
	private decimal _avgEntryPrice;
	private decimal _entryVolume;
	private Sides? _entrySide;
	private int _consecutiveLosses;

	/// <summary>
	/// Maximum risk per trade expressed as portfolio percentage.
	/// </summary>
	public decimal MaximumRisk
	{
		get => _maximumRisk.Value;
		set => _maximumRisk.Value = value;
	}

	/// <summary>
	/// Factor that reduces position size after consecutive losses.
	/// </summary>
	public decimal DecreaseFactor
	{
		get => _decreaseFactor.Value;
		set => _decreaseFactor.Value = value;
	}

	/// <summary>
	/// Simple moving average period.
	/// </summary>
	public int MovingPeriod
	{
		get => _movingPeriod.Value;
		set => _movingPeriod.Value = value;
	}

	/// <summary>
	/// Number of completed bars used to shift the moving average.
	/// </summary>
	public int MovingShift
	{
		get => _movingShift.Value;
		set => _movingShift.Value = value;
	}

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

	/// <summary>
	/// Initializes a new instance of the <see cref="MovingAveragesStrategy"/>.
	/// </summary>
	public MovingAveragesStrategy()
	{
		// Configure risk management parameters.
		_maximumRisk = Param(nameof(MaximumRisk), 0.02m)
			.SetGreaterThanZero()
			.SetDisplay("Maximum Risk", "Fraction of equity risked per trade", "Risk");

		_decreaseFactor = Param(nameof(DecreaseFactor), 3m)
			.SetGreaterThanZero()
			.SetDisplay("Decrease Factor", "Loss streak divisor for position sizing", "Risk");

		// Configure indicator settings.
		_movingPeriod = Param(nameof(MovingPeriod), 12)
			.SetGreaterThanZero()
			.SetDisplay("Moving Period", "Simple moving average lookback", "Indicator");

		_movingShift = Param(nameof(MovingShift), 6)
			.SetNotNegative()
			.SetDisplay("Moving Shift", "Bars to shift the moving average", "Indicator");

		// Configure candle source for the strategy.
		_candleType = Param(nameof(CandleType), TimeSpan.FromHours(4).TimeFrame())
			.SetDisplay("Candle Type", "Timeframe used for signals", "Data");
	}

	/// <inheritdoc />
	protected override void OnReseted()
	{
		base.OnReseted();
		_shiftBuffer = Array.Empty<decimal>();
		_shiftIndex = 0;
		_shiftFillCount = 0;
		_consecutiveLosses = 0;
		ResetEntryState();
	}

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

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

		// Initialize indicator and buffers.
		_sma = new SMA { Length = MovingPeriod };
		_shiftBuffer = new decimal[Math.Max(1, MovingShift + 1)];
		_shiftIndex = 0;
		_shiftFillCount = 0;
		_consecutiveLosses = 0;
		ResetEntryState();

		// Subscribe to candles and bind indicator values.
		var subscription = SubscribeCandles(CandleType);
		subscription
			.Bind(_sma, ProcessCandle)
			.Start();

		// Add optional chart visuals.
		var area = CreateChartArea();
		if (area != null)
		{
			DrawCandles(area, subscription);
			DrawIndicator(area, _sma);
			DrawOwnTrades(area);
		}
	}

	private void ProcessCandle(ICandleMessage candle, decimal maValue)
	{
		// Process only finished candles to match bar-based logic.
		if (candle.State != CandleStates.Finished)
			return;

		// Wait until the moving average has enough data.
		if (!_sma.IsFormed)
			return;

		// Update shifted buffer to emulate MetaTrader style MA shift.
		UpdateShiftBuffer(maValue);

		if (!IsShiftReady())
			return;

		var shiftedMa = GetShiftedValue();

		var crossDown = candle.OpenPrice > shiftedMa && candle.ClosePrice < shiftedMa;
		var crossUp = candle.OpenPrice < shiftedMa && candle.ClosePrice > shiftedMa;

		// Manage existing long position before searching for new entries.
		if (Position > 0)
		{
			if (crossDown)
			{
				CloseLongPosition(candle, shiftedMa);
			}

			return;
		}

		// Manage existing short position before searching for new entries.
		if (Position < 0)
		{
			if (crossUp)
			{
				CloseShortPosition(candle, shiftedMa);
			}

			return;
		}

		// No open position, evaluate entry opportunities.
		if (crossUp)
		{
			OpenLongPosition(candle, shiftedMa);
		}
		else if (crossDown)
		{
			OpenShortPosition(candle, shiftedMa);
		}
	}

	private void OpenLongPosition(ICandleMessage candle, decimal shiftedMa)
	{
		var volume = CalculateOrderVolume(candle.ClosePrice);
		if (volume <= 0m)
			return;

		BuyMarket();
		LogInfo($"Enter long on bullish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
	}

	private void OpenShortPosition(ICandleMessage candle, decimal shiftedMa)
	{
		var volume = CalculateOrderVolume(candle.ClosePrice);
		if (volume <= 0m)
			return;

		SellMarket();
		LogInfo($"Enter short on bearish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
	}

	private void CloseLongPosition(ICandleMessage candle, decimal shiftedMa)
	{
		var volume = Math.Abs(Position);
		if (volume <= 0m)
			return;

		SellMarket();
		LogInfo($"Exit long due to bearish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
	}

	private void CloseShortPosition(ICandleMessage candle, decimal shiftedMa)
	{
		var volume = Math.Abs(Position);
		if (volume <= 0m)
			return;

		BuyMarket();
		LogInfo($"Exit short due to bullish cross. Price={candle.ClosePrice}, SMA={shiftedMa}");
	}

	private decimal CalculateOrderVolume(decimal price)
	{
		if (price <= 0m)
			return 0m;

		// Base position size uses portfolio value and risk percentage.
		var portfolioValue = Portfolio?.CurrentValue ?? 0m;
		var baseVolume = Volume > 0m ? Volume : 1m;

		if (portfolioValue > 0m && MaximumRisk > 0m)
			baseVolume = portfolioValue * MaximumRisk / price;

		// Apply loss streak reduction similar to the original MQL logic.
		if (DecreaseFactor > 0m && _consecutiveLosses > 0)
		{
			var reduction = baseVolume * _consecutiveLosses / DecreaseFactor;
			baseVolume -= reduction;
		}

		return baseVolume > 0m ? baseVolume : 0m;
	}

	private void UpdateShiftBuffer(decimal value)
	{
		_shiftBuffer[_shiftIndex] = value;
		if (_shiftFillCount < _shiftBuffer.Length)
			_shiftFillCount++;

		_shiftIndex++;
		if (_shiftIndex >= _shiftBuffer.Length)
			_shiftIndex = 0;
	}

	private bool IsShiftReady()
	{
		return _shiftFillCount > MovingShift;
	}

	private decimal GetShiftedValue()
	{
		if (_shiftBuffer.Length == 0)
			return 0m;

		var offset = Math.Min(MovingShift, _shiftFillCount - 1);
		var index = _shiftIndex - 1 - offset;

		while (index < 0)
			index += _shiftBuffer.Length;

		return _shiftBuffer[index];
	}

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

		if (trade?.Order == null)
			return;

		var tradePrice = trade.Trade.Price;
		var tradeVolume = trade.Trade.Volume;

		// Track entries and exits to evaluate profit streaks.
		if (trade.Order.Side == Sides.Buy)
		{
			if (Position > 0)
			{
				RegisterEntry(tradePrice, tradeVolume, Sides.Buy);
			}
			else if (Position == 0 && _entrySide == Sides.Sell)
			{
				EvaluateClosedTrade(tradePrice);
			}
		}
		else if (trade.Order.Side == Sides.Sell)
		{
			if (Position < 0)
			{
				RegisterEntry(tradePrice, tradeVolume, Sides.Sell);
			}
			else if (Position == 0 && _entrySide == Sides.Buy)
			{
				EvaluateClosedTrade(tradePrice);
			}
		}
	}

	private void RegisterEntry(decimal price, decimal volume, Sides side)
	{
		if (volume <= 0m)
			return;

		var totalVolume = _entryVolume + volume;
		if (totalVolume <= 0m)
		{
			ResetEntryState();
			return;
		}

		_avgEntryPrice = _entryVolume > 0m
			? (_avgEntryPrice * _entryVolume + price * volume) / totalVolume
			: price;

		_entryVolume = totalVolume;
		_entrySide = side;
	}

	private void EvaluateClosedTrade(decimal exitPrice)
	{
		if (_entrySide == null || _entryVolume <= 0m)
		{
			ResetEntryState();
			return;
		}

		decimal profit = 0m;

		if (_entrySide == Sides.Buy)
			profit = exitPrice - _avgEntryPrice;
		else if (_entrySide == Sides.Sell)
			profit = _avgEntryPrice - exitPrice;

		if (profit > 0m)
		{
			_consecutiveLosses = 0;
		}
		else if (profit < 0m)
		{
			_consecutiveLosses++;
		}

		LogInfo($"Trade closed. Side={_entrySide}, Entry={_avgEntryPrice}, Exit={exitPrice}, Profit={profit}, LossStreak={_consecutiveLosses}");

		ResetEntryState();
	}

	private void ResetEntryState()
	{
		_avgEntryPrice = 0m;
		_entryVolume = 0m;
		_entrySide = null;
	}
}