Ver en GitHub

Estrategia de Medias Móviles

Descripción General

La estrategia de Medias Móviles replica el experto clásico de MetaTrader que opera cruzamientos del precio respecto a una media móvil simple (SMA) desplazada. El algoritmo procesa únicamente velas completadas, asegurando que todas las decisiones de trading se basen en barras completamente formadas. El dimensionamiento de posición sigue un modelo de riesgo dinámico vinculado al capital de la cuenta y se adapta a rachas de pérdidas, imitando la implementación MQL original.

Lógica de Trading

  • Se calcula una media móvil simple con un período configurable y un desplazamiento hacia adelante adicional medido en barras completadas.
  • En cada vela completada, la estrategia verifica si la barra abrió por encima de la SMA desplazada y cerró por debajo (cruce bajista) o abrió por debajo y cerró por encima (cruce alcista).
  • El sistema solo gestiona una posición a la vez. Cuando ocurre un cruce contra la posición activa, la posición se cierra primero y no se envían órdenes de reversión en la misma barra.
  • Si no hay posición abierta:
    • Un cruce alcista abre una posición larga.
    • Un cruce bajista abre una posición corta.

Gestión de Posiciones

  • Las posiciones largas se cierran cuando ocurre un cruce bajista.
  • Las posiciones cortas se cierran cuando ocurre un cruce alcista.
  • La ejecución de operaciones usa órdenes de mercado en el instrumento de la estrategia.
  • Se registra el historial de operaciones para calcular el precio de entrada efectivo y poder medir ganancias y pérdidas al cerrar la posición.

Gestión de Riesgos y Dimensionamiento de Posición

  • El volumen base de orden se deriva del capital del portafolio multiplicado por el parámetro Maximum Risk, dividido por el precio de cierre actual. Si el capital no está disponible, la estrategia usa el volumen predeterminado de la estrategia.
  • Un parámetro Decrease Factor reduce el volumen de orden calculado cuando se detectan operaciones perdedoras consecutivas. La reducción es proporcional a la racha de pérdidas, reproduciendo la lógica de dimensionamiento adaptativo de la versión MQL.
  • El volumen de orden nunca es negativo; cuando la reducción compensa completamente el monto base, la operación se omite.

Parámetros

Nombre Descripción Predeterminado
MaximumRisk Fracción del capital de la cuenta arriesgada en cada operación. 0.02
DecreaseFactor Divisor usado para reducir el volumen después de pérdidas consecutivas. 3
MovingPeriod Período de la SMA utilizada para señales. 12
MovingShift Número de barras completadas usadas para desplazar la SMA hacia adelante. 6
CandleType Serie de velas usada para cálculos (marco temporal). Velas 5m

Notas

  • El desplazamiento de la media móvil se implementa mediante un búfer circular interno para que la estrategia use el valor de la SMA de varias barras atrás, igual que el parámetro de desplazamiento del indicador de MetaTrader.
  • Las órdenes solo se generan cuando tanto la SMA como el búfer desplazado están completamente formados, evitando operaciones prematuras durante el calentamiento.
  • Los mensajes de registro documentan entradas, salidas y resultados de operaciones para facilitar la depuración y el análisis de rendimiento.
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;
	}
}